aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 00:43:15 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 00:43:15 -0400
commit7c120073a7de96e67a4f51e539c45d2d22d74f81 (patch)
tree88dbf98445eff382e28f9219437468781940127c /.ai/scripts/tests
parent21639cb395bd363f9406694adebd9a3675bf1096 (diff)
downloadrulesets-7c120073a7de96e67a4f51e539c45d2d22d74f81.tar.gz
rulesets-7c120073a7de96e67a4f51e539c45d2d22d74f81.zip
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: <destination> 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.
Diffstat (limited to '.ai/scripts/tests')
-rw-r--r--.ai/scripts/tests/route-batch.bats202
1 files changed, 202 insertions, 0 deletions
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 "<destination>\t<heading>" 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 ]
+}