Commitc28ce026Recorded2 Aug 2026Repositoryfolio

Add folio/task-move-batch for bulk triage

Message

Triaging 227 inbox items on 2026-08-02 cost 226 individual MCP round-trips, hand-batched into groups. It is slow, burns enormous context, and makes a full triage something nobody does routinely -- which is a large part of why that inbox went untriaged from April to August. This is a workflow fix, not an optimisation.

Two shapes, because the triage that motivated it spanned 14 projects:

  {ids: [...], project: "x"}                  one shared target
  {moves: [{id, project}, ...]}               per-id targets, one call

An entry with no project moves that task to the inbox.

Returns a result line per id, so a partial failure is visible rather than swallowed -- a batch that hid them would be worse than the individual calls it replaces. The header counts SUCCESSES rather than attempts, because "Moved 2 task(s)" printed above two failures reads as the opposite of what happened; there is a test for that.

Because ids are now stable across moves, the reported id is the one the task landed under, so a caller never has to re-query to find out where its tasks went.

Mirrors the shape of the existing folio/task-complete-batch. arg->list lets both accept a JSON array or a plain list and treats a missing argument as empty rather than an error.

Changed
 src/folio/tools.sgl           |  80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 test/test-task-move-batch.sgl | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 231 insertions(+), 1 deletion(-)
