80 lines
2.8 KiB
Python
80 lines
2.8 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Apply task-block requalifications across repo workplans from a JSON plan.
|
||
|
|
|
||
|
|
Deterministic replacement for agent sessions that re-read wait tasks after a
|
||
|
|
decision resolves. Plan format (list of objects):
|
||
|
|
|
||
|
|
{"id": "RPF-WP-0044-T03", "set": {"status": "todo"},
|
||
|
|
"drop": ["needs_human", "decision_id"], "note": "D2 resolved 2026-10-11"}
|
||
|
|
|
||
|
|
`set` replaces or adds keys (values are written verbatim). `drop` removes keys.
|
||
|
|
`blocking_reason` is dropped automatically when the new status is not `wait`.
|
||
|
|
`note` is appended after the task block as a dated line. Use --dry-run first.
|
||
|
|
"""
|
||
|
|
import argparse, glob, json, os, re, sys
|
||
|
|
|
||
|
|
BLOCK = re.compile(r"```task\n(.*?)```", re.S)
|
||
|
|
|
||
|
|
|
||
|
|
def edit_block(body, change):
|
||
|
|
lines = body.rstrip("\n").split("\n")
|
||
|
|
drop = set(change.get("drop", []))
|
||
|
|
new_status = change.get("set", {}).get("status")
|
||
|
|
if new_status and new_status != "wait":
|
||
|
|
drop |= {"blocking_reason", "needs_human", "blocked_on"}
|
||
|
|
out, seen = [], set()
|
||
|
|
for ln in lines:
|
||
|
|
key = ln.split(":", 1)[0].strip()
|
||
|
|
if key in drop:
|
||
|
|
continue
|
||
|
|
if key in change.get("set", {}):
|
||
|
|
ln = f"{key}: {change['set'][key]}"
|
||
|
|
seen.add(key)
|
||
|
|
out.append(ln)
|
||
|
|
# insert new keys before state_hub_task_id so the writeback field stays last
|
||
|
|
extra = [f"{k}: {v}" for k, v in change.get("set", {}).items() if k not in seen]
|
||
|
|
idx = next((i for i, l in enumerate(out) if l.startswith("state_hub_task_id")), len(out))
|
||
|
|
out[idx:idx] = extra
|
||
|
|
return "\n".join(out) + "\n"
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
ap = argparse.ArgumentParser()
|
||
|
|
ap.add_argument("plan")
|
||
|
|
ap.add_argument("--root", default=os.path.expanduser("~"))
|
||
|
|
ap.add_argument("--dry-run", action="store_true")
|
||
|
|
a = ap.parse_args()
|
||
|
|
plan = {c["id"]: c for c in json.load(open(a.plan))}
|
||
|
|
touched, found = set(), set()
|
||
|
|
for f in glob.glob(os.path.join(a.root, "*/workplans/*.md")):
|
||
|
|
text = open(f, encoding="utf-8").read()
|
||
|
|
|
||
|
|
def repl(m):
|
||
|
|
body = m.group(1)
|
||
|
|
mid = re.search(r"^id:\s*(\S+)", body, re.M)
|
||
|
|
if not mid or mid.group(1) not in plan:
|
||
|
|
return m.group(0)
|
||
|
|
c = plan[mid.group(1)]
|
||
|
|
found.add(c["id"])
|
||
|
|
new = "```task\n" + edit_block(body, c) + "```"
|
||
|
|
if c.get("note"):
|
||
|
|
new += f"\n\n{c['note']}"
|
||
|
|
return new
|
||
|
|
|
||
|
|
new_text = BLOCK.sub(repl, text)
|
||
|
|
if new_text != text:
|
||
|
|
touched.add(f)
|
||
|
|
if not a.dry_run:
|
||
|
|
open(f, "w", encoding="utf-8").write(new_text)
|
||
|
|
for f in sorted(touched):
|
||
|
|
print(("would edit " if a.dry_run else "edited ") + f)
|
||
|
|
missing = set(plan) - found
|
||
|
|
if missing:
|
||
|
|
print("NOT FOUND:", ", ".join(sorted(missing)), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|