@stylusnexus/work-plan 2026.6.9-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,867 @@
1
+ """handoff subcommand — derive what was touched + suggest what's next.
2
+
3
+ Reads git activity (commits + uncommitted files since last_handoff), GitHub
4
+ issue state changes (open → closed since last_handoff), and frontmatter
5
+ next_up. Presents a summary, appends to session log, and outputs a
6
+ fresh-session prompt the user can paste into a new Claude Code session.
7
+
8
+ Use --interactive (or -i) for the legacy blank-prompt mode where you fill in
9
+ each section by hand.
10
+ """
11
+ import fnmatch
12
+ import subprocess
13
+ from datetime import datetime, timedelta
14
+
15
+ from lib.config import load_config, ConfigError
16
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
17
+ from lib.frontmatter import write_file
18
+ from lib.session_log import append_session_log, SESSION_LOG_HEADER
19
+ from lib.git_state import (
20
+ has_uncommitted, current_branch, parse_iso_timestamp,
21
+ gap_seconds_to_label, uncommitted_file_count, commits_ahead,
22
+ )
23
+ from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
24
+ from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
25
+ from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
26
+ from lib.next_up import suggest_next_up
27
+ from lib.prompts import prompt_lines, parse_flags, prompt_input
28
+
29
+
30
+ def run(args: list[str]) -> int:
31
+ flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--repo"})
32
+ interactive = flags.get("--interactive", False) or flags.get("-i", False)
33
+ auto_next = flags.get("--auto-next", False)
34
+
35
+ # Support both --set-next=4167,4148 (parse_flags handles via key=value) and
36
+ # --set-next 4167,4148 (space-separated). For the space form, parse_flags
37
+ # marks --set-next as True; we then claim the first positional that looks
38
+ # like a comma-separated issue list.
39
+ set_next_raw = flags.get("--set-next")
40
+
41
+ if auto_next and set_next_raw is not None and set_next_raw is not False:
42
+ # Both passed → ambiguous intent. Fail loudly rather than silently
43
+ # letting the second flag clobber the first.
44
+ print("ERROR: --set-next and --auto-next are mutually exclusive. "
45
+ "Pick one — explicit list or interactive suggestion.")
46
+ return 2
47
+ if set_next_raw is True:
48
+ for i, p in enumerate(positional):
49
+ if _looks_like_issue_list(p):
50
+ set_next_raw = positional.pop(i)
51
+ break
52
+ else:
53
+ print("ERROR: --set-next requires a comma-separated list of issue numbers (e.g. --set-next 4167,4148).")
54
+ return 2
55
+
56
+ track_arg = positional[0] if positional else None
57
+ repo_qualifier = flags.get("--repo") if flags.get("--repo") is not True else None
58
+
59
+ # Support <track>@<repo> syntax in positional
60
+ if track_arg:
61
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
62
+ track_arg = name_from_arg
63
+ if repo_from_arg:
64
+ repo_qualifier = repo_from_arg
65
+
66
+ try:
67
+ cfg = load_config()
68
+ except ConfigError as e:
69
+ print(f"ERROR: {e}")
70
+ return 1
71
+
72
+ tracks = discover_tracks(cfg)
73
+ try:
74
+ track = _resolve_track(tracks, track_arg, repo_qualifier=repo_qualifier)
75
+ except AmbiguousTrackError as e:
76
+ print(str(e))
77
+ return 1
78
+ if not track:
79
+ return 1
80
+
81
+ # Apply --set-next first if present, so derived/interactive output reflects it.
82
+ if isinstance(set_next_raw, str):
83
+ rc = _apply_set_next(track, set_next_raw, cfg)
84
+ if rc != 0:
85
+ return rc
86
+ # Re-load track meta after write so downstream handoff sees new next_up
87
+ from lib.frontmatter import parse_file
88
+ track.meta, track.body = parse_file(track.path)
89
+
90
+ # --auto-next: compute a suggested next_up from open issues, prompt user
91
+ # to apply / edit / skip. Runs after --set-next so an explicit list still
92
+ # wins if both are passed (--set-next is the manual override).
93
+ if auto_next:
94
+ rc = _apply_auto_next(track, cfg)
95
+ if rc != 0:
96
+ return rc
97
+ from lib.frontmatter import parse_file
98
+ track.meta, track.body = parse_file(track.path)
99
+
100
+ if interactive:
101
+ return _interactive_handoff(track)
102
+ return _derived_handoff(track)
103
+
104
+
105
+ def _looks_like_issue_list(s: str) -> bool:
106
+ """True if `s` is a comma-separated list of integers (e.g. '4167,4148')."""
107
+ parts = [p.strip() for p in s.split(",")]
108
+ return bool(parts) and all(p.isdigit() for p in parts)
109
+
110
+
111
+ def _apply_set_next(track, raw: str, cfg: dict) -> int:
112
+ """Parse comma-list of issue numbers and persist to track frontmatter."""
113
+ try:
114
+ nums = [int(p.strip()) for p in raw.split(",") if p.strip()]
115
+ except ValueError:
116
+ print(f"ERROR: --set-next expects comma-separated integers, got: {raw!r}")
117
+ return 2
118
+ if not nums:
119
+ print("ERROR: --set-next received an empty list.")
120
+ return 2
121
+ if not _check_next_up_collisions(track, nums, cfg):
122
+ print("Skipped — next_up unchanged.")
123
+ return 0
124
+ track.meta["next_up"] = nums
125
+ write_file(track.path, track.meta, track.body)
126
+ print(f"✓ next_up set to: {nums}")
127
+ return 0
128
+
129
+
130
+ def _apply_auto_next(track, cfg: dict) -> int:
131
+ """Suggest a next_up list from open issues; prompt user to apply/edit/skip.
132
+
133
+ Algorithm lives in lib.next_up.suggest_next_up — open, non-blocker issues
134
+ sorted by priority then most-recently-updated. The interactive prompt
135
+ keeps the user in control (no silent overwrite of a hand-curated list).
136
+
137
+ Cross-track collisions are handled by SKIPPING — auto-next's pitch is
138
+ non-interactive, so issues already next_up on a sibling active track in
139
+ the same repo are dropped from the suggestion before the user sees it,
140
+ with one transparent "↷ skipped" line per drop. The edit branch reverts
141
+ to --set-next-style warn/confirm because the user is being explicit there.
142
+ """
143
+ if not track.repo:
144
+ print(f"ERROR: --auto-next needs a github.repo on the track ({track.name}).")
145
+ return 2
146
+ issue_nums = track.meta.get("github", {}).get("issues") or []
147
+ if not issue_nums:
148
+ print(f"No issues attached to {track.name}; nothing to suggest.")
149
+ return 0
150
+
151
+ issues = fetch_issues(track.repo, issue_nums)
152
+ blocker_nums = track.meta.get("blockers") or []
153
+ track_milestone = track.meta.get("milestone_alignment") or None
154
+ raw_suggestion = suggest_next_up(issues, blocker_nums, track_milestone=track_milestone)
155
+ if not raw_suggestion:
156
+ print(f"No open, non-blocker issues for {track.name}; next_up unchanged.")
157
+ return 0
158
+
159
+ # Filter sibling-claimed issues out of the suggestion. Print one line
160
+ # per skip so the user knows what was dropped and why.
161
+ claimed = _sibling_claimed_next_up_map(track, cfg)
162
+ suggestion = []
163
+ for num in raw_suggestion:
164
+ if num in claimed:
165
+ print(f"↷ skipped #{num} (already next_up on '{claimed[num]}')")
166
+ else:
167
+ suggestion.append(num)
168
+
169
+ if not suggestion:
170
+ print(f"All suggested issues are already next_up on sibling tracks; next_up unchanged.")
171
+ return 0
172
+
173
+ # Decorate with title + priority + milestone for the preview.
174
+ by_num = {i["number"]: i for i in issues}
175
+ print(f"\nSuggested next_up for {track.name}:")
176
+ for num in suggestion:
177
+ i = by_num.get(num, {})
178
+ pri = extract_priority(i.get("labels", []))
179
+ ms = short_milestone(i.get("milestone"))
180
+ ms_tag = f" [{ms}]" if ms else ""
181
+ print(f" #{num} [{pri}]{ms_tag} {i.get('title', '')}")
182
+
183
+ answer = prompt_input("\nApply this list to next_up? [Y/n/edit] ").strip().lower()
184
+ if answer in ("", "y", "yes"):
185
+ candidate = suggestion
186
+ elif answer in ("n", "no"):
187
+ print("Skipped — next_up unchanged.")
188
+ return 0
189
+ elif answer in ("e", "edit"):
190
+ raw = prompt_input("Enter comma-separated issue numbers: ").strip()
191
+ if not raw:
192
+ print("Empty — next_up unchanged.")
193
+ return 0
194
+ try:
195
+ candidate = [int(p.strip()) for p in raw.split(",") if p.strip()]
196
+ except ValueError:
197
+ print(f"ERROR: expected comma-separated integers, got: {raw!r}")
198
+ return 2
199
+ # User went manual — treat the same as --set-next: warn-and-confirm
200
+ # so they can override if the collision was intentional.
201
+ if not _check_next_up_collisions(track, candidate, cfg):
202
+ print("Skipped — next_up unchanged.")
203
+ return 0
204
+ else:
205
+ # Anything else: refuse to guess. Better to fail than silently apply.
206
+ print(f"ERROR: unrecognized response {answer!r}; expected y / n / edit.")
207
+ return 2
208
+
209
+ track.meta["next_up"] = candidate
210
+ write_file(track.path, track.meta, track.body)
211
+ print(f"✓ next_up set to: {track.meta['next_up']}")
212
+ return 0
213
+
214
+
215
+ def _sibling_claimed_next_up_map(track, cfg: dict) -> dict:
216
+ """Return {issue_num: sibling_track_name} for every issue claimed as
217
+ next_up on a sibling active track in the same repo. Read-only on local
218
+ frontmatter — no GitHub calls. If multiple siblings claim the same
219
+ issue, the first encountered wins (stable order from discover_tracks)."""
220
+ claimed: dict = {}
221
+ for sib in discover_tracks(cfg):
222
+ if (not sib.has_frontmatter
223
+ or sib.path == track.path
224
+ or not sib.repo
225
+ or sib.repo != track.repo
226
+ or sib.meta.get("status") not in ("active", "in-progress", "blocked")):
227
+ continue
228
+ for num in (sib.meta.get("next_up") or []):
229
+ claimed.setdefault(num, sib.name)
230
+ return claimed
231
+
232
+
233
+ def _check_next_up_collisions(track, proposed: list[int], cfg: dict) -> bool:
234
+ """Warn when a proposed next_up issue is already next_up on a sibling
235
+ active track in the same repo. Returns True if no collisions or the user
236
+ accepts the prompt; False if the user declines.
237
+
238
+ Read-only: scans local frontmatter only — no GitHub calls. Same-path
239
+ tracks (i.e. the track being updated itself) are excluded so re-applying
240
+ an existing list isn't flagged as a self-collision. Parked / abandoned
241
+ sibling tracks are skipped because they don't compete for attention.
242
+ """
243
+ siblings = [t for t in discover_tracks(cfg)
244
+ if t.has_frontmatter
245
+ and t.path != track.path
246
+ and t.repo
247
+ and t.repo == track.repo
248
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
249
+ if not siblings:
250
+ return True
251
+
252
+ proposed_set = set(proposed)
253
+ collisions = []
254
+ for sib in siblings:
255
+ for num in (sib.meta.get("next_up") or []):
256
+ if num in proposed_set:
257
+ collisions.append((num, sib.name))
258
+
259
+ if not collisions:
260
+ return True
261
+
262
+ print()
263
+ for num, sib_name in collisions:
264
+ print(f"⚠️ #{num} is already next_up on track '{sib_name}'")
265
+ answer = prompt_input("Apply anyway? [y/N] ").strip().lower()
266
+ return answer in ("y", "yes")
267
+
268
+
269
+ def _resolve_track(tracks, track_arg, repo_qualifier=None):
270
+ if track_arg:
271
+ track = find_track_by_name(track_arg, tracks, repo=repo_qualifier)
272
+ if not track:
273
+ print(f"No track matching '{track_arg}'.")
274
+ return track
275
+ # No name: try current branch
276
+ active = [t for t in tracks if t.has_frontmatter
277
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
278
+ for t in active:
279
+ cb = current_branch(t.local_path) if t.local_path else None
280
+ for b in (t.meta.get("github", {}).get("branches") or []):
281
+ if cb == b:
282
+ return t
283
+ print("Specify a track name (couldn't infer from current branch):")
284
+ for t in active:
285
+ print(f" {t.name}")
286
+ return None
287
+
288
+
289
+ def _derived_handoff(track) -> int:
290
+ """Derive last-touched + what's next, leading with markdown body and frontmatter.
291
+ Git and GitHub are supplements — only shown when they have real data.
292
+ """
293
+ now = datetime.now()
294
+ iso_now = now.strftime("%Y-%m-%dT%H:%M")
295
+
296
+ last_handoff_iso = track.meta.get("last_handoff")
297
+ last_handoff_dt = parse_iso_timestamp(last_handoff_iso) if last_handoff_iso else None
298
+ next_up = track.meta.get("next_up") or []
299
+
300
+ # === Body data (always available) ===
301
+ last_session = _extract_last_session(track.body)
302
+ open_from_body = _open_items_from_canonical(track.body)
303
+
304
+ # === Git data (only if attributable) ===
305
+ commits = _recent_commits(track, last_handoff_dt)
306
+ uncommitted = _uncommitted_files(track)
307
+ repo_wide_commits = (
308
+ _repo_commits_since(track.local_path, last_handoff_dt)
309
+ if not commits else 0
310
+ )
311
+
312
+ # === GitHub data (only if reachable) ===
313
+ issue_nums = track.meta.get("github", {}).get("issues") or []
314
+ issues = fetch_issues(track.repo, issue_nums) if (track.repo and issue_nums) else []
315
+ closed_since_last = _issues_closed_since(issues, last_handoff_dt)
316
+ issues_by_num = {i["number"]: i for i in issues}
317
+ open_from_github = [i for i in issues if i.get("state") not in ("CLOSED", "MERGED")]
318
+
319
+ slug = track.meta.get("track", track.name)
320
+ new_issues = []
321
+ if track.repo and last_handoff_dt:
322
+ days = max(1, int((now - last_handoff_dt).total_seconds() / 86400))
323
+ slug_labels = build_slug_labels([track])
324
+ new_map = find_new_issues_for_tracks(track.repo, [slug], slug_labels=slug_labels, since_days=days)
325
+ listed_set = set(issue_nums)
326
+ new_issues = [i for i in new_map.get(slug, []) if i["number"] not in listed_set]
327
+
328
+ # === Present (body-first, git/GitHub as supplements) ===
329
+ print("=" * 70)
330
+ print(f"HANDOFF — {track.name}")
331
+ print("=" * 70)
332
+ if last_handoff_dt:
333
+ gap = (now - last_handoff_dt).total_seconds()
334
+ print(f"Last handoff: {last_handoff_iso} ({gap_seconds_to_label(int(gap))})")
335
+ else:
336
+ print("Last handoff: (never — first handoff for this track)")
337
+ print()
338
+
339
+ # WHERE YOU LEFT OFF (the most important section)
340
+ print("WHERE YOU LEFT OFF:")
341
+ if last_session:
342
+ for line in last_session.split("\n"):
343
+ print(f" {line}")
344
+ else:
345
+ print(" (no prior session log — this is your first handoff)")
346
+ print()
347
+
348
+ # WHAT'S STILL OPEN (from markdown — primary fallback when git is silent)
349
+ print("WHAT'S STILL OPEN:")
350
+ open_source = None
351
+ open_items = []
352
+ if open_from_github:
353
+ open_source = "GitHub"
354
+ open_items = [(i["number"], i.get("title", ""),
355
+ short_milestone(i.get("milestone"))) for i in open_from_github]
356
+ elif open_from_body:
357
+ open_source = "markdown (canonical table)"
358
+ # Body source has no milestone data — pad with empty string for shape parity.
359
+ open_items = [(num, title, "") for num, title in open_from_body]
360
+ if open_items:
361
+ print(f" ({len(open_items)} item(s), source: {open_source})")
362
+ for num, title, ms in open_items[:8]:
363
+ ms_tag = f"[{ms}] " if ms else ""
364
+ print(f" 🔲 #{num} {ms_tag}{title}")
365
+ if len(open_items) > 8:
366
+ print(f" ... and {len(open_items) - 8} more (full list: /work-plan orient {slug})")
367
+ else:
368
+ print(" (no open items — track may be ready to close)")
369
+ print()
370
+
371
+ # WHAT'S NEXT (from frontmatter next_up)
372
+ print("WHAT'S NEXT:")
373
+ if next_up:
374
+ for num in next_up:
375
+ i = issues_by_num.get(num)
376
+ if i and i.get("state") not in ("CLOSED", "MERGED"):
377
+ ms = short_milestone(i.get("milestone"))
378
+ ms_tag = f"[{ms}] " if ms else ""
379
+ print(f" → #{num} {ms_tag}{i.get('title', '')}")
380
+ elif i:
381
+ print(f" (#{num} is now closed — consider updating next_up)")
382
+ else:
383
+ # Fall back to body-derived title if available
384
+ title = next((t for n, t in open_from_body if n == num), "")
385
+ print(f" → #{num} {title}".rstrip())
386
+ else:
387
+ print(" next_up is empty — set it in the frontmatter to mark your next pick.")
388
+ print()
389
+
390
+ # SUPPLEMENT 1: Recent commits (if attribution worked)
391
+ if commits:
392
+ print("RECENT COMMITS (attributed to this track):")
393
+ for c in commits[:8]:
394
+ print(f" • [{c['date'][:10]}] {c['subject']} ({c['sha'][:7]})")
395
+ if len(commits) > 8:
396
+ print(f" ... and {len(commits) - 8} more")
397
+ print()
398
+ elif repo_wide_commits > 0:
399
+ print(f"RECENT COMMITS: 0 attributed to this track "
400
+ f"({repo_wide_commits} repo-wide since last handoff)")
401
+ print(" Attribution: commit message (subject or body) must reference an issue")
402
+ print(" in `github.issues`, or a changed path must match a glob in `github.paths`.")
403
+ print()
404
+
405
+ # SUPPLEMENT 2: Uncommitted (if current branch belongs to this track)
406
+ if uncommitted:
407
+ print(f"IN-FLIGHT ({len(uncommitted)} uncommitted file(s)):")
408
+ for f in uncommitted[:8]:
409
+ print(f" • {f}")
410
+ if len(uncommitted) > 8:
411
+ print(f" ... and {len(uncommitted) - 8} more")
412
+ print()
413
+
414
+ # SUPPLEMENT 3: Closed since last handoff
415
+ if closed_since_last:
416
+ print(f"CLOSED SINCE LAST HANDOFF ({len(closed_since_last)}):")
417
+ for i in closed_since_last[:6]:
418
+ print(f" ✅ #{i['number']} {i.get('title', '')}")
419
+ if len(closed_since_last) > 6:
420
+ print(f" ... and {len(closed_since_last) - 6} more")
421
+ print()
422
+
423
+ # SUPPLEMENT 4: New related issues
424
+ if new_issues:
425
+ print(f"NEW RELATED ISSUES (consider slotting):")
426
+ for n in new_issues[:5]:
427
+ print(f" #{n['number']} {n['title']} → /work-plan slot {n['number']} {slug}")
428
+ print()
429
+
430
+ # FRESH-SESSION PROMPT
431
+ print("FRESH-SESSION PROMPT (copy-paste into a new terminal):")
432
+ print("-" * 70)
433
+ prompt_text = _build_fresh_session_prompt(
434
+ track, commits, uncommitted, last_session, open_items, open_source,
435
+ next_up, issues_by_num, repo_wide_commits,
436
+ )
437
+ print(prompt_text)
438
+ print("-" * 70)
439
+
440
+ # Build session log entry from derived data
441
+ touched_lines = [f"{c['subject']} ({c['sha'][:7]})" for c in commits]
442
+ if uncommitted:
443
+ touched_lines.append(f"In-flight: {len(uncommitted)} uncommitted file(s)")
444
+ if not touched_lines:
445
+ # No git activity attributed — note that the snapshot is body-derived
446
+ if open_items:
447
+ touched_lines.append(f"(no git activity attributed; {len(open_items)} open from {open_source})")
448
+ else:
449
+ touched_lines.append("(no derivable activity since last handoff)")
450
+ next_lines = []
451
+ for num in next_up:
452
+ i = issues_by_num.get(num)
453
+ if i:
454
+ next_lines.append(f"#{num} {i.get('title', '')}")
455
+ else:
456
+ next_lines.append(f"#{num}")
457
+
458
+ new_body = append_session_log(
459
+ track.body,
460
+ timestamp=now.strftime("%Y-%m-%d %H:%M"),
461
+ touched=touched_lines,
462
+ next_up=next_lines,
463
+ blockers=[],
464
+ )
465
+
466
+ # Update body status table from current GitHub state, then self-heal any
467
+ # membership drift: append rows for frontmatter issues missing from the
468
+ # canonical table (issue #77).
469
+ if issues:
470
+ for i in issues:
471
+ new_body = update_row_status(new_body, i["number"], state_to_status_label(i.get("state")))
472
+ new_body, _ = sync_missing_rows(new_body, issue_nums, issues_by_num)
473
+
474
+ # Update frontmatter timestamps
475
+ track.meta["last_touched"] = iso_now
476
+ track.meta["last_handoff"] = iso_now
477
+ if track.meta.get("status") == "in-progress":
478
+ if not (track.local_path and has_uncommitted(track.local_path)):
479
+ track.meta["status"] = "active"
480
+
481
+ write_file(track.path, track.meta, new_body)
482
+ print(f"\n✓ Session log appended to {track.path.name}.")
483
+ print(" (Run with --interactive if you want to add manual notes.)")
484
+ return 0
485
+
486
+
487
+ def _recent_commits(track, since_dt) -> list[dict]:
488
+ """Get commits ATTRIBUTABLE to this track since since_dt.
489
+
490
+ Attribution rules (in order):
491
+ 1. If track has explicit `github.branches`, use those branches' history.
492
+ Path globs do not apply here — explicit branches are the contract.
493
+ 2. Otherwise, scan ALL recent commits across the repo and keep those:
494
+ - whose commit message (subject OR body) mentions an issue number
495
+ (#NNNN) in `github.issues`. Squash-merged PRs typically carry
496
+ the issue ref in the body (e.g. "Closes #1234"), so scanning
497
+ both is required for conventional-commit subjects to attribute.
498
+ - OR whose changed paths match any glob in `github.paths` (fnmatch
499
+ syntax, e.g. "apps/web/src/components/ux/**", "**/useToast*").
500
+ 3. If neither yields anything, return empty (don't fall back to current
501
+ branch — that's almost always wrong for multi-track repos).
502
+ """
503
+ if not since_dt or not track.local_path:
504
+ return []
505
+ import re as _re
506
+ track_issues = set(track.meta.get("github", {}).get("issues") or [])
507
+ issue_re = _re.compile(r"#(\d+)")
508
+ branches = track.meta.get("github", {}).get("branches") or []
509
+ path_globs = track.meta.get("github", {}).get("paths") or []
510
+ since_iso = since_dt.strftime("%Y-%m-%dT%H:%M:%S")
511
+
512
+ seen = set()
513
+ out = []
514
+
515
+ if branches:
516
+ for b in branches:
517
+ proc = subprocess.run(
518
+ ["git", "-C", str(track.local_path), "log", b,
519
+ f"--since={since_iso}",
520
+ "--pretty=format:%H|%s|%cI"],
521
+ capture_output=True, text=True,
522
+ )
523
+ if proc.returncode != 0 or not proc.stdout.strip():
524
+ continue
525
+ for line in proc.stdout.strip().split("\n"):
526
+ try:
527
+ sha, subject, date = line.split("|", 2)
528
+ except ValueError:
529
+ continue
530
+ if sha in seen:
531
+ continue
532
+ seen.add(sha)
533
+ out.append({"sha": sha, "subject": subject, "date": date})
534
+ out.sort(key=lambda c: c["date"], reverse=True)
535
+ return out
536
+
537
+ if not track_issues and not path_globs:
538
+ return []
539
+
540
+ pretty = "format:---COMMIT---%n%H|%s|%cI%n---BODY---%n%b%n---ENDBODY---"
541
+ cmd = ["git", "-C", str(track.local_path), "log", "--all",
542
+ f"--since={since_iso}", f"--pretty={pretty}"]
543
+ if path_globs:
544
+ cmd.append("--name-only")
545
+ proc = subprocess.run(cmd, capture_output=True, text=True)
546
+ if proc.returncode != 0 or not proc.stdout.strip():
547
+ return []
548
+
549
+ blocks = [b for b in proc.stdout.split("---COMMIT---\n") if b.strip()]
550
+ for block in blocks:
551
+ block_lines = block.split("\n")
552
+ try:
553
+ sha, subject, date = block_lines[0].split("|", 2)
554
+ except (IndexError, ValueError):
555
+ continue
556
+ if sha in seen:
557
+ continue
558
+ body_lines: list[str] = []
559
+ files: list[str] = []
560
+ in_body = False
561
+ for ln in block_lines[1:]:
562
+ if ln == "---BODY---":
563
+ in_body = True
564
+ continue
565
+ if ln == "---ENDBODY---":
566
+ in_body = False
567
+ continue
568
+ if in_body:
569
+ body_lines.append(ln)
570
+ elif ln:
571
+ files.append(ln)
572
+ body = "\n".join(body_lines)
573
+ message = subject + "\n" + body
574
+ mentioned = {int(m) for m in issue_re.findall(message)}
575
+ match_issue = bool(mentioned & track_issues)
576
+ match_path = bool(path_globs) and any(
577
+ fnmatch.fnmatch(f, pat) for f in files for pat in path_globs
578
+ )
579
+ if not (match_issue or match_path):
580
+ continue
581
+ seen.add(sha)
582
+ out.append({"sha": sha, "subject": subject, "date": date})
583
+
584
+ out.sort(key=lambda c: c["date"], reverse=True)
585
+ return out
586
+
587
+
588
+ def _repo_commits_since(local_path, since_dt) -> int:
589
+ """Total repo-wide commit count across all branches since since_dt.
590
+
591
+ Used to render a 'silence is expected' signal when zero commits attribute
592
+ to the track but the repo has activity.
593
+ """
594
+ if not since_dt or not local_path:
595
+ return 0
596
+ since_iso = since_dt.strftime("%Y-%m-%dT%H:%M:%S")
597
+ proc = subprocess.run(
598
+ ["git", "-C", str(local_path), "log", "--all",
599
+ f"--since={since_iso}", "--pretty=format:%H"],
600
+ capture_output=True, text=True,
601
+ )
602
+ if proc.returncode != 0 or not proc.stdout.strip():
603
+ return 0
604
+ return sum(1 for ln in proc.stdout.splitlines() if ln.strip())
605
+
606
+
607
+ def _uncommitted_files(track) -> list[str]:
608
+ """Return uncommitted files only if the current branch belongs to this track.
609
+
610
+ If the current branch isn't in track's listed branches AND we can't tell,
611
+ return empty — the uncommitted files probably belong to a different track.
612
+ """
613
+ if not track.local_path:
614
+ return []
615
+ branches = track.meta.get("github", {}).get("branches") or []
616
+ cur = current_branch(track.local_path)
617
+ if branches and cur not in branches:
618
+ return []
619
+ if not branches and cur:
620
+ # No way to know if current branch belongs to this track. Be conservative.
621
+ return []
622
+ proc = subprocess.run(
623
+ ["git", "-C", str(track.local_path), "status", "--short"],
624
+ capture_output=True, text=True,
625
+ )
626
+ if proc.returncode != 0:
627
+ return []
628
+ files = []
629
+ for line in proc.stdout.splitlines():
630
+ line = line.rstrip()
631
+ if not line:
632
+ continue
633
+ files.append(line[3:] if len(line) > 3 else line)
634
+ return files
635
+
636
+
637
+ def _issues_closed_since(issues: list[dict], since_dt) -> list[dict]:
638
+ """Filter to issues that closed AFTER since_dt.
639
+
640
+ Requires `closedAt` in the fetched issue data. fetch_issues currently
641
+ requests state,labels,title,milestone,url — so we need to ensure closedAt
642
+ is available. If absent, fall back to no filtering (which would over-report).
643
+ """
644
+ if not since_dt:
645
+ return []
646
+ out = []
647
+ for i in issues:
648
+ if i.get("state") not in ("CLOSED", "MERGED"):
649
+ continue
650
+ closed_at = i.get("closedAt")
651
+ if not closed_at:
652
+ continue # Skip if we can't tell when it closed
653
+ try:
654
+ # Trim to ISO without timezone for naive comparison
655
+ s = closed_at.split("+")[0].split("Z")[0].split(".")[0]
656
+ closed_dt = datetime.fromisoformat(s)
657
+ except (ValueError, AttributeError):
658
+ continue
659
+ if closed_dt > since_dt:
660
+ out.append(i)
661
+ return out
662
+
663
+
664
+ def _build_fresh_session_prompt(track, commits, uncommitted, last_session,
665
+ open_items, open_source, next_up, issues_by_num,
666
+ repo_wide_commits=0) -> str:
667
+ """Build a copy-pasteable prompt for a fresh Claude Code session.
668
+
669
+ Body-first: leads with the last session log + open items (always available).
670
+ Git and GitHub are supplements when they have data.
671
+ """
672
+ slug = track.meta.get("track", track.name)
673
+ lines = [
674
+ f"# Resuming work on track: {slug}",
675
+ f"",
676
+ f"Track file: `{track.path}`",
677
+ f"Repo: {track.repo} · Priority: {track.meta.get('launch_priority', 'P3')} · Milestone: {track.meta.get('milestone_alignment', '—')}",
678
+ ]
679
+ if track.local_path:
680
+ lines.append(f"Local clone: `{track.local_path}`")
681
+ lines.append("")
682
+
683
+ if last_session:
684
+ lines.append("## Where I left off (last session log)")
685
+ for ln in last_session.split("\n"):
686
+ lines.append(ln)
687
+ lines.append("")
688
+
689
+ if open_items:
690
+ lines.append(f"## What's still open ({len(open_items)} from {open_source})")
691
+ for num, title, ms in open_items[:10]:
692
+ ms_tag = f"[{ms}] " if ms else ""
693
+ lines.append(f"- #{num} {ms_tag}{title}")
694
+ if len(open_items) > 10:
695
+ lines.append(f"- ... and {len(open_items) - 10} more")
696
+ lines.append("")
697
+
698
+ if commits:
699
+ lines.append("## Recent commits attributed to this track")
700
+ for c in commits[:5]:
701
+ lines.append(f"- `{c['sha'][:7]}` {c['subject']}")
702
+ lines.append("")
703
+ elif repo_wide_commits > 0:
704
+ lines.append("## Recent commits")
705
+ lines.append(
706
+ f"0 attributed to this track ({repo_wide_commits} repo-wide since last handoff). "
707
+ "Attribution requires an issue ref in the commit message (subject or body) "
708
+ "or a path match against `github.paths`."
709
+ )
710
+ lines.append("")
711
+
712
+ if uncommitted:
713
+ lines.append(f"## In-flight ({len(uncommitted)} uncommitted file(s))")
714
+ for f in uncommitted[:10]:
715
+ lines.append(f"- {f}")
716
+ lines.append("")
717
+
718
+ if next_up:
719
+ lines.append("## What's next (from frontmatter `next_up`)")
720
+ for num in next_up:
721
+ i = issues_by_num.get(num)
722
+ if i:
723
+ lines.append(f"- #{num} {i.get('title', '')} (state: {i.get('state','?').lower()})")
724
+ else:
725
+ title = next((t for n, t, _ in open_items if n == num), "")
726
+ lines.append(f"- #{num} {title}".rstrip())
727
+ lines.append("")
728
+
729
+ lines.append("## Suggested first action")
730
+ if uncommitted:
731
+ lines.append("Resume the uncommitted work above. Check `git status` first.")
732
+ elif next_up:
733
+ first_actionable = _first_actionable_next_up(next_up, issues_by_num)
734
+ if first_actionable is not None:
735
+ lines.append(f"Pick up #{first_actionable} from the `next_up` list.")
736
+ else:
737
+ lines.append(
738
+ f"All `next_up` items are closed — run `/work-plan handoff {slug}` to rotate."
739
+ )
740
+ elif open_items:
741
+ lines.append(f"No `next_up` set. Pick from the {len(open_items)} open items above.")
742
+ else:
743
+ lines.append("Run `/work-plan orient " + slug + "` to see all open issues for this track and pick one.")
744
+ return "\n".join(lines)
745
+
746
+
747
+ def _first_actionable_next_up(next_up: list, issues_by_num: dict):
748
+ """Return the first next_up issue number whose GitHub state is not closed
749
+ or merged. Unknown numbers (no fetched issue data) are returned as-is —
750
+ we can't verify, so we prefer to surface rather than silently skip.
751
+ Returns None only when every entry is verified-closed."""
752
+ for num in next_up:
753
+ i = issues_by_num.get(num)
754
+ if i is None:
755
+ return num
756
+ if i.get("state") not in ("CLOSED", "MERGED"):
757
+ return num
758
+ return None
759
+
760
+
761
+ def _extract_last_session(body: str) -> str:
762
+ """Pull the most recent ### Session — block from the body."""
763
+ if "### Session — " not in body:
764
+ return ""
765
+ idx = body.rfind("### Session — ")
766
+ rest = body[idx:]
767
+ end = len(rest)
768
+ for marker in ("\n### ", "\n## "):
769
+ m = rest.find(marker, 1)
770
+ if m != -1 and m < end:
771
+ end = m
772
+ return rest[:end].strip()
773
+
774
+
775
+ def _open_items_from_canonical(body: str) -> list[tuple[int, str]]:
776
+ """Read the canonical status table and return [(issue_num, title), ...]
777
+ for rows where status is NOT shipped/closed/merged.
778
+
779
+ Falls back gracefully if no canonical table exists.
780
+ """
781
+ tables = find_canonical_status_tables(body)
782
+ if not tables:
783
+ return []
784
+ table = tables[0]
785
+ sidx = table["status_col_index"]
786
+ out = []
787
+ for row in table["rows"]:
788
+ if sidx >= len(row["cells"]):
789
+ continue
790
+ status = row["cells"][sidx].strip().lower()
791
+ # Skip rows that look closed/shipped/merged
792
+ if any(k in status for k in ("✅", "shipped", "merged", "closed")):
793
+ continue
794
+ # Find issue number and title in row cells
795
+ nums = []
796
+ title = ""
797
+ for cell in row["cells"]:
798
+ for m in ISSUE_NUM_RE.findall(cell):
799
+ nums.append(int(m))
800
+ # Title = first non-issue-number cell that isn't the status col
801
+ for idx, cell in enumerate(row["cells"]):
802
+ if idx == sidx:
803
+ continue
804
+ txt = cell.strip()
805
+ if not txt or ISSUE_NUM_RE.fullmatch(txt.replace("#", "").replace(" ", "")):
806
+ continue
807
+ if not title and not txt.startswith("#"):
808
+ title = txt
809
+ break
810
+ for num in nums:
811
+ out.append((num, title))
812
+ return out
813
+
814
+
815
+ def _interactive_handoff(track) -> int:
816
+ """Legacy interactive mode — blank prompts, user fills in."""
817
+ print(f"Handoff for: {track.name} (interactive mode)\n")
818
+
819
+ print("What did you touch this session? (one item per line, blank line to finish):")
820
+ touched = prompt_lines()
821
+
822
+ print("\nWhat's next? (one item per line, blank line to finish):")
823
+ next_up_text = prompt_lines()
824
+
825
+ print("\nBlockers? (format: #NNNN reason — one per line, blank to finish):")
826
+ blocker_lines = prompt_lines()
827
+ blockers = []
828
+ for line in blocker_lines:
829
+ if not line.startswith("#"):
830
+ continue
831
+ parts = line.split(maxsplit=1)
832
+ try:
833
+ num = int(parts[0][1:])
834
+ reason = parts[1] if len(parts) > 1 else "(no reason given)"
835
+ blockers.append({"number": num, "reason": reason})
836
+ except (ValueError, IndexError):
837
+ continue
838
+
839
+ now = datetime.now()
840
+ iso_now = now.strftime("%Y-%m-%dT%H:%M")
841
+ track.meta["last_touched"] = iso_now
842
+ track.meta["last_handoff"] = iso_now
843
+ if blockers:
844
+ track.meta["blockers"] = [b["number"] for b in blockers]
845
+
846
+ if track.meta.get("status") == "in-progress":
847
+ if not (track.local_path and has_uncommitted(track.local_path)):
848
+ track.meta["status"] = "active"
849
+
850
+ new_body = append_session_log(
851
+ track.body,
852
+ timestamp=now.strftime("%Y-%m-%d %H:%M"),
853
+ touched=touched,
854
+ next_up=next_up_text,
855
+ blockers=blockers,
856
+ )
857
+
858
+ issue_nums = track.meta.get("github", {}).get("issues") or []
859
+ if issue_nums and track.repo:
860
+ issues = fetch_issues(track.repo, issue_nums)
861
+ for i in issues:
862
+ new_body = update_row_status(new_body, i["number"], state_to_status_label(i.get("state")))
863
+ new_body, _ = sync_missing_rows(new_body, issue_nums, {i["number"]: i for i in issues})
864
+
865
+ write_file(track.path, track.meta, new_body)
866
+ print(f"\n✓ Updated {track.path.name}")
867
+ return 0