Add archive-on-done: stories become the dated record when an epic finishes

- ticket_tree.py archive verb: a done/dropped epic's leaves move to
  .archive/<date>-<slug>/ (dot folders are off every verb's board);
  --purge deletes instead when the record of truth lives elsewhere
  (e.g. stories synced to Jira); satisfied depends_on edges into the
  archived set are dropped; the envelope stays live carrying the
  epic-level covers
- Guards: stored done/dropped required; all leaves finished; live deps
  on non-done archived leaves refuse; sub-epics archive first
- next-id scans dot folders so archived ids are never reissued
- update_ticket returns an archive hint when done lands on an epic;
  the skill offers the archive — never automatic
- write_index extracted for reuse; regression tests in both homes
This commit is contained in:
Brian Madison
2026-08-02 20:15:55 -05:00
parent 677c6bd6d0
commit c1f2010d6e
9 changed files with 407 additions and 19 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ A loosely named target resolves in tiers: exact id/slug/title match (`ticket_tre
## Tree queries
Derived state is never hand-computed: `uv run {skill-root}/scripts/ticket_tree.py <verb> --root {workflow.tickets_output_path}``next-id` before allocating, `index` after any structural write, `validate` after every write (schema, placeholders, dep resolution, cycles — fix what it names before presenting), `list` for the id/title/status/path inventory, `frontier` for "what's workable now," `board` for rollups (computed epic state included), `coverage --require "<ids>"` for the coverage check (`--proposed` pre-gate, before anything is on disk), `graph --mermaid` for the dependency graph, parallel lanes, and critical path. When the user asks to optimize sequencing or dependencies — and as an offer after incept writes — render `graph --mermaid` and walk the lanes with them: false edges, over-serialized independents, the critical path.
Derived state is never hand-computed: `uv run {skill-root}/scripts/ticket_tree.py <verb> --root {workflow.tickets_output_path}``next-id` before allocating, `index` after any structural write, `validate` after every write (schema, placeholders, dep resolution, cycles — fix what it names before presenting), `list` for the id/title/status/path inventory, `frontier` for "what's workable now," `board` for rollups (computed epic state included), `coverage --require "<ids>"` for the coverage check (`--proposed` pre-gate, before anything is on disk), `graph --mermaid` for the dependency graph, parallel lanes, and critical path. When the user asks to optimize sequencing or dependencies — and as an offer after incept writes — render `graph --mermaid` and walk the lanes with them: false edges, over-serialized independents, the critical path. When `done` lands on an epic (the gate returns the hint), offer the archive: `archive --epic KEY-n` moves its stories to the dated `.archive/` record — the envelope stays as the durable layer — or `--purge` removes them when the record of truth lives elsewhere (e.g. synced to Jira). An offer, never automatic.
References: `slice-epics.md` (Route 2) · `incept-stories.md` (Route 3) · `greenfield-guidelines.md` (net-new project, at epic proposal) · `v6-migration.md` (v6 shapes + migration) · type templates in `assets/` via `{workflow.<type>_template}`.
Run `{workflow.on_complete}` if set when we reach a terminal state.
@@ -267,6 +267,85 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual(len(out["lanes"]), n)
self.assertEqual(len(out["critical_path"]), n)
def _finish_alert_rules(self):
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
extra='description: "Rules people manage"\nstatus: done\n'))
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
ticket("ALRT-13", "story", "Rule eval", status="done",
deps="[ALRT-12]", covers="[CAP-5]"))
def test_archive_moves_leaves_and_drops_satisfied_edges(self):
self._finish_alert_rules()
(self.root / "ALRT-45-followup.md").write_text(
ticket("ALRT-45", "task", "Follow-up", deps="[ALRT-12, ALRT-40]"))
code, out = run("archive", "--root", str(self.root),
"--epic", "ALRT-3", "--date", "2026-08-02")
self.assertEqual(code, 0, out)
self.assertEqual(out["archived"], ["ALRT-12", "ALRT-13"])
dest = self.root / ".archive" / "2026-08-02-alert-rules"
self.assertTrue((dest / "ALRT-12-rule-crud.md").is_file())
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
self.assertTrue((self.root / "alert-rules" / "ticket.md").is_file()) # envelope stays
# satisfied edge into the archive dropped; unrelated edge kept
self.assertIn("depends_on: [ALRT-40]",
(self.root / "ALRT-45-followup.md").read_text())
# archived leaves are off the board everywhere
code, out = run("list", "--root", str(self.root))
ids = [r["id"] for r in out["tickets"]]
self.assertNotIn("ALRT-12", ids)
self.assertIn("ALRT-3", ids)
code, out = run("validate", "--root", str(self.root))
self.assertEqual(code, 0, out)
def test_archive_refuses_unfinished(self):
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
self.assertEqual(code, 1)
self.assertIn("not marked done", out["error"])
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
extra='description: "Rules people manage"\nstatus: done\n'))
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
self.assertEqual(code, 1) # ALRT-13 still backlog
self.assertIn("still open", out["error"])
self.assertIn("ALRT-13", out["error"])
def test_archive_refuses_dep_on_dropped_leaf(self):
self._finish_alert_rules()
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
ticket("ALRT-13", "story", "Rule eval", status="dropped"))
(self.root / "ALRT-46-waiting.md").write_text(
ticket("ALRT-46", "task", "Waiting", deps="[ALRT-13]"))
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
self.assertEqual(code, 1)
self.assertIn("dropped", out["error"])
self.assertIn("ALRT-46", out["error"])
def test_archive_purge_deletes(self):
self._finish_alert_rules()
code, out = run("archive", "--root", str(self.root),
"--epic", "ALRT-3", "--purge")
self.assertEqual(code, 0, out)
self.assertEqual(out["purged"], ["ALRT-12", "ALRT-13"])
self.assertIsNone(out["dest"])
self.assertFalse((self.root / ".archive").exists())
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
def test_archived_ids_never_reissued(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "index.md").write_text("---\nkey: NEW\n---\n")
e = root / "one"
e.mkdir()
(e / "ticket.md").write_text(ticket(
"NEW-1", "epic", "One", extra='description: "x"\nstatus: done\n'))
(e / "NEW-2-only.md").write_text(
ticket("NEW-2", "task", "Only", status="done"))
code, out = run("archive", "--root", str(root), "--epic", "NEW-1")
self.assertEqual(code, 0, out)
code, out = run("next-id", "--root", str(root))
self.assertEqual(out["id"], "NEW-3") # NEW-2 lives in .archive, still owns its id
def test_render_monofile_view(self):
out_file = self.root / "epics-and-stories.md"
code, out = run("render", "--root", str(self.root), "--out", str(out_file))
@@ -115,6 +115,7 @@ class UpdateTicketTests(unittest.TestCase):
code, out = run("--path", str(self.epic), "--set", "status=done")
self.assertEqual(code, 0)
self.assertIn("status: done", self.epic.read_text())
self.assertIn("archive", out.get("hint", "")) # done → archive offer surfaces
code, out = run("--path", str(self.epic), "--set", "status=dropped")
self.assertEqual(code, 0)
self.assertIn("status: dropped", self.epic.read_text())
@@ -17,6 +17,9 @@ Everything here is derived by scan — nothing is stored. Verbs:
coverage covers: vs an inventory uv run ticket_tree.py coverage --root R [--require "CAP-1,FR-2"] [--proposed "CAP-3"]
render single epics-and-stories uv run ticket_tree.py render --root R --out FILE
markdown view (generated; the tree stays the source of truth)
archive move a done epic's leaves uv run ticket_tree.py archive --root R --epic KEY-n [--purge] [--date YYYY-MM-DD]
to .archive/<date>-<slug>/ as the dated record (--purge deletes
instead, for stories whose record lives elsewhere, e.g. Jira)
Output is one JSON object per call. Stdlib only. Dual-homed: canonical at
src/scripts/ (installed to {project-root}/_bmad/scripts/ for any skill or
@@ -25,6 +28,7 @@ Keep both in sync.
"""
import argparse
import datetime
import json
import os
import re
@@ -94,16 +98,20 @@ def parse_frontmatter(text):
return None
def scan(root):
def scan(root, include_dot=False):
"""Return (tickets, by_id, problems). Each ticket: frontmatter + _path + _rel.
problems: files that claim to be tickets but can't be trusted — unreadable,
unparseable frontmatter, missing id/type, or a duplicate id (first file wins)."""
unparseable frontmatter, missing id/type, or a duplicate id (first file wins).
Dot folders (.archive/, .git/) are off the board unless include_dot."""
tickets = []
by_id = {}
problems = []
for p in sorted(root.rglob("*.md")):
if p.name == "index.md":
continue
rel_parts = p.relative_to(root).parts
if not include_dot and any(part.startswith(".") for part in rel_parts):
continue
rel = p.relative_to(root).as_posix()
try:
text = p.read_text(encoding="utf-8")
@@ -216,7 +224,8 @@ def id_num(ticket_id):
def cmd_next_id(root, args):
key = resolve_key(root, args.key)
tickets, _, problems = scan(root)
# include_dot: archived tickets still own their ids — never reissue them.
tickets, _, problems = scan(root, include_dot=True)
nums = [id_num(t["id"]) for t in tickets
if re.fullmatch(re.escape(key) + r"-\d+", str(t["id"]), re.IGNORECASE)]
nxt = (max(nums) + 1) if nums else 1
@@ -226,8 +235,7 @@ def cmd_next_id(root, args):
print(json.dumps(out))
def cmd_index(root, args):
key = resolve_key(root, args.key)
def write_index(root, key):
tickets, _, problems = scan(root)
def entry_lines(t, depth):
@@ -253,7 +261,13 @@ def cmd_index(root, args):
body.extend(entry_lines(lf, 0))
body.append("")
atomic_write(root / "index.md", "\n".join(body))
out = {"ok": True, "file": str(root / "index.md"), "entries": len(tickets)}
return len(tickets), problems
def cmd_index(root, args):
key = resolve_key(root, args.key)
entries, problems = write_index(root, key)
out = {"ok": True, "file": str(root / "index.md"), "entries": entries}
if problems:
out["warnings"] = problems
print(json.dumps(out))
@@ -456,6 +470,96 @@ def cmd_render(root, args):
print(json.dumps(result))
def _rewrite_depends_on(path, new_deps):
"""Replace a ticket's depends_on entry (inline or block form) with an
inline list. Only used by archive to drop satisfied edges."""
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
new_line = "depends_on: [" + ", ".join(str(d) for d in new_deps) + "]\n"
for i, line in enumerate(lines):
if re.match(r"^depends_on:", line):
j = i + 1
if line.split(":", 1)[1].strip() == "":
while j < len(lines) and re.match(r"^\s+-\s+", lines[j]):
j += 1
lines[i:j] = [new_line]
break
atomic_write(path, "".join(lines))
def cmd_archive(root, args):
"""Move a finished epic's leaves to .archive/<date>-<slug>/ — the dated,
off-board record of a moment. --purge deletes instead, for stories whose
record of truth lives elsewhere (e.g. synced to Jira). The envelope stays:
it is the durable thin layer carrying the epic-level covers."""
tickets, by_id, problems = scan(root)
epic = by_id.get(args.epic)
if epic is None or epic.get("type") != "epic":
fail(f"no epic with id '{args.epic}' in the tree")
if epic.get("status") not in (DONE, DROPPED):
fail(f"epic {args.epic} is not marked done or dropped — archive is for "
"finished records; store the intentional status first (update_ticket.py)")
kids = children_of(epic, tickets)
if any(k["type"] == "epic" for k in kids):
fail(f"epic {args.epic} contains sub-epics — archive those first")
leaves = [k for k in kids if k["type"] in LEAF_TYPES]
if not leaves:
fail(f"epic {args.epic} has no leaves to archive")
still_open = sorted(str(k["id"]) for k in leaves
if k.get("status") not in (DONE, DROPPED))
if still_open:
fail("archive is for finished records — still open under "
f"{args.epic}: {', '.join(still_open)}")
leaf_ids = {str(k["id"]): k for k in leaves}
done_ids = {i for i, k in leaf_ids.items() if k.get("status") == DONE}
blockers, edge_drops = [], {}
for t in tickets:
tid = str(t["id"])
if tid in leaf_ids or t is epic:
continue
refs = [str(d) for d in as_list(t, "depends_on") if str(d) in leaf_ids]
if not refs:
continue
undone = [r for r in refs if r not in done_ids]
if undone:
blockers.append(f"{tid} depends on dropped {', '.join(undone)}")
else:
edge_drops[tid] = refs
if blockers:
fail("live tickets depend on non-done leaves in the archive set — resolve "
"these edges first: " + "; ".join(blockers))
date = args.date or datetime.date.today().isoformat()
if not DATE_RE.match(date):
fail(f"--date must be YYYY-MM-DD, got '{date}'")
dest = None
if args.purge:
for k in leaves:
k["_path"].unlink()
else:
dest = root / ".archive" / f"{date}-{epic['_path'].parent.name}"
dest.mkdir(parents=True, exist_ok=True)
for k in leaves:
os.replace(k["_path"], dest / k["_path"].name)
for tid, refs in edge_drops.items():
t = by_id[tid]
kept = [str(d) for d in as_list(t, "depends_on") if str(d) not in refs]
_rewrite_depends_on(t["_path"], kept)
key = read_index_key(root)
if key:
write_index(root, key)
out = {"ok": True, "epic": args.epic,
("purged" if args.purge else "archived"): sorted(leaf_ids),
"dest": str(dest) if dest else None,
"edges_dropped": edge_drops}
if problems:
out["warnings"] = problems
print(json.dumps(out))
def _placeholderish(v):
return isinstance(v, str) and ("[" in v or "]" in v or "YYYY" in v)
@@ -586,7 +690,7 @@ def main():
ap = argparse.ArgumentParser(description=__doc__)
sub = ap.add_subparsers(dest="verb", required=True)
for verb in ("next-id", "index", "validate", "list", "frontier", "board",
"coverage", "graph", "render"):
"coverage", "graph", "render", "archive"):
p = sub.add_parser(verb)
p.add_argument("--root", required=True, help="ticket tree root")
if verb in ("next-id", "index"):
@@ -602,6 +706,11 @@ def main():
if verb == "render":
p.add_argument("--out", required=True,
help="path for the generated epics-and-stories markdown")
if verb == "archive":
p.add_argument("--epic", required=True, help="id of the done/dropped epic")
p.add_argument("--purge", action="store_true",
help="delete instead of moving (record lives elsewhere, e.g. Jira)")
p.add_argument("--date", help="archive folder date, defaults to today")
args = ap.parse_args()
root = Path(args.root)
if not root.is_dir():
@@ -610,7 +719,7 @@ def main():
{"next-id": cmd_next_id, "index": cmd_index, "validate": cmd_validate,
"list": cmd_list, "frontier": cmd_frontier, "board": cmd_board,
"coverage": cmd_coverage, "graph": cmd_graph,
"render": cmd_render}[args.verb](root, args)
"render": cmd_render, "archive": cmd_archive}[args.verb](root, args)
except SystemExit:
raise
except Exception as e: # keep the JSON output contract on fs/encoding errors
@@ -387,7 +387,12 @@ def main():
os.unlink(tmp)
raise
print(json.dumps({"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}))
out = {"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}
if ticket_type == "epic" and changes.get("status", {}).get("to") == "done":
out["hint"] = ("epic marked done — offer to archive its stories to the dated "
"record: ticket_tree.py archive --epic <id> (--purge if the "
"record lives elsewhere, e.g. Jira)")
print(json.dumps(out))
if __name__ == "__main__":
+79
View File
@@ -267,6 +267,85 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual(len(out["lanes"]), n)
self.assertEqual(len(out["critical_path"]), n)
def _finish_alert_rules(self):
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
extra='description: "Rules people manage"\nstatus: done\n'))
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
ticket("ALRT-13", "story", "Rule eval", status="done",
deps="[ALRT-12]", covers="[CAP-5]"))
def test_archive_moves_leaves_and_drops_satisfied_edges(self):
self._finish_alert_rules()
(self.root / "ALRT-45-followup.md").write_text(
ticket("ALRT-45", "task", "Follow-up", deps="[ALRT-12, ALRT-40]"))
code, out = run("archive", "--root", str(self.root),
"--epic", "ALRT-3", "--date", "2026-08-02")
self.assertEqual(code, 0, out)
self.assertEqual(out["archived"], ["ALRT-12", "ALRT-13"])
dest = self.root / ".archive" / "2026-08-02-alert-rules"
self.assertTrue((dest / "ALRT-12-rule-crud.md").is_file())
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
self.assertTrue((self.root / "alert-rules" / "ticket.md").is_file()) # envelope stays
# satisfied edge into the archive dropped; unrelated edge kept
self.assertIn("depends_on: [ALRT-40]",
(self.root / "ALRT-45-followup.md").read_text())
# archived leaves are off the board everywhere
code, out = run("list", "--root", str(self.root))
ids = [r["id"] for r in out["tickets"]]
self.assertNotIn("ALRT-12", ids)
self.assertIn("ALRT-3", ids)
code, out = run("validate", "--root", str(self.root))
self.assertEqual(code, 0, out)
def test_archive_refuses_unfinished(self):
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
self.assertEqual(code, 1)
self.assertIn("not marked done", out["error"])
(self.root / "alert-rules" / "ticket.md").write_text(ticket(
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
extra='description: "Rules people manage"\nstatus: done\n'))
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
self.assertEqual(code, 1) # ALRT-13 still backlog
self.assertIn("still open", out["error"])
self.assertIn("ALRT-13", out["error"])
def test_archive_refuses_dep_on_dropped_leaf(self):
self._finish_alert_rules()
(self.root / "alert-rules" / "ALRT-13-rule-eval.md").write_text(
ticket("ALRT-13", "story", "Rule eval", status="dropped"))
(self.root / "ALRT-46-waiting.md").write_text(
ticket("ALRT-46", "task", "Waiting", deps="[ALRT-13]"))
code, out = run("archive", "--root", str(self.root), "--epic", "ALRT-3")
self.assertEqual(code, 1)
self.assertIn("dropped", out["error"])
self.assertIn("ALRT-46", out["error"])
def test_archive_purge_deletes(self):
self._finish_alert_rules()
code, out = run("archive", "--root", str(self.root),
"--epic", "ALRT-3", "--purge")
self.assertEqual(code, 0, out)
self.assertEqual(out["purged"], ["ALRT-12", "ALRT-13"])
self.assertIsNone(out["dest"])
self.assertFalse((self.root / ".archive").exists())
self.assertFalse((self.root / "alert-rules" / "ALRT-12-rule-crud.md").exists())
def test_archived_ids_never_reissued(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "index.md").write_text("---\nkey: NEW\n---\n")
e = root / "one"
e.mkdir()
(e / "ticket.md").write_text(ticket(
"NEW-1", "epic", "One", extra='description: "x"\nstatus: done\n'))
(e / "NEW-2-only.md").write_text(
ticket("NEW-2", "task", "Only", status="done"))
code, out = run("archive", "--root", str(root), "--epic", "NEW-1")
self.assertEqual(code, 0, out)
code, out = run("next-id", "--root", str(root))
self.assertEqual(out["id"], "NEW-3") # NEW-2 lives in .archive, still owns its id
def test_render_monofile_view(self):
out_file = self.root / "epics-and-stories.md"
code, out = run("render", "--root", str(self.root), "--out", str(out_file))
+1
View File
@@ -115,6 +115,7 @@ class UpdateTicketTests(unittest.TestCase):
code, out = run("--path", str(self.epic), "--set", "status=done")
self.assertEqual(code, 0)
self.assertIn("status: done", self.epic.read_text())
self.assertIn("archive", out.get("hint", "")) # done → archive offer surfaces
code, out = run("--path", str(self.epic), "--set", "status=dropped")
self.assertEqual(code, 0)
self.assertIn("status: dropped", self.epic.read_text())
+117 -8
View File
@@ -17,6 +17,9 @@ Everything here is derived by scan — nothing is stored. Verbs:
coverage covers: vs an inventory uv run ticket_tree.py coverage --root R [--require "CAP-1,FR-2"] [--proposed "CAP-3"]
render single epics-and-stories uv run ticket_tree.py render --root R --out FILE
markdown view (generated; the tree stays the source of truth)
archive move a done epic's leaves uv run ticket_tree.py archive --root R --epic KEY-n [--purge] [--date YYYY-MM-DD]
to .archive/<date>-<slug>/ as the dated record (--purge deletes
instead, for stories whose record lives elsewhere, e.g. Jira)
Output is one JSON object per call. Stdlib only. Dual-homed: canonical at
src/scripts/ (installed to {project-root}/_bmad/scripts/ for any skill or
@@ -25,6 +28,7 @@ Keep both in sync.
"""
import argparse
import datetime
import json
import os
import re
@@ -94,16 +98,20 @@ def parse_frontmatter(text):
return None
def scan(root):
def scan(root, include_dot=False):
"""Return (tickets, by_id, problems). Each ticket: frontmatter + _path + _rel.
problems: files that claim to be tickets but can't be trusted — unreadable,
unparseable frontmatter, missing id/type, or a duplicate id (first file wins)."""
unparseable frontmatter, missing id/type, or a duplicate id (first file wins).
Dot folders (.archive/, .git/) are off the board unless include_dot."""
tickets = []
by_id = {}
problems = []
for p in sorted(root.rglob("*.md")):
if p.name == "index.md":
continue
rel_parts = p.relative_to(root).parts
if not include_dot and any(part.startswith(".") for part in rel_parts):
continue
rel = p.relative_to(root).as_posix()
try:
text = p.read_text(encoding="utf-8")
@@ -216,7 +224,8 @@ def id_num(ticket_id):
def cmd_next_id(root, args):
key = resolve_key(root, args.key)
tickets, _, problems = scan(root)
# include_dot: archived tickets still own their ids — never reissue them.
tickets, _, problems = scan(root, include_dot=True)
nums = [id_num(t["id"]) for t in tickets
if re.fullmatch(re.escape(key) + r"-\d+", str(t["id"]), re.IGNORECASE)]
nxt = (max(nums) + 1) if nums else 1
@@ -226,8 +235,7 @@ def cmd_next_id(root, args):
print(json.dumps(out))
def cmd_index(root, args):
key = resolve_key(root, args.key)
def write_index(root, key):
tickets, _, problems = scan(root)
def entry_lines(t, depth):
@@ -253,7 +261,13 @@ def cmd_index(root, args):
body.extend(entry_lines(lf, 0))
body.append("")
atomic_write(root / "index.md", "\n".join(body))
out = {"ok": True, "file": str(root / "index.md"), "entries": len(tickets)}
return len(tickets), problems
def cmd_index(root, args):
key = resolve_key(root, args.key)
entries, problems = write_index(root, key)
out = {"ok": True, "file": str(root / "index.md"), "entries": entries}
if problems:
out["warnings"] = problems
print(json.dumps(out))
@@ -456,6 +470,96 @@ def cmd_render(root, args):
print(json.dumps(result))
def _rewrite_depends_on(path, new_deps):
"""Replace a ticket's depends_on entry (inline or block form) with an
inline list. Only used by archive to drop satisfied edges."""
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
new_line = "depends_on: [" + ", ".join(str(d) for d in new_deps) + "]\n"
for i, line in enumerate(lines):
if re.match(r"^depends_on:", line):
j = i + 1
if line.split(":", 1)[1].strip() == "":
while j < len(lines) and re.match(r"^\s+-\s+", lines[j]):
j += 1
lines[i:j] = [new_line]
break
atomic_write(path, "".join(lines))
def cmd_archive(root, args):
"""Move a finished epic's leaves to .archive/<date>-<slug>/ — the dated,
off-board record of a moment. --purge deletes instead, for stories whose
record of truth lives elsewhere (e.g. synced to Jira). The envelope stays:
it is the durable thin layer carrying the epic-level covers."""
tickets, by_id, problems = scan(root)
epic = by_id.get(args.epic)
if epic is None or epic.get("type") != "epic":
fail(f"no epic with id '{args.epic}' in the tree")
if epic.get("status") not in (DONE, DROPPED):
fail(f"epic {args.epic} is not marked done or dropped — archive is for "
"finished records; store the intentional status first (update_ticket.py)")
kids = children_of(epic, tickets)
if any(k["type"] == "epic" for k in kids):
fail(f"epic {args.epic} contains sub-epics — archive those first")
leaves = [k for k in kids if k["type"] in LEAF_TYPES]
if not leaves:
fail(f"epic {args.epic} has no leaves to archive")
still_open = sorted(str(k["id"]) for k in leaves
if k.get("status") not in (DONE, DROPPED))
if still_open:
fail("archive is for finished records — still open under "
f"{args.epic}: {', '.join(still_open)}")
leaf_ids = {str(k["id"]): k for k in leaves}
done_ids = {i for i, k in leaf_ids.items() if k.get("status") == DONE}
blockers, edge_drops = [], {}
for t in tickets:
tid = str(t["id"])
if tid in leaf_ids or t is epic:
continue
refs = [str(d) for d in as_list(t, "depends_on") if str(d) in leaf_ids]
if not refs:
continue
undone = [r for r in refs if r not in done_ids]
if undone:
blockers.append(f"{tid} depends on dropped {', '.join(undone)}")
else:
edge_drops[tid] = refs
if blockers:
fail("live tickets depend on non-done leaves in the archive set — resolve "
"these edges first: " + "; ".join(blockers))
date = args.date or datetime.date.today().isoformat()
if not DATE_RE.match(date):
fail(f"--date must be YYYY-MM-DD, got '{date}'")
dest = None
if args.purge:
for k in leaves:
k["_path"].unlink()
else:
dest = root / ".archive" / f"{date}-{epic['_path'].parent.name}"
dest.mkdir(parents=True, exist_ok=True)
for k in leaves:
os.replace(k["_path"], dest / k["_path"].name)
for tid, refs in edge_drops.items():
t = by_id[tid]
kept = [str(d) for d in as_list(t, "depends_on") if str(d) not in refs]
_rewrite_depends_on(t["_path"], kept)
key = read_index_key(root)
if key:
write_index(root, key)
out = {"ok": True, "epic": args.epic,
("purged" if args.purge else "archived"): sorted(leaf_ids),
"dest": str(dest) if dest else None,
"edges_dropped": edge_drops}
if problems:
out["warnings"] = problems
print(json.dumps(out))
def _placeholderish(v):
return isinstance(v, str) and ("[" in v or "]" in v or "YYYY" in v)
@@ -586,7 +690,7 @@ def main():
ap = argparse.ArgumentParser(description=__doc__)
sub = ap.add_subparsers(dest="verb", required=True)
for verb in ("next-id", "index", "validate", "list", "frontier", "board",
"coverage", "graph", "render"):
"coverage", "graph", "render", "archive"):
p = sub.add_parser(verb)
p.add_argument("--root", required=True, help="ticket tree root")
if verb in ("next-id", "index"):
@@ -602,6 +706,11 @@ def main():
if verb == "render":
p.add_argument("--out", required=True,
help="path for the generated epics-and-stories markdown")
if verb == "archive":
p.add_argument("--epic", required=True, help="id of the done/dropped epic")
p.add_argument("--purge", action="store_true",
help="delete instead of moving (record lives elsewhere, e.g. Jira)")
p.add_argument("--date", help="archive folder date, defaults to today")
args = ap.parse_args()
root = Path(args.root)
if not root.is_dir():
@@ -610,7 +719,7 @@ def main():
{"next-id": cmd_next_id, "index": cmd_index, "validate": cmd_validate,
"list": cmd_list, "frontier": cmd_frontier, "board": cmd_board,
"coverage": cmd_coverage, "graph": cmd_graph,
"render": cmd_render}[args.verb](root, args)
"render": cmd_render, "archive": cmd_archive}[args.verb](root, args)
except SystemExit:
raise
except Exception as e: # keep the JSON output contract on fs/encoding errors
+6 -1
View File
@@ -387,7 +387,12 @@ def main():
os.unlink(tmp)
raise
print(json.dumps({"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}))
out = {"ok": True, "file": str(path), "changes": changes, "unchanged": unchanged}
if ticket_type == "epic" and changes.get("status", {}).get("to") == "done":
out["hint"] = ("epic marked done — offer to archive its stories to the dated "
"record: ticket_tree.py archive --epic <id> (--purge if the "
"record lives elsewhere, e.g. Jira)")
print(json.dumps(out))
if __name__ == "__main__":