#!/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())