From 7c120073a7de96e67a4f51e539c45d2d22d74f81 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 00:43:15 -0400 Subject: feat(routing): wire the wrap-up cross-project router end to end This closes the build half of the wrap-up routing spec: Phases 2 and 4 here, with the engine and discovery already shipped. inbox.org's "File as TODO" disposition now runs route_recommend on each keeper and stamps :ROUTE_CANDIDATE: on strong and weak matches, so the wrap-up router has a candidate set without ever scanning the standing backlog. wrap-it-up.org Step 3 gains the optional router after the inbox sanity check, with the gate-vs-optional split named in the prose: surface the batch with destinations and confidence labels, then go or skip. An empty set stays silent. The go path is mechanical rather than prose-driven: the new route-batch helper lists candidates read-only, and on go extracts each subtree (children ride along, markers stripped, headings promoted), delivers it via inbox-send for provenance, and removes the local copy only after a successful send, rewriting todo.org per send so a crash never strands an already-sent task locally. Overlapping candidate spans (a tagged child inside a tagged parent) are a loud conflict, left in place with a non-zero exit, because routing either span would silently take the other along. A 13-test bats suite covers list/backlog exclusion, empty-set silence, delivery with provenance and children, promotion, drawer pruning, the no-todo.org destination, failed-send recovery with the marker intact, the nested-candidate conflict, and duplicate-marker dedupe. cross-project.md notes the router as a sanctioned cross-project write path. --- .ai/scripts/tests/route-batch.bats | 202 +++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 .ai/scripts/tests/route-batch.bats (limited to '.ai/scripts/tests') diff --git a/.ai/scripts/tests/route-batch.bats b/.ai/scripts/tests/route-batch.bats new file mode 100644 index 0000000..84ded5f --- /dev/null +++ b/.ai/scripts/tests/route-batch.bats @@ -0,0 +1,202 @@ +#!/usr/bin/env bats +# +# Tests for claude-templates/.ai/scripts/route-batch — the wrap-up router's +# mechanical go path (wrapup-routing spec, Phase 4 / D7 / D9). +# +# Contract under test: +# route-batch --list one "\t" line per task +# carrying :ROUTE_CANDIDATE:; silent when none; +# never modifies anything +# route-batch --go per candidate: write the subtree (minus the +# :ROUTE_CANDIDATE: line) as a one-task handoff, +# deliver via inbox-send to the destination's +# inbox/, then remove the subtree from the local +# todo.org. Send failure leaves the task in +# place and exits non-zero. Empty set: no-op. +# +# Strategy: fixture roots under $TEST_DIR hold a source project and two +# destination projects; INBOX_SEND_ROOTS sandboxes inbox-send's discovery to +# them (the same hook inbox-send's own tests use). + +SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/route-batch" + +setup() { + TEST_DIR="$(mktemp -d -t route-batch-bats.XXXXXX)" + ROOTS="$TEST_DIR/roots" + SRC="$ROOTS/srcproj" + mkdir -p "$SRC/.ai" "$SRC/inbox" \ + "$ROOTS/alpha/.ai" "$ROOTS/alpha/inbox" \ + "$ROOTS/beta/.ai" "$ROOTS/beta/inbox" + touch "$ROOTS/alpha/todo.org" # alpha has a todo.org; beta deliberately not + + cat > "$SRC/todo.org" <<'EOF' +* Srcproj Open Work +** TODO [#B] Alpha-bound task :feature: +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:END: +Body line about the alpha work. +*** TODO Sub-task that rides along +** TODO [#C] Purely local task +Local body stays put. +** TODO [#C] Beta-bound task :quick: +:PROPERTIES: +:CREATED: [2026-07-01 Tue] +:ROUTE_CANDIDATE: beta +:END: +Beta body. +EOF + + export INBOX_SEND_ROOTS="$ROOTS" + cd "$SRC" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# ---- --list ------------------------------------------------------------ + +@test "route-batch --list: one destination+heading line per candidate, backlog excluded" { + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [[ "$output" == *"alpha"*"Alpha-bound task"* ]] + [[ "$output" == *"beta"*"Beta-bound task"* ]] + [[ "$output" != *"Purely local task"* ]] +} + +@test "route-batch --list: empty candidate set is silent (exit 0)" { + sed -i '/:ROUTE_CANDIDATE:/d' todo.org + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "route-batch --list: modifies nothing (skip leaves all in place)" { + before="$(cat todo.org)" + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ "$(cat todo.org)" = "$before" ] + [ -z "$(ls "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" 2>/dev/null | grep -v ':')" ] +} + +# ---- --go -------------------------------------------------------------- + +@test "route-batch --go: delivers each candidate to its destination inbox with provenance" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f) + [ -n "$alpha_file" ] + [ -n "$beta_file" ] + grep -q 'Alpha-bound task' "$alpha_file" + grep -q 'Sub-task that rides along' "$alpha_file" # children ride along + grep -q 'Beta-bound task' "$beta_file" + ! grep -q ':ROUTE_CANDIDATE:' "$alpha_file" + ! grep -q ':ROUTE_CANDIDATE:' "$beta_file" +} + +@test "route-batch --go: removes routed subtrees from todo.org, leaves local tasks" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + ! grep -q 'Alpha-bound task' todo.org + ! grep -q 'Sub-task that rides along' todo.org + ! grep -q 'Beta-bound task' todo.org + grep -q 'Purely local task' todo.org + grep -q 'Local body stays put' todo.org +} + +@test "route-batch --go: a kept property drawer survives minus the marker" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f) + grep -q ':CREATED: \[2026-07-01 Tue\]' "$beta_file" +} + +@test "route-batch --go: destination with inbox/ but no todo.org still delivers" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ ! -f "$ROOTS/beta/todo.org" ] + [ -n "$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch --go: empty candidate set is a silent no-op (exit 0)" { + sed -i '/:ROUTE_CANDIDATE:/d' todo.org + before="$(cat todo.org)" + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ -z "$output" ] + [ "$(cat todo.org)" = "$before" ] +} + +@test "route-batch --go: a failed send leaves that task in place, marker intact, and exits non-zero" { + sed -i 's/:ROUTE_CANDIDATE: beta/:ROUTE_CANDIDATE: ghost/' todo.org + run "$SCRIPT" --go + [ "$status" -ne 0 ] + grep -q 'Beta-bound task' todo.org # failed route stays local + grep -q ':ROUTE_CANDIDATE: ghost' todo.org # marker survives so it resurfaces next wrap + ! grep -q 'Alpha-bound task' todo.org # the good route still landed + [ -n "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch --go: handoff headings are promoted to top level" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + grep -q '^\* TODO \[#B\] Alpha-bound task' "$alpha_file" + grep -q '^\*\* TODO Sub-task that rides along' "$alpha_file" +} + +@test "route-batch --go: a drawer emptied by the marker strip is pruned from the handoff" { + run "$SCRIPT" --go + [ "$status" -eq 0 ] + alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f) + ! grep -q ':PROPERTIES:' "$alpha_file" +} + +# ---- Overlapping candidates (nested marker data-loss regression) -------- + +@test "route-batch --go: nested candidates conflict — both stay, bystander survives, exit non-zero" { + cat > todo.org <<'EOF' +* Srcproj Open Work +** TODO [#B] Parent bound for alpha +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:END: +Parent body. +*** TODO Child bound for beta +:PROPERTIES: +:ROUTE_CANDIDATE: beta +:END: +Child body. +** TODO [#C] Innocent bystander task +Bystander body. +EOF + run "$SCRIPT" --go + [ "$status" -ne 0 ] + [[ "$output" == *"CONFLICT"* ]] + grep -q 'Parent bound for alpha' todo.org + grep -q 'Child bound for beta' todo.org + grep -q 'Innocent bystander task' todo.org + grep -q 'Bystander body' todo.org + [ -z "$(find "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ] +} + +@test "route-batch: duplicate identical markers in one drawer dedupe to a single route" { + cat > todo.org <<'EOF' +* Srcproj Open Work +** TODO [#B] Double-tagged for alpha +:PROPERTIES: +:ROUTE_CANDIDATE: alpha +:ROUTE_CANDIDATE: alpha +:END: +Body. +EOF + run "$SCRIPT" --list + [ "$status" -eq 0 ] + [ "$(echo "$output" | grep -c 'Double-tagged')" -eq 1 ] + [[ "$output" != *"CONFLICT"* ]] + run "$SCRIPT" --go + [ "$status" -eq 0 ] + [ "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f | wc -l)" -eq 1 ] +} -- cgit v1.2.3