@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,53 @@
1
+ """set-notes-root subcommand — non-interactively relocate notes_root in config.
2
+
3
+ Called by the VS Code viewer's cold-start onboarding when the user picks a
4
+ folder. Config writes stay in the CLI (the engine), not the extension.
5
+
6
+ Usage: set-notes-root <path>
7
+ """
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from lib.config import load_config, DEFAULT_CONFIG_PATH
12
+ from lib.prompts import parse_flags
13
+ from lib.tracks import discover_tracks
14
+
15
+
16
+ def run(args: list[str]) -> int:
17
+ _, positional = parse_flags(args, set())
18
+
19
+ if not positional:
20
+ print("usage: work_plan.py set-notes-root <path>")
21
+ return 2
22
+
23
+ new_root = Path(positional[0]).expanduser().resolve()
24
+
25
+ cfg = load_config()
26
+ current_root = Path(cfg["notes_root"]).expanduser().resolve()
27
+
28
+ # Orphan warning (informational only — no moves, no prompt)
29
+ if new_root != current_root:
30
+ tracks = discover_tracks(cfg)
31
+ if tracks:
32
+ print(
33
+ f"WARN: {len(tracks)} track(s) exist under {current_root}. "
34
+ "They will NOT be moved — move them manually to the new location "
35
+ "before using the viewer, or they won't appear."
36
+ )
37
+
38
+ # Ensure the target directory exists
39
+ new_root.mkdir(parents=True, exist_ok=True)
40
+
41
+ # Write the new notes_root into config via yq (mikefarah/yq)
42
+ yq_expr = f'.notes_root = "{new_root}"'
43
+ try:
44
+ subprocess.run(
45
+ ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
46
+ check=True, capture_output=True, text=True,
47
+ )
48
+ except subprocess.CalledProcessError as e:
49
+ print(f"ERROR: yq failed to update config: {e.stderr}")
50
+ return 1
51
+
52
+ print(f"✓ notes_root set to {new_root}")
53
+ return 0
@@ -0,0 +1,139 @@
1
+ """slot subcommand."""
2
+ import json
3
+ import subprocess
4
+
5
+ from lib.config import load_config, ConfigError
6
+ from lib.tracks import discover_tracks, find_track_by_name
7
+ from lib.frontmatter import write_file
8
+ from lib.write_guard import needs_confirm, make_token, valid_token
9
+ from lib.prompts import parse_flags, prompt_input
10
+
11
+
12
+ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
13
+ """Active tracks in `repo` (excluding `target_name`) whose frontmatter
14
+ already lists `issue_num`. Lets slot offer a move when GitHub labels
15
+ moved an issue across tracks but the old frontmatter still claims it."""
16
+ owners = []
17
+ for t in tracks:
18
+ if not t.has_frontmatter or t.name == target_name or t.repo != repo:
19
+ continue
20
+ if t.meta.get("status") not in ("active", "in-progress", "blocked"):
21
+ continue
22
+ if issue_num in (t.meta.get("github", {}).get("issues") or []):
23
+ owners.append(t)
24
+ return owners
25
+
26
+
27
+ def run(args: list[str]) -> int:
28
+ # --confirm uses equals form: --confirm=<token>
29
+ # --move / --no-move are bare flags
30
+ flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move"})
31
+ if not positional:
32
+ print("usage: work_plan.py slot <issue-num> [track-name]")
33
+ return 2
34
+ try:
35
+ issue_num = int(positional[0])
36
+ except ValueError:
37
+ print(f"ERROR: '{positional[0]}' is not an issue number.")
38
+ return 2
39
+ target_name = positional[1] if len(positional) > 1 else None
40
+
41
+ if "--move" in flags and "--no-move" in flags:
42
+ print("ERROR: --move and --no-move are mutually exclusive.")
43
+ return 2
44
+
45
+ try:
46
+ cfg = load_config()
47
+ except ConfigError as e:
48
+ print(f"ERROR: {e}")
49
+ return 1
50
+
51
+ tracks = discover_tracks(cfg)
52
+ active = [t for t in tracks if t.has_frontmatter
53
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
54
+
55
+ if target_name:
56
+ target = find_track_by_name(target_name, tracks, active_only=True)
57
+ if not target:
58
+ print(f"No active track matching '{target_name}'.")
59
+ return 1
60
+ else:
61
+ print("Active tracks:")
62
+ for i, t in enumerate(active, 1):
63
+ print(f" [{i}] {t.name} ({t.meta.get('launch_priority','P3')}, "
64
+ f"{t.meta.get('milestone_alignment','—')})")
65
+ choice = prompt_input("\nSlot into which? (number or name):")
66
+ if not choice:
67
+ print("No selection. Cancelled.")
68
+ return 1
69
+ if choice.isdigit():
70
+ idx = int(choice) - 1
71
+ if not (0 <= idx < len(active)):
72
+ print("Out of range.")
73
+ return 1
74
+ target = active[idx]
75
+ else:
76
+ matching = [t for t in active if t.name == choice or t.meta.get("track") == choice]
77
+ if not matching:
78
+ print(f"No active track matching '{choice}'.")
79
+ return 1
80
+ target = matching[0]
81
+
82
+ issues = list(target.meta.get("github", {}).get("issues") or [])
83
+ if issue_num in issues:
84
+ print(f"#{issue_num} already in track '{target.name}'.")
85
+ return 0
86
+
87
+ # Public-repo confirm gate (the extension surfaces this as a modal).
88
+ # Placed after target resolution and the "already in track" no-op so we
89
+ # don't gate a no-op write.
90
+ confirm = flags.get("--confirm")
91
+ if target.repo and needs_confirm(target.repo, cfg) and not (
92
+ isinstance(confirm, str) and valid_token(confirm, target.repo, target.name)
93
+ ):
94
+ print(json.dumps({
95
+ "needs_confirm": True,
96
+ "reason": (
97
+ f"{target.repo} is PUBLIC (or visibility unknown); "
98
+ f"slotting #{issue_num} will be written there."
99
+ ),
100
+ "token": make_token(target.repo, target.name),
101
+ }))
102
+ return 0
103
+
104
+ # Determine move behavior from flags.
105
+ # --move: remove issue from prior owners.
106
+ # Default / --no-move: add-only; print a note naming prior owners.
107
+ do_move = "--move" in flags
108
+
109
+ sources = _find_prior_owners(issue_num, target.repo, target.name, tracks)
110
+
111
+ issues.append(issue_num)
112
+ target.meta.setdefault("github", {})["issues"] = sorted(issues)
113
+
114
+ proc = subprocess.run(
115
+ ["gh", "issue", "view", str(issue_num),
116
+ "--repo", target.repo, "--json", "milestone"],
117
+ capture_output=True, text=True,
118
+ )
119
+ if proc.returncode == 0:
120
+ info = json.loads(proc.stdout)
121
+ m = info.get("milestone", {})
122
+ if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"):
123
+ print(f"⚠ #{issue_num} is on milestone '{m['title']}', "
124
+ f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.")
125
+
126
+ if sources and do_move:
127
+ for src in sources:
128
+ src_issues = [n for n in (src.meta.get("github", {}).get("issues") or [])
129
+ if n != issue_num]
130
+ src.meta.setdefault("github", {})["issues"] = src_issues
131
+ write_file(src.path, src.meta, src.body)
132
+ print(f" ✓ Removed #{issue_num} from '{src.name}'.")
133
+ elif sources and not do_move:
134
+ names = ", ".join(f"'{t.name}'" for t in sources)
135
+ print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.")
136
+
137
+ write_file(target.path, target.meta, target.body)
138
+ print(f"✓ Slotted #{issue_num} into '{target.name}'.")
139
+ return 0
@@ -0,0 +1,132 @@
1
+ """suggest-priorities subcommand: prepare batch for AI labeling.
2
+
3
+ Two-step:
4
+ 1. CLI fetches all unlabeled open issues, writes JSON batch + prints prompt
5
+ 2. Agent fills priorities into ~/.claude/work-plan/cache/priorities.answers.json
6
+ 3. Run with --apply to apply via gh
7
+ """
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from lib.config import load_config, ConfigError
14
+ from lib.scratch import cache_dir
15
+
16
+
17
+ def _batch_path() -> Path:
18
+ return cache_dir() / "priorities.json"
19
+ PROMPT_TEMPLATE = """\
20
+ For each GitHub issue below, suggest a priority label (P0/P1/P2/P3) based on
21
+ title, milestone, and labels. Return JSON: [{"number": N, "priority": "P0"}, ...]
22
+
23
+ Heuristics:
24
+ - P0: launch-critical bugs/features tagged for v0.4.0 or v1.0.0 with urgent verbs (blocks, breaks, must)
25
+ - P1: important but not blocking; v0.4.0/v1.0.0 features
26
+ - P2: should ship eventually; v1.0.0 nice-to-haves, v2.0.0 features
27
+ - P3: backlog; long-tail polish, parked work
28
+
29
+ Skip issues with insufficient signal. Output ONLY valid JSON.
30
+
31
+ Issues:
32
+ """
33
+
34
+
35
+ def run(args: list[str]) -> int:
36
+ apply_mode = "--apply" in args
37
+ repo_arg = next((a for a in args if a.startswith("--repo=")), None)
38
+
39
+ try:
40
+ cfg = load_config()
41
+ except ConfigError as e:
42
+ print(f"ERROR: {e}")
43
+ return 1
44
+
45
+ if apply_mode:
46
+ return _apply(cfg)
47
+
48
+ repos = list(cfg["repos"].keys())
49
+ if repo_arg:
50
+ repo_folder = repo_arg.split("=", 1)[1]
51
+ repos = [repo_folder]
52
+ elif len(repos) > 1:
53
+ print("Multiple repos in config. Specify with --repo=<folder-name>.")
54
+ return 1
55
+
56
+ folder = repos[0]
57
+ repo = cfg["repos"][folder]["github"]
58
+ print(f"Fetching unlabeled issues in {repo}...")
59
+
60
+ proc = subprocess.run(
61
+ ["gh", "issue", "list", "--repo", repo,
62
+ "--state", "open", "--limit", "100",
63
+ "--json", "number,title,milestone,labels,url"],
64
+ capture_output=True, text=True,
65
+ )
66
+ if proc.returncode != 0:
67
+ print(f"ERROR fetching issues: {proc.stderr}")
68
+ return 1
69
+ all_issues = json.loads(proc.stdout) if proc.stdout.strip() else []
70
+
71
+ unlabeled = [
72
+ i for i in all_issues
73
+ if not any(l["name"].startswith("priority/") for l in i.get("labels", []))
74
+ ]
75
+ if not unlabeled:
76
+ print("All open issues already have priority labels.")
77
+ return 0
78
+
79
+ batch_path = _batch_path()
80
+ batch_path.write_text(json.dumps({"repo": repo, "issues": unlabeled}, indent=2))
81
+ print(f"Wrote {len(unlabeled)} issues to {batch_path}")
82
+ print()
83
+ print("=" * 60)
84
+ print(PROMPT_TEMPLATE)
85
+ for i in unlabeled:
86
+ m = i.get("milestone", {})
87
+ m_title = m.get("title", "—") if m else "—"
88
+ labels = [l["name"] for l in i.get("labels", [])]
89
+ print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
90
+ print("=" * 60)
91
+ print()
92
+ print(f"After agent returns JSON, save to {batch_path.with_suffix('.answers.json')}")
93
+ print(f"Then run: python3 ~/.claude/skills/work-plan/work_plan.py suggest-priorities --apply")
94
+ return 0
95
+
96
+
97
+ def _apply(cfg: dict) -> int:
98
+ batch_path = _batch_path()
99
+ answers_path = batch_path.with_suffix(".answers.json")
100
+ if not answers_path.exists():
101
+ print(f"ERROR: {answers_path} not found. Run without --apply first.")
102
+ return 1
103
+ if not batch_path.exists():
104
+ print(f"ERROR: {batch_path} not found.")
105
+ return 1
106
+ batch = json.loads(batch_path.read_text())
107
+ repo = batch["repo"]
108
+ allowed_repos = {entry.get("github") for entry in cfg.get("repos", {}).values()}
109
+ if repo not in allowed_repos:
110
+ print(f"ERROR: batch repo '{repo}' not in config.yml repos.")
111
+ return 1
112
+ answers = json.loads(answers_path.read_text())
113
+
114
+ print(f"Applying {len(answers)} priority labels to {repo}...")
115
+ for ans in answers:
116
+ num = ans["number"]
117
+ priority = ans["priority"]
118
+ if priority not in ("P0", "P1", "P2", "P3"):
119
+ print(f" SKIP #{num}: invalid priority '{priority}'")
120
+ continue
121
+ proc = subprocess.run(
122
+ ["gh", "issue", "edit", str(num),
123
+ "--repo", repo,
124
+ "--add-label", f"priority/{priority}"],
125
+ capture_output=True, text=True,
126
+ )
127
+ if proc.returncode == 0:
128
+ print(f" ✓ #{num} → priority/{priority}")
129
+ else:
130
+ print(f" ✗ #{num}: {proc.stderr.strip()}")
131
+ print("Done.")
132
+ return 0
@@ -0,0 +1,325 @@
1
+ """where-was-i / orient subcommand.
2
+
3
+ Two modes:
4
+
5
+ 1. With a track name (`/work-plan orient ux-redesign`):
6
+ Prints a tight ~15-line paste-ready block summarizing where the track stands.
7
+ Header rule, priority + milestone + repo, track + local paths, last session
8
+ timestamp + one-line summary, the next pick by issue number + title, up to 3
9
+ issues behind it, current local-git state, and (if any) new related issues
10
+ filed since last handoff.
11
+
12
+ 2. With no track name (`/work-plan orient`):
13
+ Snapshot of the current working directory — branch, ahead-of-upstream count,
14
+ uncommitted file count, last 3 commits, modified files. Use this when you're
15
+ working on something that doesn't yet belong to a track.
16
+
17
+ Add `--pick` to force the interactive track picker instead of cwd-snapshot mode.
18
+
19
+ No closed/merged dump — that's what the GitHub issue list is for.
20
+ """
21
+ import re
22
+ import subprocess
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from lib.config import load_config, ConfigError
28
+ from lib.tracks import discover_tracks, find_track_by_name
29
+ from lib.prompts import prompt_input, parse_flags
30
+ from lib.github_state import fetch_issues, short_milestone
31
+ from lib.git_state import (
32
+ parse_iso_timestamp,
33
+ current_branch, uncommitted_file_count, commits_ahead,
34
+ )
35
+ from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
36
+
37
+
38
+ RULE_CHAR = "─"
39
+ RULE_WIDTH = 57
40
+
41
+
42
+ def run(args: list[str]) -> int:
43
+ flags, positional = parse_flags(args, {"--pick"})
44
+ track_name = positional[0] if positional else None
45
+
46
+ try:
47
+ cfg = load_config()
48
+ except ConfigError as e:
49
+ print(f"ERROR: {e}")
50
+ return 1
51
+
52
+ # Mode 1 (track named): orient on that track.
53
+ # Mode 2 (--pick): interactive track picker (preserves old default behavior).
54
+ # Mode 3 (no args, no flag): cwd snapshot.
55
+ if not track_name and "--pick" not in flags:
56
+ return _orient_cwd()
57
+
58
+ tracks = discover_tracks(cfg)
59
+
60
+ if not track_name:
61
+ # --pick: interactive
62
+ active = [t for t in tracks if t.has_frontmatter
63
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
64
+ if not active:
65
+ print("No active tracks.")
66
+ return 1
67
+ print("Active tracks:")
68
+ for i, t in enumerate(active, 1):
69
+ print(f" [{i}] {t.name} ({t.meta.get('launch_priority','P3')})")
70
+ choice = prompt_input("\nWhich track? (number or name):")
71
+ if not choice:
72
+ print("No selection. Cancelled.")
73
+ return 1
74
+ if choice.isdigit():
75
+ idx = int(choice) - 1
76
+ if not (0 <= idx < len(active)):
77
+ print("Out of range.")
78
+ return 1
79
+ track = active[idx]
80
+ else:
81
+ track = find_track_by_name(choice, tracks)
82
+ if not track:
83
+ print(f"No track matching '{choice}'.")
84
+ return 1
85
+ else:
86
+ track = find_track_by_name(track_name, tracks)
87
+ if not track:
88
+ print(f"No track matching '{track_name}'.")
89
+ return 1
90
+
91
+ return _orient_track(track)
92
+
93
+
94
+ def _orient_track(track) -> int:
95
+ """Render the track paste-block (mode 1)."""
96
+ slug = track.meta.get("track", track.name)
97
+ priority = track.meta.get("launch_priority", "P3")
98
+ milestone = track.meta.get("milestone_alignment", "—")
99
+ repo = track.repo or "—"
100
+ next_up = track.meta.get("next_up") or []
101
+ last_handoff_iso = track.meta.get("last_handoff")
102
+
103
+ issue_nums = track.meta.get("github", {}).get("issues") or []
104
+ titles_by_num: dict[int, str] = {}
105
+ states_by_num: dict[int, str] = {}
106
+ milestones_by_num: dict[int, str] = {}
107
+ if track.repo and next_up:
108
+ wanted = next_up[:4]
109
+ fetched = fetch_issues(track.repo, wanted)
110
+ for i in fetched:
111
+ titles_by_num[i["number"]] = i.get("title", "")
112
+ states_by_num[i["number"]] = (i.get("state") or "").upper()
113
+ milestones_by_num[i["number"]] = short_milestone(i.get("milestone"))
114
+
115
+ print(_top_rule(slug))
116
+ print(f"Priority: {priority} · Milestone: {milestone} · Repo: {repo}")
117
+ print(f"Track: {track.path}")
118
+ if track.local_path:
119
+ print(f"Local: {track.local_path}")
120
+ print()
121
+
122
+ last_ts, last_summary = _last_session_summary(track.body)
123
+ if last_ts:
124
+ print(f"Last session ({last_ts}):")
125
+ print(f" {last_summary}")
126
+ else:
127
+ print("Last session: (none yet)")
128
+ print()
129
+
130
+ if next_up:
131
+ pick_num = next_up[0]
132
+ pick_title = titles_by_num.get(pick_num, "")
133
+ pick_suffix = _state_suffix(states_by_num.get(pick_num))
134
+ pick_ms = _milestone_prefix(milestones_by_num.get(pick_num))
135
+ print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}".rstrip())
136
+ if _is_closed(states_by_num.get(pick_num)):
137
+ print(f" ⚠ next_up:[0] has shipped — run `/work-plan handoff {slug}` to rotate")
138
+ rest = next_up[1:4]
139
+ if rest:
140
+ print()
141
+ print("Behind it:")
142
+ for num in rest:
143
+ title = titles_by_num.get(num, "")
144
+ suffix = _state_suffix(states_by_num.get(num))
145
+ ms = _milestone_prefix(milestones_by_num.get(num))
146
+ print(f" #{num} {ms}{title}{suffix}".rstrip())
147
+ else:
148
+ print("Next pick: (none set — run `/work-plan handoff` to set one)")
149
+
150
+ if track.local_path:
151
+ cur = current_branch(track.local_path)
152
+ if cur:
153
+ ahead = commits_ahead(cur, "dev", track.local_path)
154
+ uc = uncommitted_file_count(track.local_path)
155
+ print()
156
+ print(f"Local: on {cur} ({ahead} ahead of dev, {uc} uncommitted)")
157
+
158
+ new_unlisted = _new_issues_since_handoff(track, last_handoff_iso, slug, issue_nums)
159
+ if new_unlisted:
160
+ print()
161
+ print(f"New issues since last handoff ({len(new_unlisted)}):")
162
+ for i in new_unlisted[:6]:
163
+ print(f" #{i['number']} {i['title']}")
164
+
165
+ print(_bottom_rule())
166
+ return 0
167
+
168
+
169
+ def _orient_cwd() -> int:
170
+ """Render the cwd snapshot (mode 3) — for non-track-bound work."""
171
+ cwd = Path.cwd()
172
+ if not _is_git_repo(cwd):
173
+ print("ERROR: not inside a git repository.")
174
+ print(" cwd-snapshot mode of orient needs git state to display.")
175
+ print(" Use `/work-plan orient <track>` for a track paste-block instead,")
176
+ print(" or `/work-plan orient --pick` for the interactive track picker.")
177
+ return 1
178
+
179
+ branch = _git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
180
+ upstream, ahead = _ahead_of_upstream(cwd)
181
+ modified = _modified_files(cwd)
182
+ commits = _recent_commits(cwd, n=3)
183
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
184
+
185
+ print(_top_rule("current directory"))
186
+ print(f"Path: {cwd}")
187
+ if upstream:
188
+ print(f"Branch: {branch} ({ahead} ahead of {upstream}, {len(modified)} uncommitted)")
189
+ else:
190
+ print(f"Branch: {branch} (no upstream tracked, {len(modified)} uncommitted)")
191
+
192
+ if commits:
193
+ print()
194
+ print("Last 3 commits:")
195
+ for sha, msg in commits:
196
+ print(f" {sha} {msg}")
197
+
198
+ if modified:
199
+ print()
200
+ print("Modified:")
201
+ for entry in modified[:20]:
202
+ print(f" {entry}")
203
+ if len(modified) > 20:
204
+ print(f" … and {len(modified) - 20} more")
205
+
206
+ print()
207
+ print(f"Snapshot: {now}")
208
+ print(_bottom_rule())
209
+ return 0
210
+
211
+
212
+ def _is_closed(state: Optional[str]) -> bool:
213
+ return (state or "").upper() in ("CLOSED", "MERGED")
214
+
215
+
216
+ def _state_suffix(state: Optional[str]) -> str:
217
+ return " (closed)" if _is_closed(state) else ""
218
+
219
+
220
+ def _milestone_prefix(ms: Optional[str]) -> str:
221
+ return f"[{ms}] " if ms else ""
222
+
223
+
224
+ def _top_rule(slug: str) -> str:
225
+ label = f" {slug} "
226
+ left = RULE_CHAR * 3
227
+ used = len(left) + len(label)
228
+ right = RULE_CHAR * max(3, RULE_WIDTH - used)
229
+ return f"{left}{label}{right}"
230
+
231
+
232
+ def _bottom_rule() -> str:
233
+ return RULE_CHAR * RULE_WIDTH
234
+
235
+
236
+ def _last_session_summary(body: str) -> tuple[Optional[str], str]:
237
+ """Return (timestamp, one-line summary) of the most recent session block."""
238
+ if "### Session — " not in body:
239
+ return (None, "")
240
+ idx = body.rfind("### Session — ")
241
+ rest = body[idx:]
242
+ end = len(rest)
243
+ for marker in ("\n### ", "\n## "):
244
+ m = rest.find(marker, 1)
245
+ if m != -1 and m < end:
246
+ end = m
247
+ block = rest[:end]
248
+
249
+ lines = block.split("\n")
250
+ header = lines[0]
251
+ ts_match = re.match(r"### Session — (.+?)\s*$", header)
252
+ ts = ts_match.group(1).strip() if ts_match else None
253
+
254
+ summary = ""
255
+ for line in lines[1:]:
256
+ s = line.strip()
257
+ if not s:
258
+ continue
259
+ if s.startswith("- "):
260
+ s = s[2:].strip()
261
+ summary = s
262
+ break
263
+ return (ts, summary)
264
+
265
+
266
+ def _new_issues_since_handoff(track, last_handoff_iso: Optional[str],
267
+ slug: str, listed_nums: list[int]) -> list[dict]:
268
+ if not (track.repo and last_handoff_iso):
269
+ return []
270
+ try:
271
+ last_dt = parse_iso_timestamp(last_handoff_iso)
272
+ except ValueError:
273
+ return []
274
+ days = max(1, int((datetime.now() - last_dt).total_seconds() / 86400))
275
+ slug_labels = build_slug_labels([track])
276
+ new_map = find_new_issues_for_tracks(track.repo, [slug], slug_labels=slug_labels, since_days=days)
277
+ listed = set(listed_nums)
278
+ return [i for i in new_map.get(slug, []) if i["number"] not in listed]
279
+
280
+
281
+ # === Helpers for cwd-snapshot mode ===
282
+
283
+ def _is_git_repo(cwd: Path) -> bool:
284
+ proc = subprocess.run(
285
+ ["git", "rev-parse", "--is-inside-work-tree"],
286
+ cwd=str(cwd), capture_output=True, text=True,
287
+ )
288
+ return proc.returncode == 0 and proc.stdout.strip() == "true"
289
+
290
+
291
+ def _git(args: list[str], cwd: Path) -> str:
292
+ proc = subprocess.run(
293
+ ["git", *args], cwd=str(cwd), capture_output=True, text=True,
294
+ )
295
+ return proc.stdout.strip() if proc.returncode == 0 else ""
296
+
297
+
298
+ def _ahead_of_upstream(cwd: Path) -> tuple[str, int]:
299
+ upstream = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd)
300
+ if not upstream:
301
+ return ("", 0)
302
+ ahead_str = _git(["rev-list", "--count", f"{upstream}..HEAD"], cwd)
303
+ try:
304
+ return (upstream, int(ahead_str or "0"))
305
+ except ValueError:
306
+ return (upstream, 0)
307
+
308
+
309
+ def _modified_files(cwd: Path) -> list[str]:
310
+ out = _git(["status", "--porcelain"], cwd)
311
+ if not out:
312
+ return []
313
+ return [line for line in out.split("\n") if line.strip()]
314
+
315
+
316
+ def _recent_commits(cwd: Path, n: int = 3) -> list[tuple[str, str]]:
317
+ out = _git(["log", f"-{n}", "--pretty=format:%h %s"], cwd)
318
+ if not out:
319
+ return []
320
+ pairs = []
321
+ for line in out.split("\n"):
322
+ if " " in line:
323
+ sha, msg = line.split(" ", 1)
324
+ pairs.append((sha, msg))
325
+ return pairs
File without changes