Diff
src/folio/tools.sglmodified
@@ -32,6 +32,7 @@
32
make-tool-task-complete-batch
33
make-tool-inbox-triage
34
make-tool-task-move
+35
make-tool-task-move-batch
36
move-task-in-store!)
37
(begin
38
@@ -274,6 +275,22 @@
275
(description . "List of task IDs to mark as done (e.g., [\"t-a3f2\", \"t-b1c4\"])")))))
276
(required . ("ids"))))
277
+278
(define task-move-batch-schema
+279
'((type . "object")
+280
(properties . ((ids . ((type . "array")
+281
(items . ((type . "string")))
+282
(description . "Task IDs to move to a single target (use with 'project')")))
+283
(project . ((type . "string")
+284
(description . "Target project for every id in 'ids' (omit to move them to the inbox)")))
+285
(moves . ((type . "array")
+286
(items . ((type . "object")
+287
(properties . ((id . ((type . "string")
+288
(description . "Task ID to move")))
+289
(project . ((type . "string")
+290
(description . "Target project (omit for inbox)")))))
+291
(required . ("id"))))
+292
(description . "Per-id targets, for fanning one call out across many projects. Takes precedence over 'ids'.")))))
+293
(required . ())))
294
295
;; ============================================================
296
@@ -1187,9 +1204,66 @@
1204
(write-file-string file-path (string-join new-lines "\n")))
1205
(values (length swept) (reverse swept))))))
1206
+1207
;; Accept either a JSON array or a plain list, and treat a missing
+1208
;; argument as empty rather than as an error.
+1209
(define (arg->list v)
+1210
(cond ((not v) '())
+1211
((array? v) (array->list v))
+1212
((list? v) v)
+1213
(else (list v))))
+1214
+1215
;; Perform one move of a batch and render its result line.
+1216
;;
+1217
;; Per-id reporting is the point: a batch that swallowed partial failures
+1218
;; would be worse than the individual calls it replaces.
+1219
(define (batch-move-one store id target)
+1220
(let ((result (move-task-in-store! store id target)))
+1221
(format " ~a -> ~a: ~a~a"
+1222
id
+1223
(or target "inbox")
+1224
result
+1225
;; Ids are stable across moves, so the resulting id is the one
+1226
;; asked for. Stated explicitly so a caller never has to
+1227
;; re-query to learn where a task landed.
+1228
(if (string-starts-with? result "Moved")
+1229
(format " (id: ~a)" id)
+1230
""))))
+1231
+1232
(define (make-tool-task-move-batch store)
+1233
(lambda (args)
+1234
(let* ((moves-arg (dict-ref args moves: #f))
+1235
(entries
+1236
(if moves-arg
+1237
;; Per-id targets: one call fans out across projects.
+1238
(map (lambda (m)
+1239
(cons (dict-ref m id: #f) (dict-ref m project: #f)))
+1240
(arg->list moves-arg))
+1241
;; One shared target for every id.
+1242
(let ((target (dict-ref args project: #f)))
+1243
(map (lambda (id) (cons id target))
+1244
(arg->list (dict-ref args ids: #f))))))
+1245
(results
+1246
(map (lambda (e)
+1247
(if (car e)
+1248
(batch-move-one store (car e) (cdr e))
+1249
" (skipped an entry with no id)"))
+1250
entries)))
+1251
(if (null? entries)
+1252
"No tasks to move."
+1253
;; Count the successes rather than the attempts: a header
+1254
;; reading "Moved 3 task(s)" above three failures would report
+1255
;; the opposite of what happened.
+1256
(string-join
+1257
(cons (format "Moved ~a of ~a task(s):"
+1258
(length (filter (lambda (r) (string-contains? r "(id: "))
+1259
results))
+1260
(length entries))
+1261
results)
+1262
"\n")))))
+1263
1264
(define (make-tool-task-complete-batch store)
1265
(lambda (args)
1192
(let* ((ids (array->list (require-arg args ids: "ids")))
+1266
(let* ((ids (arg->list (require-arg args ids: "ids")))
1267
(results
1268
(map (lambda (id)
1269
(let ((result (complete-task-in-store! store id)))
@@ -1542,6 +1616,10 @@
1616
"folio/task-complete-batch" "Mark multiple tasks as done by ID list"
1617
task-complete-batch-schema (make-tool-task-complete-batch store))
1618
+1619
(mcp-server-register-tool! server
+1620
"folio/task-move-batch"
+1621
"Move many tasks at once — one shared target via 'ids' + 'project', or per-id targets via 'moves'. Reports each id separately. Task ids are preserved by a move."
+1622
task-move-batch-schema (make-tool-task-move-batch store))
1623
1624
;; Queries
1625
(mcp-server-register-tool! server
test/test-task-move-batch.sgladded
@@ -0,0 +1,152 @@
+1
;;; folio/task-move-batch — bulk triage in one call.
+2
;;;
+3
;;; Triaging 227 inbox items on 2026-08-02 cost 226 individual MCP round-trips,
+4
;;; hand-batched into groups. That cost is a large part of why the inbox went
+5
;;; untriaged from April to August: a full triage was something nobody would do
+6
;;; routinely. This is a workflow fix, not an optimisation.
+7
;;;
+8
;;; Partial failures are reported per id rather than swallowed — a batch that
+9
;;; hid them would be worse than the individual calls it replaces.
+10
+11
(import (sigil test)
+12
(sigil struct)
+13
(sigil string)
+14
(sigil dict)
+15
(sigil fs)
+16
(folio store)
+17
(folio task)
+18
(folio inbox)
+19
(folio project)
+20
(folio tools))
+21
+22
(test-group "task-move-batch"
+23
(test "moves many ids to one project and reports each"
+24
(call-with-temp-directory
+25
(lambda (dir)
+26
(let* ((store (make-folio-store dir))
+27
(file (project-create! store "Target" "Goal" #f))
+28
(a (inbox-add! store "Item A" '()))
+29
(b (inbox-add! store "Item B" '()))
+30
(c (inbox-add! store "Item C" '()))
+31
(handler (make-tool-task-move-batch store))
+32
(result (handler (dict ids: (list (folio-task-id a)
+33
(folio-task-id b)
+34
(folio-task-id c))
+35
project: "target"))))
+36
(assert-true (string-contains? result (folio-task-id a)))
+37
(assert-true (string-contains? result (folio-task-id b)))
+38
(assert-true (string-contains? result (folio-task-id c)))
+39
(assert-equal 0 (length (read-inbox store)))
+40
(assert-equal 3 (length (folio-project-tasks (read-project file))))))))
+41
+42
(test "fans out across several projects via {id, project} pairs"
+43
(call-with-temp-directory
+44
(lambda (dir)
+45
(let* ((store (make-folio-store dir))
+46
(f1 (project-create! store "One" "Goal" #f))
+47
(f2 (project-create! store "Two" "Goal" #f))
+48
(a (inbox-add! store "Item A" '()))
+49
(b (inbox-add! store "Item B" '()))
+50
(handler (make-tool-task-move-batch store)))
+51
(handler (dict moves: (list (dict id: (folio-task-id a) project: "one")
+52
(dict id: (folio-task-id b) project: "two"))))
+53
(assert-equal 1 (length (folio-project-tasks (read-project f1))))
+54
(assert-equal 1 (length (folio-project-tasks (read-project f2))))
+55
(assert-equal 0 (length (read-inbox store)))))))
+56
+57
(test "a moves entry with no project sends that task to the inbox"
+58
(call-with-temp-directory
+59
(lambda (dir)
+60
(let* ((store (make-folio-store dir))
+61
(f1 (project-create! store "One" "Goal" #f))
+62
(src (project-create! store "Source" "Goal" #f))
+63
(a (project-add-task! src "Stay" '()))
+64
(b (project-add-task! src "Go home" '()))
+65
(handler (make-tool-task-move-batch store)))
+66
(handler (dict moves: (list (dict id: (folio-task-id a) project: "one")
+67
(dict id: (folio-task-id b)))))
+68
(assert-equal 1 (length (folio-project-tasks (read-project f1))))
+69
(assert-equal 1 (length (read-inbox store)))
+70
(assert-equal (folio-task-id b)
+71
(folio-task-id (car (read-inbox store))))))))
+72
+73
(test "reports the resulting id, which is the id asked for"
+74
(call-with-temp-directory
+75
(lambda (dir)
+76
(let* ((store (make-folio-store dir))
+77
(file (project-create! store "Target" "Goal" #f))
+78
(a (inbox-add! store "Item A" '()))
+79
(handler (make-tool-task-move-batch store))
+80
(result (handler (dict ids: (list (folio-task-id a))
+81
project: "target")))
+82
(landed (car (folio-project-tasks (read-project file)))))
+83
(assert-equal (folio-task-id a) (folio-task-id landed))
+84
(assert-true (string-contains? result (folio-task-id landed)))))))
+85
+86
(test "a partial failure is visible per id and does not stop the others"
+87
(call-with-temp-directory
+88
(lambda (dir)
+89
(let* ((store (make-folio-store dir))
+90
(file (project-create! store "Target" "Goal" #f))
+91
(a (inbox-add! store "Item A" '()))
+92
(handler (make-tool-task-move-batch store))
+93
(result (handler (dict ids: (list (folio-task-id a) "t-dead")
+94
project: "target"))))
+95
(assert-true (string-contains? result "t-dead"))
+96
(assert-true (string-contains? result "not found"))
+97
;; the good one still moved
+98
(assert-equal 1 (length (folio-project-tasks (read-project file))))))))
+99
+100
;; The header is a claim about what happened and must not contradict the
+101
;; lines below it: "Moved 2 task(s)" above two failures reads as success.
+102
(test "the header counts successes, not attempts"
+103
(call-with-temp-directory
+104
(lambda (dir)
+105
(let* ((store (make-folio-store dir))
+106
(file (project-create! store "Target" "Goal" #f))
+107
(handler (make-tool-task-move-batch store))
+108
(result (handler (dict ids: (list "t-dead" "t-beef")
+109
project: "target"))))
+110
(assert-true (string-contains? result "Moved 0 of 2"))))))
+111
+112
(test "an unknown target project fails every id without moving anything"
+113
(call-with-temp-directory
+114
(lambda (dir)
+115
(let* ((store (make-folio-store dir))
+116
(a (inbox-add! store "Item A" '()))
+117
(handler (make-tool-task-move-batch store))
+118
(result (handler (dict ids: (list (folio-task-id a))
+119
project: "nonexistent"))))
+120
(assert-true (string-contains? result "not found"))
+121
;; put back where it was, not lost
+122
(assert-equal 1 (length (read-inbox store)))
+123
(assert-equal (folio-task-id a)
+124
(folio-task-id (car (read-inbox store))))))))
+125
+126
(test "moves to the inbox when no project is given"
+127
(call-with-temp-directory
+128
(lambda (dir)
+129
(let* ((store (make-folio-store dir))
+130
(file (project-create! store "Source" "Goal" #f))
+131
(a (project-add-task! file "Send back" '()))
+132
(handler (make-tool-task-move-batch store)))
+133
(handler (dict ids: (list (folio-task-id a))))
+134
(assert-equal 1 (length (read-inbox store)))
+135
(assert-equal (folio-task-id a)
+136
(folio-task-id (car (read-inbox store))))))))
+137
+138
(test "an empty id list is not an error"
+139
(call-with-temp-directory
+140
(lambda (dir)
+141
(let* ((store (make-folio-store dir))
+142
(handler (make-tool-task-move-batch store))
+143
(result (handler (dict ids: '() project: "target"))))
+144
(assert-true (string? result))))))
+145
+146
(test "a missing ids argument is not an error"
+147
(call-with-temp-directory
+148
(lambda (dir)
+149
(let* ((store (make-folio-store dir))
+150
(handler (make-tool-task-move-batch store))
+151
(result (handler (dict))))
+152
(assert-true (string? result)))))))