@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,111 @@
1
+ """duplicates subcommand: find likely-duplicate GitHub issues by title similarity.
2
+
3
+ Uses difflib.SequenceMatcher (stdlib) on normalized titles. No deps.
4
+ """
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from difflib import SequenceMatcher
9
+
10
+ from lib.config import load_config, ConfigError
11
+
12
+ # Common conventional-commit prefixes to strip before comparison
13
+ PREFIX_RE = re.compile(
14
+ r"^(feat|fix|chore|docs|test|refactor|perf|ci|spec|style|build|infra|epic|assets|fix-up)"
15
+ r"(\([^)]+\))?:\s*",
16
+ re.IGNORECASE,
17
+ )
18
+ WHITESPACE_RE = re.compile(r"\s+")
19
+
20
+
21
+ def run(args: list[str]) -> int:
22
+ repo_arg = next((a for a in args if a.startswith("--repo=")), None)
23
+ threshold_arg = next((a for a in args if a.startswith("--min-similarity=")), None)
24
+ limit_arg = next((a for a in args if a.startswith("--limit=")), None)
25
+ state_arg = next((a for a in args if a.startswith("--state=")), None)
26
+
27
+ threshold = float(threshold_arg.split("=", 1)[1]) if threshold_arg else 0.70
28
+ limit = int(limit_arg.split("=", 1)[1]) if limit_arg else 20
29
+ state = state_arg.split("=", 1)[1] if state_arg else "open"
30
+
31
+ try:
32
+ cfg = load_config()
33
+ except ConfigError as e:
34
+ print(f"ERROR: {e}")
35
+ return 1
36
+
37
+ repos = list(cfg["repos"].keys())
38
+ if repo_arg:
39
+ repo_folder = repo_arg.split("=", 1)[1]
40
+ if repo_folder not in cfg["repos"]:
41
+ print(f"ERROR: repo folder '{repo_folder}' not in config.yml.")
42
+ return 1
43
+ repos = [repo_folder]
44
+ elif len(repos) > 1:
45
+ print("Multiple repos in config. Specify with --repo=<folder-name>.")
46
+ return 1
47
+
48
+ folder = repos[0]
49
+ repo = cfg["repos"][folder]["github"]
50
+
51
+ print(f"Fetching {state} issues from {repo}...")
52
+ proc = subprocess.run(
53
+ ["gh", "issue", "list", "--repo", repo,
54
+ "--state", state, "--limit", "1000",
55
+ "--json", "number,title,url"],
56
+ capture_output=True, text=True,
57
+ )
58
+ if proc.returncode != 0:
59
+ print(f"ERROR fetching issues: {proc.stderr}")
60
+ return 1
61
+ issues = json.loads(proc.stdout) if proc.stdout.strip() else []
62
+ if len(issues) < 2:
63
+ print("Not enough issues to compare.")
64
+ return 0
65
+
66
+ print(f"Comparing {len(issues)} issues (threshold: {threshold:.0%}, limit: {limit})...")
67
+
68
+ normalized = [(i, _normalize(i["title"])) for i in issues]
69
+
70
+ # Pairwise similarity (O(n²) but fine for n<=1000)
71
+ pairs = []
72
+ for idx_a in range(len(normalized)):
73
+ a, norm_a = normalized[idx_a]
74
+ if len(norm_a) < 5:
75
+ continue
76
+ for idx_b in range(idx_a + 1, len(normalized)):
77
+ b, norm_b = normalized[idx_b]
78
+ if len(norm_b) < 5:
79
+ continue
80
+ ratio = SequenceMatcher(None, norm_a, norm_b).ratio()
81
+ if ratio >= threshold:
82
+ pairs.append((ratio, a, b))
83
+
84
+ pairs.sort(key=lambda x: -x[0])
85
+ pairs = pairs[:limit]
86
+
87
+ if not pairs:
88
+ print(f"No pairs above {threshold:.0%} similarity.")
89
+ return 0
90
+
91
+ print(f"\nLikely duplicates (top {len(pairs)}):\n")
92
+ for ratio, a, b in pairs:
93
+ print(f" {ratio:.0%} #{a['number']:5} {a['title']}")
94
+ print(f" #{b['number']:5} {b['title']}")
95
+ print(f" {a['url']}")
96
+ print(f" {b['url']}")
97
+ print()
98
+
99
+ print(f"\nReview these manually. To consolidate, close one and reference the other:")
100
+ print(f" gh issue close <newer> --comment 'Duplicate of #<older>' --repo {repo}")
101
+ return 0
102
+
103
+
104
+ def _normalize(title: str) -> str:
105
+ """Strip common prefixes + lowercase + collapse whitespace for comparison."""
106
+ s = title.strip()
107
+ # Remove conventional-commit prefix
108
+ s = PREFIX_RE.sub("", s)
109
+ s = s.lower()
110
+ s = WHITESPACE_RE.sub(" ", s)
111
+ return s
@@ -0,0 +1,69 @@
1
+ """export subcommand — emit the viewer-ready JSON read surface."""
2
+ import json
3
+ from datetime import datetime
4
+ from lib.config import load_config, ConfigError
5
+ from lib.tracks import discover_tracks
6
+ from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
7
+ from lib.export_model import build_export
8
+ from lib.prompts import parse_flags
9
+
10
+ def run(args: list[str]) -> int:
11
+ flags, _ = parse_flags(args, {"--json"})
12
+ if not flags.get("--json"):
13
+ print("usage: work-plan export --json"); return 2
14
+ try:
15
+ cfg = load_config()
16
+ except ConfigError as e:
17
+ print(json.dumps({"error": str(e)})); return 1
18
+ tracks = [t for t in discover_tracks(cfg) if t.has_frontmatter]
19
+
20
+ # Build repo_to_numbers: {repo: [number, ...]} deduped per repo, first-seen order.
21
+ repo_to_numbers: dict[str, list[int]] = {}
22
+ for t in tracks:
23
+ if not t.repo:
24
+ continue
25
+ nums = (t.meta.get("github", {}).get("issues")) or []
26
+ if not nums:
27
+ continue
28
+ seen_for_repo = repo_to_numbers.setdefault(t.repo, [])
29
+ seen_set = set(seen_for_repo)
30
+ for n in nums:
31
+ if n not in seen_set:
32
+ seen_for_repo.append(n)
33
+ seen_set.add(n)
34
+
35
+ # Bulk-fetch per repo (one gh call per repo) with per-issue fallback for misses.
36
+ issue_map = fetch_export_issues(repo_to_numbers)
37
+
38
+ # Reassemble per-track lists, preserving each track's declared issue order.
39
+ issues_by_track: dict[str, list] = {}
40
+ visibility: dict[str, object] = {}
41
+ for t in tracks:
42
+ nums = (t.meta.get("github", {}).get("issues")) or []
43
+ if t.repo and nums:
44
+ issues_by_track[t.name] = [
45
+ issue_map[(t.repo, n)]
46
+ for n in nums
47
+ if (t.repo, n) in issue_map
48
+ ]
49
+ else:
50
+ issues_by_track[t.name] = []
51
+ if t.repo and t.repo not in visibility:
52
+ visibility[t.repo] = repo_visibility(t.repo)
53
+
54
+ # Compute untracked: open issues not referenced by any track, per repo.
55
+ # One `gh issue list` call per repo — bounded by the number of tracked repos
56
+ # (typically a handful), not by issue count, so a serial loop is fine.
57
+ untracked_by_repo: dict[str, list] = {}
58
+ for repo in repo_to_numbers:
59
+ tracked = set(repo_to_numbers[repo])
60
+ open_rows = fetch_open_issues(repo)
61
+ untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
62
+
63
+ now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
64
+ print(json.dumps(
65
+ build_export(tracks, issues_by_track, visibility, now,
66
+ untracked_by_repo=untracked_by_repo),
67
+ indent=2,
68
+ ))
69
+ return 0
@@ -0,0 +1,234 @@
1
+ """group subcommand: AI-cluster GitHub issues into thematic track files.
2
+
3
+ Two-step:
4
+ 1. CLI fetches issues by filter (--milestone / --label / --search), writes JSON
5
+ batch to ~/.claude/work-plan/cache/groups.json, prints clustering prompt.
6
+ 2. Agent reads issues, produces JSON of clusters, saves to
7
+ ~/.claude/work-plan/cache/groups.answers.json.
8
+ 3. Run with --apply to create/update track files.
9
+ """
10
+ import json
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from lib.config import load_config, ConfigError
18
+ from lib.frontmatter import parse_file, write_file
19
+ from lib.scratch import cache_dir
20
+
21
+
22
+ def _batch_path() -> Path:
23
+ return cache_dir() / "groups.json"
24
+
25
+
26
+ def _answers_path() -> Path:
27
+ return cache_dir() / "groups.answers.json"
28
+
29
+ PROMPT_TEMPLATE = """\
30
+ Cluster the GitHub issues below into thematic tracks. Each track represents a
31
+ coherent workstream (a feature area, subsystem, or focused initiative).
32
+
33
+ Return JSON: [
34
+ {
35
+ "slug": "kebab-case-track-name",
36
+ "name": "Human Readable Name",
37
+ "summary": "One-line description of what this track covers",
38
+ "issues": [4254, 4255, 4256]
39
+ },
40
+ ...
41
+ ]
42
+
43
+ Heuristics:
44
+ - 8-20 issues per cluster ideally; smaller clusters acceptable for orphan themes
45
+ - Aim for 8-15 clusters total (depends on input size; cluster less aggressively
46
+ when input is small)
47
+ - Slug is kebab-case, lowercase, derives from the theme not from any one issue
48
+ - Name is short, scannable (3-5 words)
49
+ - Issues that don't fit any cluster: put them in a "misc" cluster (avoid forcing)
50
+ - Cluster by feature area / subsystem / user-facing capability
51
+ - An issue can only appear in ONE cluster (no duplicates across clusters)
52
+
53
+ Issues:
54
+ """
55
+
56
+
57
+ def run(args: list[str]) -> int:
58
+ apply_mode = "--apply" in args
59
+ repo_arg = next((a for a in args if a.startswith("--repo=")), None)
60
+ milestone_arg = next((a for a in args if a.startswith("--milestone=")), None)
61
+ label_arg = next((a for a in args if a.startswith("--label=")), None)
62
+ state_arg = next((a for a in args if a.startswith("--state=")), None)
63
+
64
+ try:
65
+ cfg = load_config()
66
+ except ConfigError as e:
67
+ print(f"ERROR: {e}")
68
+ return 1
69
+
70
+ if apply_mode:
71
+ return _apply(cfg)
72
+
73
+ # Resolve repo
74
+ repos = list(cfg["repos"].keys())
75
+ if repo_arg:
76
+ repo_folder = repo_arg.split("=", 1)[1]
77
+ if repo_folder not in cfg["repos"]:
78
+ print(f"ERROR: repo folder '{repo_folder}' not in config.yml.")
79
+ return 1
80
+ repos = [repo_folder]
81
+ elif len(repos) > 1:
82
+ print("Multiple repos in config. Specify with --repo=<folder-name>.")
83
+ return 1
84
+ elif not repos:
85
+ print("ERROR: no repos configured in config.yml.")
86
+ return 1
87
+
88
+ folder = repos[0]
89
+ repo = cfg["repos"][folder]["github"]
90
+
91
+ # Build gh search query
92
+ state = state_arg.split("=", 1)[1] if state_arg else "open"
93
+ cmd = ["gh", "issue", "list", "--repo", repo,
94
+ "--state", state, "--limit", "500",
95
+ "--json", "number,title,milestone,labels,url,assignees,state"]
96
+ if milestone_arg:
97
+ cmd.extend(["--milestone", milestone_arg.split("=", 1)[1]])
98
+ if label_arg:
99
+ cmd.extend(["--label", label_arg.split("=", 1)[1]])
100
+
101
+ print(f"Fetching issues from {repo}...")
102
+ proc = subprocess.run(cmd, capture_output=True, text=True)
103
+ if proc.returncode != 0:
104
+ print(f"ERROR fetching issues: {proc.stderr}")
105
+ return 1
106
+ issues = json.loads(proc.stdout) if proc.stdout.strip() else []
107
+ if not issues:
108
+ print("No issues match the filter.")
109
+ return 0
110
+
111
+ batch_path = _batch_path()
112
+ batch_path.write_text(json.dumps({
113
+ "repo": repo,
114
+ "folder": folder,
115
+ "milestone": milestone_arg.split("=", 1)[1] if milestone_arg else None,
116
+ "issues": issues,
117
+ }, indent=2))
118
+
119
+ print(f"Wrote {len(issues)} issues to {batch_path}")
120
+ print()
121
+ print("=" * 60)
122
+ print(PROMPT_TEMPLATE)
123
+ for i in issues:
124
+ m = i.get("milestone", {})
125
+ m_title = m.get("title", "—") if m else "—"
126
+ labels = [l["name"] for l in i.get("labels", [])]
127
+ print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
128
+ print("=" * 60)
129
+ print()
130
+ print(f"After agent returns clusters JSON, save to {_answers_path()}")
131
+ print("Then run: python3 ~/.claude/skills/work-plan/work_plan.py group --apply")
132
+ return 0
133
+
134
+
135
+ def _apply(cfg: dict) -> int:
136
+ answers_path = _answers_path()
137
+ batch_path = _batch_path()
138
+ if not answers_path.exists():
139
+ print(f"ERROR: {answers_path} not found. Run without --apply first.")
140
+ return 1
141
+ if not batch_path.exists():
142
+ print(f"ERROR: {batch_path} not found.")
143
+ return 1
144
+
145
+ batch = json.loads(batch_path.read_text())
146
+ repo = batch["repo"]
147
+ folder = batch["folder"]
148
+ if folder not in cfg.get("repos", {}):
149
+ print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
150
+ return 1
151
+ batch_milestone = batch.get("milestone") or "v1.0.0"
152
+ answers = json.loads(answers_path.read_text())
153
+
154
+ notes_root = Path(cfg["notes_root"])
155
+ track_dir = notes_root / folder
156
+ if not track_dir.exists():
157
+ print(f"ERROR: {track_dir} doesn't exist. Create it first.")
158
+ return 1
159
+
160
+ issues_by_num = {i["number"]: i for i in batch["issues"]}
161
+
162
+ print(f"Applying {len(answers)} clusters to {track_dir}/")
163
+ created = 0
164
+ updated = 0
165
+ for cluster in answers:
166
+ slug = _slugify(cluster["slug"])
167
+ name = cluster.get("name", slug)
168
+ summary = cluster.get("summary", "")
169
+ cluster_issues = sorted(set(cluster.get("issues") or []))
170
+ if not cluster_issues:
171
+ print(f" SKIP {slug}: no issues")
172
+ continue
173
+
174
+ path = track_dir / f"{slug}.md"
175
+ if path.exists():
176
+ existing_meta, existing_body = parse_file(path)
177
+ if not existing_meta:
178
+ print(f" SKIP {slug}: file exists but has no frontmatter; use init first")
179
+ continue
180
+ existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
181
+ merged = sorted(set(existing_issues) | set(cluster_issues))
182
+ existing_meta.setdefault("github", {})["issues"] = merged
183
+ existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
184
+ write_file(path, existing_meta, existing_body)
185
+ print(f" ↻ {slug}.md — merged ({len(cluster_issues)} new, "
186
+ f"{len(merged)} total)")
187
+ updated += 1
188
+ else:
189
+ now = datetime.now().strftime("%Y-%m-%dT%H:%M")
190
+ meta = {
191
+ "track": slug, "status": "active",
192
+ "launch_priority": "P3",
193
+ "milestone_alignment": batch_milestone,
194
+ "github": {"repo": repo, "issues": cluster_issues, "branches": []},
195
+ "related_tracks": [],
196
+ "last_touched": now, "last_handoff": now,
197
+ "next_up": [], "blockers": [],
198
+ }
199
+ body = _build_body(name, summary, cluster_issues, issues_by_num)
200
+ write_file(path, meta, body)
201
+ print(f" ✓ {slug}.md created ({len(cluster_issues)} issues)")
202
+ created += 1
203
+
204
+ print()
205
+ print(f"Done: {created} new track files, {updated} updated.")
206
+ print("Next: review priorities (P3 default — edit frontmatter or use slot),")
207
+ print(" then run /work-plan brief.")
208
+ return 0
209
+
210
+
211
+ def _slugify(s: str) -> str:
212
+ s = s.strip().lower()
213
+ s = re.sub(r"[^a-z0-9-]+", "-", s)
214
+ return s.strip("-") or "untitled"
215
+
216
+
217
+ def _build_body(name: str, summary: str, issues: list[int],
218
+ issues_by_num: dict) -> str:
219
+ lines = [f"# {name}\n"]
220
+ if summary:
221
+ lines.append(summary + "\n")
222
+ lines.append("## Issues\n")
223
+ lines.append("| # | Title | Assignee | Status |")
224
+ lines.append("|---|---|---|---|")
225
+ for num in issues:
226
+ i = issues_by_num.get(num, {})
227
+ title = i.get("title", "")
228
+ assignees = i.get("assignees") or []
229
+ assignee_str = ", ".join(f"@{a['login']}" for a in assignees) if assignees else "—"
230
+ state = (i.get("state") or "OPEN").upper()
231
+ status_str = "✅ Shipped" if state == "CLOSED" else "🔲 Open"
232
+ lines.append(f"| #{num} | {title} | {assignee_str} | {status_str} |")
233
+ lines.append("")
234
+ return "\n".join(lines) + "\n"