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/route-batch | 175 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100755 .ai/scripts/route-batch (limited to '.ai/scripts/route-batch') 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: 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 "\t" 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- 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()) -- cgit v1.2.3