@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,119 @@
1
+ ---
2
+ name: work-plan
3
+ description: Use when starting or ending a work session across many GitHub issues, switching between parallel agent sessions on different workstreams, re-orienting on what to do next, sweeping for stale tracking state, or bootstrapping a new repo into a daily-planning system.
4
+ argument-hint: "[brief|handoff|orient|reconcile|hygiene|--help]"
5
+ ---
6
+
7
+ # Work Plan
8
+
9
+ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file that references GitHub issues by ID; the CLI derives state live from `gh`/`git`. Composes with `/repo-activity-summary` for the global multi-repo view.
10
+
11
+ ## Subcommand reference
12
+
13
+ | Subcommand | When |
14
+ |---|---|
15
+ | `/work-plan brief` | Starting work or after a gap. Multi-track snapshot. |
16
+ | `/work-plan handoff [track] [--auto-next \| --set-next 1,2,3]` | Wrapping up a work block. Captures touched + next + blockers; writes session log. Add `--auto-next` to suggest a priority-sorted next_up list from open issues (interactive: apply / edit / skip). Tracks with `next_up_auto: true` in frontmatter get the auto-derived list surfaced in `brief` automatically. |
17
+ | `/work-plan orient [track]` (alias `where-was-i`) | Re-orienting. With a track: ~15-line track paste-block. Without: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
18
+ | `/work-plan hygiene` | Weekly all-in-one cleanup: refresh-md --all + reconcile --all + duplicates. |
19
+ | `/work-plan slot <issue-num> [track]` | A new GitHub issue should belong to a track. If the issue is already listed in another active track's frontmatter, you'll be prompted to move it (remove from source) instead of duplicating. |
20
+ | `/work-plan close [track]` | Track is done (shipped) / paused (parked) / won't ship (abandoned). |
21
+ | `/work-plan refresh-md <track> \| --all` | Status icons drifted from GitHub state. **You usually don't need to call this directly:** `handoff` already rewrites the body table for its own track on every run, and `brief` reads GitHub live. Reach for `refresh-md` (or `--all`) when a sibling track has drifted because you haven't `handoff`'d it lately. |
22
+ | `/work-plan list [--all]` | List active tracks (or all including parked/archived). |
23
+ | `/work-plan init <path>` | Add frontmatter to a new track .md file. |
24
+ | `/work-plan init-repo <key> [--github=<slug>] [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. |
25
+ | `/work-plan suggest-priorities --repo=<key>` | Two-step AI label backfill (one-time migration). |
26
+ | `/work-plan group [--milestone=X] [--label=Y] [--repo=Z]` | Two-step AI clustering: turn a flat list of issues into thematic track files. |
27
+ | `/work-plan reconcile <track> \| --all [--draft]` | Sync track frontmatter with GitHub labels (read-only on GitHub). Default label is `track/<slug>`; override per-track via `github.labels` in frontmatter. Add `--draft` to preview proposed ADDs/FLAGs without prompting or writing. |
28
+ | `/work-plan duplicates [--min-similarity=0.7]` | Find likely-duplicate issues by title similarity (stdlib difflib). |
29
+ | `/work-plan plan-status [--repo=<key>] [--stamp [--draft]] [--type=plan\|spec]` | **Doc/plan liveness.** "Which of my plan/spec docs actually shipped, half-shipped, or died?" Correlates each plan's declared file-manifest (Create/Modify/Test paths) against git + filesystem — not the unreliable checkboxes. Reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. Read-only by default; `--stamp` writes an idempotent status header into each doc (`--draft` previews, writes nothing). Natural-language triggers: "what's done vs unfinished in `<repo>`", "stamp the plan statuses", "which plans are stale/dead". |
30
+
31
+ ## How to invoke
32
+
33
+ All subcommands route through the Python CLI. Prefer the `work-plan` launcher (on
34
+ PATH as a plugin, and installed by `install.sh`): `work-plan <subcommand>`. It
35
+ resolves `work_plan.py` relative to itself, then via `${CLAUDE_PLUGIN_ROOT}` /
36
+ `${PLUGIN_ROOT}` / `~/.claude` / `~/.agents`. If the launcher isn't on PATH, call
37
+ the CLI directly, first match wins:
38
+
39
+ 1. `${CLAUDE_PLUGIN_ROOT}/skills/work-plan/work_plan.py` (Claude plugin; Codex sets this too)
40
+ 2. `${PLUGIN_ROOT}/skills/work-plan/work_plan.py` (Codex plugin)
41
+ 3. `~/.claude/skills/work-plan/work_plan.py` (install.sh → Claude Code)
42
+ 4. `~/.agents/skills/work-plan/work_plan.py` (install.sh → Codex)
43
+
44
+ Run via Bash. Don't reimplement the logic in chat.
45
+
46
+ ## Verbatim relay (orientation subcommands)
47
+
48
+ For `brief`, `handoff`, `orient` (`where-was-i`), and `hygiene`, the Python output IS the deliverable. After running the Bash command, **reproduce the full Python output verbatim in a fenced code block in your chat reply.** Don't summarize, paraphrase, or truncate — users copy-paste from chat into other terminals/sessions, so any rewording loses information.
49
+
50
+ `plan-status` is also verbatim-relay, with one exception: its report can run to hundreds of docs. If the output is large, relay the headline line (counts + lie-gap) and the actionable **🟡 partial** bucket verbatim, then offer the full report rather than flooding the chat. The `--stamp`/`--draft` summary line (`stamped N doc(s)` / `would stamp N doc(s)`) is always relayed verbatim.
51
+
52
+ ## Handoff: Claude-driven `next_up`
53
+
54
+ After running `handoff`:
55
+
56
+ 1. Read the output (open issues, last session log, priority, milestone).
57
+ 2. Survey the user's project memory (e.g., a `MEMORY.md` index in their working directory or `~/.claude/projects/.../memory/`) for related signals — deploy gates, blocked items, in-flight clusters.
58
+ 3. Pick a "next" — single ticket OR tight cluster (2-4 issues) — based on track priority, milestone, what's gating other work, what cluster naturally goes together.
59
+ 4. Justify the pick in chat (1-2 sentences).
60
+ 5. Persist via `python3 <skill-path>/work_plan.py handoff <track> --set-next <comma-list>` (e.g. `--set-next 4167,4148,4149`).
61
+ 6. Show the user what was set so they can override.
62
+
63
+ ## Two-step AI subcommands (`suggest-priorities`, `group`)
64
+
65
+ Both are two-step:
66
+
67
+ 1. CLI fetches issues + writes prompt to terminal.
68
+ 2. **You** read the issues, output the requested JSON, save via Write tool to `~/.claude/work-plan/cache/priorities.answers.json` or `~/.claude/work-plan/cache/groups.answers.json`.
69
+ 3. Re-run with `--apply` to commit changes.
70
+
71
+ Show the proposed labels/clusters BEFORE applying. The user may want to override.
72
+
73
+ ## Track ↔ GitHub label mapping
74
+
75
+ By default, `reconcile` and the `brief` new-issue suggester look for the label `track/<slug>` on GitHub issues. If your repo uses a different scheme (flat labels like `storytelling`, namespaced labels like `area/maps`, or no `track/*` namespace at all), declare the labels per-track in the markdown frontmatter:
76
+
77
+ ```yaml
78
+ ---
79
+ track: storytelling-enhancements
80
+ status: active
81
+ github:
82
+ repo: your-org/your-repo
83
+ labels: [storytelling, campaigns] # OR semantics — issue matches if ANY label is present
84
+ issues: [4296, 4290, ...]
85
+ ---
86
+ ```
87
+
88
+ This is read-only on GitHub: the skill never adds, removes, or rewrites labels on the remote — it only reads them to know which issues belong to a track. The only writes are to your local markdown frontmatter, gated behind interactive confirmation. If `github.labels` is omitted, the default `track/<slug>` pattern is used (existing setups keep working unchanged).
89
+
90
+ ## Track ↔ commit attribution
91
+
92
+ `handoff` shows commits attributed to a track since the last handoff. Attribution rules (in order):
93
+
94
+ 1. **Explicit branches** — if frontmatter has `github.branches: [feature/x, ...]`, only commits on those branches count. Path globs do not apply.
95
+ 2. **Issue mention OR path glob** — otherwise, scan all branches and keep commits whose message (subject OR body) mentions an issue in `github.issues`, OR whose changed paths match any glob in `github.paths` (fnmatch syntax — `*`, `?`, `**`, `[seq]`). Scanning the body matters for squash-merged PRs whose subjects follow Conventional Commits (e.g. `feat(scope): description`) and carry the issue ref in the body (`Closes #1234`).
96
+
97
+ ```yaml
98
+ github:
99
+ repo: your-org/your-repo
100
+ issues: [4148, 4149, ...]
101
+ paths:
102
+ - "apps/web/src/components/ux/**"
103
+ - "**/useToast*"
104
+ ```
105
+
106
+ When zero commits attribute to the track but the repo has activity in the same window, the handoff renders a soft signal (`0 attributed / N repo-wide since last handoff`) so the silence isn't mistaken for "nothing happened."
107
+
108
+ ## Setup
109
+
110
+ Run `./install.sh` (macOS / Linux / WSL) or `.\install.ps1` (Windows) from the toolkit root. Then `/work-plan init-repo <key> --github=<org/repo>` to bootstrap your first repo. See the toolkit README for full setup, requirements, and platform-specific install commands.
111
+
112
+ ## Common mistakes
113
+
114
+ | Mistake | Fix |
115
+ |---|---|
116
+ | Calling `gh` directly to check issue state | `brief` / `orient` already do it, with track context. |
117
+ | Editing track frontmatter manually | Prefer `handoff` or `slot` — they update timestamps and dedupe. |
118
+ | Forgetting to label issues with `priority/PN` | `brief` sorts by priority; without labels everything looks the same. |
119
+ | Setting `local:` in config to a path that doesn't exist | In-progress detection silently no-ops. Verify path. |
File without changes
@@ -0,0 +1,247 @@
1
+ """brief subcommand — fully featured."""
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from lib.config import load_config, ConfigError
6
+ from lib.tracks import discover_tracks, discover_archived_tracks, filter_tracks_by_repo
7
+ from lib.github_state import fetch_issues, extract_priority, short_milestone
8
+ from lib.prompts import parse_flags
9
+ from lib.git_state import (
10
+ parse_iso_timestamp, gap_seconds_to_label,
11
+ branch_in_progress, commits_ahead, uncommitted_file_count, current_branch,
12
+ )
13
+ from lib.closure import compute_signals, is_closure_ready
14
+ from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
15
+ from lib.next_up import suggest_next_up
16
+ from lib.drift import detect_drift
17
+ from lib.render import time_aware_framing, render_track_row, render_archived_reopen
18
+
19
+
20
+ def run(args: list[str]) -> int:
21
+ flags, _ = parse_flags(args, {"--repo"})
22
+ repo_key = flags.get("--repo")
23
+ if repo_key is True:
24
+ print("usage: work_plan.py brief [--repo=<key>]")
25
+ return 2
26
+
27
+ try:
28
+ cfg = load_config()
29
+ except ConfigError as e:
30
+ print(f"ERROR: {e}", flush=True)
31
+ return 1
32
+
33
+ tracks = discover_tracks(cfg)
34
+ if repo_key:
35
+ scoped = filter_tracks_by_repo(tracks, repo_key)
36
+ if not scoped:
37
+ print(f"No tracks found for repo '{repo_key}'.")
38
+ available = sorted((cfg.get("repos") or {}).keys())
39
+ if available:
40
+ print(f"Configured repo keys: {', '.join(available)}")
41
+ return 0
42
+ tracks = scoped
43
+ active = [t for t in tracks if t.has_frontmatter
44
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
45
+
46
+ if not active and not tracks:
47
+ print("No tracks found.")
48
+ return 0
49
+
50
+ now = datetime.now()
51
+ most_recent = max(
52
+ (parse_iso_timestamp(t.meta["last_touched"]) for t in active if t.meta.get("last_touched")),
53
+ default=None,
54
+ )
55
+ gap = int((now - most_recent).total_seconds()) if most_recent else 999999
56
+ handoff_today = any(
57
+ t.meta.get("last_handoff", "").startswith(now.strftime("%Y-%m-%d")) for t in active
58
+ )
59
+ framing = time_aware_framing(gap, now.hour, handoff_today)
60
+
61
+ print(f"DAILY BRIEF — {now.strftime('%Y-%m-%d %H:%M')} (gap: {gap_seconds_to_label(gap)})")
62
+ print()
63
+ print(framing)
64
+ print()
65
+
66
+ blocks = []
67
+ for t in active:
68
+ b = _build_track_block(t, cfg, now)
69
+ blocks.append((b["sort_key"], b))
70
+
71
+ blocks.sort(key=lambda x: x[0])
72
+ for _, block in blocks:
73
+ print(render_track_row(block))
74
+ print()
75
+
76
+ needs_init = [t for t in tracks if t.needs_init]
77
+ needs_filing = [t for t in tracks if t.needs_filing]
78
+ if needs_init or needs_filing:
79
+ print("--- Setup needed ---")
80
+ for t in needs_init:
81
+ print(f" needs init: {t.path} → /work-plan init '{t.path}'")
82
+ for t in needs_filing:
83
+ print(f" needs filing: {t.path} → move into a repo subfolder")
84
+ print()
85
+
86
+ _surface_archived_reopens(cfg, repo_key=repo_key)
87
+
88
+ n_active = len(active)
89
+ n_in_progress = sum(1 for _, b in blocks if b["operational_status"] == "in-progress")
90
+ n_closure = sum(1 for _, b in blocks if b["closure_ready"])
91
+ n_drift = sum(1 for _, b in blocks if b["drift_items"])
92
+ n_new = sum(len(b["new_issues"]) for _, b in blocks)
93
+ print(f"{n_active} active tracks. "
94
+ f"{n_in_progress} in-progress. {n_closure} closure-ready. "
95
+ f"{n_drift} with drift. {n_new} new issues to slot.")
96
+
97
+ return 0
98
+
99
+
100
+ def _build_track_block(track, cfg, now: datetime) -> dict:
101
+ meta = track.meta
102
+ repo = track.repo
103
+ local = track.local_path
104
+
105
+ issue_nums = meta.get("github", {}).get("issues") or []
106
+ stored_next_up = meta.get("next_up") or []
107
+ # Fetch state for stored next_up issues even if they're not in github.issues,
108
+ # so stale closed entries surface as a clear signal rather than vanishing.
109
+ fetch_nums = sorted(set(issue_nums) | set(stored_next_up))
110
+ issues = fetch_issues(repo, fetch_nums) if (repo and fetch_nums) else []
111
+ issues_by_num = {i["number"]: i for i in issues}
112
+
113
+ # When `next_up_auto: true` is set in track frontmatter, derive the list
114
+ # live from open issues (priority-sorted, blockers excluded) instead of
115
+ # reading the stored `next_up`. The track's persisted list is ignored
116
+ # for display purposes — useful for tracks where you don't want to
117
+ # hand-curate but still want a sensible "what's next" surfaced.
118
+ track_milestone = meta.get("milestone_alignment") or None
119
+ if meta.get("next_up_auto") and issues:
120
+ blocker_nums = meta.get("blockers") or []
121
+ next_up_nums = suggest_next_up(issues, blocker_nums, track_milestone=track_milestone)
122
+ else:
123
+ next_up_nums = stored_next_up
124
+
125
+ next_up_items = []
126
+ next_up_closed_count = 0
127
+ for num in next_up_nums:
128
+ i = issues_by_num.get(num)
129
+ if not i:
130
+ continue
131
+ state = (i.get("state") or "").upper()
132
+ if state in ("CLOSED", "MERGED"):
133
+ next_up_closed_count += 1
134
+ continue
135
+ next_up_items.append({
136
+ "number": num, "title": i.get("title", ""),
137
+ "priority": extract_priority(i.get("labels", [])),
138
+ "state": state.lower() or "open",
139
+ "milestone": short_milestone(i.get("milestone")),
140
+ })
141
+
142
+ branch_names = meta.get("github", {}).get("branches") or []
143
+ active_branches = []
144
+ branch_in_prog = False
145
+ for bn in branch_names:
146
+ in_prog = branch_in_progress(bn, local)
147
+ if in_prog:
148
+ branch_in_prog = True
149
+ active_branches.append({
150
+ "name": bn,
151
+ "ahead": commits_ahead(bn, "dev", local) if local else 0,
152
+ "uncommitted_files": (
153
+ uncommitted_file_count(local)
154
+ if local and current_branch(local) == bn else 0
155
+ ),
156
+ })
157
+
158
+ stored_status = meta.get("status", "active")
159
+ if stored_status == "active" and branch_in_prog:
160
+ operational_status = "in-progress"
161
+ else:
162
+ operational_status = stored_status
163
+
164
+ track_slug = meta.get("track", track.name)
165
+ slug_labels = build_slug_labels([track])
166
+ new_issues_map = find_new_issues_for_tracks(repo, [track_slug], slug_labels=slug_labels, since_days=7) if repo else {}
167
+ listed_set = set(issue_nums)
168
+ new_issues = []
169
+ for issue in new_issues_map.get(track_slug, []):
170
+ if issue["number"] in listed_set:
171
+ continue
172
+ new_issues.append({"number": issue["number"], "title": issue["title"]})
173
+
174
+ drift_items = detect_drift(track.body, issues) if issues else []
175
+
176
+ related_recent_count = len(new_issues_map.get(track_slug, []))
177
+ signals = compute_signals(meta, issues, local, related_recent_count)
178
+ closure_ready, _ = is_closure_ready(signals)
179
+ if closure_ready:
180
+ closure_signals_summary = None
181
+ else:
182
+ green = sum([signals.all_issues_closed, signals.all_branches_done,
183
+ signals.next_up_empty, signals.cold_14d, signals.no_recent_related_issues])
184
+ closure_signals_summary = f"{green}/5 signals green"
185
+
186
+ blockers = [{"number": bn, "reason": "manually flagged"}
187
+ for bn in (meta.get("blockers") or [])]
188
+
189
+ def lbl(key):
190
+ if not meta.get(key):
191
+ return "?"
192
+ gs = (now - parse_iso_timestamp(meta[key])).total_seconds()
193
+ return gap_seconds_to_label(int(gs))
194
+
195
+ in_prog_rank = 0 if operational_status == "in-progress" else 1
196
+ pri_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(meta.get("launch_priority", "P3"), 3)
197
+ recency_key = (
198
+ -parse_iso_timestamp(meta["last_touched"]).timestamp()
199
+ if meta.get("last_touched") else 0
200
+ )
201
+
202
+ return {
203
+ "name": meta.get("track", track.name),
204
+ "operational_status": operational_status,
205
+ "launch_priority": meta.get("launch_priority", "P3"),
206
+ "milestone_alignment": meta.get("milestone_alignment", "—"),
207
+ "last_touched_label": lbl("last_touched"),
208
+ "last_handoff_label": lbl("last_handoff"),
209
+ "next_up": next_up_items,
210
+ "next_up_stale_closed_count": (
211
+ next_up_closed_count if not next_up_items and next_up_nums else 0
212
+ ),
213
+ "track_slug": meta.get("track", track.name),
214
+ "active_branches": active_branches,
215
+ "new_issues": new_issues,
216
+ "blockers": blockers,
217
+ "drift_items": drift_items,
218
+ "closure_ready": closure_ready,
219
+ "closure_signals_summary": closure_signals_summary,
220
+ "archived_reopen": [],
221
+ "sort_key": (in_prog_rank, pri_rank, recency_key),
222
+ }
223
+
224
+
225
+ def _surface_archived_reopens(cfg: dict, repo_key: str = None) -> None:
226
+ archived = discover_archived_tracks(cfg)
227
+ if repo_key:
228
+ archived = filter_tracks_by_repo(archived, repo_key)
229
+ if not archived:
230
+ return
231
+ by_repo: dict[str, list] = {}
232
+ for a in archived:
233
+ if a.repo:
234
+ by_repo.setdefault(a.repo, []).append(a)
235
+ callouts = []
236
+ for repo, tracks_in_repo in by_repo.items():
237
+ slugs = [a.meta.get("track", a.name) for a in tracks_in_repo]
238
+ slug_labels = build_slug_labels(tracks_in_repo)
239
+ new_map = find_new_issues_for_tracks(repo, slugs, slug_labels=slug_labels, since_days=14)
240
+ for slug, issues in new_map.items():
241
+ for issue in issues:
242
+ callouts.append((slug, issue))
243
+ if callouts:
244
+ print("--- Archived tracks with new activity ---")
245
+ for slug, issue in callouts:
246
+ print(" " + render_archived_reopen(repo, slug, issue))
247
+ print()
@@ -0,0 +1,122 @@
1
+ """canonicalize subcommand: add a canonical master issue table to a track.
2
+
3
+ Generates one-row-per-issue table from frontmatter github.issues, with assignee
4
+ and status columns. Inserts at top of body with a marker so refresh-md targets
5
+ ONLY this table (skipping narrative tables in the existing body).
6
+
7
+ Use --all to canonicalize every active track that doesn't yet have one.
8
+ """
9
+ from lib.config import load_config, ConfigError
10
+ from lib.tracks import discover_tracks, find_track_by_name
11
+ from lib.github_state import fetch_issues, state_to_status_label, format_assignees
12
+ from lib.frontmatter import write_file
13
+ from lib.status_table import CANONICAL_MARKER, find_canonical_status_tables, render_issue_row
14
+ from lib.prompts import parse_flags
15
+
16
+
17
+ def run(args: list[str]) -> int:
18
+ flags, positional = parse_flags(args, {"--all", "--force"})
19
+ do_all = flags.get("--all", False)
20
+ force = flags.get("--force", False)
21
+ track_name = positional[0] if positional else None
22
+
23
+ if not do_all and not track_name:
24
+ print("usage: work_plan.py canonicalize <track-name> | --all [--force]")
25
+ return 2
26
+
27
+ try:
28
+ cfg = load_config()
29
+ except ConfigError as e:
30
+ print(f"ERROR: {e}")
31
+ return 1
32
+
33
+ tracks = discover_tracks(cfg)
34
+
35
+ if do_all:
36
+ targets = [t for t in tracks if t.has_frontmatter
37
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
38
+ else:
39
+ target = find_track_by_name(track_name, tracks, active_only=True)
40
+ if not target:
41
+ print(f"No active track matching '{track_name}'.")
42
+ return 1
43
+ targets = [target]
44
+
45
+ any_changes = False
46
+ for track in targets:
47
+ existing = find_canonical_status_tables(track.body)
48
+ if existing and not force:
49
+ print(f" skip {track.name}: already has canonical table (use --force to replace)")
50
+ continue
51
+
52
+ issue_nums = track.meta.get("github", {}).get("issues") or []
53
+ if not issue_nums or not track.repo:
54
+ print(f" skip {track.name}: no issues or repo")
55
+ continue
56
+
57
+ print(f" fetching {len(issue_nums)} issue(s) for {track.name}...")
58
+ issues = fetch_issues(track.repo, issue_nums)
59
+ issues_by_num = {i["number"]: i for i in issues}
60
+
61
+ new_body = _insert_canonical_table(
62
+ track.body, issue_nums, issues_by_num, replace=force,
63
+ )
64
+ write_file(track.path, track.meta, new_body)
65
+ print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
66
+ any_changes = True
67
+
68
+ if not any_changes:
69
+ print("Nothing to do.")
70
+ return 0
71
+
72
+
73
+ def _insert_canonical_table(body: str, issue_nums: list[int],
74
+ issues_by_num: dict, replace: bool = False) -> str:
75
+ """Insert (or replace) a canonical table at the top of the body."""
76
+ table_md = _render_canonical_table(issue_nums, issues_by_num)
77
+
78
+ if replace:
79
+ # Strip existing canonical block (marker + heading + table + separator)
80
+ body = _strip_existing_canonical(body)
81
+
82
+ # Prepend table after any leading whitespace
83
+ body_stripped = body.lstrip("\n")
84
+ leading_whitespace = body[: len(body) - len(body_stripped)]
85
+ return leading_whitespace + table_md + "\n---\n\n" + body_stripped
86
+
87
+
88
+ def _render_canonical_table(issue_nums: list[int], issues_by_num: dict) -> str:
89
+ lines = [
90
+ "## Issues (canonical)",
91
+ "",
92
+ f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
93
+ "",
94
+ "| # | Title | Assignee | Status |",
95
+ "|---|---|---|---|",
96
+ ]
97
+ for num in sorted(issue_nums):
98
+ i = issues_by_num.get(num, {})
99
+ lines.append(render_issue_row(
100
+ num, i.get("title", "(not fetched)"),
101
+ format_assignees(i), state_to_status_label(i.get("state")),
102
+ ))
103
+ lines.append("")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def _strip_existing_canonical(body: str) -> str:
108
+ """Remove an existing canonical-table block from the top of the body."""
109
+ if CANONICAL_MARKER not in body:
110
+ return body
111
+ # Find the start of the heading "## Issues (canonical)" if present, else the marker
112
+ heading_idx = body.find("## Issues (canonical)")
113
+ marker_idx = body.find(CANONICAL_MARKER)
114
+ start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
115
+ # Find end: the next "---\n" separator after the marker
116
+ sep_idx = body.find("\n---\n", marker_idx)
117
+ if sep_idx == -1:
118
+ # No separator — strip just the marker line
119
+ end = body.find("\n", marker_idx) + 1
120
+ else:
121
+ end = sep_idx + len("\n---\n")
122
+ return body[:start] + body[end:].lstrip("\n")
@@ -0,0 +1,83 @@
1
+ """close subcommand — non-interactive, flag-driven."""
2
+ import json
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from lib.config import load_config, ConfigError
7
+ from lib.tracks import discover_tracks, find_track_by_name
8
+ from lib.frontmatter import write_file
9
+ from lib.write_guard import needs_confirm, make_token, valid_token
10
+ from lib.prompts import parse_flags
11
+
12
+ VALID_STATES = {"shipped", "parked", "abandoned"}
13
+
14
+
15
+ def run(args: list[str]) -> int:
16
+ # --confirm uses equals form: --confirm=<token>
17
+ # --state and --note also use equals form: --state=shipped, --note=...
18
+ flags, positional = parse_flags(args, {"--state", "--note", "--confirm"})
19
+
20
+ if not positional:
21
+ print("usage: work_plan.py close <track-name> --state=shipped|parked|abandoned [--note=<text>] [--confirm=<token>]")
22
+ return 2
23
+
24
+ track_name = positional[0]
25
+
26
+ # Validate --state (required)
27
+ end_state = flags.get("--state")
28
+ if not end_state or end_state not in VALID_STATES:
29
+ if not end_state:
30
+ print("ERROR: --state is required (shipped|parked|abandoned).")
31
+ else:
32
+ print(f"ERROR: --state={end_state!r} is not valid (allowed: abandoned, parked, shipped).")
33
+ return 2
34
+
35
+ try:
36
+ cfg = load_config()
37
+ except ConfigError as e:
38
+ print(f"ERROR: {e}")
39
+ return 1
40
+
41
+ tracks = discover_tracks(cfg)
42
+ track = find_track_by_name(track_name, tracks)
43
+ if not track:
44
+ print(f"No track matching '{track_name}'.")
45
+ return 1
46
+
47
+ # Public-repo confirm gate (the extension surfaces this as a modal).
48
+ # Placed after track resolution but before any write/move.
49
+ confirm = flags.get("--confirm")
50
+ if track.repo and needs_confirm(track.repo, cfg) and not (
51
+ isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)
52
+ ):
53
+ print(json.dumps({
54
+ "needs_confirm": True,
55
+ "reason": (
56
+ f"{track.repo} is PUBLIC (or visibility unknown); "
57
+ f"closing '{track.name}' will be written there."
58
+ ),
59
+ "token": make_token(track.repo, track.name),
60
+ }))
61
+ return 0
62
+
63
+ # Apply state and optional wrap-up note.
64
+ track.meta["status"] = end_state
65
+ new_body = track.body
66
+ note = flags.get("--note")
67
+ if note and isinstance(note, str) and note.strip():
68
+ new_body += f"\n\n## Wrap-up\n\n{note}\n"
69
+
70
+ write_file(track.path, track.meta, new_body)
71
+
72
+ if end_state == "parked":
73
+ print(f"✓ '{track.name}' marked parked. Stays in place.")
74
+ return 0
75
+
76
+ notes_root = Path(cfg["notes_root"])
77
+ folder = track.path.parent
78
+ archive_dir = folder / "archive" / end_state
79
+ archive_dir.mkdir(parents=True, exist_ok=True)
80
+ dest = archive_dir / track.path.name
81
+ shutil.move(str(track.path), str(dest))
82
+ print(f"✓ '{track.name}' marked {end_state}, moved to {dest.relative_to(notes_root)}")
83
+ return 0