Add folio/task-move-batch for bulk triage
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 callAn 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.
src/folio/tools.sgl | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
test/test-task-move-batch.sgl | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 231 insertions(+), 1 deletion(-)src/folio/tools.sglmodified
make-tool-task-complete-batch make-tool-inbox-triage make-tool-task-move make-tool-task-move-batch move-task-in-store!) (begin (description . "List of task IDs to mark as done (e.g., [\"t-a3f2\", \"t-b1c4\"])"))))) (required . ("ids")))) (define task-move-batch-schema '((type . "object") (properties . ((ids . ((type . "array") (items . ((type . "string"))) (description . "Task IDs to move to a single target (use with 'project')"))) (project . ((type . "string") (description . "Target project for every id in 'ids' (omit to move them to the inbox)"))) (moves . ((type . "array") (items . ((type . "object") (properties . ((id . ((type . "string") (description . "Task ID to move"))) (project . ((type . "string") (description . "Target project (omit for inbox)"))))) (required . ("id")))) (description . "Per-id targets, for fanning one call out across many projects. Takes precedence over 'ids'."))))) (required . ()))) ;; ============================================================ (write-file-string file-path (string-join new-lines "\n"))) (values (length swept) (reverse swept)))))) ;; Accept either a JSON array or a plain list, and treat a missing ;; argument as empty rather than as an error. (define (arg->list v) (cond ((not v) '()) ((array? v) (array->list v)) ((list? v) v) (else (list v)))) ;; Perform one move of a batch and render its result line. ;; ;; Per-id reporting is the point: a batch that swallowed partial failures ;; would be worse than the individual calls it replaces. (define (batch-move-one store id target) (let ((result (move-task-in-store! store id target))) (format " ~a -> ~a: ~a~a" id (or target "inbox") result ;; Ids are stable across moves, so the resulting id is the one ;; asked for. Stated explicitly so a caller never has to ;; re-query to learn where a task landed. (if (string-starts-with? result "Moved") (format " (id: ~a)" id) "")))) (define (make-tool-task-move-batch store) (lambda (args) (let* ((moves-arg (dict-ref args moves: #f)) (entries (if moves-arg ;; Per-id targets: one call fans out across projects. (map (lambda (m) (cons (dict-ref m id: #f) (dict-ref m project: #f))) (arg->list moves-arg)) ;; One shared target for every id. (let ((target (dict-ref args project: #f))) (map (lambda (id) (cons id target)) (arg->list (dict-ref args ids: #f)))))) (results (map (lambda (e) (if (car e) (batch-move-one store (car e) (cdr e)) " (skipped an entry with no id)")) entries))) (if (null? entries) "No tasks to move." ;; Count the successes rather than the attempts: a header ;; reading "Moved 3 task(s)" above three failures would report ;; the opposite of what happened. (string-join (cons (format "Moved ~a of ~a task(s):" (length (filter (lambda (r) (string-contains? r "(id: ")) results)) (length entries)) results) "\n"))))) (define (make-tool-task-complete-batch store) (lambda (args) (let* ((ids (array->list (require-arg args ids: "ids"))) (let* ((ids (arg->list (require-arg args ids: "ids"))) (results (map (lambda (id) (let ((result (complete-task-in-store! store id))) "folio/task-complete-batch" "Mark multiple tasks as done by ID list" task-complete-batch-schema (make-tool-task-complete-batch store)) (mcp-server-register-tool! server "folio/task-move-batch" "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." task-move-batch-schema (make-tool-task-move-batch store)) ;; Queries (mcp-server-register-tool! servertest/test-task-move-batch.sgladded
;;; folio/task-move-batch — bulk triage in one call.;;;;;; Triaging 227 inbox items on 2026-08-02 cost 226 individual MCP round-trips,;;; hand-batched into groups. That cost is a large part of why the inbox went;;; untriaged from April to August: a full triage was something nobody would do;;; routinely. This is a workflow fix, not an optimisation.;;;;;; Partial failures are reported per id rather than swallowed — a batch that;;; hid them would be worse than the individual calls it replaces.(import (sigil test) (sigil struct) (sigil string) (sigil dict) (sigil fs) (folio store) (folio task) (folio inbox) (folio project) (folio tools))(test-group "task-move-batch" (test "moves many ids to one project and reports each" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (a (inbox-add! store "Item A" '())) (b (inbox-add! store "Item B" '())) (c (inbox-add! store "Item C" '())) (handler (make-tool-task-move-batch store)) (result (handler (dict ids: (list (folio-task-id a) (folio-task-id b) (folio-task-id c)) project: "target")))) (assert-true (string-contains? result (folio-task-id a))) (assert-true (string-contains? result (folio-task-id b))) (assert-true (string-contains? result (folio-task-id c))) (assert-equal 0 (length (read-inbox store))) (assert-equal 3 (length (folio-project-tasks (read-project file)))))))) (test "fans out across several projects via {id, project} pairs" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (f1 (project-create! store "One" "Goal" #f)) (f2 (project-create! store "Two" "Goal" #f)) (a (inbox-add! store "Item A" '())) (b (inbox-add! store "Item B" '())) (handler (make-tool-task-move-batch store))) (handler (dict moves: (list (dict id: (folio-task-id a) project: "one") (dict id: (folio-task-id b) project: "two")))) (assert-equal 1 (length (folio-project-tasks (read-project f1)))) (assert-equal 1 (length (folio-project-tasks (read-project f2)))) (assert-equal 0 (length (read-inbox store))))))) (test "a moves entry with no project sends that task to the inbox" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (f1 (project-create! store "One" "Goal" #f)) (src (project-create! store "Source" "Goal" #f)) (a (project-add-task! src "Stay" '())) (b (project-add-task! src "Go home" '())) (handler (make-tool-task-move-batch store))) (handler (dict moves: (list (dict id: (folio-task-id a) project: "one") (dict id: (folio-task-id b))))) (assert-equal 1 (length (folio-project-tasks (read-project f1)))) (assert-equal 1 (length (read-inbox store))) (assert-equal (folio-task-id b) (folio-task-id (car (read-inbox store)))))))) (test "reports the resulting id, which is the id asked for" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (a (inbox-add! store "Item A" '())) (handler (make-tool-task-move-batch store)) (result (handler (dict ids: (list (folio-task-id a)) project: "target"))) (landed (car (folio-project-tasks (read-project file))))) (assert-equal (folio-task-id a) (folio-task-id landed)) (assert-true (string-contains? result (folio-task-id landed))))))) (test "a partial failure is visible per id and does not stop the others" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (a (inbox-add! store "Item A" '())) (handler (make-tool-task-move-batch store)) (result (handler (dict ids: (list (folio-task-id a) "t-dead") project: "target")))) (assert-true (string-contains? result "t-dead")) (assert-true (string-contains? result "not found")) ;; the good one still moved (assert-equal 1 (length (folio-project-tasks (read-project file)))))))) ;; The header is a claim about what happened and must not contradict the ;; lines below it: "Moved 2 task(s)" above two failures reads as success. (test "the header counts successes, not attempts" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Target" "Goal" #f)) (handler (make-tool-task-move-batch store)) (result (handler (dict ids: (list "t-dead" "t-beef") project: "target")))) (assert-true (string-contains? result "Moved 0 of 2")))))) (test "an unknown target project fails every id without moving anything" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (a (inbox-add! store "Item A" '())) (handler (make-tool-task-move-batch store)) (result (handler (dict ids: (list (folio-task-id a)) project: "nonexistent")))) (assert-true (string-contains? result "not found")) ;; put back where it was, not lost (assert-equal 1 (length (read-inbox store))) (assert-equal (folio-task-id a) (folio-task-id (car (read-inbox store)))))))) (test "moves to the inbox when no project is given" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (file (project-create! store "Source" "Goal" #f)) (a (project-add-task! file "Send back" '())) (handler (make-tool-task-move-batch store))) (handler (dict ids: (list (folio-task-id a)))) (assert-equal 1 (length (read-inbox store))) (assert-equal (folio-task-id a) (folio-task-id (car (read-inbox store)))))))) (test "an empty id list is not an error" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (handler (make-tool-task-move-batch store)) (result (handler (dict ids: '() project: "target")))) (assert-true (string? result)))))) (test "a missing ids argument is not an error" (call-with-temp-directory (lambda (dir) (let* ((store (make-folio-store dir)) (handler (make-tool-task-move-batch store)) (result (handler (dict)))) (assert-true (string? result)))))))