From a6347aad4324849d6b68a6214f500679d057e0e1 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 13 Jul 2026 17:47:00 +0100 Subject: [PATCH] feat(lifecycle): add `record` command for go-forward ship completion (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lifecycle.py record [--date] [--prompt] — writes ONE dated record complete///.md from its just-appended complete.md entry, folding + removing the active/ prompt. Keeps complete.md (the ledger) and the dated records paired 1:1 by slug; check already enforces the pairing. This is the go-forward hook so NEW completions land as rich dated records, matching the historical split. Verified: dry-run + apply round-trip, check green. Co-Authored-By: Claude Opus 4.8 --- scripts/lifecycle.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/scripts/lifecycle.py b/scripts/lifecycle.py index db851c7f..87a119e0 100644 --- a/scripts/lifecycle.py +++ b/scripts/lifecycle.py @@ -211,6 +211,68 @@ def cmd_split_complete(args) -> int: return 0 +# --------------------------------------------------------------------------- # +# record (single-entry: the go-forward ship_* hook) +# --------------------------------------------------------------------------- # +def cmd_record(args) -> int: + """Write ONE dated record from its complete.md entry, keeping complete.md and + the record paired 1:1 by slug. Called by ship_library/ship_workspace after the + rich entry is appended to complete.md, so a new completion lands as a dated + record (not just a complete.md append + a thin active/ prompt).""" + entries = parse_complete_md() + slug = args.slug + entry = entries.get(slug) + if entry is None: # tolerate kebab/space variants + want = safe_name(slug) + cand = [s for s in entries if safe_name(s) == want] + if cand: + slug, entry = cand[0], entries[cand[0]] + if entry is None: + print(f"lifecycle record: no complete.md entry for {args.slug!r} " + f"(append it first)", file=sys.stderr) + return 1 + date = entry["date"] + if args.date: + m = re.match(r"(\d{4})-(\d{2})-(\d{2})", args.date) + if not m: + print(f"lifecycle record: bad --date {args.date!r}", file=sys.stderr) + return 1 + date = (m.group(1), m.group(2)) + + dest = complete_bucket(date) / f"{safe_name(slug)}.md" + body = "\n".join(entry["lines"]).rstrip() + "\n" + # fold the original active/ prompt (explicit --prompt, else guess from slug) + prompt = None + if args.prompt: + p = ACTIVE_DIR / args.prompt + if p.exists(): + prompt = p + if prompt is None: + guess = ACTIVE_DIR / f"{safe_name(slug).replace('-', '_')}.md" + if guess.exists(): + prompt = guess + if prompt is not None: + body += "\n## Original prompt\n\n" + prompt.read_text(errors="replace") + + print(f"record: {dest.relative_to(ROOT)}" + + (f" (+folds active/{prompt.name})" if prompt else "")) + if not args.apply: + print("(dry run; pass --apply)") + return 0 + import subprocess + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body) + subprocess.run(["git", "-C", str(ROOT), "add", str(dest)], + capture_output=True, text=True) + if prompt is not None: + r = subprocess.run(["git", "-C", str(ROOT), "rm", "-q", str(prompt)], + capture_output=True, text=True) + if r.returncode != 0: + prompt.unlink(missing_ok=True) + return 0 + + # --------------------------------------------------------------------------- # # migrate (legacy issued/ -> active/ was a wholesale rename in PR-A; here we # classify those files into still-active vs complete) @@ -327,6 +389,13 @@ def main() -> int: mg.add_argument("--apply", action="store_true") mg.set_defaults(func=cmd_migrate) + r = sub.add_parser("record", help="write one dated record from its complete.md entry (ship_* hook)") + r.add_argument("slug", help="the complete.md heading slug for the shipped task") + r.add_argument("--date", help="completion date YYYY-MM-DD (else from complete.md)") + r.add_argument("--prompt", help="active/ prompt filename to fold + remove") + r.add_argument("--apply", action="store_true") + r.set_defaults(func=cmd_record) + c = sub.add_parser("check", help="drift guard (non-zero exit on drift)") c.set_defaults(func=cmd_check)