@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -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 +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,296 @@
1
+ """plan-status — reach a verdict on every plan/spec doc in a repo by
2
+ correlating each plan's declared file-manifest against the filesystem + git.
3
+
4
+ Phase 1: read-only. Reports a human table or --json. Never mutates a doc.
5
+ Manifest-less (prose) docs are flagged 👻 for the Phase 1b LLM pass.
6
+ """
7
+ import json
8
+ import sys
9
+ from datetime import date
10
+ from pathlib import Path
11
+
12
+ from lib import config as config_mod
13
+ from lib import doc_discovery, manifest, git_state, github_state
14
+ from lib import verdict as verdict_mod
15
+ from lib import status_header
16
+ from lib import llm_evidence
17
+ from lib import reconcile_actions
18
+ from lib.scratch import cache_dir
19
+ from lib.prompts import parse_flags, prompt_yes_no
20
+
21
+ KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
22
+ "--llm", "--apply", "--archive", "--issues"}
23
+ _ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
24
+
25
+
26
+ def _resolve_repo_root(flags) -> Path:
27
+ repo = flags.get("--repo")
28
+ if repo and repo is not True:
29
+ cfg = config_mod.load_config()
30
+ local = config_mod.resolve_local_path_for_folder(repo, cfg)
31
+ if not local or not local.exists():
32
+ print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
33
+ raise SystemExit(2)
34
+ return local
35
+ return Path.cwd()
36
+
37
+
38
+ def _evaluate(doc, repo_root, today, dead_days) -> dict:
39
+ text = doc.path.read_text(encoding="utf-8", errors="replace")
40
+ decls = manifest.parse_declared_paths(text)
41
+ pdate = manifest.plan_date_from_filename(doc.path.name)
42
+ score = manifest.score_manifest(decls, repo_root, pdate)
43
+ done, total_chk = manifest.count_checkboxes(text)
44
+ last_dt = git_state.path_last_commit_date(doc.rel, repo_root)
45
+ last_d = last_dt.date() if last_dt else None
46
+ if decls and manifest.out_of_tree_ratio(decls, repo_root) >= verdict_mod.FOREIGN_RATIO:
47
+ v = verdict_mod.Verdict(
48
+ "foreign", "🧳", "declared paths point outside this repo — misfiled?")
49
+ else:
50
+ v = verdict_mod.classify(score, done, total_chk, last_d, today, dead_days)
51
+ return {
52
+ "rel": doc.rel, "kind": doc.kind,
53
+ "verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
54
+ "files_present": score.satisfied, "files_declared": score.total,
55
+ "checkboxes_done": done, "checkboxes_total": total_chk,
56
+ "last_touched": last_d.isoformat() if last_d else None,
57
+ }
58
+
59
+
60
+ def _render(rows, repo_root) -> None:
61
+ print(f"# plan-status — {repo_root}\n")
62
+ by = {}
63
+ for r in rows:
64
+ by.setdefault(r["verdict"], []).append(r)
65
+ lie_gap = sum(
66
+ 1 for r in rows
67
+ if r["verdict"] == "shipped" and r["checkboxes_total"]
68
+ and r["checkboxes_done"] / r["checkboxes_total"] < 0.25
69
+ )
70
+ summary = " · ".join(f"{len(by[k])} {k}" for k in _ORDER if by.get(k))
71
+ print(f"{len(rows)} docs · {summary}")
72
+ print(f"lie-gap (shipped but <25% boxes checked): {lie_gap}\n")
73
+ for k in _ORDER:
74
+ group = by.get(k)
75
+ if not group:
76
+ continue
77
+ glyph = group[0]["glyph"]
78
+ print(f"## {glyph} {k} ({len(group)})")
79
+ for r in sorted(group, key=lambda x: x["rel"]):
80
+ print(f" {r['rel']}")
81
+ print(f" {r['rationale']}")
82
+ print()
83
+
84
+
85
+ def _stamp_docs(docs, rows, draft: bool) -> None:
86
+ changed = []
87
+ for doc, row in zip(docs, rows):
88
+ text = doc.path.read_text(encoding="utf-8", errors="replace")
89
+ new = status_header.stamp(text, row)
90
+ if new != text:
91
+ changed.append(doc.rel)
92
+ if not draft:
93
+ doc.path.write_text(new, encoding="utf-8")
94
+ verb = "would stamp" if draft else "stamped"
95
+ print(f"\n{verb} {len(changed)} doc(s):")
96
+ for rel in changed:
97
+ print(f" {rel}")
98
+
99
+
100
+ _LLM_VERDICTS = {"shipped", "partial", "dead"}
101
+ _LLM_GLYPH = {"shipped": "✅", "partial": "🟡", "dead": "💀"}
102
+
103
+ _LLM_PROMPT = """\
104
+ You are judging whether each doc below represents work that SHIPPED, is PARTIAL
105
+ (in progress), or is DEAD (abandoned). These are docs mechanical scoring could
106
+ not resolve: prose specs with no file list, or plans whose files look absent.
107
+ Use the title, kind, last-touched date, and excerpt. Return ONLY a JSON array:
108
+ [{"rel": "...", "verdict": "shipped|partial|dead", "confidence": 0.0-1.0,
109
+ "rationale": "one short line"}]
110
+ """
111
+
112
+
113
+ def _llm_prepare(docs, rows, repo_root) -> int:
114
+ by_rel = {d.rel: d for d in docs}
115
+ candidates = llm_evidence.select_candidates(rows)
116
+ if not candidates:
117
+ print("No docs need an LLM verdict — mechanical scoring resolved them all.")
118
+ return 0
119
+ evidence = [llm_evidence.gather_evidence(by_rel[r["rel"]], repo_root)
120
+ for r in candidates if r["rel"] in by_rel]
121
+ batch_path = cache_dir() / "plan_status.json"
122
+ batch_path.write_text(
123
+ json.dumps({"repo_root": str(repo_root), "docs": evidence}, indent=2))
124
+ answers_path = batch_path.with_suffix(".answers.json")
125
+ print(f"Wrote {len(evidence)} candidate doc(s) to {batch_path}\n")
126
+ print("=" * 60)
127
+ print(_LLM_PROMPT)
128
+ for e in evidence:
129
+ print(f"\n--- {e['rel']} ({e['kind']}, last touched {e['last_touched'] or 'unknown'}) ---")
130
+ print(f"title: {e['title']}")
131
+ print(e["excerpt"])
132
+ print("=" * 60)
133
+ print(f"\nSave the JSON array to {answers_path}")
134
+ print("Then run: python3 ~/.claude/skills/work-plan/work_plan.py "
135
+ "plan-status --repo=<key> --llm --apply")
136
+ return 0
137
+
138
+
139
+ def _llm_apply(docs, rows, repo_root, stamp: bool, draft: bool) -> int:
140
+ batch_path = cache_dir() / "plan_status.json"
141
+ answers_path = batch_path.with_suffix(".answers.json")
142
+ if not batch_path.exists() or not answers_path.exists():
143
+ print(f"ERROR: run `--llm` first; expected {answers_path}")
144
+ return 1
145
+ batch = json.loads(batch_path.read_text())
146
+ if batch.get("repo_root") != str(repo_root):
147
+ print(f"ERROR: batch repo_root '{batch.get('repo_root')}' != current "
148
+ f"'{repo_root}' — refusing to apply a batch from another repo.")
149
+ return 1
150
+ allowed = {d["rel"] for d in batch.get("docs", [])}
151
+ answers = json.loads(answers_path.read_text())
152
+
153
+ verdicts = {}
154
+ for ans in answers:
155
+ rel = ans.get("rel")
156
+ verdict = ans.get("verdict")
157
+ if rel not in allowed:
158
+ print(f" SKIP '{rel}': not in the prepared batch (possible injection).")
159
+ continue
160
+ if verdict not in _LLM_VERDICTS:
161
+ print(f" SKIP '{rel}': invalid verdict '{verdict}'.")
162
+ continue
163
+ verdicts[rel] = ans
164
+
165
+ for r in rows:
166
+ ans = verdicts.get(r["rel"])
167
+ if ans:
168
+ r["verdict"] = ans["verdict"]
169
+ r["glyph"] = _LLM_GLYPH[ans["verdict"]]
170
+ r["rationale"] = f"{ans.get('rationale', '').strip()} (LLM)"
171
+
172
+ _render(rows, repo_root)
173
+ if stamp:
174
+ _stamp_docs(docs, rows, draft=draft)
175
+ return 0
176
+
177
+
178
+ def _archive_dead(docs, rows, repo_root, draft: bool) -> int:
179
+ dead = reconcile_actions.dead_rows(rows)
180
+ if not dead:
181
+ print("No dead plans to archive.")
182
+ return 0
183
+ print(f"\n{'Would archive' if draft else 'Archive'} {len(dead)} dead plan(s):")
184
+ for r in dead:
185
+ print(f" {r['rel']} -> {reconcile_actions.archive_dest(r['rel'])}")
186
+ if draft:
187
+ return 0
188
+ if not prompt_yes_no(f"Move {len(dead)} plan(s) to archive/abandoned/? [y/N]"):
189
+ print("Skipped.")
190
+ return 0
191
+ moved = 0
192
+ for r in dead:
193
+ dest = reconcile_actions.archive_dest(r["rel"])
194
+ if git_state.git_mv(r["rel"], dest, repo_root):
195
+ moved += 1
196
+ print(f" ✓ {r['rel']}")
197
+ else:
198
+ print(f" ✗ {r['rel']} (git mv failed)")
199
+ print(f"Archived {moved}/{len(dead)}.")
200
+ return 0
201
+
202
+
203
+ def _repo_slug(flags):
204
+ """Resolve the org/repo GitHub slug for the --repo key (for issue creation)."""
205
+ repo = flags.get("--repo")
206
+ if not repo or repo is True:
207
+ return None
208
+ return config_mod.resolve_github_for_folder(repo, config_mod.load_config())
209
+
210
+
211
+ def _issues_for_partials(docs, rows, repo_root, repo_slug, draft: bool) -> int:
212
+ by_rel = {d.rel: d for d in docs}
213
+ partials = reconcile_actions.partial_rows(rows)
214
+ if not partials:
215
+ print("No partial plans to open issues for.")
216
+ return 0
217
+ items = []
218
+ for r in partials:
219
+ doc = by_rel.get(r["rel"])
220
+ if not doc:
221
+ continue
222
+ text = doc.path.read_text(encoding="utf-8", errors="replace")
223
+ decls = manifest.parse_declared_paths(text)
224
+ pdate = manifest.plan_date_from_filename(doc.path.name)
225
+ missing = manifest.unsatisfied_paths(decls, repo_root, pdate)
226
+ title, body = reconcile_actions.issue_for(doc, r, missing)
227
+ items.append((title, body))
228
+
229
+ print(f"\n{'Would open' if draft else 'Open'} {len(items)} issue(s) for partial plans:")
230
+ for title, body in items:
231
+ print(f" • {title}")
232
+ for line in body.splitlines():
233
+ if line.startswith("- [ ]"):
234
+ print(f" {line}")
235
+ if draft:
236
+ return 0
237
+ if not repo_slug:
238
+ print("ERROR: --issues needs --repo=<key> with a github slug in config.")
239
+ return 1
240
+ if not prompt_yes_no(f"Open {len(items)} GitHub issue(s) in {repo_slug}? [y/N]"):
241
+ print("Skipped.")
242
+ return 0
243
+ opened = 0
244
+ for title, body in items:
245
+ url = github_state.create_issue(repo_slug, title, body)
246
+ if url:
247
+ opened += 1
248
+ print(f" ✓ {url}")
249
+ else:
250
+ print(f" ✗ failed: {title}")
251
+ print(f"Opened {opened}/{len(items)}.")
252
+ return 0
253
+
254
+
255
+ def run(args: list) -> int:
256
+ flags, _ = parse_flags(args, KNOWN)
257
+ repo_root = _resolve_repo_root(flags)
258
+ raw_days = flags.get("--since-days")
259
+ if raw_days in (None, True):
260
+ dead_days = verdict_mod.DEAD_DAYS
261
+ else:
262
+ try:
263
+ dead_days = int(raw_days)
264
+ except ValueError:
265
+ print(f"--since-days must be an integer, got '{raw_days}'", file=sys.stderr)
266
+ return 2
267
+ today = date.today()
268
+
269
+ docs = doc_discovery.discover_docs(repo_root)
270
+ type_filter = flags.get("--type")
271
+ if type_filter and type_filter is not True:
272
+ docs = [d for d in docs if d.kind == type_filter]
273
+
274
+ rows = [_evaluate(d, repo_root, today, dead_days) for d in docs]
275
+
276
+ if flags.get("--llm"):
277
+ if flags.get("--apply"):
278
+ return _llm_apply(docs, rows, repo_root,
279
+ stamp=bool(flags.get("--stamp")),
280
+ draft=bool(flags.get("--draft")))
281
+ return _llm_prepare(docs, rows, repo_root)
282
+
283
+ if flags.get("--archive"):
284
+ return _archive_dead(docs, rows, repo_root, draft=bool(flags.get("--draft")))
285
+
286
+ if flags.get("--issues"):
287
+ return _issues_for_partials(docs, rows, repo_root, _repo_slug(flags),
288
+ draft=bool(flags.get("--draft")))
289
+
290
+ if flags.get("--json"):
291
+ print(json.dumps({"repo": str(repo_root), "docs": rows}, indent=2))
292
+ return 0
293
+ _render(rows, repo_root)
294
+ if flags.get("--stamp"):
295
+ _stamp_docs(docs, rows, draft=bool(flags.get("--draft")))
296
+ return 0
@@ -0,0 +1,225 @@
1
+ """reconcile subcommand: sync track frontmatter with GitHub issue labels.
2
+
3
+ For a given track:
4
+ - Determine the GitHub labels that mark issues as belonging to this track.
5
+ By default `track/<slug>`. Override per-track via frontmatter:
6
+ github:
7
+ labels: [storytelling, campaigns] # OR semantics — match if any present
8
+ - Fetch all issues AND pull requests with any of those labels from the repo,
9
+ in any state (open/closed/merged). PRs are included because frontmatter
10
+ `github.issues` lists may reference PR numbers, and closed-state coverage
11
+ keeps the FLAG count tied to actual frontmatter-vs-labels drift instead
12
+ of "anything closed looks unlabeled."
13
+ - Compare against frontmatter `github.issues`.
14
+ - Propose ADDS (labeled in GitHub but missing from frontmatter).
15
+ - Propose FLAGS (in frontmatter but no longer labeled — possible move out).
16
+ - User confirms before writing to the LOCAL frontmatter file.
17
+
18
+ READ-ONLY GITHUB CONTRACT
19
+ reconcile only READS GitHub via `gh issue list` and `gh pr list`. It NEVER
20
+ writes labels, edits issues, or modifies remote state. The only writes are
21
+ to the local track .md frontmatter, and only with explicit user confirmation.
22
+ Any future change must preserve this property — write paths to GitHub belong
23
+ in `suggest-priorities --apply` or `group --apply`, not here.
24
+
25
+ Run with --all to reconcile every active track in one pass.
26
+ """
27
+ import json
28
+ import subprocess
29
+ from concurrent.futures import ThreadPoolExecutor
30
+
31
+ from lib.config import load_config, ConfigError
32
+ from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
33
+ from lib.frontmatter import write_file
34
+ from lib.prompts import parse_flags, prompt_input
35
+
36
+
37
+ PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
38
+
39
+
40
+ def _resolve_labels(track) -> list[str]:
41
+ """Return the GitHub label(s) marking issues as belonging to this track.
42
+
43
+ Prefers `track.meta.github.labels` (list). Falls back to `track/<slug>`
44
+ so existing setups keep working without frontmatter changes.
45
+ """
46
+ slug = track.meta.get("track", track.name)
47
+ labels = track.meta.get("github", {}).get("labels")
48
+ if labels:
49
+ cleaned = [str(lab) for lab in labels if str(lab).strip()]
50
+ if cleaned:
51
+ return cleaned
52
+ return [f"track/{slug}"]
53
+
54
+
55
+ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
56
+ """Read-only fetch of issues + PRs matching ANY of `labels`. Unions the results.
57
+
58
+ Issues and PRs share the same numeric namespace in a GitHub repo, so a
59
+ single `seen` dict keyed on `number` is correct. Both kinds use
60
+ `--state all` so the FLAG count reflects frontmatter-vs-labels drift
61
+ rather than "anything closed/merged looks unlabeled."
62
+ """
63
+ seen: dict[int, dict] = {}
64
+ for lab in labels:
65
+ for kind in ("issue", "pr"):
66
+ try:
67
+ proc = subprocess.run(
68
+ ["gh", kind, "list", "--repo", repo,
69
+ "--label", lab,
70
+ "--state", "all", "--limit", "200",
71
+ "--json", "number,title,state"],
72
+ capture_output=True, text=True,
73
+ timeout=PER_TRACK_TIMEOUT,
74
+ )
75
+ except subprocess.TimeoutExpired:
76
+ raise RuntimeError(
77
+ f"gh {kind} query timed out for label '{lab}'"
78
+ )
79
+ if proc.returncode != 0:
80
+ raise RuntimeError(
81
+ f"gh {kind} query failed for label '{lab}': {proc.stderr.strip()}"
82
+ )
83
+ for item in (json.loads(proc.stdout) if proc.stdout.strip() else []):
84
+ seen.setdefault(item["number"], item)
85
+ return list(seen.values())
86
+
87
+
88
+ def run(args: list[str]) -> int:
89
+ flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
90
+ do_all = flags.get("--all", False)
91
+ draft = flags.get("--draft", False)
92
+ repo_key = flags.get("--repo")
93
+ if repo_key is True:
94
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
95
+ return 2
96
+ track_arg = positional[0] if positional else None
97
+
98
+ if not do_all and not track_arg and not repo_key:
99
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
100
+ return 2
101
+
102
+ track_name = track_arg
103
+ repo_qualifier = repo_key
104
+ if track_arg:
105
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
106
+ track_name = name_from_arg
107
+ if repo_from_arg:
108
+ repo_qualifier = repo_from_arg
109
+
110
+ try:
111
+ cfg = load_config()
112
+ except ConfigError as e:
113
+ print(f"ERROR: {e}")
114
+ return 1
115
+
116
+ tracks = discover_tracks(cfg)
117
+ active = [t for t in tracks if t.has_frontmatter
118
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
119
+
120
+ if do_all or (repo_key and not track_arg):
121
+ targets = active
122
+ if repo_key:
123
+ targets = filter_tracks_by_repo(targets, repo_key)
124
+ if not targets:
125
+ print(f"No active tracks for repo '{repo_key}'.")
126
+ return 0
127
+ else:
128
+ try:
129
+ target = find_track_by_name(track_name, tracks, active_only=True,
130
+ repo=repo_qualifier)
131
+ except AmbiguousTrackError as e:
132
+ print(str(e))
133
+ return 1
134
+ if not target:
135
+ print(f"No active track matching '{track_name}'.")
136
+ return 1
137
+ targets = [target]
138
+
139
+ # Phase 1: parallel fetch of labeled issues for all tracks
140
+ work_items = [(track, _resolve_labels(track)) for track in targets if track.repo]
141
+ results: dict = {} # track.name → list[dict] or None (timeout/error)
142
+
143
+ total = len(work_items)
144
+ if total > 1:
145
+ # Parallel fetch when there are multiple tracks
146
+ with ThreadPoolExecutor(max_workers=4) as pool:
147
+ submitted: list = []
148
+ for i, (track, labels) in enumerate(work_items, 1):
149
+ print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
150
+ submitted.append((i, track, pool.submit(_fetch_labeled_issues, track.repo, labels)))
151
+ # Iterate in submit order for readable output; futures run in parallel
152
+ for i, track, future in submitted:
153
+ try:
154
+ results[track.name] = future.result()
155
+ print(f" [{i}/{total}] ✓ {track.name}")
156
+ except RuntimeError as e:
157
+ print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
158
+ results[track.name] = None
159
+ else:
160
+ # Single track: fetch directly (no thread overhead)
161
+ for i, (track, labels) in enumerate(work_items, 1):
162
+ print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
163
+ try:
164
+ results[track.name] = _fetch_labeled_issues(track.repo, labels)
165
+ print(f" [{i}/{total}] ✓ {track.name}")
166
+ except RuntimeError as e:
167
+ print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
168
+ results[track.name] = None
169
+
170
+ # Phase 2: serial diff, report, and confirm (prompts must NOT be in threads)
171
+ any_changes = False
172
+ for track in targets:
173
+ slug = track.meta.get("track", track.name)
174
+ if not track.repo:
175
+ continue
176
+
177
+ labeled = results.get(track.name)
178
+ if labeled is None:
179
+ continue
180
+
181
+ labels = _resolve_labels(track)
182
+ labeled_nums = {i["number"] for i in labeled}
183
+ listed_nums = set(track.meta.get("github", {}).get("issues") or [])
184
+
185
+ adds = sorted(labeled_nums - listed_nums)
186
+ flag_nums = sorted(listed_nums - labeled_nums)
187
+
188
+ if not adds and not flag_nums:
189
+ continue
190
+
191
+ any_changes = True
192
+ labels_pretty = ", ".join(labels)
193
+ print(f"\n▸ {slug} (labels: {labels_pretty})")
194
+ if adds:
195
+ print(f" ADD ({len(adds)}) — labeled but not in frontmatter:")
196
+ issue_lookup = {i["number"]: i for i in labeled}
197
+ for num in adds:
198
+ i = issue_lookup[num]
199
+ print(f" #{num} ({i['state'].lower()}) {i['title']}")
200
+ if flag_nums:
201
+ print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
202
+ for num in flag_nums:
203
+ print(f" #{num} (label removed; consider /work-plan slot to move)")
204
+
205
+ if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
206
+ print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
207
+ print(f" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
208
+ print(f" If you just want to update issue state in the body table, try:")
209
+ print(f" /work-plan refresh-md {slug}")
210
+
211
+ if draft:
212
+ # --draft: print the analysis above and stop. No prompt, no write.
213
+ # Useful for sweep audits and scripted reports.
214
+ continue
215
+
216
+ choice = prompt_input(f"\n Apply ADDs to {track.path.name}? [y/N/skip-flags]").lower()
217
+ if choice == "y":
218
+ new_issues = sorted(listed_nums | labeled_nums)
219
+ track.meta.setdefault("github", {})["issues"] = new_issues
220
+ write_file(track.path, track.meta, track.body)
221
+ print(f" ✓ Updated {track.path.name} ({len(adds)} added)")
222
+
223
+ if not any_changes:
224
+ print("All tracks in sync with configured labels.")
225
+ return 0
@@ -0,0 +1,145 @@
1
+ """refresh-md subcommand."""
2
+ from lib.config import load_config, ConfigError
3
+ from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
4
+ from lib.github_state import fetch_issues, state_to_status_label
5
+ from lib.frontmatter import write_file
6
+ from lib.status_table import find_all_status_tables, find_canonical_status_tables, sync_missing_rows, ISSUE_NUM_RE
7
+ from lib.prompts import prompt_yes_no, parse_flags
8
+
9
+
10
+ def run(args: list[str]) -> int:
11
+ flags, positional = parse_flags(args, {"--all", "--yes", "--repo"})
12
+ do_all = flags.get("--all", False)
13
+ yes = flags.get("--yes", False)
14
+ repo_key = flags.get("--repo")
15
+ if repo_key is True:
16
+ print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
17
+ return 2
18
+ track_arg = positional[0] if positional else None
19
+
20
+ if not do_all and not track_arg and not repo_key:
21
+ print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
22
+ return 2
23
+
24
+ track_name = track_arg
25
+ repo_qualifier = repo_key
26
+ if track_arg:
27
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
28
+ track_name = name_from_arg
29
+ if repo_from_arg:
30
+ repo_qualifier = repo_from_arg
31
+
32
+ try:
33
+ cfg = load_config()
34
+ except ConfigError as e:
35
+ print(f"ERROR: {e}")
36
+ return 1
37
+
38
+ tracks = discover_tracks(cfg)
39
+ if do_all or (repo_key and not track_arg):
40
+ targets = [t for t in tracks if t.has_frontmatter
41
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
42
+ if repo_key:
43
+ targets = filter_tracks_by_repo(targets, repo_key)
44
+ if not targets:
45
+ print(f"No active tracks to refresh for repo '{repo_key}'.")
46
+ return 0
47
+ elif not targets:
48
+ print("No active tracks to refresh.")
49
+ return 0
50
+ return _refresh_many(targets, yes)
51
+
52
+ try:
53
+ track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
54
+ except AmbiguousTrackError as e:
55
+ print(str(e))
56
+ return 1
57
+ if not track:
58
+ print(f"No track matching '{track_name}'.")
59
+ return 1
60
+ return _refresh_many([track], yes)
61
+
62
+
63
+ def _refresh_many(tracks: list, yes: bool) -> int:
64
+ """Refresh one or more tracks. Computes proposed updates, then asks one
65
+ confirmation (or applies all if --yes)."""
66
+ pending = []
67
+ for i, track in enumerate(tracks, 1):
68
+ print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
69
+ canonical = find_canonical_status_tables(track.body)
70
+ all_tables = find_all_status_tables(track.body)
71
+ tables = canonical if canonical else all_tables
72
+ if not tables:
73
+ continue
74
+
75
+ all_issue_nums = set()
76
+ for table in tables:
77
+ for row in table["rows"]:
78
+ for cell in row["cells"]:
79
+ for m in ISSUE_NUM_RE.findall(cell):
80
+ all_issue_nums.add(int(m))
81
+
82
+ # Frontmatter is canonical for membership: issues listed there but
83
+ # missing from the table need a fresh row (issue #77). Fetch the union
84
+ # so appended rows carry live title/assignee/status too.
85
+ frontmatter_nums = track.meta.get("github", {}).get("issues") or []
86
+ fetch_nums = sorted(all_issue_nums | set(frontmatter_nums))
87
+ if not fetch_nums:
88
+ continue
89
+
90
+ issues = fetch_issues(track.repo, fetch_nums)
91
+ issues_by_num = {i["number"]: i for i in issues}
92
+ state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
93
+
94
+ lines = track.body.split("\n")
95
+ cell_updates = 0
96
+ for table in tables:
97
+ sidx = table["status_col_index"]
98
+ for row in table["rows"]:
99
+ nums = []
100
+ for cell in row["cells"]:
101
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
102
+ for num in nums:
103
+ if num not in state_by_num:
104
+ continue
105
+ new_status = state_by_num[num]
106
+ if sidx >= len(row["cells"]):
107
+ continue
108
+ current = row["cells"][sidx].strip()
109
+ if current == new_status.strip():
110
+ continue
111
+ new_label = new_status.strip().split(" ", 1)[-1].lower()
112
+ if new_label and new_label in current.lower():
113
+ continue
114
+ new_cells = list(row["cells"])
115
+ new_cells[sidx] = " " + new_status + " "
116
+ lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
117
+ cell_updates += 1
118
+
119
+ new_body = "\n".join(lines)
120
+ # Slot in rows for frontmatter issues missing from the table, each at
121
+ # its frontmatter-order position. Cell updates above preserve the line
122
+ # count, so the table's line indices stay valid for sync_missing_rows.
123
+ new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
124
+
125
+ if new_body == track.body:
126
+ continue
127
+ pending.append((track, new_body, cell_updates, rows_added))
128
+
129
+ if not pending:
130
+ print("All tracks in sync.")
131
+ return 0
132
+
133
+ print(f"Pending updates across {len(pending)} track(s):\n")
134
+ for track, _, cells, added in pending:
135
+ added_str = f", {added} row(s) added" if added else ""
136
+ print(f" {track.path.name:50} {cells} cell(s){added_str}")
137
+
138
+ if not yes and not prompt_yes_no("\nApply all? [y/N]"):
139
+ print("Cancelled.")
140
+ return 0
141
+
142
+ for track, new_body, _, _ in pending:
143
+ write_file(track.path, track.meta, new_body)
144
+ print(f"\n✓ Updated {len(pending)} file(s).")
145
+ return 0