@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.
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- 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
|