@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,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,172 @@
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
+
30
+ from lib.config import load_config, ConfigError
31
+ from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo
32
+ from lib.frontmatter import write_file
33
+ from lib.prompts import parse_flags, prompt_input
34
+
35
+
36
+ def _resolve_labels(track) -> list[str]:
37
+ """Return the GitHub label(s) marking issues as belonging to this track.
38
+
39
+ Prefers `track.meta.github.labels` (list). Falls back to `track/<slug>`
40
+ so existing setups keep working without frontmatter changes.
41
+ """
42
+ slug = track.meta.get("track", track.name)
43
+ labels = track.meta.get("github", {}).get("labels")
44
+ if labels:
45
+ cleaned = [str(lab) for lab in labels if str(lab).strip()]
46
+ if cleaned:
47
+ return cleaned
48
+ return [f"track/{slug}"]
49
+
50
+
51
+ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
52
+ """Read-only fetch of issues + PRs matching ANY of `labels`. Unions the results.
53
+
54
+ Issues and PRs share the same numeric namespace in a GitHub repo, so a
55
+ single `seen` dict keyed on `number` is correct. Both kinds use
56
+ `--state all` so the FLAG count reflects frontmatter-vs-labels drift
57
+ rather than "anything closed/merged looks unlabeled."
58
+ """
59
+ seen: dict[int, dict] = {}
60
+ for lab in labels:
61
+ for kind in ("issue", "pr"):
62
+ proc = subprocess.run(
63
+ ["gh", kind, "list", "--repo", repo,
64
+ "--label", lab,
65
+ "--state", "all", "--limit", "200",
66
+ "--json", "number,title,state"],
67
+ capture_output=True, text=True,
68
+ )
69
+ if proc.returncode != 0:
70
+ raise RuntimeError(
71
+ f"gh {kind} query failed for label '{lab}': {proc.stderr.strip()}"
72
+ )
73
+ for item in (json.loads(proc.stdout) if proc.stdout.strip() else []):
74
+ seen.setdefault(item["number"], item)
75
+ return list(seen.values())
76
+
77
+
78
+ def run(args: list[str]) -> int:
79
+ flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
80
+ do_all = flags.get("--all", False)
81
+ draft = flags.get("--draft", False)
82
+ repo_key = flags.get("--repo")
83
+ if repo_key is True:
84
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
85
+ return 2
86
+ track_name = positional[0] if positional else None
87
+
88
+ if not do_all and not track_name and not repo_key:
89
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
90
+ return 2
91
+
92
+ try:
93
+ cfg = load_config()
94
+ except ConfigError as e:
95
+ print(f"ERROR: {e}")
96
+ return 1
97
+
98
+ tracks = discover_tracks(cfg)
99
+ active = [t for t in tracks if t.has_frontmatter
100
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
101
+
102
+ if do_all or repo_key:
103
+ targets = active
104
+ if repo_key:
105
+ targets = filter_tracks_by_repo(targets, repo_key)
106
+ if not targets:
107
+ print(f"No active tracks for repo '{repo_key}'.")
108
+ return 0
109
+ else:
110
+ target = find_track_by_name(track_name, tracks, active_only=True)
111
+ if not target:
112
+ print(f"No active track matching '{track_name}'.")
113
+ return 1
114
+ targets = [target]
115
+
116
+ any_changes = False
117
+ for track in targets:
118
+ slug = track.meta.get("track", track.name)
119
+ if not track.repo:
120
+ continue
121
+
122
+ labels = _resolve_labels(track)
123
+ try:
124
+ labeled = _fetch_labeled_issues(track.repo, labels)
125
+ except RuntimeError as e:
126
+ print(f" ⚠ {slug}: {e}")
127
+ continue
128
+
129
+ labeled_nums = {i["number"] for i in labeled}
130
+ listed_nums = set(track.meta.get("github", {}).get("issues") or [])
131
+
132
+ adds = sorted(labeled_nums - listed_nums)
133
+ flag_nums = sorted(listed_nums - labeled_nums)
134
+
135
+ if not adds and not flag_nums:
136
+ continue
137
+
138
+ any_changes = True
139
+ labels_pretty = ", ".join(labels)
140
+ print(f"\n▸ {slug} (labels: {labels_pretty})")
141
+ if adds:
142
+ print(f" ADD ({len(adds)}) — labeled but not in frontmatter:")
143
+ issue_lookup = {i["number"]: i for i in labeled}
144
+ for num in adds:
145
+ i = issue_lookup[num]
146
+ print(f" #{num} ({i['state'].lower()}) {i['title']}")
147
+ if flag_nums:
148
+ print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
149
+ for num in flag_nums:
150
+ print(f" #{num} (label removed; consider /work-plan slot to move)")
151
+
152
+ if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
153
+ print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
154
+ print(f" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
155
+ print(f" If you just want to update issue state in the body table, try:")
156
+ print(f" /work-plan refresh-md {slug}")
157
+
158
+ if draft:
159
+ # --draft: print the analysis above and stop. No prompt, no write.
160
+ # Useful for sweep audits and scripted reports.
161
+ continue
162
+
163
+ choice = prompt_input(f"\n Apply ADDs to {track.path.name}? [y/N/skip-flags]").lower()
164
+ if choice == "y":
165
+ new_issues = sorted(listed_nums | labeled_nums)
166
+ track.meta.setdefault("github", {})["issues"] = new_issues
167
+ write_file(track.path, track.meta, track.body)
168
+ print(f" ✓ Updated {track.path.name} ({len(adds)} added)")
169
+
170
+ if not any_changes:
171
+ print("All tracks in sync with configured labels.")
172
+ return 0
@@ -0,0 +1,132 @@
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
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_name = positional[0] if positional else None
19
+
20
+ if not do_all and not track_name and not repo_key:
21
+ print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
22
+ return 2
23
+
24
+ try:
25
+ cfg = load_config()
26
+ except ConfigError as e:
27
+ print(f"ERROR: {e}")
28
+ return 1
29
+
30
+ tracks = discover_tracks(cfg)
31
+ if do_all or repo_key:
32
+ targets = [t for t in tracks if t.has_frontmatter
33
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
34
+ if repo_key:
35
+ targets = filter_tracks_by_repo(targets, repo_key)
36
+ if not targets:
37
+ print(f"No active tracks to refresh for repo '{repo_key}'.")
38
+ return 0
39
+ elif not targets:
40
+ print("No active tracks to refresh.")
41
+ return 0
42
+ return _refresh_many(targets, yes)
43
+
44
+ track = find_track_by_name(track_name, tracks)
45
+ if not track:
46
+ print(f"No track matching '{track_name}'.")
47
+ return 1
48
+ return _refresh_many([track], yes)
49
+
50
+
51
+ def _refresh_many(tracks: list, yes: bool) -> int:
52
+ """Refresh one or more tracks. Computes proposed updates, then asks one
53
+ confirmation (or applies all if --yes)."""
54
+ pending = []
55
+ for track in tracks:
56
+ canonical = find_canonical_status_tables(track.body)
57
+ all_tables = find_all_status_tables(track.body)
58
+ tables = canonical if canonical else all_tables
59
+ if not tables:
60
+ continue
61
+
62
+ all_issue_nums = set()
63
+ for table in tables:
64
+ for row in table["rows"]:
65
+ for cell in row["cells"]:
66
+ for m in ISSUE_NUM_RE.findall(cell):
67
+ all_issue_nums.add(int(m))
68
+
69
+ # Frontmatter is canonical for membership: issues listed there but
70
+ # missing from the table need a fresh row (issue #77). Fetch the union
71
+ # so appended rows carry live title/assignee/status too.
72
+ frontmatter_nums = track.meta.get("github", {}).get("issues") or []
73
+ fetch_nums = sorted(all_issue_nums | set(frontmatter_nums))
74
+ if not fetch_nums:
75
+ continue
76
+
77
+ issues = fetch_issues(track.repo, fetch_nums)
78
+ issues_by_num = {i["number"]: i for i in issues}
79
+ state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
80
+
81
+ lines = track.body.split("\n")
82
+ cell_updates = 0
83
+ for table in tables:
84
+ sidx = table["status_col_index"]
85
+ for row in table["rows"]:
86
+ nums = []
87
+ for cell in row["cells"]:
88
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
89
+ for num in nums:
90
+ if num not in state_by_num:
91
+ continue
92
+ new_status = state_by_num[num]
93
+ if sidx >= len(row["cells"]):
94
+ continue
95
+ current = row["cells"][sidx].strip()
96
+ if current == new_status.strip():
97
+ continue
98
+ new_label = new_status.strip().split(" ", 1)[-1].lower()
99
+ if new_label and new_label in current.lower():
100
+ continue
101
+ new_cells = list(row["cells"])
102
+ new_cells[sidx] = " " + new_status + " "
103
+ lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
104
+ cell_updates += 1
105
+
106
+ new_body = "\n".join(lines)
107
+ # Slot in rows for frontmatter issues missing from the table, each at
108
+ # its frontmatter-order position. Cell updates above preserve the line
109
+ # count, so the table's line indices stay valid for sync_missing_rows.
110
+ new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
111
+
112
+ if new_body == track.body:
113
+ continue
114
+ pending.append((track, new_body, cell_updates, rows_added))
115
+
116
+ if not pending:
117
+ print("All tracks in sync.")
118
+ return 0
119
+
120
+ print(f"Pending updates across {len(pending)} track(s):\n")
121
+ for track, _, cells, added in pending:
122
+ added_str = f", {added} row(s) added" if added else ""
123
+ print(f" {track.path.name:50} {cells} cell(s){added_str}")
124
+
125
+ if not yes and not prompt_yes_no("\nApply all? [y/N]"):
126
+ print("Cancelled.")
127
+ return 0
128
+
129
+ for track, new_body, _, _ in pending:
130
+ write_file(track.path, track.meta, new_body)
131
+ print(f"\n✓ Updated {len(pending)} file(s).")
132
+ return 0
@@ -0,0 +1,54 @@
1
+ """set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
2
+ import json
3
+ from lib.config import load_config, ConfigError
4
+ from lib.tracks import discover_tracks, find_track_by_name
5
+ from lib.frontmatter import write_file
6
+ from lib.write_guard import needs_confirm, make_token, valid_token
7
+ from lib.prompts import parse_flags
8
+
9
+ ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up"}
10
+ LIST_FIELDS = {"blockers", "next_up"}
11
+ STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
12
+
13
+ def run(args: list[str]) -> int:
14
+ # Confirm token is passed as --confirm=<token> (equals form: parse_flags only
15
+ # understands --key=value or bare --key, so a space-separated token would be
16
+ # mis-read as a positional). The VS Code extension invokes the equals form.
17
+ flags, positional = parse_flags(args, {"--confirm"})
18
+ if len(positional) < 2:
19
+ print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>]"); return 2
20
+ name, assignments = positional[0], positional[1:]
21
+ parsed = {}
22
+ for a in assignments:
23
+ if "=" not in a:
24
+ print(f"ERROR: bad assignment {a!r} (expected field=value)"); return 2
25
+ k, v = a.split("=", 1)
26
+ if k not in ALLOWED:
27
+ print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
28
+ if k in LIST_FIELDS:
29
+ try:
30
+ parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
31
+ except ValueError:
32
+ print(f"ERROR: {k} takes comma-separated integers (got {v!r})"); return 2
33
+ elif k == "status" and v not in STATUSES:
34
+ print(f"ERROR: status {v!r} invalid (allowed: {sorted(STATUSES)})"); return 2
35
+ else:
36
+ parsed[k] = v
37
+ try:
38
+ cfg = load_config()
39
+ except ConfigError as e:
40
+ print(f"ERROR: {e}"); return 1
41
+ track = find_track_by_name(name, discover_tracks(cfg))
42
+ if not track:
43
+ print(f"No track matching {name!r}."); return 1
44
+ # Public-repo confirm gate (the extension surfaces this as a modal).
45
+ confirm = flags.get("--confirm")
46
+ if track.repo and needs_confirm(track.repo, cfg) and not (isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)):
47
+ print(json.dumps({"needs_confirm": True,
48
+ "reason": f"{track.repo} is PUBLIC (or visibility unknown); edit will be written there.",
49
+ "token": make_token(track.repo, track.name)}))
50
+ return 0
51
+ track.meta.update(parsed)
52
+ write_file(track.path, track.meta, track.body)
53
+ print(f"✓ set {', '.join(parsed)} on {track.name}")
54
+ return 0