diff --git a/README.md b/README.md index 35b01c6..2292f15 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ flowchart TB - For tracks where you don't want to bother curating at all, set `next_up_auto: true` in the track's frontmatter — `brief` will then derive the list live each invocation, ignoring whatever's stored. - **Ranking presets** — when `next_up_auto: true` is on, the default ranking is `flow` (milestone → dependency → priority → recency). Override per-track with `set-next-up --preset=`, or set `next_up_default: ` in your config for a global fallback. Named presets: `flow` (the default), `priority-driven` (priority first, no milestone bias — good for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work). Custom criterion order: `set-next-up --order=aging,priority,dependency`. Clear a track's override with `--clear`. Toggle auto-derivation itself with `--auto=on|off` (no hand-editing frontmatter required). - **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `dedupe-tiers` (report-only) + `duplicates` in sequence to keep status icons, GitHub labels, tier dedup, and issue-dedup state honest. + - ⚠️ **Keep rationale in the body, not in frontmatter comments.** Frontmatter is rewritten by these commands and YAML comments cannot survive the write (#491). `lift-rationale` migrates existing ones. > **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md ` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly. @@ -533,6 +534,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h | `hygiene [--repo=]` | Weekly all-in-one: `refresh-md` + `reconcile` + `dedupe-tiers` (report-only) + `duplicates`. With `--repo=`, steps 1–3 scope to that repo and the global `duplicates` step is skipped. | | `doctor [--json] [--fix]` | Detect config drift: a renamed local folder or GitHub repo that `config.yml` no longer matches, a non-git local path, duplicate entries, an invalid/missing `notes_root`, an orphaned notes folder, or a stale per-track `github.repo`. Run this right after any rename/move. `--fix` corrects only the two mechanically-safe cases (a GitHub-confirmed rename, a stale track slug) and always re-scans afterward. `--json` for machine output. | | `dedupe-tiers [--repo=] [--apply]` | Remove private track copies that a shared twin in a repo's `.work-plan/` supersedes (#359). When a track is promoted to the shared tier, its private original under `notes_root` is sometimes left behind (bulk/manual promotion, or a failed unlink during `push-track`) — `discover_tracks` then warns `exists in both shared and private` on every run with no cleanup path. This removes the safe orphans and **refuses** any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is `issue_refs(private) ⊆ issue_refs(shared)`). Covers active and archived tiers. Default is a **dry-run report**; `--apply` deletes (auto-committed to `notes_root`, so undoable via `notes-vcs undo`). `--repo=` scopes to one repo. | +| `lift-rationale [--repo=] [--track=] [--apply]` | Move a track's rationale out of YAML frontmatter comments into a `## Ranking rationale` body section, where writes preserve it (#491). Frontmatter is round-tripped through JSON, which has no comment concept, so **every** writer (`refresh-md`, `reconcile`, `slot`, `hygiene`) erases **every** frontmatter comment — a routine `hygiene` run silently deleted 213 lines of ranking rationale from a real track while the `next_up` *order* survived intact, which is precisely what made the loss invisible. Comments attached to a `next_up` entry become bullets naming that issue; section headers become paragraphs. The body is also the only place rationale is **visible** — frontmatter comments never render as markdown, never reach the VS Code viewer, and never appear in `export --json`. Dry-run by default; `--apply` writes. | | `list [--all] [--sort=recent\|priority]` | List active tracks (or all including parked/archived). `--sort=recent` orders by `last_touched` (most recent first); `--sort=priority` orders by `launch_priority` (P0→P3) with recency as tiebreaker. Default keeps discovery order. | | `init [--priority=P0..P3] [--milestone=]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. | | `init-repo --github= [--local=] [--update [--clear-local]]` | Bootstrap a new repo: create `//archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required for an add; `--local` is optional. `--update` on an existing key changes its local/github; `--update --clear-local` forgets the saved local path (keeps github + other fields). `--clear-local` and `--local` are mutually exclusive. | diff --git a/skills/work-plan/commands/lift_rationale.py b/skills/work-plan/commands/lift_rationale.py new file mode 100644 index 0000000..19537d3 --- /dev/null +++ b/skills/work-plan/commands/lift_rationale.py @@ -0,0 +1,229 @@ +"""lift-rationale — move frontmatter comments into the body, where they survive (#491). + +## Why this exists + +`lib/frontmatter.write_file` round-trips frontmatter through JSON. JSON has no +comment concept, so EVERY write erases EVERY YAML comment — `refresh-md`, +`reconcile`, `slot`, `hygiene`, all of them. A routine hygiene run deleted 213 +lines of ranking rationale from a real track. The `next_up` order survived +perfectly, which is what made the loss invisible: the data was fine, the +reasoning was gone. + +The body does NOT have this problem — `write_file` passes it through verbatim. +So the durable place for rationale is a body section, not frontmatter comments. + +There is a second reason to move, independent of durability: frontmatter +comments are invisible everywhere except the raw file. They do not render in +markdown, do not reach the VS Code viewer, and do not appear in `export --json`. +Rationale kept there was already hidden from every surface anyone reads. + +## What it does + +Extracts comment lines from a track's frontmatter and appends them, as markdown, +under a `## Ranking rationale` heading in the body. Comments attached to a +`next_up` entry are rendered as a bullet naming that issue, so the association +survives the move; free-standing comment blocks become paragraphs. + +The frontmatter keys themselves are untouched — only comments move. + +Dry-run by default. `--apply` writes. + +Usage: + work_plan.py lift-rationale [--repo=] [--track=] [--apply] +""" +import re +import sys +from pathlib import Path + +from lib.config import load_config, ConfigError +from lib.frontmatter import FRONTMATTER_RE, parse_file, write_file +from lib.prompts import parse_flags +from lib.tracks import discover_tracks + +KNOWN = {"--repo", "--track", "--apply"} + +HEADING = "## Ranking rationale" + +_ENTRY_RE = re.compile(r"^(\s*)-\s*(\d+)\s*(?:#\s?(.*))?$") +_COMMENT_RE = re.compile(r"^(\s*)#\s?(.*)$") + + +def _raw_frontmatter(path: Path) -> str: + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + return "" + m = FRONTMATTER_RE.match(text) + return m.group(1) if m else "" + + +def extract_rationale(frontmatter_text: str) -> list: + """Group frontmatter comments into blocks, associating trailing comment runs + with the `next_up` entry they follow. + + Returns a list of (issue_number_or_None, [lines]) in document order. A block + keyed to an issue number is rationale written under that entry; a block keyed + None is a free-standing comment (a section header, a preamble). + + Association rule is INDENTATION, not adjacency. A comment indented deeper + than the most recent `- ` entry is that entry's rationale; a comment + at or above the entry indent is free-standing. This is how the format is + actually written: + + # TIER 1 — the golden path is broken <- indent 2, header + - 6374 # first line, inline <- entry, indent 2 + # continues here <- indent 9, belongs to 6374 + - 6368 # ... + + Adjacency alone cannot distinguish those two cases: both a trailing comment + and a section header sit between two entries. + + An inline comment on the entry line itself starts that entry's block — the + real format puts the first (and often most important) line of rationale + there, so dropping it would lose the summary of every entry. + """ + blocks: list = [] + owner = None # owner of the block being accumulated + current: list = [] + last_issue = None + entry_indent = 0 + + def flush(): + if current: + blocks.append((owner, list(current))) + current.clear() + + for line in frontmatter_text.split("\n"): + em = _ENTRY_RE.match(line) + if em: + flush() + entry_indent = len(em.group(1)) + last_issue = int(em.group(2)) + inline = (em.group(3) or "").rstrip() + owner = last_issue + if inline: + current.append(inline) + continue + + cm = _COMMENT_RE.match(line) + if cm: + indent = len(cm.group(1)) + text = cm.group(2).rstrip() + this_owner = (last_issue if (last_issue is not None + and indent > entry_indent) else None) + if current and this_owner != owner: + flush() + owner = this_owner + current.append(text) + continue + + # Any other YAML line ends the current run. A new top-level key also + # ends the entry context, so a comment under an unrelated key later in + # the document is not misattributed to the last issue seen. + flush() + if line.strip() and not line.startswith((" ", "\t")): + last_issue = None + owner = None + + flush() + return blocks + + +def render_rationale(blocks: list) -> str: + """Render extracted blocks as a markdown section body (no heading).""" + out: list = [] + for issue, lines in blocks: + text = " ".join(l for l in lines if l).strip() + if not text: + continue + if issue is None: + out.append(text) + else: + out.append(f"- **#{issue}** — {text}") + return "\n\n".join(out) + + +def strip_frontmatter_comments(frontmatter_text: str) -> str: + """Frontmatter with comment-only lines removed (values keep their order).""" + kept = [l for l in frontmatter_text.split("\n") + if not l.strip().startswith("#")] + return "\n".join(kept) + + +def _append_section(body: str, rendered: str) -> str: + """Append (or replace) the rationale section at the end of the body.""" + marker = f"\n\n{HEADING}\n\n" + idx = body.find(f"\n{HEADING}\n") + if idx != -1: + body = body[:idx].rstrip() + return body.rstrip() + marker + rendered + "\n" + + +def run(args: list) -> int: + flags, _ = parse_flags(args, KNOWN) + for f in ("--repo", "--track"): + if flags.get(f) is True: + print("usage: work_plan.py lift-rationale [--repo=] " + "[--track=] [--apply]", file=sys.stderr) + return 2 + repo_key = flags.get("--repo") + track_name = flags.get("--track") + apply = bool(flags.get("--apply")) + + try: + cfg = load_config() + except ConfigError as e: + print(f"ERROR: {e}", file=sys.stderr) + return 1 + + tracks = discover_tracks(cfg) + if repo_key: + k = repo_key.lower() + tracks = [t for t in tracks + if (t.folder or "").lower() == k or (t.repo or "").lower() == k] + if track_name: + tracks = [t for t in tracks if t.name == track_name] + + if not tracks: + print("No matching tracks. Nothing to lift.") + return 0 + + touched = 0 + for t in tracks: + raw = _raw_frontmatter(t.path) + blocks = extract_rationale(raw) + if not blocks: + continue + rendered = render_rationale(blocks) + if not rendered: + continue + n_lines = sum(len(lines) for _, lines in blocks) + touched += 1 + print(f"\n{t.name} ({n_lines} comment line(s) in {len(blocks)} block(s))") + if not apply: + preview = rendered.split("\n\n")[:3] + for p in preview: + print(f" {p[:100]}") + if len(rendered.split('\n\n')) > 3: + print(" ...") + continue + + meta, body = parse_file(t.path) + new_body = _append_section(body, rendered) + # write_file re-dumps frontmatter from `meta`, which never carried the + # comments — so they are already gone from what gets written. The body + # now holds them, which is the whole point. + write_file(t.path, meta, new_body) + print(f" -> lifted into '{HEADING}'") + + print() + if touched == 0: + print("No frontmatter comments found. Nothing to lift.") + elif apply: + print(f"✓ Lifted rationale in {touched} track(s) into the body, where " + "writes preserve it.") + else: + print(f"{touched} track(s) carry frontmatter comments that ANY write will " + "destroy (#491).") + print("Re-run with --apply to move them into the body.") + return 0 diff --git a/skills/work-plan/lib/frontmatter.py b/skills/work-plan/lib/frontmatter.py index 6e196a6..cb3e6e4 100644 --- a/skills/work-plan/lib/frontmatter.py +++ b/skills/work-plan/lib/frontmatter.py @@ -20,6 +20,28 @@ def parse_file(path: Path) -> Tuple[dict, str]: return (meta, match.group(2)) +def count_frontmatter_comments(path: Path) -> int: + """Number of `#` comment lines in a file's EXISTING frontmatter (#491). + + Frontmatter is written by round-tripping through JSON (`_yaml_to_dict` -> + `_dict_to_yaml`), and JSON has no comment concept — so every write erases + every comment, structurally. This counts what a pending write would destroy + so the loss can be announced instead of silent. + + Never raises: an unreadable or frontmatter-less file simply has nothing to + lose, and a warning path must not be able to break a write. + """ + try: + text = Path(path).read_text(encoding="utf-8") + except OSError: + return 0 + match = FRONTMATTER_RE.match(text) + if not match: + return 0 + return sum(1 for line in match.group(1).split("\n") + if line.strip().startswith("#")) + + def write_file(path: Path, meta: dict, body: str) -> None: """Write markdown with frontmatter. Empty meta = body only. @@ -27,6 +49,13 @@ def write_file(path: Path, meta: dict, body: str) -> None: a target outside the notes tree would otherwise let a write land on an arbitrary file. Track files are never legitimately symlinks, so this rejects nothing valid; raises ValueError if one is encountered. + + WARNS on frontmatter comment loss (#491). The JSON round-trip below cannot + preserve comments, so a routine `hygiene` run silently deleted 213 lines of + ranking rationale from a real track — the `next_up` ORDER survived intact, + which is exactly what made it invisible. This does not prevent the loss (the + durable fix is to keep rationale in the BODY, which passes through this + function untouched); it makes the loss announce itself. """ p = Path(path) if p.is_symlink(): @@ -34,6 +63,11 @@ def write_file(path: Path, meta: dict, body: str) -> None: if not meta: p.write_text(body, encoding="utf-8") return + lost = count_frontmatter_comments(p) + if lost: + print(f"WARNING: {p.name}: dropping {lost} frontmatter comment line(s) — " + "YAML comments cannot survive a write (#491). Move rationale into " + "the body (see `/work-plan lift-rationale`), where it is preserved.") yaml_text = _dict_to_yaml(meta) p.write_text(f"---\n{yaml_text}---\n{body}", encoding="utf-8") diff --git a/skills/work-plan/tests/test_lift_rationale.py b/skills/work-plan/tests/test_lift_rationale.py new file mode 100644 index 0000000..0645031 --- /dev/null +++ b/skills/work-plan/tests/test_lift_rationale.py @@ -0,0 +1,211 @@ +"""Tests for lift-rationale and the frontmatter comment-loss warning (#491).""" +import io +import unittest +import sys +import tempfile +from contextlib import redirect_stdout +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from commands.lift_rationale import ( # noqa: E402 + HEADING, + _append_section, + extract_rationale, + render_rationale, + strip_frontmatter_comments, +) +from lib.frontmatter import ( # noqa: E402 + count_frontmatter_comments, + parse_file, + write_file, +) + +FM = """track: demo +next_up: + # TIER 1 — the golden path is broken + - 100 + # first reason + # second line of the same reason + - 200 + # a reason for 200 + # TIER 2 — real but not urgent + - 300 +""" + + +class TestExtract(unittest.TestCase): + def setUp(self): + self.blocks = extract_rationale(FM) + + def test_finds_every_block(self): + self.assertEqual(len(self.blocks), 4) + + def test_header_stays_free_standing(self): + # A 'TIER 1' banner introduces what FOLLOWS. Gluing it onto the previous + # issue would attribute a section header to an unrelated entry. + self.assertIsNone(self.blocks[0][0]) + self.assertIn("TIER 1", self.blocks[0][1][0]) + + def test_trailing_comment_binds_to_its_entry(self): + owner, lines = self.blocks[1] + self.assertEqual(owner, 100) + self.assertEqual(len(lines), 2) + + def test_second_entry_gets_its_own_reason(self): + owner, lines = self.blocks[2] + self.assertEqual(owner, 200) + self.assertIn("reason for 200", lines[0]) + + def test_second_header_is_free_standing(self): + self.assertIsNone(self.blocks[3][0]) + self.assertIn("TIER 2", self.blocks[3][1][0]) + + def test_no_comments_yields_no_blocks(self): + self.assertEqual(extract_rationale("track: demo\nnext_up:\n - 1\n"), []) + + def test_empty_input_is_safe(self): + self.assertEqual(extract_rationale(""), []) + + +REAL_FORMAT = """next_up: + # TIER 1 — a GM abandons the product + - 6374 # #1. NOT previously on this track (it sits on track/ai-generators), + # which is why it never surfaced in a launch-critical view. + - 6368 # The only issue with a ~100% day-zero hit rate. + # TIER 2 — real, launch-relevant + - 6253 # Run Sheet is the artifact used LIVE. +other_key: value + # a comment under an unrelated key +""" + + +class TestRealFormat(unittest.TestCase): + """The shape the actual track file uses: inline first line + indented + continuations. Written from the real file, not invented.""" + + def setUp(self): + self.blocks = extract_rationale(REAL_FORMAT) + self.by_owner = {o: lines for o, lines in self.blocks if o is not None} + + def test_inline_comment_on_the_entry_line_is_captured(self): + # The first line of rationale lives ON the entry line. Dropping it would + # lose the summary of every single entry. + self.assertIn(6374, self.by_owner) + self.assertTrue(self.by_owner[6374][0].startswith("#1. NOT previously")) + + def test_indented_continuation_joins_its_entry(self): + self.assertEqual(len(self.by_owner[6374]), 2) + self.assertIn("never surfaced", self.by_owner[6374][1]) + + def test_tier_headers_stay_free_standing(self): + free = [" ".join(l) for o, l in self.blocks if o is None] + self.assertTrue(any("TIER 1" in f for f in free)) + self.assertTrue(any("TIER 2" in f for f in free)) + + def test_header_is_not_glued_to_the_previous_entry(self): + # 'TIER 2' must not end up attached to #6368. + self.assertNotIn("TIER 2", " ".join(self.by_owner[6368])) + + def test_comment_under_an_unrelated_key_is_not_attributed_to_an_issue(self): + free = [" ".join(l) for o, l in self.blocks if o is None] + self.assertTrue(any("unrelated key" in f for f in free)) + for lines in self.by_owner.values(): + self.assertNotIn("unrelated key", " ".join(lines)) + + def test_every_entry_keeps_its_own_rationale(self): + self.assertEqual(set(self.by_owner), {6374, 6368, 6253}) + + +class TestRender(unittest.TestCase): + def test_issue_blocks_become_bullets(self): + out = render_rationale(extract_rationale(FM)) + self.assertIn("- **#100** — first reason second line of the same reason", out) + self.assertIn("- **#200** — a reason for 200", out) + + def test_headers_become_paragraphs(self): + out = render_rationale(extract_rationale(FM)) + self.assertIn("TIER 1 — the golden path is broken", out) + self.assertNotIn("- **#None**", out) + + def test_blank_comment_lines_do_not_emit_empty_bullets(self): + blocks = extract_rationale("next_up:\n - 1\n #\n") + self.assertEqual(render_rationale(blocks), "") + + +class TestStrip(unittest.TestCase): + def test_removes_only_comment_lines(self): + out = strip_frontmatter_comments(FM) + self.assertNotIn("#", out) + self.assertIn("- 100", out) + self.assertIn("- 300", out) + + +class TestAppendSection(unittest.TestCase): + def test_appends_when_absent(self): + out = _append_section("# Track\n\nsome prose\n", "rendered text") + self.assertIn(HEADING, out) + self.assertIn("some prose", out) + self.assertTrue(out.rstrip().endswith("rendered text")) + + def test_replaces_when_already_present(self): + first = _append_section("# Track\n\nprose\n", "old rationale") + second = _append_section(first, "new rationale") + self.assertEqual(second.count(HEADING), 1) + self.assertIn("new rationale", second) + self.assertNotIn("old rationale", second) + self.assertIn("prose", second) + + +class TestWarning(unittest.TestCase): + def test_counts_existing_frontmatter_comments(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + p.write_text(f"---\n{FM}---\nbody\n", encoding="utf-8") + self.assertEqual(count_frontmatter_comments(p), 5) + + def test_missing_file_counts_zero_not_error(self): + self.assertEqual(count_frontmatter_comments(Path("/nope/nope.md")), 0) + + def test_no_frontmatter_counts_zero(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + p.write_text("just a body\n", encoding="utf-8") + self.assertEqual(count_frontmatter_comments(p), 0) + + def test_write_warns_when_comments_would_be_lost(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + p.write_text(f"---\n{FM}---\nbody\n", encoding="utf-8") + meta, body = parse_file(p) + buf = io.StringIO() + with redirect_stdout(buf): + write_file(p, meta, body) + self.assertIn("dropping 5 frontmatter comment line(s)", buf.getvalue()) + + def test_write_is_silent_when_there_is_nothing_to_lose(self): + # The falsification case: a clean track must not print a scary warning + # on every routine write, or the warning becomes noise and gets ignored. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + p.write_text("---\ntrack: demo\n---\nbody\n", encoding="utf-8") + meta, body = parse_file(p) + buf = io.StringIO() + with redirect_stdout(buf): + write_file(p, meta, body) + self.assertEqual(buf.getvalue(), "") + + def test_body_survives_a_write_verbatim(self): + # The premise the whole fix rests on: the body is NOT round-tripped. + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "t.md" + body = f"# Track\n\n{HEADING}\n\n- **#100** — reason with # hash and : colon\n" + p.write_text(f"---\ntrack: demo\n---\n{body}", encoding="utf-8") + meta, parsed_body = parse_file(p) + write_file(p, meta, parsed_body) + self.assertIn("reason with # hash and : colon", + p.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/work_plan.py b/skills/work-plan/work_plan.py index 78686d1..aec684f 100755 --- a/skills/work-plan/work_plan.py +++ b/skills/work-plan/work_plan.py @@ -49,6 +49,7 @@ def _load_version() -> str: "coverage": "commands.coverage", "canonicalize": "commands.canonicalize", "dedupe-tiers": "commands.dedupe_tiers", + "lift-rationale": "commands.lift_rationale", "hygiene": "commands.hygiene", "--hygiene": "commands.hygiene", # flag-style alias "plan-status": "commands.plan_status", @@ -172,6 +173,10 @@ def _load_version() -> str: "Remove private track copies that a shared twin in a repo's .work-plan/ supersedes (#359). When a track is promoted to the shared tier, the private original under notes_root is sometimes left behind (bulk/manual promotion, or a failed unlink) — discover_tracks then warns 'exists in both shared and private' on every run with no cleanup path. This removes the safe orphans and REFUSES any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is issue_refs(private) ⊆ issue_refs(shared)). Covers active and archived tiers. Default is a dry-run report; --apply deletes (auto-committed to notes_root, so undoable).", "When `exists in both shared and private` warnings appear, or after a bulk promote that left private originals behind.", "/work-plan dedupe-tiers --repo=critforge --apply"), + ("lift-rationale", "[--repo=] [--track=] [--apply]", + "Move a track's rationale out of YAML frontmatter comments and into a '## Ranking rationale' body section, where writes preserve it (#491). Frontmatter is round-tripped through JSON, which has no comment concept, so EVERY writer (refresh-md, reconcile, slot, hygiene) erases EVERY frontmatter comment — a routine hygiene run silently deleted 213 lines of ranking rationale from a real track, while the next_up ORDER survived intact, which is what made the loss invisible. Comments attached to a next_up entry become bullets naming that issue; section headers become paragraphs. The body is also the only place rationale is VISIBLE — frontmatter comments never render, never reach the VS Code viewer, and never appear in export --json. Dry-run by default; --apply writes.", + "ONE-TIME per track that keeps rationale in frontmatter comments — and before running hygiene on one, since hygiene will destroy them.", + "/work-plan lift-rationale --repo=critforge --apply"), ("hygiene", "[--yes] [--no-duplicates] [--repo=] [--timeout=N]", "Weekly cleanup wrapper: refresh-md + reconcile + dedupe-tiers (report-only) + duplicates. With --repo=, steps 1–3 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).", "WEEKLY — runs all three hygiene commands in sequence so you don't have to remember each. Use --repo= to clean up one project without touching the others.",