diff options
Diffstat (limited to '.ai/scripts')
| -rwxr-xr-x | .ai/scripts/route-batch | 175 | ||||
| -rw-r--r-- | .ai/scripts/tests/route-batch.bats | 202 |
2 files changed, 377 insertions, 0 deletions
diff --git a/.ai/scripts/route-batch b/.ai/scripts/route-batch new file mode 100755 index 0000000..8f27d19 --- /dev/null +++ b/.ai/scripts/route-batch @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""route-batch — the wrap-up router's mechanical go path. + +The wrap-up cross-project router (wrap-it-up.org Step 3; wrapup-routing spec +D7/D8/D9) surfaces the local tasks that inbox process mode stamped with +:ROUTE_CANDIDATE: <destination> at file time, and on "go" delivers each to its +destination project's inbox. This script does the mechanical half so the +subtree surgery is deterministic: + + route-batch --list [--todo todo.org] + One "<destination>\t<heading>" line per :ROUTE_CANDIDATE:-tagged task. + Silent with exit 0 when there are no candidates (the workflow's + empty-set-equals-zero-interaction rule). Read-only. + + route-batch --go [--todo todo.org] + For each candidate, bottom-up: extract the task's whole subtree + (children ride along), drop the :ROUTE_CANDIDATE: line (and the + property drawer if that leaves it empty), promote the subtree so its + top heading is level 1, write it to a temp file, and deliver it via + the sibling inbox-send.py to the destination's inbox/ (one file per + task, from-<source> provenance stamped by inbox-send). Only after a + successful send is the subtree removed from the local todo.org — a + failed send leaves that task in place, is reported, and the run exits + non-zero after attempting the rest. + +The candidate set is exactly the tagged tasks — never the standing backlog. +Discovery, roots, and the source-project name all come from inbox-send.py +(INBOX_SEND_ROOTS sandboxes it in tests). The reject-from-another-project +flow in inbox process mode is the mis-route recovery; that path is why +removing the local source after a successful send is safe. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +HEADING_RE = re.compile(r"^(\*+)\s+(.*)$") +MARKER_RE = re.compile(r"^\s*:ROUTE_CANDIDATE:\s+(\S+)\s*$") + + +def find_candidates(lines): + """[(heading_idx, end_idx, marker_idx, destination, heading_text)] — + end_idx is one past the subtree's last line.""" + candidates = [] + for i, line in enumerate(lines): + m = MARKER_RE.match(line) + if not m: + continue + head_idx = None + for j in range(i, -1, -1): + hm = HEADING_RE.match(lines[j]) + if hm: + head_idx = j + level = len(hm.group(1)) + heading = hm.group(2) + break + if head_idx is None: + continue + end = len(lines) + for k in range(head_idx + 1, len(lines)): + km = HEADING_RE.match(lines[k]) + if km and len(km.group(1)) <= level: + end = k + break + candidates.append((head_idx, end, i, m.group(1), heading)) + return candidates + + +def extract_handoff(lines, head_idx, end): + """The subtree as handoff text: every :ROUTE_CANDIDATE: line dropped + (a marker is meaningless at the destination), empty drawers pruned, + headings promoted so the task is level 1.""" + sub = [l for l in lines[head_idx:end] if not MARKER_RE.match(l)] + + pruned = [] + i = 0 + while i < len(sub): + if sub[i].strip() == ":PROPERTIES:" and i + 1 < len(sub) and sub[i + 1].strip() == ":END:": + i += 2 + continue + pruned.append(sub[i]) + i += 1 + + shift = len(HEADING_RE.match(pruned[0]).group(1)) - 1 + if shift > 0: + pruned = [l[shift:] if HEADING_RE.match(l) else l for l in pruned] + return "\n".join(pruned).rstrip() + "\n" + + +def send(destination, handoff_text, slug): + inbox_send = Path(__file__).with_name("inbox-send.py") + with tempfile.NamedTemporaryFile( + "w", suffix=".org", prefix=f"route-{slug}-", delete=False, encoding="utf-8" + ) as tf: + tf.write(handoff_text) + tmp = tf.name + try: + result = subprocess.run( + [sys.executable, str(inbox_send), destination, "--file", tmp], + capture_output=True, text=True, + ) + return result.returncode == 0, (result.stderr or result.stdout).strip() + finally: + os.unlink(tmp) + + +def main(): + ap = argparse.ArgumentParser(prog="route-batch") + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--list", action="store_true", dest="list_mode") + mode.add_argument("--go", action="store_true") + ap.add_argument("--todo", default="todo.org") + args = ap.parse_args() + + todo_path = Path(args.todo) + if not todo_path.is_file(): + return 0 # no todo file, no candidates + lines = todo_path.read_text(encoding="utf-8").splitlines() + candidates = find_candidates(lines) + + # Two markers in one task's drawer are one candidate, not two: same span + + # same destination dedupes. Everything else that overlaps — a tagged child + # inside a tagged parent, one task tagged for two destinations — is a + # conflict: routing either span would silently take the other (or, with a + # stale end index, a bystander task) along. Conflicts are left in place + # and reported; the human untangles which project the pieces belong to. + deduped = [] + for cand in candidates: + if not any(c[0] == cand[0] and c[1] == cand[1] and c[3] == cand[3] for c in deduped): + deduped.append(cand) + conflicted = set() + for a in deduped: + for b in deduped: + if a is not b and a[0] <= b[0] and b[1] <= a[1]: + conflicted.add(a) + conflicted.add(b) + routable = [c for c in deduped if c not in conflicted] + + if not deduped: + return 0 + + if args.list_mode: + for _h, _e, _m, dest, heading in deduped: + flag = "\tCONFLICT (overlapping candidates — resolve by hand)" if (_h, _e, _m, dest, heading) in conflicted else "" + print(f"{dest}\t{heading}{flag}") + return 0 + + failures = 0 + for _h, _e, _m, dest, heading in sorted(conflicted): + failures += 1 + print(f"CONFLICT: {dest}\t{heading}\t(overlapping candidate subtrees — left in place, resolve by hand)") + + # Bottom-up so earlier indices stay valid as subtrees are removed; the + # file is rewritten after every successful send so a crash mid-run never + # leaves an already-sent task still present locally. + for head_idx, end, _marker_idx, dest, heading in sorted(routable, reverse=True): + handoff = extract_handoff(lines, head_idx, end) + slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "task" + ok, detail = send(dest, handoff, slug) + if ok: + del lines[head_idx:end] + todo_path.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8") + print(f"routed: {dest}\t{heading}") + else: + failures += 1 + print(f"FAILED: {dest}\t{heading}\t({detail})") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) 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 ] +} |
