@stylusnexus/work-plan 2026.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. package/skills/work-plan/work_plan.py +210 -0
@@ -0,0 +1,68 @@
1
+ """Shared CLI helpers: prompts and arg parsing."""
2
+
3
+
4
+ def prompt_input(message: str, default: str = "") -> str:
5
+ """Print prompt and read a free-form line. Treats EOF (no stdin) as default.
6
+
7
+ Returns the stripped input, or `default` if EOF or blank.
8
+ """
9
+ print(message)
10
+ try:
11
+ line = input().strip()
12
+ except EOFError:
13
+ return default
14
+ return line if line else default
15
+
16
+
17
+ def prompt_lines() -> list[str]:
18
+ """Read lines from stdin until blank line or EOF. Returns list of non-blank lines."""
19
+ out = []
20
+ try:
21
+ while True:
22
+ line = input().rstrip()
23
+ if not line:
24
+ break
25
+ out.append(line)
26
+ except EOFError:
27
+ pass
28
+ return out
29
+
30
+
31
+ def prompt_yes_no(message: str = "Apply? [y/N]") -> bool:
32
+ """Print prompt and read y/N. Treats EOF (no stdin) as no.
33
+
34
+ Returns True only if user explicitly types 'y' (case-insensitive).
35
+ """
36
+ print(message)
37
+ try:
38
+ choice = input().strip().lower()
39
+ except EOFError:
40
+ print("(no input — cancelled)")
41
+ return False
42
+ return choice == "y"
43
+
44
+
45
+ def parse_flags(args: list[str], known: set[str]) -> tuple[dict, list[str]]:
46
+ """Split CLI args into recognized flags + positional args.
47
+
48
+ `known` is the set of flag names this command supports (e.g. {"--all", "--yes"}).
49
+ For `--key=value` flags, key.split("=", 1)[0] is matched against `known`.
50
+
51
+ Returns: (flags_dict, positional_list).
52
+ - flags_dict: {"--all": True, "--repo": "critforge", ...} for flags found.
53
+ - positional_list: args that aren't flags.
54
+
55
+ Unknown flags are passed through as positional args (caller decides what to do).
56
+ """
57
+ flags = {}
58
+ positional = []
59
+ for arg in args:
60
+ if not arg.startswith("--"):
61
+ positional.append(arg)
62
+ continue
63
+ key, _, val = arg.partition("=")
64
+ if key in known:
65
+ flags[key] = val if val else True
66
+ else:
67
+ positional.append(arg)
68
+ return flags, positional
@@ -0,0 +1,34 @@
1
+ """Pure helpers for the gated reconcile actions: select actionable rows, compute
2
+ an archive destination, and build the issue title/body for a partial plan.
3
+ """
4
+ from pathlib import PurePosixPath
5
+
6
+
7
+ def dead_rows(rows: list) -> list:
8
+ return [r for r in rows if r["verdict"] == "dead"]
9
+
10
+
11
+ def partial_rows(rows: list) -> list:
12
+ return [r for r in rows if r["verdict"] == "partial"]
13
+
14
+
15
+ def archive_dest(rel: str) -> str:
16
+ """docs/.../plans/x.md -> docs/.../plans/archive/abandoned/x.md"""
17
+ p = PurePosixPath(rel)
18
+ return str(p.parent / "archive" / "abandoned" / p.name)
19
+
20
+
21
+ def issue_for(doc, row, unsatisfied) -> tuple:
22
+ """Build (title, body) for a partial plan's follow-up issue."""
23
+ stem = PurePosixPath(doc.rel).stem
24
+ title = f"Finish plan: {stem}"
25
+ lines = [
26
+ f"Plan `{doc.rel}` is **partial** "
27
+ f"({row['files_present']}/{row['files_declared']} declared files present).",
28
+ "",
29
+ "Unsatisfied files:",
30
+ ]
31
+ for d in unsatisfied:
32
+ lines.append(f"- [ ] {d.kind}: `{d.path}`")
33
+ lines += ["", "_Opened by `work-plan plan-status --issues`._"]
34
+ return title, "\n".join(lines)
@@ -0,0 +1,83 @@
1
+ """Compose terminal output strings."""
2
+
3
+
4
+ def time_aware_framing(gap_seconds: int, current_hour: int, handoff_today: bool = True) -> str:
5
+ """Adapt framing to gap-since-last-activity + hour."""
6
+ six_hours = 6 * 3600
7
+ one_hour = 3600
8
+
9
+ if gap_seconds > six_hours or current_hour < 11:
10
+ line = "Fresh start. Here's what changed since you stepped away."
11
+ elif gap_seconds >= one_hour:
12
+ line = "Picking back up. Here's what was active when you stepped away."
13
+ else:
14
+ line = "Continuing. Drift since last brief:"
15
+
16
+ if current_hour >= 23 and not handoff_today:
17
+ line += "\n Want a handoff before bed? Run /work-plan handoff [track]."
18
+
19
+ return line
20
+
21
+
22
+ def render_track_row(t: dict) -> str:
23
+ """Render one track block in the brief output."""
24
+ lines = []
25
+
26
+ badge_parts = []
27
+ if t["operational_status"] == "in-progress":
28
+ badge_parts.append("in-progress")
29
+ elif t["operational_status"] == "blocked":
30
+ badge_parts.append("blocked")
31
+ badge_parts.append(t["launch_priority"])
32
+ badge_parts.append(t["milestone_alignment"])
33
+ badge_parts.append(f"last touched {t['last_touched_label']}, last handoff {t['last_handoff_label']}")
34
+ lines.append(f"▸ {t['name']} ({' · '.join(badge_parts)})")
35
+
36
+ if t["next_up"]:
37
+ for idx, item in enumerate(t["next_up"]):
38
+ bits = [item["priority"], item["state"]]
39
+ if item.get("milestone"):
40
+ bits.append(item["milestone"])
41
+ label = f"#{item['number']} {item['title']} ({', '.join(bits)})"
42
+ prefix = " Up next: " if idx == 0 else " "
43
+ lines.append(prefix + label)
44
+ elif t.get("next_up_stale_closed_count"):
45
+ n = t["next_up_stale_closed_count"]
46
+ slug = t.get("track_slug") or t["name"]
47
+ plural = "item has" if n == 1 else "items have"
48
+ lines.append(f" Up next: <all {n} {plural} shipped — "
49
+ f"run /work-plan handoff {slug} to rotate>")
50
+ else:
51
+ lines.append(" Up next: <empty — set 'next_up:' or all items show backlog>")
52
+
53
+ for b in t["active_branches"]:
54
+ ahead = f"ahead {b['ahead']}" if b["ahead"] else "no commits ahead"
55
+ uc = f", uncommitted: {b['uncommitted_files']} file(s)" if b["uncommitted_files"] else ""
56
+ lines.append(f" Active: {b['name']} ({ahead}{uc})")
57
+
58
+ for n in t["new_issues"]:
59
+ lines.append(f" New: #{n['number']} {n['title']} — slot? [run: /work-plan slot {n['number']}]")
60
+
61
+ if t["blockers"]:
62
+ for b in t["blockers"]:
63
+ reason = b.get("reason", "manually flagged")
64
+ lines.append(f" Blocker: #{b['number']} — {reason}")
65
+ else:
66
+ lines.append(" Blockers: none")
67
+
68
+ if t["drift_items"]:
69
+ items = ", ".join(f"#{d['issue']}" for d in t["drift_items"])
70
+ lines.append(f" Drift: {items} — body says open but GitHub says closed (or vice versa). "
71
+ f"Run /work-plan refresh-md {t['name']}")
72
+
73
+ if t["closure_ready"]:
74
+ lines.append(f" Closure?: YES — run /work-plan close {t['name']}")
75
+ elif t.get("closure_signals_summary"):
76
+ lines.append(f" Closure?: {t['closure_signals_summary']}")
77
+
78
+ return "\n".join(lines)
79
+
80
+
81
+ def render_archived_reopen(repo: str, slug: str, issue: dict) -> str:
82
+ return (f"⚠ archive/{slug}.md (shipped) — new issue #{issue['number']} "
83
+ f"matches this slug. Re-open or slot into a different track?")
@@ -0,0 +1,14 @@
1
+ """Per-user scratch directory for inter-invocation state.
2
+
3
+ Replaces /tmp/ for the two-step AI subcommands (`group`, `suggest-priorities`)
4
+ so batch + answers files can't be planted by other same-UID processes (#18).
5
+ """
6
+ from pathlib import Path
7
+
8
+
9
+ def cache_dir() -> Path:
10
+ """Return ~/.claude/work-plan/cache/, created mode 0700 if missing."""
11
+ p = Path.home() / ".claude" / "work-plan" / "cache"
12
+ p.mkdir(parents=True, exist_ok=True, mode=0o700)
13
+ p.chmod(0o700)
14
+ return p
@@ -0,0 +1,39 @@
1
+ """Append session log entries to track body."""
2
+
3
+ SESSION_LOG_HEADER = "## Session log"
4
+
5
+
6
+ def append_session_log(body: str, timestamp: str,
7
+ touched: list[str], next_up: list[str],
8
+ blockers: list[dict]) -> str:
9
+ """Append a `### Session — <timestamp>` block under the Session log section."""
10
+ block_lines = [f"### Session — {timestamp}\n"]
11
+ if touched:
12
+ for t in touched:
13
+ block_lines.append(f"- Touched: {t}")
14
+ else:
15
+ block_lines.append("- Touched: (nothing committed)")
16
+ if next_up:
17
+ for n in next_up:
18
+ block_lines.append(f"- Next: {n}")
19
+ else:
20
+ block_lines.append("- Next: (open)")
21
+ if blockers:
22
+ for b in blockers:
23
+ block_lines.append(f"- Blocker: #{b['number']} — {b['reason']}")
24
+ block_lines.append("")
25
+ block = "\n".join(block_lines)
26
+
27
+ if SESSION_LOG_HEADER in body:
28
+ idx = body.index(SESSION_LOG_HEADER)
29
+ rest = body[idx + len(SESSION_LOG_HEADER):]
30
+ next_h2 = rest.find("\n## ")
31
+ if next_h2 == -1:
32
+ insertion = rest + "\n" + block
33
+ else:
34
+ insertion = rest[:next_h2] + "\n" + block + rest[next_h2:]
35
+ return body[:idx] + SESSION_LOG_HEADER + insertion
36
+
37
+ if not body.endswith("\n"):
38
+ body += "\n"
39
+ return body + f"\n{SESSION_LOG_HEADER}\n\n{block}"
@@ -0,0 +1,60 @@
1
+ """Idempotent status-header stamping for plan/spec docs.
2
+
3
+ The block is derived ENTIRELY from evidence (no volatile timestamp), so
4
+ re-stamping with unchanged evidence yields a byte-identical document.
5
+ """
6
+ import re
7
+
8
+ BEGIN = "<!-- plan-status: BEGIN -->"
9
+ END = "<!-- plan-status: END -->"
10
+
11
+ _BLOCK_RE = re.compile(re.escape(BEGIN) + r".*?" + re.escape(END), re.DOTALL)
12
+ # Orphan (unpaired) BEGIN/END markers left by a truncated edit or bad merge.
13
+ _ORPHAN_RE = re.compile(
14
+ r"^[ \t]*(?:" + re.escape(BEGIN) + r"|" + re.escape(END) + r")[ \t]*\n?",
15
+ re.MULTILINE,
16
+ )
17
+
18
+
19
+ def render_block(row: dict) -> str:
20
+ """Render the delimited status block from an evaluated row dict."""
21
+ last = row.get("last_touched") or "unknown"
22
+ line = (
23
+ f"> **Status:** {row['glyph']} {row['verdict']} · "
24
+ f"{row['files_present']}/{row['files_declared']} files · "
25
+ f"last touched {last}"
26
+ )
27
+ return f"{BEGIN}\n{line}\n{END}"
28
+
29
+
30
+ def stamp(text: str, row: dict) -> str:
31
+ """Insert or replace the status block. Idempotent for unchanged evidence.
32
+
33
+ Hardening: if a doc somehow contains multiple complete blocks, the first is
34
+ refreshed in place and the rest are removed (no permanently-stale duplicate).
35
+ If it contains an orphan (unpaired) marker, that is stripped before inserting
36
+ so a second block can't be stacked on top.
37
+ """
38
+ block = render_block(row)
39
+ if _BLOCK_RE.search(text):
40
+ # Replace the first block in place (preserving surrounding whitespace),
41
+ # and drop any additional blocks so none goes stale.
42
+ seen = {"first": False}
43
+
44
+ def _sub(_m):
45
+ if not seen["first"]:
46
+ seen["first"] = True
47
+ return block
48
+ return ""
49
+
50
+ return _BLOCK_RE.sub(_sub, text)
51
+
52
+ # No complete block. Clear any orphan markers (corrupted/dangling) so we
53
+ # don't stack a duplicate block on top. No-op on well-formed docs.
54
+ text = _ORPHAN_RE.sub("", text)
55
+ lines = text.splitlines(keepends=True)
56
+ for i, ln in enumerate(lines):
57
+ if ln.startswith("# "):
58
+ lines.insert(i + 1, "\n" + block + "\n")
59
+ return "".join(lines)
60
+ return block + "\n\n" + text
@@ -0,0 +1,227 @@
1
+ """Find + update first markdown table with a Status column."""
2
+ import re
3
+ from typing import Optional
4
+
5
+ ISSUE_NUM_RE = re.compile(r"#(\d+)")
6
+ CANONICAL_MARKER = "<!-- canonical-issue-table"
7
+
8
+
9
+ def find_status_table(body: str) -> Optional[dict]:
10
+ """Find the first markdown table with a 'Status' column AND issue refs.
11
+
12
+ Prefers tables whose data rows contain `#NNNN` references over tables that
13
+ happen to have a 'Status' column for non-issue purposes. Falls back to the
14
+ first 'Status' table if none have issue refs.
15
+ """
16
+ tables = find_all_status_tables(body, with_issue_refs_only=False)
17
+ with_refs = [t for t in tables if t["has_issue_refs"]]
18
+ if with_refs:
19
+ return with_refs[0]
20
+ return tables[0] if tables else None
21
+
22
+
23
+ def find_all_status_tables(body: str, with_issue_refs_only: bool = True) -> list[dict]:
24
+ """Find every markdown table with a 'Status' column.
25
+
26
+ Returns a list of table dicts, each with: header_line_idx, rows,
27
+ status_col_index, has_issue_refs, is_canonical.
28
+
29
+ `is_canonical` is True if the table is preceded (within 3 lines) by a
30
+ `<!-- canonical-issue-table -->` comment. Refresh-md prefers canonical
31
+ tables when present.
32
+
33
+ If with_issue_refs_only=True (default), only returns tables whose data rows
34
+ contain `#NNNN` references.
35
+ """
36
+ lines = body.split("\n")
37
+ tables = []
38
+ i = 0
39
+ while i < len(lines):
40
+ line = lines[i]
41
+ if "|" not in line:
42
+ i += 1
43
+ continue
44
+ cells = _parse_row(line)
45
+ if not cells:
46
+ i += 1
47
+ continue
48
+ status_idx = next((idx for idx, c in enumerate(cells) if c.strip().lower() == "status"), None)
49
+ if status_idx is None:
50
+ i += 1
51
+ continue
52
+ if i + 1 >= len(lines) or not _is_separator(lines[i + 1]):
53
+ i += 1
54
+ continue
55
+ # Look backward up to 3 lines for canonical marker
56
+ is_canonical = any(
57
+ CANONICAL_MARKER in lines[k]
58
+ for k in range(max(0, i - 3), i)
59
+ )
60
+ rows = []
61
+ j = i + 2
62
+ while j < len(lines):
63
+ if "|" not in lines[j]:
64
+ break
65
+ row_cells = _parse_row(lines[j])
66
+ if not row_cells:
67
+ break
68
+ rows.append({"raw": lines[j], "cells": row_cells, "line_idx": j})
69
+ j += 1
70
+ has_refs = any(ISSUE_NUM_RE.search(cell) for row in rows for cell in row["cells"])
71
+ if not with_issue_refs_only or has_refs:
72
+ tables.append({
73
+ "header_line_idx": i,
74
+ "rows": rows,
75
+ "status_col_index": status_idx,
76
+ "has_issue_refs": has_refs,
77
+ "is_canonical": is_canonical,
78
+ })
79
+ i = j
80
+ return tables
81
+
82
+
83
+ def find_canonical_status_tables(body: str) -> list[dict]:
84
+ """Return only canonical-marked status tables."""
85
+ return [t for t in find_all_status_tables(body) if t["is_canonical"]]
86
+
87
+
88
+ def update_row_status(body: str, issue_num: int, new_status: str) -> str:
89
+ table = find_status_table(body)
90
+ if not table:
91
+ return body
92
+ lines = body.split("\n")
93
+ sidx = table["status_col_index"]
94
+ for row in table["rows"]:
95
+ nums = []
96
+ for cell in row["cells"]:
97
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
98
+ if issue_num not in nums:
99
+ continue
100
+ new_cells = list(row["cells"])
101
+ new_cells[sidx] = " " + new_status + " "
102
+ lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
103
+ break
104
+ return "\n".join(lines)
105
+
106
+
107
+ def render_issue_row(num: int, title: str, assignee: str, status: str) -> str:
108
+ """Render a canonical issue-table row: `| #N | title | assignee | status |`.
109
+
110
+ Single source of truth for the canonical row shape — used by canonicalize
111
+ (initial table) and by sync_missing_rows (drift-healing appends)."""
112
+ return f"| #{num} | {title} | {assignee} | {status} |"
113
+
114
+
115
+ def append_rows(body: str, table: dict, row_lines: list[str]) -> str:
116
+ """Insert pre-rendered `row_lines` after the last data row of `table`.
117
+
118
+ `table` is a dict from find_*_status_tables. New rows land directly below
119
+ the table's existing rows (or after the header separator if the table has
120
+ none), so any narrative content below the table is preserved. The table's
121
+ line indices must still be valid for `body` (callers that rewrite cells in
122
+ place keep the line count stable, so this holds)."""
123
+ if not row_lines:
124
+ return body
125
+ lines = body.split("\n")
126
+ if table["rows"]:
127
+ insert_at = table["rows"][-1]["line_idx"] + 1
128
+ else:
129
+ insert_at = table["header_line_idx"] + 2 # past header + separator
130
+ lines[insert_at:insert_at] = row_lines
131
+ return "\n".join(lines)
132
+
133
+
134
+ def _row_primary_num(row: dict) -> Optional[int]:
135
+ """First `#NNNN` issue ref in a row, or None. A row's frontmatter-order
136
+ anchor for ordered inserts."""
137
+ for cell in row["cells"]:
138
+ m = ISSUE_NUM_RE.search(cell)
139
+ if m:
140
+ return int(m.group(1))
141
+ return None
142
+
143
+
144
+ def sync_missing_rows(body: str, frontmatter_nums: list, issues_by_num: dict):
145
+ """Insert a canonical row for every frontmatter issue missing from the table.
146
+
147
+ Picks the canonical table when present (else the first status table),
148
+ diffs `frontmatter_nums` against the issue numbers already in that table,
149
+ and slots a row for each missing number into its FRONTMATTER-ORDER
150
+ position — a missing #487 lands above an existing #678 if frontmatter
151
+ lists 487 first, rather than tacking onto the end (issue #79). Existing
152
+ rows keep their relative order and are re-emitted verbatim, so the diff
153
+ only shows the inserted lines. Live title/assignee/status come from
154
+ `issues_by_num` (a {num: gh-issue-dict} map); a number with no fetched
155
+ data still gets a placeholder row so membership never silently drifts.
156
+
157
+ Returns `(new_body, rows_added)`. No-ops (returns body unchanged, 0) when
158
+ there is no table or nothing is missing."""
159
+ from lib.github_state import state_to_status_label, format_assignees
160
+
161
+ canonical = find_canonical_status_tables(body)
162
+ tables = canonical if canonical else find_all_status_tables(body)
163
+ if not tables:
164
+ return body, 0
165
+ table = tables[0]
166
+
167
+ existing = set()
168
+ for row in table["rows"]:
169
+ for cell in row["cells"]:
170
+ existing.update(int(m) for m in ISSUE_NUM_RE.findall(cell))
171
+
172
+ # frontmatter order is the canonical ranking; missing keeps that order.
173
+ rank = {n: i for i, n in enumerate(frontmatter_nums)}
174
+ missing = [n for n in frontmatter_nums if n not in existing]
175
+ if not missing:
176
+ return body, 0
177
+
178
+ new_row = {}
179
+ for num in missing:
180
+ issue = issues_by_num.get(num) or {}
181
+ new_row[num] = render_issue_row(
182
+ num, issue.get("title", "(not fetched)"),
183
+ format_assignees(issue),
184
+ state_to_status_label(issue.get("state")),
185
+ )
186
+
187
+ # No existing rows: nothing to interleave against — drop them all in
188
+ # frontmatter order after the header separator.
189
+ if not table["rows"]:
190
+ return append_rows(body, table, [new_row[n] for n in missing]), len(missing)
191
+
192
+ # Interleave: walk existing rows in place, flushing each pending missing
193
+ # row before the first existing row that outranks it. Existing rows with
194
+ # no frontmatter rank impose no constraint, so they never trigger a flush.
195
+ out, mi = [], 0
196
+ for row in table["rows"]:
197
+ r_rank = rank.get(_row_primary_num(row))
198
+ if r_rank is not None:
199
+ while mi < len(missing) and rank[missing[mi]] < r_rank:
200
+ out.append(new_row[missing[mi]])
201
+ mi += 1
202
+ out.append(row["raw"])
203
+ out.extend(new_row[n] for n in missing[mi:])
204
+
205
+ lines = body.split("\n")
206
+ first = table["rows"][0]["line_idx"]
207
+ last = table["rows"][-1]["line_idx"]
208
+ lines[first:last + 1] = out
209
+ return "\n".join(lines), len(missing)
210
+
211
+
212
+ def _parse_row(line: str) -> list[str]:
213
+ s = line.strip()
214
+ if "|" not in s:
215
+ return []
216
+ if s.startswith("|"):
217
+ s = s[1:]
218
+ if s.endswith("|"):
219
+ s = s[:-1]
220
+ return s.split("|")
221
+
222
+
223
+ def _is_separator(line: str) -> bool:
224
+ s = line.strip()
225
+ if not s:
226
+ return False
227
+ return all(c in "|-: " for c in s)
@@ -0,0 +1,109 @@
1
+ """Discover tracks under notes_root."""
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from lib.frontmatter import parse_file
7
+ from lib.config import resolve_github_for_folder, resolve_local_path_for_folder
8
+
9
+
10
+ @dataclass
11
+ class Track:
12
+ path: Path
13
+ name: str
14
+ has_frontmatter: bool
15
+ needs_init: bool
16
+ needs_filing: bool
17
+ repo: Optional[str] = None
18
+ folder: Optional[str] = None
19
+ local_path: Optional[Path] = None
20
+ meta: dict = field(default_factory=dict)
21
+ body: str = ""
22
+
23
+
24
+ def discover_tracks(cfg: dict) -> list[Track]:
25
+ """Walk notes_root for active (non-archived) .md files."""
26
+ notes_root = Path(cfg["notes_root"]).expanduser()
27
+ if not notes_root.exists():
28
+ return []
29
+ return _walk(notes_root, cfg, include_archive=False)
30
+
31
+
32
+ def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
33
+ """Filter tracks by repo. Matches the config-key folder name OR the
34
+ `org/repo` GitHub slug, so users can pass either. Case-insensitive."""
35
+ k = key.lower()
36
+ return [t for t in tracks
37
+ if (t.folder and t.folder.lower() == k)
38
+ or (t.repo and t.repo.lower() == k)]
39
+
40
+
41
+ def find_track_by_name(name: str, tracks: list[Track],
42
+ *, active_only: bool = False) -> Optional[Track]:
43
+ """Find a single Track matching `name` (filename stem OR frontmatter `track`).
44
+
45
+ If active_only=True, only considers tracks with status active/in-progress/blocked.
46
+ Returns the single match or None. Used by every command that takes a track arg.
47
+ """
48
+ candidates = tracks
49
+ if active_only:
50
+ candidates = [t for t in candidates if t.has_frontmatter
51
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
52
+ matching = [t for t in candidates if t.has_frontmatter
53
+ and (t.name == name or t.meta.get("track") == name)]
54
+ return matching[0] if len(matching) == 1 else None
55
+
56
+
57
+ def discover_archived_tracks(cfg: dict) -> list[Track]:
58
+ """Walk notes_root for archived .md files only."""
59
+ notes_root = Path(cfg["notes_root"]).expanduser()
60
+ if not notes_root.exists():
61
+ return []
62
+ out = []
63
+ for md_path in sorted(notes_root.rglob("*.md")):
64
+ if "archive" not in md_path.parts:
65
+ continue
66
+ if md_path.name.startswith((".", "_")):
67
+ continue
68
+ out.append(_build_track(md_path, notes_root, cfg))
69
+ return out
70
+
71
+
72
+ def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
73
+ out = []
74
+ for md_path in sorted(notes_root.rglob("*.md")):
75
+ if not include_archive and "archive" in md_path.parts:
76
+ continue
77
+ if md_path.name.startswith((".", "_")):
78
+ continue
79
+ out.append(_build_track(md_path, notes_root, cfg))
80
+ return out
81
+
82
+
83
+ def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> Track:
84
+ meta, body = parse_file(md_path)
85
+ has_fm = bool(meta)
86
+ rel = md_path.relative_to(notes_root)
87
+ in_subfolder = len(rel.parts) > 1
88
+ folder_name = rel.parts[0] if in_subfolder else None
89
+
90
+ repo = None
91
+ if has_fm and meta.get("github", {}).get("repo"):
92
+ repo = meta["github"]["repo"]
93
+ elif folder_name:
94
+ repo = resolve_github_for_folder(folder_name, cfg)
95
+
96
+ local = resolve_local_path_for_folder(folder_name, cfg) if folder_name else None
97
+
98
+ return Track(
99
+ path=md_path,
100
+ name=md_path.stem,
101
+ has_frontmatter=has_fm,
102
+ needs_init=in_subfolder and not has_fm,
103
+ needs_filing=not in_subfolder,
104
+ repo=repo,
105
+ folder=folder_name,
106
+ local_path=local,
107
+ meta=meta,
108
+ body=body,
109
+ )