mindsystem-cc 3.19.0 → 3.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -6
- package/agents/ms-designer.md +5 -2
- package/agents/ms-mockup-designer.md +1 -1
- package/agents/ms-plan-writer.md +8 -1
- package/agents/ms-product-researcher.md +69 -0
- package/agents/ms-research-synthesizer.md +1 -1
- package/agents/ms-researcher.md +8 -8
- package/agents/ms-roadmapper.md +9 -13
- package/bin/install.js +68 -5
- package/commands/ms/add-phase.md +30 -18
- package/commands/ms/adhoc.md +1 -1
- package/commands/ms/audit-milestone.md +12 -12
- package/commands/ms/complete-milestone.md +25 -22
- package/commands/ms/config.md +202 -0
- package/commands/ms/design-phase.md +34 -29
- package/commands/ms/discuss-phase.md +26 -22
- package/commands/ms/doctor.md +22 -202
- package/commands/ms/execute-phase.md +18 -7
- package/commands/ms/help.md +46 -39
- package/commands/ms/insert-phase.md +29 -17
- package/commands/ms/new-milestone.md +42 -19
- package/commands/ms/new-project.md +88 -103
- package/commands/ms/plan-milestone-gaps.md +4 -5
- package/commands/ms/plan-phase.md +5 -3
- package/commands/ms/progress.md +2 -4
- package/commands/ms/research-phase.md +7 -12
- package/commands/ms/research-project.md +12 -12
- package/mindsystem/references/continuation-format.md +3 -3
- package/mindsystem/references/plan-format.md +11 -1
- package/mindsystem/references/principles.md +1 -1
- package/mindsystem/references/questioning.md +50 -8
- package/mindsystem/references/routing/audit-result-routing.md +12 -11
- package/mindsystem/references/routing/between-milestones-routing.md +2 -2
- package/mindsystem/references/routing/milestone-complete-routing.md +1 -1
- package/mindsystem/references/routing/next-phase-routing.md +4 -2
- package/mindsystem/templates/context.md +7 -6
- package/mindsystem/templates/milestone-archive.md +5 -5
- package/mindsystem/templates/milestone-context.md +1 -1
- package/mindsystem/templates/milestone.md +9 -9
- package/mindsystem/templates/project.md +70 -64
- package/mindsystem/templates/research-subagent-prompt.md +3 -3
- package/mindsystem/templates/roadmap-milestone.md +14 -14
- package/mindsystem/templates/roadmap.md +9 -7
- package/mindsystem/workflows/adhoc.md +1 -1
- package/mindsystem/workflows/complete-milestone.md +66 -107
- package/mindsystem/workflows/discuss-phase.md +137 -65
- package/mindsystem/workflows/doctor-fixes.md +273 -0
- package/mindsystem/workflows/execute-phase.md +7 -3
- package/mindsystem/workflows/execute-plan.md +6 -5
- package/mindsystem/workflows/map-codebase.md +2 -2
- package/mindsystem/workflows/mockup-generation.md +1 -1
- package/mindsystem/workflows/plan-phase.md +28 -3
- package/mindsystem/workflows/transition.md +20 -25
- package/mindsystem/workflows/verify-work.md +1 -1
- package/package.json +1 -1
- package/scripts/__pycache__/ms-tools.cpython-314.pyc +0 -0
- package/scripts/__pycache__/test_ms_tools.cpython-314-pytest-9.0.2.pyc +0 -0
- package/scripts/fixtures/scan-context/.planning/ROADMAP.md +16 -0
- package/scripts/fixtures/scan-context/.planning/adhoc/20260220-fix-token-SUMMARY.md +12 -0
- package/scripts/fixtures/scan-context/.planning/config.json +3 -0
- package/scripts/fixtures/scan-context/.planning/debug/resolved/token-bug.md +11 -0
- package/scripts/fixtures/scan-context/.planning/knowledge/auth.md +11 -0
- package/scripts/fixtures/scan-context/.planning/phases/02-infra/02-1-SUMMARY.md +20 -0
- package/scripts/fixtures/scan-context/.planning/phases/04-setup/04-1-SUMMARY.md +21 -0
- package/scripts/fixtures/scan-context/.planning/phases/05-auth/05-1-SUMMARY.md +28 -0
- package/scripts/fixtures/scan-context/.planning/todos/done/setup-db.md +10 -0
- package/scripts/fixtures/scan-context/.planning/todos/pending/add-logout.md +10 -0
- package/scripts/fixtures/scan-context/expected-output.json +257 -0
- package/scripts/ms-tools.py +2139 -0
- package/scripts/test_ms_tools.py +836 -0
- package/commands/ms/list-phase-assumptions.md +0 -56
- package/mindsystem/workflows/list-phase-assumptions.md +0 -178
- package/scripts/__pycache__/compare_mockups.cpython-314.pyc +0 -0
- package/scripts/archive-milestone-files.sh +0 -68
- package/scripts/archive-milestone-phases.sh +0 -138
- package/scripts/doctor-scan.sh +0 -379
- package/scripts/gather-milestone-stats.sh +0 -179
- package/scripts/generate-adhoc-patch.sh +0 -79
- package/scripts/generate-phase-patch.sh +0 -169
- package/scripts/scan-artifact-subsystems.sh +0 -55
- package/scripts/scan-planning-context.py +0 -839
- package/scripts/update-state.sh +0 -59
- package/scripts/validate-execution-order.sh +0 -104
|
@@ -0,0 +1,2139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = ["pyyaml"]
|
|
5
|
+
# ///
|
|
6
|
+
"""Mindsystem CLI tools.
|
|
7
|
+
|
|
8
|
+
Single-file CLI with subcommands for all mechanical operations:
|
|
9
|
+
phase discovery, state updates, artifact counting, diagnostics,
|
|
10
|
+
patch generation, archival, and planning context scanning.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import datetime
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# JSON encoder
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _SafeEncoder(json.JSONEncoder):
|
|
32
|
+
"""Handle YAML types that json.dump can't serialize (date, datetime)."""
|
|
33
|
+
|
|
34
|
+
def default(self, o: object) -> Any:
|
|
35
|
+
if isinstance(o, (datetime.date, datetime.datetime)):
|
|
36
|
+
return o.isoformat()
|
|
37
|
+
return super().default(o)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Shared helpers
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_git_root() -> Path:
|
|
46
|
+
"""Find the git repository root. Exit with error if not in a repo."""
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
check=True,
|
|
53
|
+
)
|
|
54
|
+
return Path(result.stdout.strip())
|
|
55
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
56
|
+
print("Error: Not in a git repository", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def find_planning_dir() -> Path:
|
|
61
|
+
"""Find .planning/ from git root. Exit with error if missing."""
|
|
62
|
+
planning = find_git_root() / ".planning"
|
|
63
|
+
if not planning.is_dir():
|
|
64
|
+
print("Error: No .planning/ directory found", file=sys.stderr)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
return planning
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def slugify(name: str) -> str:
|
|
70
|
+
"""Convert a milestone name to a URL-safe slug.
|
|
71
|
+
|
|
72
|
+
Lowercase, replace spaces/underscores with hyphens, strip non-alphanumeric
|
|
73
|
+
(except hyphens), collapse consecutive hyphens, trim edges.
|
|
74
|
+
"""
|
|
75
|
+
s = name.lower()
|
|
76
|
+
s = re.sub(r"[\s_]+", "-", s)
|
|
77
|
+
s = re.sub(r"[^a-z0-9-]", "", s)
|
|
78
|
+
s = re.sub(r"-{2,}", "-", s)
|
|
79
|
+
s = s.strip("-")
|
|
80
|
+
return s
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_planning_dir_optional() -> Path | None:
|
|
84
|
+
"""Find .planning/ from git root. Return None if missing."""
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
planning = Path(result.stdout.strip()) / ".planning"
|
|
93
|
+
return planning if planning.is_dir() else None
|
|
94
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def normalize_phase(phase_str: str) -> str:
|
|
99
|
+
"""Normalize phase input: '5' -> '05', '05' -> '05', '2.1' -> '02.1'."""
|
|
100
|
+
match = re.match(r"^(\d+)(?:\.(\d+))?$", phase_str)
|
|
101
|
+
if not match:
|
|
102
|
+
return phase_str
|
|
103
|
+
integer = int(match.group(1))
|
|
104
|
+
decimal = match.group(2)
|
|
105
|
+
if decimal:
|
|
106
|
+
return f"{integer:02d}.{decimal}"
|
|
107
|
+
return f"{integer:02d}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_phase_dir(planning: Path, phase: str) -> Path | None:
|
|
111
|
+
"""Find the phase directory matching a normalized phase number."""
|
|
112
|
+
phases_dir = planning / "phases"
|
|
113
|
+
if not phases_dir.is_dir():
|
|
114
|
+
return None
|
|
115
|
+
matches = sorted(phases_dir.glob(f"{phase}-*"))
|
|
116
|
+
dirs = [m for m in matches if m.is_dir()]
|
|
117
|
+
return dirs[0] if dirs else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_git(*args: str) -> str:
|
|
121
|
+
"""Run a git command and return stdout. Raise on failure."""
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
["git", *args],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
check=True,
|
|
127
|
+
)
|
|
128
|
+
return result.stdout.strip()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_json_config(planning: Path) -> dict:
|
|
132
|
+
"""Read .planning/config.json."""
|
|
133
|
+
config_path = planning / "config.json"
|
|
134
|
+
if not config_path.is_file():
|
|
135
|
+
return {}
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(config_path.read_text(encoding="utf-8"))
|
|
138
|
+
except (json.JSONDecodeError, OSError):
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def in_range(phase_num: str, start: int, end: int) -> bool:
|
|
143
|
+
"""Check if a phase number (possibly decimal) is within start..end range."""
|
|
144
|
+
try:
|
|
145
|
+
val = float(phase_num)
|
|
146
|
+
return start <= val <= end + 0.999
|
|
147
|
+
except ValueError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# YAML frontmatter parsing
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?\n)---\s*\n", re.DOTALL)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_frontmatter(path: Path) -> dict[str, Any] | None:
|
|
159
|
+
"""Extract YAML frontmatter from a markdown file."""
|
|
160
|
+
try:
|
|
161
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
162
|
+
except OSError:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
match = _FRONTMATTER_RE.match(text)
|
|
166
|
+
if not match:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
return yaml.safe_load(match.group(1)) or {}
|
|
171
|
+
except yaml.YAMLError:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Patch generation helpers (shared between generate-phase-patch and
|
|
177
|
+
# generate-adhoc-patch)
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
PATCH_EXCLUSIONS = [
|
|
181
|
+
# Documentation
|
|
182
|
+
".planning",
|
|
183
|
+
# Flutter/Dart generated
|
|
184
|
+
"*.g.dart",
|
|
185
|
+
"*.freezed.dart",
|
|
186
|
+
"*.gr.dart",
|
|
187
|
+
"generated",
|
|
188
|
+
".dart_tool",
|
|
189
|
+
# Next.js/TypeScript generated
|
|
190
|
+
"node_modules",
|
|
191
|
+
".next",
|
|
192
|
+
"dist",
|
|
193
|
+
"build",
|
|
194
|
+
"*.d.ts",
|
|
195
|
+
".turbo",
|
|
196
|
+
# Common build artifacts
|
|
197
|
+
"*.lock",
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_exclude_pathspecs() -> list[str]:
|
|
202
|
+
"""Build git pathspec exclusion list."""
|
|
203
|
+
return [f":!{p}" for p in PATCH_EXCLUSIONS]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ===================================================================
|
|
207
|
+
# Subcommand: update-state
|
|
208
|
+
# ===================================================================
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_update_state(args: argparse.Namespace) -> None:
|
|
212
|
+
"""Update .planning/STATE.md Plan and Status lines.
|
|
213
|
+
|
|
214
|
+
Contract:
|
|
215
|
+
Args: completed (int), total (int)
|
|
216
|
+
Output: text — confirmation message
|
|
217
|
+
Exit codes: 0 = success, 1 = STATE.md missing or completed > total
|
|
218
|
+
Side effects: writes STATE.md
|
|
219
|
+
"""
|
|
220
|
+
completed = args.completed
|
|
221
|
+
total = args.total
|
|
222
|
+
|
|
223
|
+
if completed > total:
|
|
224
|
+
print(f"Error: Completed ({completed}) cannot exceed total ({total})", file=sys.stderr)
|
|
225
|
+
sys.exit(1)
|
|
226
|
+
|
|
227
|
+
state_file = find_git_root() / ".planning" / "STATE.md"
|
|
228
|
+
if not state_file.is_file():
|
|
229
|
+
print(f"Error: STATE.md not found at {state_file}", file=sys.stderr)
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
|
|
232
|
+
text = state_file.read_text(encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
# Update Plan line
|
|
235
|
+
text = re.sub(
|
|
236
|
+
r"^Plan:.*$",
|
|
237
|
+
f"Plan: {completed} of {total} complete in current phase",
|
|
238
|
+
text,
|
|
239
|
+
flags=re.MULTILINE,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Update Status line
|
|
243
|
+
if completed == total:
|
|
244
|
+
status = "All plans executed, pending verification"
|
|
245
|
+
else:
|
|
246
|
+
status = f"In progress — plan {completed} of {total} complete"
|
|
247
|
+
text = re.sub(r"^Status:.*$", f"Status: {status}", text, flags=re.MULTILINE)
|
|
248
|
+
|
|
249
|
+
state_file.write_text(text, encoding="utf-8")
|
|
250
|
+
print(f"STATE.md updated: {completed} of {total} plans complete")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ===================================================================
|
|
254
|
+
# Subcommand: validate-execution-order
|
|
255
|
+
# ===================================================================
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_validate_execution_order(args: argparse.Namespace) -> None:
|
|
259
|
+
"""Validate EXECUTION-ORDER.md against plan files in a phase directory.
|
|
260
|
+
|
|
261
|
+
Contract:
|
|
262
|
+
Args: phase_dir (str) — path to phase directory
|
|
263
|
+
Output: text — PASS/FAIL message with plan count and wave count
|
|
264
|
+
Exit codes: 0 = all plans matched, 1 = mismatch or missing files
|
|
265
|
+
Side effects: read-only
|
|
266
|
+
"""
|
|
267
|
+
phase_dir = Path(args.phase_dir)
|
|
268
|
+
if not phase_dir.is_dir():
|
|
269
|
+
print(f"FAIL: Directory does not exist: {phase_dir}")
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
|
|
272
|
+
exec_order = phase_dir / "EXECUTION-ORDER.md"
|
|
273
|
+
if not exec_order.is_file():
|
|
274
|
+
print(f"FAIL: EXECUTION-ORDER.md not found in {phase_dir}")
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
# Collect plan files on disk
|
|
278
|
+
disk_plans = sorted(p.name for p in phase_dir.glob("*-PLAN.md"))
|
|
279
|
+
if not disk_plans:
|
|
280
|
+
print(f"FAIL: No *-PLAN.md files found in {phase_dir}")
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
|
|
283
|
+
# Parse EXECUTION-ORDER.md for plan filenames
|
|
284
|
+
exec_text = exec_order.read_text(encoding="utf-8")
|
|
285
|
+
plan_pattern = re.compile(r"[0-9][0-9.]*-[0-9]+-PLAN\.md")
|
|
286
|
+
order_plans = sorted(set(plan_pattern.findall(exec_text)))
|
|
287
|
+
|
|
288
|
+
errors: list[str] = []
|
|
289
|
+
|
|
290
|
+
# Check 1: Every disk plan is listed
|
|
291
|
+
for plan in disk_plans:
|
|
292
|
+
if plan not in order_plans:
|
|
293
|
+
errors.append(f" Missing from EXECUTION-ORDER.md: {plan}")
|
|
294
|
+
|
|
295
|
+
# Check 2: Every listed plan exists on disk
|
|
296
|
+
for plan in order_plans:
|
|
297
|
+
if plan not in disk_plans:
|
|
298
|
+
errors.append(f" Listed in EXECUTION-ORDER.md but file missing: {plan}")
|
|
299
|
+
|
|
300
|
+
if errors:
|
|
301
|
+
print("FAIL: Plan/execution-order mismatch")
|
|
302
|
+
for err in errors:
|
|
303
|
+
print(err)
|
|
304
|
+
sys.exit(1)
|
|
305
|
+
|
|
306
|
+
# Check 3 (warning): File conflicts within waves
|
|
307
|
+
current_wave = ""
|
|
308
|
+
wave_count = 0
|
|
309
|
+
current_wave_files: set[str] = set()
|
|
310
|
+
|
|
311
|
+
for line in exec_text.splitlines():
|
|
312
|
+
wave_match = re.match(r"^## Wave (\d+)", line)
|
|
313
|
+
if wave_match:
|
|
314
|
+
current_wave = wave_match.group(1)
|
|
315
|
+
wave_count += 1
|
|
316
|
+
current_wave_files = set()
|
|
317
|
+
elif current_wave:
|
|
318
|
+
plan_match = plan_pattern.search(line)
|
|
319
|
+
if plan_match:
|
|
320
|
+
plan_file = plan_match.group()
|
|
321
|
+
plan_path = phase_dir / plan_file
|
|
322
|
+
if plan_path.is_file():
|
|
323
|
+
plan_text = plan_path.read_text(encoding="utf-8")
|
|
324
|
+
for files_match in re.finditer(r"\*\*Files:\*\*(.+)", plan_text):
|
|
325
|
+
file_paths = files_match.group(1)
|
|
326
|
+
for fpath in file_paths.replace("`", "").split(","):
|
|
327
|
+
fpath = fpath.strip()
|
|
328
|
+
if fpath:
|
|
329
|
+
if fpath in current_wave_files:
|
|
330
|
+
print(f"WARNING: File '{fpath}' appears in multiple plans within Wave {current_wave}")
|
|
331
|
+
else:
|
|
332
|
+
current_wave_files.add(fpath)
|
|
333
|
+
|
|
334
|
+
if wave_count == 0:
|
|
335
|
+
print("FAIL: No '## Wave N' headers found in EXECUTION-ORDER.md")
|
|
336
|
+
sys.exit(1)
|
|
337
|
+
|
|
338
|
+
print(f"PASS: {len(disk_plans)} plans across {wave_count} waves")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ===================================================================
|
|
342
|
+
# Subcommand: doctor-scan
|
|
343
|
+
# ===================================================================
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_doctor_scan(args: argparse.Namespace) -> None:
|
|
347
|
+
"""Single-pass diagnostic scan of the .planning/ tree.
|
|
348
|
+
|
|
349
|
+
Contract:
|
|
350
|
+
Args: (none)
|
|
351
|
+
Output: text — per-check PASS/FAIL/SKIP status and summary
|
|
352
|
+
Exit codes: 0 = all checks passed, 1 = any check failed, 2 = missing .planning/ or config.json
|
|
353
|
+
Side effects: read-only
|
|
354
|
+
"""
|
|
355
|
+
git_root = find_git_root()
|
|
356
|
+
planning = git_root / ".planning"
|
|
357
|
+
|
|
358
|
+
if not planning.is_dir():
|
|
359
|
+
print("Error: No .planning/ directory found")
|
|
360
|
+
sys.exit(2)
|
|
361
|
+
|
|
362
|
+
config_path = planning / "config.json"
|
|
363
|
+
if not config_path.is_file():
|
|
364
|
+
print(f"Error: No config.json found at {config_path}")
|
|
365
|
+
sys.exit(2)
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
369
|
+
except (json.JSONDecodeError, OSError):
|
|
370
|
+
print(f"Error: Cannot parse {config_path}")
|
|
371
|
+
sys.exit(2)
|
|
372
|
+
|
|
373
|
+
milestones_file = planning / "MILESTONES.md"
|
|
374
|
+
phases_dir = planning / "phases"
|
|
375
|
+
milestones_dir = planning / "milestones"
|
|
376
|
+
knowledge_dir = planning / "knowledge"
|
|
377
|
+
|
|
378
|
+
pass_count = 0
|
|
379
|
+
fail_count = 0
|
|
380
|
+
skip_count = 0
|
|
381
|
+
failed_checks: list[str] = []
|
|
382
|
+
|
|
383
|
+
def record(status: str, name: str) -> None:
|
|
384
|
+
nonlocal pass_count, fail_count, skip_count
|
|
385
|
+
if status == "PASS":
|
|
386
|
+
pass_count += 1
|
|
387
|
+
elif status == "FAIL":
|
|
388
|
+
fail_count += 1
|
|
389
|
+
failed_checks.append(name)
|
|
390
|
+
else:
|
|
391
|
+
skip_count += 1
|
|
392
|
+
|
|
393
|
+
def format_phase_prefix(phase: str) -> str:
|
|
394
|
+
if "." in phase:
|
|
395
|
+
int_part, dec_part = phase.split(".", 1)
|
|
396
|
+
return f"{int(int_part):02d}.{dec_part}"
|
|
397
|
+
return f"{int(phase):02d}"
|
|
398
|
+
|
|
399
|
+
def parse_phase_numbers(line: str) -> list[str]:
|
|
400
|
+
"""Parse phase numbers from a 'Phases completed' line."""
|
|
401
|
+
range_match = re.search(r"(\d+)-(\d+)", line)
|
|
402
|
+
if range_match:
|
|
403
|
+
start, end = int(range_match.group(1)), int(range_match.group(2))
|
|
404
|
+
return [str(i) for i in range(start, end + 1)]
|
|
405
|
+
return re.findall(r"\d+(?:\.\d+)?", line.split(":")[-1] if ":" in line else line)
|
|
406
|
+
|
|
407
|
+
subsystems = config.get("subsystems", [])
|
|
408
|
+
subsystem_count = len(subsystems)
|
|
409
|
+
|
|
410
|
+
# ---- CHECK 1: Subsystem Vocabulary ----
|
|
411
|
+
print("=== Subsystem Vocabulary ===")
|
|
412
|
+
if subsystem_count == 0:
|
|
413
|
+
print("Status: FAIL")
|
|
414
|
+
print("No subsystems array in config.json (or empty)")
|
|
415
|
+
record("FAIL", "Subsystem Vocabulary")
|
|
416
|
+
else:
|
|
417
|
+
print(f"Subsystems: {subsystem_count} configured")
|
|
418
|
+
for s in subsystems:
|
|
419
|
+
print(f" - {s}")
|
|
420
|
+
|
|
421
|
+
# Run artifact scan inline
|
|
422
|
+
artifact_values = _scan_artifact_subsystem_values(planning)
|
|
423
|
+
mismatches = [v for v in artifact_values if v not in subsystems]
|
|
424
|
+
|
|
425
|
+
if mismatches:
|
|
426
|
+
print("Status: FAIL")
|
|
427
|
+
print(f"Artifact values not in canonical list: {' '.join(mismatches)}")
|
|
428
|
+
record("FAIL", "Subsystem Vocabulary")
|
|
429
|
+
else:
|
|
430
|
+
print(f"Artifacts scanned: {len(artifact_values)} (all OK)")
|
|
431
|
+
print("Status: PASS")
|
|
432
|
+
record("PASS", "Subsystem Vocabulary")
|
|
433
|
+
print()
|
|
434
|
+
|
|
435
|
+
# ---- CHECK 2: Milestone Directory Structure ----
|
|
436
|
+
print("=== Milestone Directory Structure ===")
|
|
437
|
+
if not milestones_dir.is_dir():
|
|
438
|
+
if milestones_file.is_file() and any(
|
|
439
|
+
line.startswith("## ")
|
|
440
|
+
for line in milestones_file.read_text(encoding="utf-8").splitlines()
|
|
441
|
+
):
|
|
442
|
+
print("Status: FAIL")
|
|
443
|
+
print("MILESTONES.md has entries but no milestones/ directory")
|
|
444
|
+
record("FAIL", "Milestone Directory Structure")
|
|
445
|
+
else:
|
|
446
|
+
print("Status: SKIP")
|
|
447
|
+
print("No completed milestones")
|
|
448
|
+
record("SKIP", "Milestone Directory Structure")
|
|
449
|
+
else:
|
|
450
|
+
flat_files = sorted(milestones_dir.glob("v*-*.md"))
|
|
451
|
+
if flat_files:
|
|
452
|
+
print("Status: FAIL")
|
|
453
|
+
print(f"Found {len(flat_files)} flat file(s) in milestones/ (old format):")
|
|
454
|
+
for f in flat_files:
|
|
455
|
+
version = re.match(r"(v[\d.]+)", f.name)
|
|
456
|
+
ver = version.group(1) if version else "?"
|
|
457
|
+
ver_dir = milestones_dir / ver
|
|
458
|
+
if ver_dir.is_dir():
|
|
459
|
+
print(f" {f.name} → directory {ver}/ exists (can restructure)")
|
|
460
|
+
else:
|
|
461
|
+
print(f" {f.name} → directory {ver}/ missing (need to create)")
|
|
462
|
+
record("FAIL", "Milestone Directory Structure")
|
|
463
|
+
else:
|
|
464
|
+
ms_dirs = [d for d in milestones_dir.iterdir() if d.is_dir()]
|
|
465
|
+
if not ms_dirs:
|
|
466
|
+
print("Status: SKIP")
|
|
467
|
+
print("No completed milestones")
|
|
468
|
+
record("SKIP", "Milestone Directory Structure")
|
|
469
|
+
else:
|
|
470
|
+
print("Status: PASS")
|
|
471
|
+
print(f"{len(ms_dirs)} milestone directories")
|
|
472
|
+
record("PASS", "Milestone Directory Structure")
|
|
473
|
+
print()
|
|
474
|
+
|
|
475
|
+
# ---- CHECK 3: Phase Archival ----
|
|
476
|
+
print("=== Phase Archival ===")
|
|
477
|
+
if not milestones_file.is_file():
|
|
478
|
+
print("Status: SKIP")
|
|
479
|
+
print("No completed milestones with phase ranges in MILESTONES.md")
|
|
480
|
+
record("SKIP", "Phase Archival")
|
|
481
|
+
else:
|
|
482
|
+
ms_text = milestones_file.read_text(encoding="utf-8")
|
|
483
|
+
phase_lines = [l for l in ms_text.splitlines() if "Phases completed" in l]
|
|
484
|
+
if not phase_lines:
|
|
485
|
+
print("Status: SKIP")
|
|
486
|
+
print("No completed milestones with phase ranges in MILESTONES.md")
|
|
487
|
+
record("SKIP", "Phase Archival")
|
|
488
|
+
else:
|
|
489
|
+
orphans: list[str] = []
|
|
490
|
+
for line in phase_lines:
|
|
491
|
+
for phase_num in parse_phase_numbers(line):
|
|
492
|
+
prefix = format_phase_prefix(phase_num)
|
|
493
|
+
if phases_dir.is_dir():
|
|
494
|
+
for d in phases_dir.glob(f"{prefix}-*/"):
|
|
495
|
+
if d.is_dir():
|
|
496
|
+
orphans.append(f" {d.name} (should be archived)")
|
|
497
|
+
if orphans:
|
|
498
|
+
print("Status: FAIL")
|
|
499
|
+
print(f"Found {len(orphans)} orphaned phase directories from completed milestones:")
|
|
500
|
+
for o in orphans:
|
|
501
|
+
print(o)
|
|
502
|
+
record("FAIL", "Phase Archival")
|
|
503
|
+
else:
|
|
504
|
+
print("Status: PASS")
|
|
505
|
+
print("All completed milestone phases are archived")
|
|
506
|
+
record("PASS", "Phase Archival")
|
|
507
|
+
print()
|
|
508
|
+
|
|
509
|
+
# ---- CHECK 4: Knowledge Files ----
|
|
510
|
+
print("=== Knowledge Files ===")
|
|
511
|
+
if subsystem_count == 0:
|
|
512
|
+
print("Status: SKIP")
|
|
513
|
+
print("No subsystems configured — knowledge check requires subsystem vocabulary")
|
|
514
|
+
record("SKIP", "Knowledge Files")
|
|
515
|
+
elif not knowledge_dir.is_dir():
|
|
516
|
+
print("Status: FAIL")
|
|
517
|
+
print("Knowledge directory missing: .planning/knowledge/")
|
|
518
|
+
print(f"Expected files for {subsystem_count} subsystems")
|
|
519
|
+
record("FAIL", "Knowledge Files")
|
|
520
|
+
else:
|
|
521
|
+
missing = [s for s in subsystems if not (knowledge_dir / f"{s}.md").is_file()]
|
|
522
|
+
orphaned = [
|
|
523
|
+
f.stem
|
|
524
|
+
for f in knowledge_dir.glob("*.md")
|
|
525
|
+
if f.stem not in subsystems
|
|
526
|
+
]
|
|
527
|
+
if missing or orphaned:
|
|
528
|
+
present = subsystem_count - len(missing)
|
|
529
|
+
print("Status: FAIL")
|
|
530
|
+
print(f"Coverage: {present}/{subsystem_count} subsystems have knowledge files")
|
|
531
|
+
if missing:
|
|
532
|
+
print("Missing:")
|
|
533
|
+
for m in missing:
|
|
534
|
+
print(f" {m}.md")
|
|
535
|
+
if orphaned:
|
|
536
|
+
print("Orphaned:")
|
|
537
|
+
for o in orphaned:
|
|
538
|
+
print(f" {o}.md (not in subsystems list)")
|
|
539
|
+
record("FAIL", "Knowledge Files")
|
|
540
|
+
else:
|
|
541
|
+
print("Status: PASS")
|
|
542
|
+
print(f"All {subsystem_count} subsystems have knowledge files")
|
|
543
|
+
record("PASS", "Knowledge Files")
|
|
544
|
+
print()
|
|
545
|
+
|
|
546
|
+
# ---- CHECK 5: Phase Summaries ----
|
|
547
|
+
print("=== Phase Summaries ===")
|
|
548
|
+
if not milestones_dir.is_dir():
|
|
549
|
+
print("Status: SKIP")
|
|
550
|
+
print("No milestones directory")
|
|
551
|
+
record("SKIP", "Phase Summaries")
|
|
552
|
+
else:
|
|
553
|
+
ms_dirs = sorted(d for d in milestones_dir.iterdir() if d.is_dir())
|
|
554
|
+
if not ms_dirs:
|
|
555
|
+
print("Status: SKIP")
|
|
556
|
+
print("No milestone directories")
|
|
557
|
+
record("SKIP", "Phase Summaries")
|
|
558
|
+
else:
|
|
559
|
+
missing_summaries = [
|
|
560
|
+
d.name for d in ms_dirs if not (d / "PHASE-SUMMARIES.md").is_file()
|
|
561
|
+
]
|
|
562
|
+
if missing_summaries:
|
|
563
|
+
print("Status: FAIL")
|
|
564
|
+
print(f"Missing PHASE-SUMMARIES.md in {len(missing_summaries)} milestone(s):")
|
|
565
|
+
for m in missing_summaries:
|
|
566
|
+
print(f" {m}/PHASE-SUMMARIES.md")
|
|
567
|
+
record("FAIL", "Phase Summaries")
|
|
568
|
+
else:
|
|
569
|
+
print("Status: PASS")
|
|
570
|
+
print(f"All {len(ms_dirs)} milestones have PHASE-SUMMARIES.md")
|
|
571
|
+
record("PASS", "Phase Summaries")
|
|
572
|
+
print()
|
|
573
|
+
|
|
574
|
+
# ---- CHECK 6: PLAN Cleanup ----
|
|
575
|
+
print("=== PLAN Cleanup ===")
|
|
576
|
+
if not milestones_file.is_file():
|
|
577
|
+
print("Status: SKIP")
|
|
578
|
+
print("No completed milestones — active phase PLANs are expected")
|
|
579
|
+
record("SKIP", "PLAN Cleanup")
|
|
580
|
+
else:
|
|
581
|
+
ms_text = milestones_file.read_text(encoding="utf-8")
|
|
582
|
+
phase_lines = [l for l in ms_text.splitlines() if "Phases completed" in l]
|
|
583
|
+
if not phase_lines:
|
|
584
|
+
print("Status: SKIP")
|
|
585
|
+
print("No completed milestones — active phase PLANs are expected")
|
|
586
|
+
record("SKIP", "PLAN Cleanup")
|
|
587
|
+
else:
|
|
588
|
+
leftovers: list[str] = []
|
|
589
|
+
for line in phase_lines:
|
|
590
|
+
for phase_num in parse_phase_numbers(line):
|
|
591
|
+
prefix = format_phase_prefix(phase_num)
|
|
592
|
+
if phases_dir.is_dir():
|
|
593
|
+
for d in phases_dir.glob(f"{prefix}-*/"):
|
|
594
|
+
if d.is_dir():
|
|
595
|
+
for plan in d.glob("*-PLAN.md"):
|
|
596
|
+
rel = plan.relative_to(planning)
|
|
597
|
+
leftovers.append(f" {rel}")
|
|
598
|
+
|
|
599
|
+
# Check archived milestone directories too
|
|
600
|
+
if milestones_dir.is_dir():
|
|
601
|
+
for ver_dir in milestones_dir.iterdir():
|
|
602
|
+
if not ver_dir.is_dir():
|
|
603
|
+
continue
|
|
604
|
+
archived_phases = ver_dir / "phases"
|
|
605
|
+
if archived_phases.is_dir():
|
|
606
|
+
for phase_d in archived_phases.iterdir():
|
|
607
|
+
if phase_d.is_dir():
|
|
608
|
+
for plan in phase_d.glob("*-PLAN.md"):
|
|
609
|
+
rel = plan.relative_to(planning)
|
|
610
|
+
leftovers.append(f" {rel}")
|
|
611
|
+
|
|
612
|
+
if leftovers:
|
|
613
|
+
print("Status: FAIL")
|
|
614
|
+
print(f"Found {len(leftovers)} leftover PLAN file(s) in completed phases:")
|
|
615
|
+
for l in leftovers:
|
|
616
|
+
print(l)
|
|
617
|
+
record("FAIL", "PLAN Cleanup")
|
|
618
|
+
else:
|
|
619
|
+
print("Status: PASS")
|
|
620
|
+
print("No leftover PLAN files in completed phases")
|
|
621
|
+
record("PASS", "PLAN Cleanup")
|
|
622
|
+
print()
|
|
623
|
+
|
|
624
|
+
# ---- CHECK 7: CLI Wrappers ----
|
|
625
|
+
print("=== CLI Wrappers ===")
|
|
626
|
+
wrapper_names = ["ms-tools", "ms-lookup", "ms-compare-mockups"]
|
|
627
|
+
missing_wrappers = [w for w in wrapper_names if shutil.which(w) is None]
|
|
628
|
+
if missing_wrappers:
|
|
629
|
+
print("Status: FAIL")
|
|
630
|
+
print(f"Not on PATH: {', '.join(missing_wrappers)}")
|
|
631
|
+
print("Fix: re-run `npx mindsystem-cc` to regenerate wrappers and PATH hook")
|
|
632
|
+
record("FAIL", "CLI Wrappers")
|
|
633
|
+
else:
|
|
634
|
+
print("Status: PASS")
|
|
635
|
+
print(f"All {len(wrapper_names)} CLI wrappers found on PATH")
|
|
636
|
+
record("PASS", "CLI Wrappers")
|
|
637
|
+
print()
|
|
638
|
+
|
|
639
|
+
# ---- CHECK 8: Milestone Naming Convention ----
|
|
640
|
+
print("=== Milestone Naming Convention ===")
|
|
641
|
+
if not milestones_dir.is_dir():
|
|
642
|
+
print("Status: SKIP")
|
|
643
|
+
print("No milestones directory")
|
|
644
|
+
record("SKIP", "Milestone Naming Convention")
|
|
645
|
+
else:
|
|
646
|
+
ms_dirs = [d for d in milestones_dir.iterdir() if d.is_dir()]
|
|
647
|
+
if not ms_dirs:
|
|
648
|
+
print("Status: SKIP")
|
|
649
|
+
print("No milestone directories")
|
|
650
|
+
record("SKIP", "Milestone Naming Convention")
|
|
651
|
+
else:
|
|
652
|
+
versioned = _detect_versioned_milestone_dirs(planning)
|
|
653
|
+
if versioned:
|
|
654
|
+
print("Status: FAIL")
|
|
655
|
+
print(f"Found {len(versioned)} version-prefixed milestone directories:")
|
|
656
|
+
for v in versioned:
|
|
657
|
+
dirname = v["path"].split("/", 1)[1] if "/" in v["path"] else v["path"]
|
|
658
|
+
print(f" {dirname} ({v['type']})")
|
|
659
|
+
record("FAIL", "Milestone Naming Convention")
|
|
660
|
+
else:
|
|
661
|
+
print("Status: PASS")
|
|
662
|
+
print("All milestone directories use name-based slugs")
|
|
663
|
+
record("PASS", "Milestone Naming Convention")
|
|
664
|
+
print()
|
|
665
|
+
|
|
666
|
+
# ---- SUMMARY ----
|
|
667
|
+
total = pass_count + fail_count + skip_count
|
|
668
|
+
print("=== Summary ===")
|
|
669
|
+
print(f"Checks: {total} total, {pass_count} passed, {fail_count} failed, {skip_count} skipped")
|
|
670
|
+
|
|
671
|
+
if fail_count > 0:
|
|
672
|
+
print(f"Issues: {' '.join(failed_checks)}")
|
|
673
|
+
sys.exit(1)
|
|
674
|
+
else:
|
|
675
|
+
print("All checks passed")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ===================================================================
|
|
679
|
+
# Subcommand: gather-milestone-stats
|
|
680
|
+
# ===================================================================
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def cmd_gather_milestone_stats(args: argparse.Namespace) -> None:
|
|
684
|
+
"""Gather milestone readiness status and statistics.
|
|
685
|
+
|
|
686
|
+
Contract:
|
|
687
|
+
Args: start_phase (int), end_phase (int)
|
|
688
|
+
Output: text — readiness status (READY/NOT READY) and git stats
|
|
689
|
+
Exit codes: 0 = success, 1 = start > end or phases dir missing
|
|
690
|
+
Side effects: read-only
|
|
691
|
+
"""
|
|
692
|
+
start = args.start_phase
|
|
693
|
+
end = args.end_phase
|
|
694
|
+
|
|
695
|
+
if start > end:
|
|
696
|
+
print(f"Error: Start phase ({start}) cannot exceed end phase ({end})", file=sys.stderr)
|
|
697
|
+
sys.exit(1)
|
|
698
|
+
|
|
699
|
+
git_root = find_git_root()
|
|
700
|
+
phases_dir = git_root / ".planning" / "phases"
|
|
701
|
+
if not phases_dir.is_dir():
|
|
702
|
+
print(f"Error: Phases directory not found at {phases_dir}", file=sys.stderr)
|
|
703
|
+
sys.exit(1)
|
|
704
|
+
|
|
705
|
+
# ---- READINESS ----
|
|
706
|
+
print("=== Readiness ===")
|
|
707
|
+
print()
|
|
708
|
+
|
|
709
|
+
phase_count = 0
|
|
710
|
+
plan_count = 0
|
|
711
|
+
complete = 0
|
|
712
|
+
incomplete_list: list[str] = []
|
|
713
|
+
phase_details: list[str] = []
|
|
714
|
+
|
|
715
|
+
for d in sorted(phases_dir.iterdir()):
|
|
716
|
+
if not d.is_dir():
|
|
717
|
+
continue
|
|
718
|
+
dirname = d.name
|
|
719
|
+
phase_num = dirname.split("-", 1)[0]
|
|
720
|
+
phase_name = dirname.split("-", 1)[1] if "-" in dirname else dirname
|
|
721
|
+
|
|
722
|
+
if in_range(phase_num, start, end):
|
|
723
|
+
phase_count += 1
|
|
724
|
+
phase_plans = 0
|
|
725
|
+
phase_complete = 0
|
|
726
|
+
|
|
727
|
+
for plan in sorted(d.glob("*-PLAN.md")):
|
|
728
|
+
plan_count += 1
|
|
729
|
+
phase_plans += 1
|
|
730
|
+
plan_base = plan.name.replace("-PLAN.md", "")
|
|
731
|
+
summary = d / f"{plan_base}-SUMMARY.md"
|
|
732
|
+
if summary.is_file():
|
|
733
|
+
complete += 1
|
|
734
|
+
phase_complete += 1
|
|
735
|
+
else:
|
|
736
|
+
incomplete_list.append(f" {dirname}/{plan.name}")
|
|
737
|
+
|
|
738
|
+
phase_details.append(f"- Phase {phase_num}: {phase_name} ({phase_complete}/{phase_plans} plans)")
|
|
739
|
+
|
|
740
|
+
print(f"Phases: {phase_count} (range {start}-{end})")
|
|
741
|
+
print(f"Plans: {plan_count} total, {complete} complete")
|
|
742
|
+
print()
|
|
743
|
+
for detail in phase_details:
|
|
744
|
+
print(detail)
|
|
745
|
+
print()
|
|
746
|
+
|
|
747
|
+
if complete == plan_count and plan_count > 0:
|
|
748
|
+
print("Status: READY")
|
|
749
|
+
else:
|
|
750
|
+
incomplete = plan_count - complete
|
|
751
|
+
print(f"Incomplete ({incomplete}):")
|
|
752
|
+
for item in incomplete_list:
|
|
753
|
+
print(item)
|
|
754
|
+
print("Status: NOT READY")
|
|
755
|
+
|
|
756
|
+
# ---- GIT STATS ----
|
|
757
|
+
print()
|
|
758
|
+
print("=== Git Stats ===")
|
|
759
|
+
print()
|
|
760
|
+
|
|
761
|
+
all_commits: list[str] = []
|
|
762
|
+
|
|
763
|
+
# Integer phases
|
|
764
|
+
for i in range(start, end + 1):
|
|
765
|
+
phase = f"{i:02d}"
|
|
766
|
+
try:
|
|
767
|
+
out = run_git("log", "--all", "--format=%H %ai %s", f"--grep=({phase}-")
|
|
768
|
+
if out:
|
|
769
|
+
all_commits.extend(out.splitlines())
|
|
770
|
+
except subprocess.CalledProcessError:
|
|
771
|
+
pass
|
|
772
|
+
|
|
773
|
+
# Decimal phases
|
|
774
|
+
for d in sorted(phases_dir.iterdir()):
|
|
775
|
+
if not d.is_dir():
|
|
776
|
+
continue
|
|
777
|
+
phase_num = d.name.split("-", 1)[0]
|
|
778
|
+
if "." in phase_num and in_range(phase_num, start, end):
|
|
779
|
+
try:
|
|
780
|
+
out = run_git("log", "--all", "--format=%H %ai %s", f"--grep=({phase_num}-")
|
|
781
|
+
if out:
|
|
782
|
+
all_commits.extend(out.splitlines())
|
|
783
|
+
except subprocess.CalledProcessError:
|
|
784
|
+
pass
|
|
785
|
+
|
|
786
|
+
# Deduplicate and sort by date
|
|
787
|
+
seen: set[str] = set()
|
|
788
|
+
unique_commits: list[str] = []
|
|
789
|
+
for c in all_commits:
|
|
790
|
+
hash_val = c.split()[0] if c.strip() else ""
|
|
791
|
+
if hash_val and hash_val not in seen:
|
|
792
|
+
seen.add(hash_val)
|
|
793
|
+
unique_commits.append(c)
|
|
794
|
+
unique_commits.sort(key=lambda x: x.split()[1] if len(x.split()) > 1 else "")
|
|
795
|
+
|
|
796
|
+
if unique_commits:
|
|
797
|
+
commit_count = len(unique_commits)
|
|
798
|
+
first = unique_commits[0].split(maxsplit=3)
|
|
799
|
+
last = unique_commits[-1].split(maxsplit=3)
|
|
800
|
+
first_hash, first_date = first[0], first[1]
|
|
801
|
+
last_hash, last_date = last[0], last[1]
|
|
802
|
+
first_msg = first[3] if len(first) > 3 else ""
|
|
803
|
+
last_msg = last[3] if len(last) > 3 else ""
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
d1 = datetime.date.fromisoformat(first_date)
|
|
807
|
+
d2 = datetime.date.fromisoformat(last_date)
|
|
808
|
+
days = (d2 - d1).days
|
|
809
|
+
except ValueError:
|
|
810
|
+
days = "?"
|
|
811
|
+
|
|
812
|
+
print(f"Commits: {commit_count}")
|
|
813
|
+
print(f"Git range: {first_hash[:7]}..{last_hash[:7]}")
|
|
814
|
+
print(f"First: {first_date} — {first_msg}")
|
|
815
|
+
print(f"Last: {last_date} — {last_msg}")
|
|
816
|
+
print(f"Timeline: {days} days ({first_date} → {last_date})")
|
|
817
|
+
|
|
818
|
+
try:
|
|
819
|
+
diffstat = run_git("diff", "--shortstat", f"{first_hash}^..{last_hash}")
|
|
820
|
+
if diffstat:
|
|
821
|
+
print(f"Changes:{diffstat}")
|
|
822
|
+
except subprocess.CalledProcessError:
|
|
823
|
+
pass
|
|
824
|
+
else:
|
|
825
|
+
print("No commits found matching phase patterns (expected 'feat(XX-YY): ...')")
|
|
826
|
+
print("Determine git range manually from git log")
|
|
827
|
+
|
|
828
|
+
print()
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# ===================================================================
|
|
832
|
+
# Subcommand: generate-phase-patch
|
|
833
|
+
# ===================================================================
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def cmd_generate_phase_patch(args: argparse.Namespace) -> None:
|
|
837
|
+
"""Generate a patch file with implementation changes from a phase.
|
|
838
|
+
|
|
839
|
+
Contract:
|
|
840
|
+
Args: phase (str), --suffix (str, optional)
|
|
841
|
+
Output: text — patch generation status and file path
|
|
842
|
+
Exit codes: 0 = success (or no matching commits), 1 = git error
|
|
843
|
+
Side effects: writes .patch file to phase directory
|
|
844
|
+
"""
|
|
845
|
+
phase_input = args.phase
|
|
846
|
+
suffix = args.suffix
|
|
847
|
+
|
|
848
|
+
git_root = find_git_root()
|
|
849
|
+
import os
|
|
850
|
+
os.chdir(git_root)
|
|
851
|
+
|
|
852
|
+
# Normalize phase number
|
|
853
|
+
if re.match(r"^\d$", phase_input):
|
|
854
|
+
phase_number = f"{int(phase_input):02d}"
|
|
855
|
+
else:
|
|
856
|
+
phase_number = phase_input
|
|
857
|
+
|
|
858
|
+
# Determine commit pattern
|
|
859
|
+
if suffix:
|
|
860
|
+
if suffix == "uat-fixes":
|
|
861
|
+
commit_pattern = f"\\({phase_number}-uat\\):"
|
|
862
|
+
print(f"Generating UAT fixes patch for phase {phase_number}...")
|
|
863
|
+
else:
|
|
864
|
+
commit_pattern = f"\\({phase_number}-{suffix}\\):"
|
|
865
|
+
print(f"Generating {suffix} patch for phase {phase_number}...")
|
|
866
|
+
else:
|
|
867
|
+
commit_pattern = f"\\({phase_number}-"
|
|
868
|
+
print(f"Generating patch for phase {phase_number}...")
|
|
869
|
+
|
|
870
|
+
# Find matching commits
|
|
871
|
+
try:
|
|
872
|
+
log_output = run_git("log", "--oneline")
|
|
873
|
+
except subprocess.CalledProcessError:
|
|
874
|
+
print("Error: Failed to read git log", file=sys.stderr)
|
|
875
|
+
sys.exit(1)
|
|
876
|
+
|
|
877
|
+
phase_commits = []
|
|
878
|
+
for line in log_output.splitlines():
|
|
879
|
+
if re.search(commit_pattern, line):
|
|
880
|
+
phase_commits.append(line.split()[0])
|
|
881
|
+
|
|
882
|
+
if not phase_commits:
|
|
883
|
+
print(f"No commits found matching pattern: {commit_pattern}")
|
|
884
|
+
print("Patch skipped")
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
print(f"Found {len(phase_commits)} commit(s)")
|
|
888
|
+
|
|
889
|
+
# Determine base commit
|
|
890
|
+
earliest_commit = phase_commits[-1]
|
|
891
|
+
try:
|
|
892
|
+
base_commit = run_git("rev-parse", f"{earliest_commit}^")
|
|
893
|
+
except subprocess.CalledProcessError:
|
|
894
|
+
base_commit = run_git("rev-list", "--max-parents=0", "HEAD")
|
|
895
|
+
|
|
896
|
+
base_msg = run_git("log", "--oneline", "-1", base_commit)
|
|
897
|
+
print(f"Base commit: {base_msg}")
|
|
898
|
+
|
|
899
|
+
# Find output directory
|
|
900
|
+
phases_dir = Path(".planning/phases")
|
|
901
|
+
phase_dir_matches = sorted(phases_dir.glob(f"{phase_number}-*")) if phases_dir.is_dir() else []
|
|
902
|
+
phase_dir = str(phase_dir_matches[0]) if phase_dir_matches else str(phases_dir)
|
|
903
|
+
|
|
904
|
+
Path(phase_dir).mkdir(parents=True, exist_ok=True)
|
|
905
|
+
print(f"Output directory: {phase_dir}/")
|
|
906
|
+
|
|
907
|
+
# Determine output filename
|
|
908
|
+
if suffix:
|
|
909
|
+
patch_file = f"{phase_dir}/{phase_number}-{suffix}.patch"
|
|
910
|
+
else:
|
|
911
|
+
patch_file = f"{phase_dir}/{phase_number}-changes.patch"
|
|
912
|
+
|
|
913
|
+
# Generate diff
|
|
914
|
+
exclude_args = build_exclude_pathspecs()
|
|
915
|
+
if suffix:
|
|
916
|
+
latest_commit = phase_commits[0]
|
|
917
|
+
diff_args = ["diff", base_commit, latest_commit, "--", "."] + exclude_args
|
|
918
|
+
else:
|
|
919
|
+
diff_args = ["diff", base_commit, "HEAD", "--", "."] + exclude_args
|
|
920
|
+
|
|
921
|
+
result = subprocess.run(
|
|
922
|
+
["git"] + diff_args,
|
|
923
|
+
capture_output=True,
|
|
924
|
+
text=True,
|
|
925
|
+
)
|
|
926
|
+
patch_content = result.stdout
|
|
927
|
+
|
|
928
|
+
if not patch_content.strip():
|
|
929
|
+
print("No implementation changes outside excluded patterns")
|
|
930
|
+
print("Patch skipped")
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
Path(patch_file).write_text(patch_content, encoding="utf-8")
|
|
934
|
+
line_count = len(patch_content.splitlines())
|
|
935
|
+
|
|
936
|
+
print()
|
|
937
|
+
print(f"Generated: {patch_file} ({line_count} lines)")
|
|
938
|
+
print()
|
|
939
|
+
print(f"Review: cat {patch_file}")
|
|
940
|
+
print(f"Apply: git apply {patch_file}")
|
|
941
|
+
print(f"Discard: rm {patch_file}")
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# ===================================================================
|
|
945
|
+
# Subcommand: generate-adhoc-patch
|
|
946
|
+
# ===================================================================
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def cmd_generate_adhoc_patch(args: argparse.Namespace) -> None:
|
|
950
|
+
"""Generate a patch file from an adhoc commit.
|
|
951
|
+
|
|
952
|
+
Contract:
|
|
953
|
+
Args: commit (str) — commit hash, output (str) — output file path
|
|
954
|
+
Output: text — patch generation status and file path
|
|
955
|
+
Exit codes: 0 = success (or no changes), 1 = commit not found
|
|
956
|
+
Side effects: writes .patch file to output path
|
|
957
|
+
"""
|
|
958
|
+
commit_hash = args.commit
|
|
959
|
+
output_path = args.output
|
|
960
|
+
|
|
961
|
+
git_root = find_git_root()
|
|
962
|
+
import os
|
|
963
|
+
os.chdir(git_root)
|
|
964
|
+
|
|
965
|
+
# Verify commit exists
|
|
966
|
+
try:
|
|
967
|
+
run_git("rev-parse", commit_hash)
|
|
968
|
+
except subprocess.CalledProcessError:
|
|
969
|
+
print(f"Error: Commit {commit_hash} not found", file=sys.stderr)
|
|
970
|
+
sys.exit(1)
|
|
971
|
+
|
|
972
|
+
exclude_args = build_exclude_pathspecs()
|
|
973
|
+
diff_args = ["diff", f"{commit_hash}^", commit_hash, "--", "."] + exclude_args
|
|
974
|
+
|
|
975
|
+
result = subprocess.run(
|
|
976
|
+
["git"] + diff_args,
|
|
977
|
+
capture_output=True,
|
|
978
|
+
text=True,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
if not result.stdout.strip():
|
|
982
|
+
print("No implementation changes outside excluded patterns")
|
|
983
|
+
print("Patch skipped")
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
987
|
+
Path(output_path).write_text(result.stdout, encoding="utf-8")
|
|
988
|
+
line_count = len(result.stdout.splitlines())
|
|
989
|
+
print(f"Generated: {output_path} ({line_count} lines)")
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
# ===================================================================
|
|
993
|
+
# Subcommand: archive-milestone-phases
|
|
994
|
+
# ===================================================================
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cmd_archive_milestone_phases(args: argparse.Namespace) -> None:
|
|
998
|
+
"""Consolidate summaries, delete artifacts, move phase dirs to milestone archive.
|
|
999
|
+
|
|
1000
|
+
Contract:
|
|
1001
|
+
Args: start_phase (int), end_phase (int), milestone (str — slug)
|
|
1002
|
+
Output: text — per-stage counts and archive summary
|
|
1003
|
+
Exit codes: 0 = success, 1 = start > end or dirs missing
|
|
1004
|
+
Side effects: writes PHASE-SUMMARIES.md, deletes artifact files, moves phase dirs
|
|
1005
|
+
"""
|
|
1006
|
+
start = args.start_phase
|
|
1007
|
+
end = args.end_phase
|
|
1008
|
+
milestone = args.milestone
|
|
1009
|
+
|
|
1010
|
+
if start > end:
|
|
1011
|
+
print(f"Error: Start phase ({start}) cannot exceed end phase ({end})", file=sys.stderr)
|
|
1012
|
+
sys.exit(1)
|
|
1013
|
+
|
|
1014
|
+
git_root = find_git_root()
|
|
1015
|
+
phases_dir = git_root / ".planning" / "phases"
|
|
1016
|
+
if not phases_dir.is_dir():
|
|
1017
|
+
print(f"Error: Phases directory not found at {phases_dir}", file=sys.stderr)
|
|
1018
|
+
sys.exit(1)
|
|
1019
|
+
|
|
1020
|
+
milestone_dir = git_root / ".planning" / "milestones" / milestone
|
|
1021
|
+
if not milestone_dir.is_dir():
|
|
1022
|
+
print(f"Error: Milestone directory not found at {milestone_dir}", file=sys.stderr)
|
|
1023
|
+
print("Run archive_milestone step first to create it")
|
|
1024
|
+
sys.exit(1)
|
|
1025
|
+
|
|
1026
|
+
# Stage 1: Consolidate summaries
|
|
1027
|
+
summaries_file = milestone_dir / "PHASE-SUMMARIES.md"
|
|
1028
|
+
summary_count = 0
|
|
1029
|
+
lines = [f"# Phase Summaries: {milestone}", ""]
|
|
1030
|
+
|
|
1031
|
+
for d in sorted(phases_dir.iterdir()):
|
|
1032
|
+
if not d.is_dir():
|
|
1033
|
+
continue
|
|
1034
|
+
dirname = d.name
|
|
1035
|
+
phase_num = dirname.split("-", 1)[0]
|
|
1036
|
+
phase_name = dirname.split("-", 1)[1] if "-" in dirname else dirname
|
|
1037
|
+
|
|
1038
|
+
if in_range(phase_num, start, end):
|
|
1039
|
+
summary_files = sorted(d.glob("*-SUMMARY.md"))
|
|
1040
|
+
if summary_files:
|
|
1041
|
+
lines.append(f"## Phase {phase_num}: {phase_name}")
|
|
1042
|
+
lines.append("")
|
|
1043
|
+
for f in summary_files:
|
|
1044
|
+
plan_id = f.stem.replace("-SUMMARY", "")
|
|
1045
|
+
lines.append(f"### {plan_id}")
|
|
1046
|
+
lines.append("")
|
|
1047
|
+
lines.append(f.read_text(encoding="utf-8"))
|
|
1048
|
+
lines.append("")
|
|
1049
|
+
summary_count += 1
|
|
1050
|
+
|
|
1051
|
+
summaries_file.write_text("\n".join(lines), encoding="utf-8")
|
|
1052
|
+
print(f"Stage 1: Consolidated {summary_count} summaries to PHASE-SUMMARIES.md")
|
|
1053
|
+
|
|
1054
|
+
# Stage 2: Delete artifacts
|
|
1055
|
+
deleted = 0
|
|
1056
|
+
artifact_patterns = [
|
|
1057
|
+
"*-CONTEXT.md", "*-DESIGN.md", "*-RESEARCH.md",
|
|
1058
|
+
"*-SUMMARY.md", "*-UAT.md", "*-VERIFICATION.md",
|
|
1059
|
+
"*-EXECUTION-ORDER.md",
|
|
1060
|
+
]
|
|
1061
|
+
for d in sorted(phases_dir.iterdir()):
|
|
1062
|
+
if not d.is_dir():
|
|
1063
|
+
continue
|
|
1064
|
+
phase_num = d.name.split("-", 1)[0]
|
|
1065
|
+
if in_range(phase_num, start, end):
|
|
1066
|
+
for pattern in artifact_patterns:
|
|
1067
|
+
for f in d.glob(pattern):
|
|
1068
|
+
f.unlink()
|
|
1069
|
+
deleted += 1
|
|
1070
|
+
|
|
1071
|
+
print(f"Stage 2: Deleted {deleted} artifact files")
|
|
1072
|
+
|
|
1073
|
+
# Stage 3: Move phase directories
|
|
1074
|
+
archive_phases = milestone_dir / "phases"
|
|
1075
|
+
archive_phases.mkdir(exist_ok=True)
|
|
1076
|
+
moved = 0
|
|
1077
|
+
|
|
1078
|
+
for d in sorted(phases_dir.iterdir()):
|
|
1079
|
+
if not d.is_dir():
|
|
1080
|
+
continue
|
|
1081
|
+
phase_num = d.name.split("-", 1)[0]
|
|
1082
|
+
if in_range(phase_num, start, end):
|
|
1083
|
+
shutil.move(str(d), str(archive_phases / d.name))
|
|
1084
|
+
moved += 1
|
|
1085
|
+
|
|
1086
|
+
print(f"Stage 3: Moved {moved} phase directories to milestones/{milestone}/phases/")
|
|
1087
|
+
print()
|
|
1088
|
+
print(f"Archive complete: {summary_count} summaries, {deleted} artifacts deleted, {moved} dirs moved")
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
# ===================================================================
|
|
1092
|
+
# Subcommand: archive-milestone-files
|
|
1093
|
+
# ===================================================================
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def cmd_archive_milestone_files(args: argparse.Namespace) -> None:
|
|
1097
|
+
"""Move optional milestone files to the milestone archive directory.
|
|
1098
|
+
|
|
1099
|
+
Contract:
|
|
1100
|
+
Args: milestone (str) — milestone slug (e.g., mvp, push-notifications)
|
|
1101
|
+
Output: text — per-file archive status
|
|
1102
|
+
Exit codes: 0 = success, 1 = milestone directory missing
|
|
1103
|
+
Side effects: moves audit, context, and research files to milestone dir
|
|
1104
|
+
"""
|
|
1105
|
+
milestone = args.milestone
|
|
1106
|
+
|
|
1107
|
+
git_root = find_git_root()
|
|
1108
|
+
planning_dir = git_root / ".planning"
|
|
1109
|
+
milestone_dir = planning_dir / "milestones" / milestone
|
|
1110
|
+
|
|
1111
|
+
if not milestone_dir.is_dir():
|
|
1112
|
+
print(f"Error: Milestone directory not found at {milestone_dir}", file=sys.stderr)
|
|
1113
|
+
print("Run archive_milestone step first to create it")
|
|
1114
|
+
sys.exit(1)
|
|
1115
|
+
|
|
1116
|
+
archived = 0
|
|
1117
|
+
|
|
1118
|
+
# Milestone audit
|
|
1119
|
+
audit = planning_dir / "MILESTONE-AUDIT.md"
|
|
1120
|
+
if audit.is_file():
|
|
1121
|
+
shutil.move(str(audit), str(milestone_dir / "MILESTONE-AUDIT.md"))
|
|
1122
|
+
print("Archived: MILESTONE-AUDIT.md → MILESTONE-AUDIT.md")
|
|
1123
|
+
archived += 1
|
|
1124
|
+
|
|
1125
|
+
# Milestone context
|
|
1126
|
+
context = planning_dir / "MILESTONE-CONTEXT.md"
|
|
1127
|
+
if context.is_file():
|
|
1128
|
+
shutil.move(str(context), str(milestone_dir / "CONTEXT.md"))
|
|
1129
|
+
print("Archived: MILESTONE-CONTEXT.md → CONTEXT.md")
|
|
1130
|
+
archived += 1
|
|
1131
|
+
|
|
1132
|
+
# Research directory
|
|
1133
|
+
research = planning_dir / "research"
|
|
1134
|
+
if research.is_dir():
|
|
1135
|
+
shutil.move(str(research), str(milestone_dir / "research"))
|
|
1136
|
+
print("Archived: research/ → research/")
|
|
1137
|
+
archived += 1
|
|
1138
|
+
|
|
1139
|
+
if archived == 0:
|
|
1140
|
+
print("No optional files to archive (audit, context, research all absent)")
|
|
1141
|
+
else:
|
|
1142
|
+
print()
|
|
1143
|
+
print(f"Archived {archived} item(s) to milestones/{milestone}/")
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
# ===================================================================
|
|
1147
|
+
# Subcommand: scan-artifact-subsystems
|
|
1148
|
+
# ===================================================================
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _scan_artifact_subsystem_values(planning: Path) -> list[str]:
|
|
1152
|
+
"""Extract all subsystem values from planning artifacts (helper for doctor-scan)."""
|
|
1153
|
+
values: list[str] = []
|
|
1154
|
+
scan_globs = [
|
|
1155
|
+
("phases", "*/*-SUMMARY.md"),
|
|
1156
|
+
("adhoc", "*-SUMMARY.md"),
|
|
1157
|
+
("debug", "*.md"),
|
|
1158
|
+
("debug/resolved", "*.md"),
|
|
1159
|
+
("todos/pending", "*.md"),
|
|
1160
|
+
("todos/done", "*.md"),
|
|
1161
|
+
]
|
|
1162
|
+
for subdir, pattern in scan_globs:
|
|
1163
|
+
target = planning / subdir
|
|
1164
|
+
if target.is_dir():
|
|
1165
|
+
for f in sorted(target.glob(pattern)):
|
|
1166
|
+
fm = parse_frontmatter(f)
|
|
1167
|
+
if fm and fm.get("subsystem"):
|
|
1168
|
+
values.append(fm["subsystem"])
|
|
1169
|
+
return values
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _detect_versioned_milestone_dirs(planning: Path) -> list[dict]:
|
|
1173
|
+
"""Detect v-prefixed milestone directories that need migration.
|
|
1174
|
+
|
|
1175
|
+
Returns list of dicts with keys: path, version, sub, type.
|
|
1176
|
+
- "standard": v-dir has .md files directly
|
|
1177
|
+
- "nested": v-dir has sub-directories (excluding phases/) and no direct .md files
|
|
1178
|
+
"""
|
|
1179
|
+
milestones_dir = planning / "milestones"
|
|
1180
|
+
if not milestones_dir.is_dir():
|
|
1181
|
+
return []
|
|
1182
|
+
|
|
1183
|
+
v_pattern = re.compile(r"^v\d+")
|
|
1184
|
+
results: list[dict] = []
|
|
1185
|
+
|
|
1186
|
+
for entry in sorted(milestones_dir.iterdir()):
|
|
1187
|
+
if not entry.is_dir() or not v_pattern.match(entry.name):
|
|
1188
|
+
continue
|
|
1189
|
+
|
|
1190
|
+
version = entry.name
|
|
1191
|
+
has_md_files = any(f.suffix == ".md" for f in entry.iterdir() if f.is_file())
|
|
1192
|
+
sub_dirs = [
|
|
1193
|
+
d for d in entry.iterdir()
|
|
1194
|
+
if d.is_dir() and d.name != "phases"
|
|
1195
|
+
]
|
|
1196
|
+
|
|
1197
|
+
if sub_dirs and not has_md_files:
|
|
1198
|
+
# Nested: each sub-dir is a separate entry
|
|
1199
|
+
for sub in sorted(sub_dirs):
|
|
1200
|
+
results.append({
|
|
1201
|
+
"path": f"milestones/{version}/{sub.name}",
|
|
1202
|
+
"version": version,
|
|
1203
|
+
"sub": sub.name,
|
|
1204
|
+
"type": "nested",
|
|
1205
|
+
})
|
|
1206
|
+
else:
|
|
1207
|
+
# Standard: v-dir itself is the milestone
|
|
1208
|
+
results.append({
|
|
1209
|
+
"path": f"milestones/{version}",
|
|
1210
|
+
"version": version,
|
|
1211
|
+
"sub": None,
|
|
1212
|
+
"type": "standard",
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
return results
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _parse_milestone_name_mapping(planning: Path) -> list[dict]:
|
|
1219
|
+
"""Parse MILESTONES.md and PROJECT.md to build version→name→slug mapping.
|
|
1220
|
+
|
|
1221
|
+
Returns list of dicts with keys: version, name, slug, and optionally current.
|
|
1222
|
+
"""
|
|
1223
|
+
results: list[dict] = []
|
|
1224
|
+
|
|
1225
|
+
# Parse MILESTONES.md shipped/started headers
|
|
1226
|
+
milestones_file = planning / "MILESTONES.md"
|
|
1227
|
+
if milestones_file.is_file():
|
|
1228
|
+
ms_text = milestones_file.read_text(encoding="utf-8")
|
|
1229
|
+
header_re = re.compile(
|
|
1230
|
+
r"^## (v[\d.]+)\s+(.+?)\s*\((?:Shipped|Started):?\s*[^)]+\)",
|
|
1231
|
+
re.MULTILINE,
|
|
1232
|
+
)
|
|
1233
|
+
for match in header_re.finditer(ms_text):
|
|
1234
|
+
version = match.group(1)
|
|
1235
|
+
name = match.group(2).strip()
|
|
1236
|
+
results.append({
|
|
1237
|
+
"version": version,
|
|
1238
|
+
"name": name,
|
|
1239
|
+
"slug": slugify(name),
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
# Parse PROJECT.md for current milestone
|
|
1243
|
+
project_file = planning / "PROJECT.md"
|
|
1244
|
+
if project_file.is_file():
|
|
1245
|
+
proj_text = project_file.read_text(encoding="utf-8")
|
|
1246
|
+
current_re = re.compile(
|
|
1247
|
+
r"^## Current Milestone:\s*(v[\d.]+)\s+(.+?)$",
|
|
1248
|
+
re.MULTILINE,
|
|
1249
|
+
)
|
|
1250
|
+
m = current_re.search(proj_text)
|
|
1251
|
+
if m:
|
|
1252
|
+
version = m.group(1)
|
|
1253
|
+
name = m.group(2).strip()
|
|
1254
|
+
results.append({
|
|
1255
|
+
"version": version,
|
|
1256
|
+
"name": name,
|
|
1257
|
+
"slug": slugify(name),
|
|
1258
|
+
"current": True,
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
return results
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def cmd_scan_artifact_subsystems(args: argparse.Namespace) -> None:
|
|
1265
|
+
"""Scan planning artifacts for subsystem YAML frontmatter values.
|
|
1266
|
+
|
|
1267
|
+
Contract:
|
|
1268
|
+
Args: --values-only (flag, optional)
|
|
1269
|
+
Output: text — subsystem values grouped by artifact type
|
|
1270
|
+
Exit codes: 0 = success, 1 = .planning/ missing
|
|
1271
|
+
Side effects: read-only
|
|
1272
|
+
"""
|
|
1273
|
+
planning = find_planning_dir()
|
|
1274
|
+
values_only = args.values_only
|
|
1275
|
+
|
|
1276
|
+
sections = [
|
|
1277
|
+
("Phase SUMMARYs", "phases", "*/*-SUMMARY.md"),
|
|
1278
|
+
("Adhoc SUMMARYs", "adhoc", "*-SUMMARY.md"),
|
|
1279
|
+
("Debug docs", "debug", "*.md"),
|
|
1280
|
+
("Debug resolved", "debug/resolved", "*.md"),
|
|
1281
|
+
("Pending Todos", "todos/pending", "*.md"),
|
|
1282
|
+
("Done Todos", "todos/done", "*.md"),
|
|
1283
|
+
]
|
|
1284
|
+
|
|
1285
|
+
for header, subdir, pattern in sections:
|
|
1286
|
+
print(f"=== {header} ===")
|
|
1287
|
+
target = planning / subdir
|
|
1288
|
+
if not target.is_dir():
|
|
1289
|
+
continue
|
|
1290
|
+
for f in sorted(target.glob(pattern)):
|
|
1291
|
+
fm = parse_frontmatter(f)
|
|
1292
|
+
if fm and fm.get("subsystem"):
|
|
1293
|
+
if values_only:
|
|
1294
|
+
print(fm["subsystem"])
|
|
1295
|
+
else:
|
|
1296
|
+
print(f"{f}\t{fm['subsystem']}")
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
# ===================================================================
|
|
1300
|
+
# Subcommand: scan-milestone-naming
|
|
1301
|
+
# ===================================================================
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def cmd_scan_milestone_naming(args: argparse.Namespace) -> None:
|
|
1305
|
+
"""Scan milestone directories for version-based naming needing migration.
|
|
1306
|
+
|
|
1307
|
+
Contract:
|
|
1308
|
+
Args: (none)
|
|
1309
|
+
Output: JSON — versioned_dirs, name_mappings, current_milestone, needs_migration
|
|
1310
|
+
Exit codes: 0 = success, 2 = missing .planning/
|
|
1311
|
+
Side effects: read-only
|
|
1312
|
+
"""
|
|
1313
|
+
planning = find_planning_dir()
|
|
1314
|
+
|
|
1315
|
+
versioned_dirs = _detect_versioned_milestone_dirs(planning)
|
|
1316
|
+
name_mappings = _parse_milestone_name_mapping(planning)
|
|
1317
|
+
|
|
1318
|
+
current_milestone = None
|
|
1319
|
+
non_current: list[dict] = []
|
|
1320
|
+
for m in name_mappings:
|
|
1321
|
+
if m.get("current"):
|
|
1322
|
+
current_milestone = {
|
|
1323
|
+
"version": m["version"],
|
|
1324
|
+
"name": m["name"],
|
|
1325
|
+
"slug": m["slug"],
|
|
1326
|
+
}
|
|
1327
|
+
else:
|
|
1328
|
+
non_current.append(m)
|
|
1329
|
+
|
|
1330
|
+
result = {
|
|
1331
|
+
"versioned_dirs": versioned_dirs,
|
|
1332
|
+
"name_mappings": non_current,
|
|
1333
|
+
"current_milestone": current_milestone,
|
|
1334
|
+
"needs_migration": len(versioned_dirs) > 0,
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
json.dump(result, sys.stdout, indent=2)
|
|
1338
|
+
sys.stdout.write("\n")
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
# ===================================================================
|
|
1342
|
+
# Subcommand: find-phase
|
|
1343
|
+
# ===================================================================
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def cmd_find_phase(args: argparse.Namespace) -> None:
|
|
1347
|
+
"""Find phase directory and validate against roadmap.
|
|
1348
|
+
|
|
1349
|
+
Contract:
|
|
1350
|
+
Args: phase (str) — phase number (e.g., 5, 05, 2.1)
|
|
1351
|
+
Output: JSON — {phase, dir, name, exists_in_roadmap}
|
|
1352
|
+
Exit codes: 0 = success, 1 = not in git repo
|
|
1353
|
+
Side effects: read-only
|
|
1354
|
+
"""
|
|
1355
|
+
phase_input = args.phase
|
|
1356
|
+
phase = normalize_phase(phase_input)
|
|
1357
|
+
|
|
1358
|
+
git_root = find_git_root()
|
|
1359
|
+
planning = git_root / ".planning"
|
|
1360
|
+
|
|
1361
|
+
result: dict[str, Any] = {
|
|
1362
|
+
"phase": phase,
|
|
1363
|
+
"dir": None,
|
|
1364
|
+
"name": None,
|
|
1365
|
+
"exists_in_roadmap": False,
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if planning.is_dir():
|
|
1369
|
+
phase_dir = find_phase_dir(planning, phase)
|
|
1370
|
+
if phase_dir:
|
|
1371
|
+
result["dir"] = str(phase_dir.relative_to(git_root))
|
|
1372
|
+
name = phase_dir.name.split("-", 1)
|
|
1373
|
+
result["name"] = name[1] if len(name) > 1 else phase_dir.name
|
|
1374
|
+
|
|
1375
|
+
# Check roadmap
|
|
1376
|
+
roadmap = planning / "ROADMAP.md"
|
|
1377
|
+
if roadmap.is_file():
|
|
1378
|
+
roadmap_text = roadmap.read_text(encoding="utf-8")
|
|
1379
|
+
# Match "Phase XX:" or "Phase XX " patterns
|
|
1380
|
+
if re.search(rf"Phase\s+{re.escape(phase)}[\s:]", roadmap_text):
|
|
1381
|
+
result["exists_in_roadmap"] = True
|
|
1382
|
+
|
|
1383
|
+
json.dump(result, sys.stdout, indent=2)
|
|
1384
|
+
sys.stdout.write("\n")
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
# ===================================================================
|
|
1388
|
+
# Subcommand: list-artifacts
|
|
1389
|
+
# ===================================================================
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def cmd_list_artifacts(args: argparse.Namespace) -> None:
|
|
1393
|
+
"""Count PLANs, SUMMARYs, and other artifacts per phase.
|
|
1394
|
+
|
|
1395
|
+
Contract:
|
|
1396
|
+
Args: phase (str) — phase number
|
|
1397
|
+
Output: JSON — {phase, plans, summaries, has_context, has_design, ...}
|
|
1398
|
+
Exit codes: 0 = success, 1 = .planning/ missing
|
|
1399
|
+
Side effects: read-only
|
|
1400
|
+
"""
|
|
1401
|
+
phase = normalize_phase(args.phase)
|
|
1402
|
+
planning = find_planning_dir()
|
|
1403
|
+
phase_dir = find_phase_dir(planning, phase)
|
|
1404
|
+
|
|
1405
|
+
result: dict[str, Any] = {
|
|
1406
|
+
"phase": phase,
|
|
1407
|
+
"plans": 0,
|
|
1408
|
+
"summaries": 0,
|
|
1409
|
+
"has_context": False,
|
|
1410
|
+
"has_design": False,
|
|
1411
|
+
"has_research": False,
|
|
1412
|
+
"has_uat": False,
|
|
1413
|
+
"has_verification": False,
|
|
1414
|
+
"has_execution_order": False,
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if phase_dir and phase_dir.is_dir():
|
|
1418
|
+
result["plans"] = len(list(phase_dir.glob("*-PLAN.md")))
|
|
1419
|
+
result["summaries"] = len(list(phase_dir.glob("*-SUMMARY.md")))
|
|
1420
|
+
result["has_context"] = any(phase_dir.glob("*-CONTEXT.md"))
|
|
1421
|
+
result["has_design"] = any(phase_dir.glob("*-DESIGN.md"))
|
|
1422
|
+
result["has_research"] = any(phase_dir.glob("*-RESEARCH.md"))
|
|
1423
|
+
result["has_uat"] = any(phase_dir.glob("*-UAT.md"))
|
|
1424
|
+
result["has_verification"] = any(phase_dir.glob("*-VERIFICATION.md"))
|
|
1425
|
+
result["has_execution_order"] = (phase_dir / "EXECUTION-ORDER.md").is_file()
|
|
1426
|
+
|
|
1427
|
+
json.dump(result, sys.stdout, indent=2)
|
|
1428
|
+
sys.stdout.write("\n")
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
# ===================================================================
|
|
1432
|
+
# Subcommand: check-artifact
|
|
1433
|
+
# ===================================================================
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def cmd_check_artifact(args: argparse.Namespace) -> None:
|
|
1437
|
+
"""Check if a specific artifact exists for a phase.
|
|
1438
|
+
|
|
1439
|
+
Contract:
|
|
1440
|
+
Args: phase (str), type (str) — artifact type (CONTEXT, DESIGN, etc.)
|
|
1441
|
+
Output: JSON — {exists, path}
|
|
1442
|
+
Exit codes: 0 = success, 1 = .planning/ missing
|
|
1443
|
+
Side effects: read-only
|
|
1444
|
+
"""
|
|
1445
|
+
phase = normalize_phase(args.phase)
|
|
1446
|
+
artifact_type = args.type.upper()
|
|
1447
|
+
planning = find_planning_dir()
|
|
1448
|
+
phase_dir = find_phase_dir(planning, phase)
|
|
1449
|
+
|
|
1450
|
+
result: dict[str, Any] = {
|
|
1451
|
+
"exists": False,
|
|
1452
|
+
"path": None,
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if phase_dir and phase_dir.is_dir():
|
|
1456
|
+
# Map artifact types to glob patterns
|
|
1457
|
+
patterns = {
|
|
1458
|
+
"CONTEXT": f"*-CONTEXT.md",
|
|
1459
|
+
"DESIGN": f"*-DESIGN.md",
|
|
1460
|
+
"RESEARCH": f"*-RESEARCH.md",
|
|
1461
|
+
"UAT": f"*-UAT.md",
|
|
1462
|
+
"VERIFICATION": f"*-VERIFICATION.md",
|
|
1463
|
+
"PLAN": f"*-PLAN.md",
|
|
1464
|
+
"SUMMARY": f"*-SUMMARY.md",
|
|
1465
|
+
"EXECUTION-ORDER": "EXECUTION-ORDER.md",
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
pattern = patterns.get(artifact_type)
|
|
1469
|
+
if pattern:
|
|
1470
|
+
matches = list(phase_dir.glob(pattern))
|
|
1471
|
+
if matches:
|
|
1472
|
+
result["exists"] = True
|
|
1473
|
+
result["path"] = str(matches[0].relative_to(find_git_root()))
|
|
1474
|
+
|
|
1475
|
+
json.dump(result, sys.stdout, indent=2)
|
|
1476
|
+
sys.stdout.write("\n")
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
# ===================================================================
|
|
1480
|
+
# Subcommand: scan-planning-context
|
|
1481
|
+
# ===================================================================
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _has_readiness_section(path: Path) -> bool:
|
|
1485
|
+
"""Check if file has a non-empty '## Next Phase Readiness' section."""
|
|
1486
|
+
try:
|
|
1487
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1488
|
+
except OSError:
|
|
1489
|
+
return False
|
|
1490
|
+
|
|
1491
|
+
idx = text.find("## Next Phase Readiness")
|
|
1492
|
+
if idx == -1:
|
|
1493
|
+
return False
|
|
1494
|
+
|
|
1495
|
+
after = text[idx + len("## Next Phase Readiness"):]
|
|
1496
|
+
next_heading = re.search(r"\n## ", after)
|
|
1497
|
+
section = after[:next_heading.start()] if next_heading else after
|
|
1498
|
+
stripped = section.strip().strip("-").strip()
|
|
1499
|
+
return len(stripped) > 0
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _extract_phase_number(phase_str: str) -> int | None:
|
|
1503
|
+
"""Extract integer phase number from phase string like '05-auth' or '05'."""
|
|
1504
|
+
match = re.match(r"^(\d+)", str(phase_str))
|
|
1505
|
+
return int(match.group(1)) if match else None
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _is_adjacent_phase(target_num: int, candidate_num: int) -> bool:
|
|
1509
|
+
"""Check if candidate is within 2 phases before target (N-1, N-2)."""
|
|
1510
|
+
diff = target_num - candidate_num
|
|
1511
|
+
return 1 <= diff <= 2
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _score_summary(
|
|
1515
|
+
fm: dict[str, Any],
|
|
1516
|
+
target_phase: str,
|
|
1517
|
+
target_num: int | None,
|
|
1518
|
+
subsystems: list[str],
|
|
1519
|
+
keywords: list[str],
|
|
1520
|
+
) -> tuple[str, list[str]]:
|
|
1521
|
+
"""Score a SUMMARY's relevance to the target phase."""
|
|
1522
|
+
reasons: list[str] = []
|
|
1523
|
+
is_high = False
|
|
1524
|
+
is_medium = False
|
|
1525
|
+
|
|
1526
|
+
# HIGH signals
|
|
1527
|
+
affects = fm.get("affects", []) or []
|
|
1528
|
+
if isinstance(affects, str):
|
|
1529
|
+
affects = [affects]
|
|
1530
|
+
for a in affects:
|
|
1531
|
+
if target_phase in str(a):
|
|
1532
|
+
reasons.append(f"affects contains '{target_phase}'")
|
|
1533
|
+
is_high = True
|
|
1534
|
+
|
|
1535
|
+
fm_subsystem = fm.get("subsystem", "")
|
|
1536
|
+
if fm_subsystem and fm_subsystem in subsystems:
|
|
1537
|
+
reasons.append(f"same subsystem '{fm_subsystem}'")
|
|
1538
|
+
is_high = True
|
|
1539
|
+
|
|
1540
|
+
requires = fm.get("requires", []) or []
|
|
1541
|
+
if isinstance(requires, list):
|
|
1542
|
+
for req in requires:
|
|
1543
|
+
if isinstance(req, dict):
|
|
1544
|
+
req_phase = str(req.get("phase", ""))
|
|
1545
|
+
else:
|
|
1546
|
+
req_phase = str(req)
|
|
1547
|
+
if target_phase in req_phase:
|
|
1548
|
+
reasons.append(f"requires references '{target_phase}'")
|
|
1549
|
+
is_high = True
|
|
1550
|
+
|
|
1551
|
+
# MEDIUM signals
|
|
1552
|
+
fm_tags = fm.get("tags", []) or []
|
|
1553
|
+
if isinstance(fm_tags, str):
|
|
1554
|
+
fm_tags = [fm_tags]
|
|
1555
|
+
fm_tags_lower = {str(t).lower() for t in fm_tags}
|
|
1556
|
+
keywords_lower = {k.lower() for k in keywords}
|
|
1557
|
+
overlap = fm_tags_lower & keywords_lower
|
|
1558
|
+
if overlap:
|
|
1559
|
+
reasons.append(f"overlapping tags: {sorted(overlap)}")
|
|
1560
|
+
is_medium = True
|
|
1561
|
+
|
|
1562
|
+
fm_phase = fm.get("phase", "")
|
|
1563
|
+
candidate_num = _extract_phase_number(str(fm_phase))
|
|
1564
|
+
if target_num is not None and candidate_num is not None:
|
|
1565
|
+
if _is_adjacent_phase(target_num, candidate_num):
|
|
1566
|
+
reasons.append(f"adjacent phase (N-{target_num - candidate_num})")
|
|
1567
|
+
is_medium = True
|
|
1568
|
+
|
|
1569
|
+
if is_high:
|
|
1570
|
+
return ("HIGH", reasons)
|
|
1571
|
+
if is_medium:
|
|
1572
|
+
return ("MEDIUM", reasons)
|
|
1573
|
+
return ("LOW", reasons)
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _resolve_transitive_requires(
|
|
1577
|
+
summaries: list[dict[str, Any]],
|
|
1578
|
+
target_phase: str,
|
|
1579
|
+
) -> set[str]:
|
|
1580
|
+
"""Find all phases transitively required by the target phase."""
|
|
1581
|
+
required: set[str] = set()
|
|
1582
|
+
for s in summaries:
|
|
1583
|
+
fm = s.get("frontmatter", {})
|
|
1584
|
+
phase_name = str(fm.get("phase", ""))
|
|
1585
|
+
affects = fm.get("affects", []) or []
|
|
1586
|
+
if isinstance(affects, str):
|
|
1587
|
+
affects = [affects]
|
|
1588
|
+
if any(target_phase in str(a) for a in affects):
|
|
1589
|
+
required.add(phase_name)
|
|
1590
|
+
requires = fm.get("requires", []) or []
|
|
1591
|
+
if isinstance(requires, list):
|
|
1592
|
+
for req in requires:
|
|
1593
|
+
if isinstance(req, dict):
|
|
1594
|
+
req_phase = str(req.get("phase", ""))
|
|
1595
|
+
else:
|
|
1596
|
+
req_phase = str(req)
|
|
1597
|
+
if req_phase:
|
|
1598
|
+
required.add(req_phase)
|
|
1599
|
+
return required
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _scan_summaries(
|
|
1603
|
+
planning: Path,
|
|
1604
|
+
target_phase: str,
|
|
1605
|
+
target_num: int | None,
|
|
1606
|
+
subsystems: list[str],
|
|
1607
|
+
keywords: list[str],
|
|
1608
|
+
parse_errors: list[dict[str, str]],
|
|
1609
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
1610
|
+
"""Scan phase summary files and score relevance."""
|
|
1611
|
+
phases_dir = planning / "phases"
|
|
1612
|
+
source_info: dict[str, Any] = {"dir": str(phases_dir), "scanned": 0, "skipped": None}
|
|
1613
|
+
|
|
1614
|
+
if not phases_dir.is_dir():
|
|
1615
|
+
source_info["skipped"] = "directory not found"
|
|
1616
|
+
return [], source_info
|
|
1617
|
+
|
|
1618
|
+
summary_files = sorted(phases_dir.glob("*/*-SUMMARY.md"))
|
|
1619
|
+
if not summary_files:
|
|
1620
|
+
source_info["skipped"] = "no SUMMARY.md files found"
|
|
1621
|
+
return [], source_info
|
|
1622
|
+
|
|
1623
|
+
results: list[dict[str, Any]] = []
|
|
1624
|
+
for path in summary_files:
|
|
1625
|
+
source_info["scanned"] += 1
|
|
1626
|
+
fm = parse_frontmatter(path)
|
|
1627
|
+
if fm is None:
|
|
1628
|
+
parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
|
|
1629
|
+
continue
|
|
1630
|
+
|
|
1631
|
+
relevance, match_reasons = _score_summary(fm, target_phase, target_num, subsystems, keywords)
|
|
1632
|
+
readiness = _has_readiness_section(path)
|
|
1633
|
+
|
|
1634
|
+
results.append({
|
|
1635
|
+
"path": str(path),
|
|
1636
|
+
"frontmatter": fm,
|
|
1637
|
+
"relevance": relevance,
|
|
1638
|
+
"match_reasons": match_reasons,
|
|
1639
|
+
"has_readiness_warnings": readiness,
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
transitive = _resolve_transitive_requires(results, target_phase)
|
|
1643
|
+
for entry in results:
|
|
1644
|
+
fm = entry["frontmatter"]
|
|
1645
|
+
phase_name = str(fm.get("phase", ""))
|
|
1646
|
+
if phase_name in transitive and entry["relevance"] != "HIGH":
|
|
1647
|
+
entry["relevance"] = "HIGH"
|
|
1648
|
+
entry["match_reasons"].append("in transitive requires chain")
|
|
1649
|
+
|
|
1650
|
+
return results, source_info
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def _scan_debug_docs(
|
|
1654
|
+
planning: Path,
|
|
1655
|
+
parse_errors: list[dict[str, str]],
|
|
1656
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
1657
|
+
"""Scan resolved debug documents for learnings."""
|
|
1658
|
+
resolved_dir = planning / "debug" / "resolved"
|
|
1659
|
+
source_info: dict[str, Any] = {"dir": str(resolved_dir), "scanned": 0, "skipped": None}
|
|
1660
|
+
|
|
1661
|
+
if not resolved_dir.is_dir():
|
|
1662
|
+
source_info["skipped"] = "directory not found"
|
|
1663
|
+
return [], source_info
|
|
1664
|
+
|
|
1665
|
+
results: list[dict[str, Any]] = []
|
|
1666
|
+
for path in sorted(resolved_dir.glob("*.md")):
|
|
1667
|
+
source_info["scanned"] += 1
|
|
1668
|
+
fm = parse_frontmatter(path)
|
|
1669
|
+
if fm is None:
|
|
1670
|
+
parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
|
|
1671
|
+
continue
|
|
1672
|
+
|
|
1673
|
+
results.append({
|
|
1674
|
+
"path": str(path),
|
|
1675
|
+
"slug": path.stem,
|
|
1676
|
+
"subsystem": fm.get("subsystem", ""),
|
|
1677
|
+
"root_cause": fm.get("root_cause", ""),
|
|
1678
|
+
"resolution": fm.get("resolution", ""),
|
|
1679
|
+
"tags": fm.get("tags", []) or [],
|
|
1680
|
+
"phase": fm.get("phase", ""),
|
|
1681
|
+
})
|
|
1682
|
+
|
|
1683
|
+
return results, source_info
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def _scan_adhoc_summaries(
|
|
1687
|
+
planning: Path,
|
|
1688
|
+
parse_errors: list[dict[str, str]],
|
|
1689
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
1690
|
+
"""Scan adhoc summary files for learnings."""
|
|
1691
|
+
adhoc_dir = planning / "adhoc"
|
|
1692
|
+
source_info: dict[str, Any] = {"dir": str(adhoc_dir), "scanned": 0, "skipped": None}
|
|
1693
|
+
|
|
1694
|
+
if not adhoc_dir.is_dir():
|
|
1695
|
+
source_info["skipped"] = "directory not found"
|
|
1696
|
+
return [], source_info
|
|
1697
|
+
|
|
1698
|
+
summary_files = sorted(adhoc_dir.glob("*-SUMMARY.md"))
|
|
1699
|
+
if not summary_files:
|
|
1700
|
+
source_info["skipped"] = "no adhoc SUMMARY.md files found"
|
|
1701
|
+
return [], source_info
|
|
1702
|
+
|
|
1703
|
+
results: list[dict[str, Any]] = []
|
|
1704
|
+
for path in summary_files:
|
|
1705
|
+
source_info["scanned"] += 1
|
|
1706
|
+
fm = parse_frontmatter(path)
|
|
1707
|
+
if fm is None:
|
|
1708
|
+
parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
|
|
1709
|
+
continue
|
|
1710
|
+
|
|
1711
|
+
learnings = fm.get("learnings", []) or []
|
|
1712
|
+
if isinstance(learnings, str):
|
|
1713
|
+
learnings = [learnings]
|
|
1714
|
+
|
|
1715
|
+
results.append({
|
|
1716
|
+
"path": str(path),
|
|
1717
|
+
"subsystem": fm.get("subsystem", ""),
|
|
1718
|
+
"learnings": learnings,
|
|
1719
|
+
"related_phase": fm.get("related_phase", ""),
|
|
1720
|
+
"tags": fm.get("tags", []) or [],
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
return results, source_info
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _scan_todos(
|
|
1727
|
+
planning: Path,
|
|
1728
|
+
subdir: str,
|
|
1729
|
+
parse_errors: list[dict[str, str]],
|
|
1730
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
1731
|
+
"""Scan todo files (done/ or pending/) for metadata."""
|
|
1732
|
+
todo_dir = planning / "todos" / subdir
|
|
1733
|
+
source_info: dict[str, Any] = {"dir": str(todo_dir), "scanned": 0, "skipped": None}
|
|
1734
|
+
|
|
1735
|
+
if not todo_dir.is_dir():
|
|
1736
|
+
source_info["skipped"] = "directory not found"
|
|
1737
|
+
return [], source_info
|
|
1738
|
+
|
|
1739
|
+
md_files = sorted(todo_dir.glob("*.md"))
|
|
1740
|
+
if not md_files:
|
|
1741
|
+
source_info["skipped"] = f"no .md files in {subdir}/"
|
|
1742
|
+
return [], source_info
|
|
1743
|
+
|
|
1744
|
+
results: list[dict[str, Any]] = []
|
|
1745
|
+
for path in md_files:
|
|
1746
|
+
source_info["scanned"] += 1
|
|
1747
|
+
fm = parse_frontmatter(path)
|
|
1748
|
+
if fm is None:
|
|
1749
|
+
parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
|
|
1750
|
+
continue
|
|
1751
|
+
|
|
1752
|
+
results.append({
|
|
1753
|
+
"path": str(path),
|
|
1754
|
+
"title": fm.get("title", path.stem),
|
|
1755
|
+
"subsystem": fm.get("subsystem", ""),
|
|
1756
|
+
"priority": fm.get("priority", ""),
|
|
1757
|
+
"phase_origin": fm.get("phase_origin", ""),
|
|
1758
|
+
})
|
|
1759
|
+
|
|
1760
|
+
return results, source_info
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def _scan_knowledge_files(
|
|
1764
|
+
planning: Path,
|
|
1765
|
+
subsystems: list[str],
|
|
1766
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
1767
|
+
"""List knowledge files and match by subsystem."""
|
|
1768
|
+
knowledge_dir = planning / "knowledge"
|
|
1769
|
+
source_info: dict[str, Any] = {"dir": str(knowledge_dir), "scanned": 0, "skipped": None}
|
|
1770
|
+
|
|
1771
|
+
if not knowledge_dir.is_dir():
|
|
1772
|
+
source_info["skipped"] = "directory not found"
|
|
1773
|
+
return [], source_info
|
|
1774
|
+
|
|
1775
|
+
md_files = sorted(knowledge_dir.glob("*.md"))
|
|
1776
|
+
if not md_files:
|
|
1777
|
+
source_info["skipped"] = "no .md files in knowledge/"
|
|
1778
|
+
return [], source_info
|
|
1779
|
+
|
|
1780
|
+
subsystems_lower = {s.lower() for s in subsystems}
|
|
1781
|
+
results: list[dict[str, Any]] = []
|
|
1782
|
+
for path in md_files:
|
|
1783
|
+
source_info["scanned"] += 1
|
|
1784
|
+
file_subsystem = path.stem.lower()
|
|
1785
|
+
matched = file_subsystem in subsystems_lower
|
|
1786
|
+
|
|
1787
|
+
results.append({
|
|
1788
|
+
"path": str(path),
|
|
1789
|
+
"subsystem": path.stem,
|
|
1790
|
+
"matched": matched,
|
|
1791
|
+
})
|
|
1792
|
+
|
|
1793
|
+
return results, source_info
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _aggregate_from_summaries(summaries: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
1797
|
+
"""Aggregate tech stack, patterns, key files, decisions from HIGH+MEDIUM summaries."""
|
|
1798
|
+
tech_added: list[str] = []
|
|
1799
|
+
patterns: list[str] = []
|
|
1800
|
+
key_files_created: list[str] = []
|
|
1801
|
+
key_files_modified: list[str] = []
|
|
1802
|
+
key_decisions: list[str] = []
|
|
1803
|
+
|
|
1804
|
+
for entry in summaries:
|
|
1805
|
+
if entry["relevance"] == "LOW":
|
|
1806
|
+
continue
|
|
1807
|
+
fm = entry["frontmatter"]
|
|
1808
|
+
|
|
1809
|
+
ts = fm.get("tech-stack", {}) or {}
|
|
1810
|
+
if isinstance(ts, dict):
|
|
1811
|
+
added = ts.get("added", []) or []
|
|
1812
|
+
if isinstance(added, str):
|
|
1813
|
+
added = [added]
|
|
1814
|
+
tech_added.extend(str(a) for a in added)
|
|
1815
|
+
pat = ts.get("patterns", []) or []
|
|
1816
|
+
if isinstance(pat, str):
|
|
1817
|
+
pat = [pat]
|
|
1818
|
+
patterns.extend(str(p) for p in pat)
|
|
1819
|
+
|
|
1820
|
+
pe = fm.get("patterns-established", []) or []
|
|
1821
|
+
if isinstance(pe, str):
|
|
1822
|
+
pe = [pe]
|
|
1823
|
+
patterns.extend(str(p) for p in pe)
|
|
1824
|
+
|
|
1825
|
+
kf = fm.get("key-files", {}) or {}
|
|
1826
|
+
if isinstance(kf, dict):
|
|
1827
|
+
created = kf.get("created", []) or []
|
|
1828
|
+
if isinstance(created, str):
|
|
1829
|
+
created = [created]
|
|
1830
|
+
key_files_created.extend(str(f) for f in created)
|
|
1831
|
+
modified = kf.get("modified", []) or []
|
|
1832
|
+
if isinstance(modified, str):
|
|
1833
|
+
modified = [modified]
|
|
1834
|
+
key_files_modified.extend(str(f) for f in modified)
|
|
1835
|
+
|
|
1836
|
+
kd = fm.get("key-decisions", []) or []
|
|
1837
|
+
if isinstance(kd, str):
|
|
1838
|
+
kd = [kd]
|
|
1839
|
+
key_decisions.extend(str(d) for d in kd)
|
|
1840
|
+
|
|
1841
|
+
return {
|
|
1842
|
+
"tech_stack_added": sorted(set(tech_added)),
|
|
1843
|
+
"patterns_established": sorted(set(patterns)),
|
|
1844
|
+
"key_files_created": sorted(set(key_files_created)),
|
|
1845
|
+
"key_files_modified": sorted(set(key_files_modified)),
|
|
1846
|
+
"key_decisions": list(dict.fromkeys(key_decisions)),
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _format_markdown(output: dict[str, Any]) -> str:
|
|
1851
|
+
"""Format scanner output as readable markdown for LLM consumption."""
|
|
1852
|
+
sections: list[str] = []
|
|
1853
|
+
agg = output.get("aggregated", {})
|
|
1854
|
+
|
|
1855
|
+
patterns = agg.get("patterns_established", [])
|
|
1856
|
+
if patterns:
|
|
1857
|
+
lines = ["### Established Patterns"]
|
|
1858
|
+
lines.extend(f"- {p}" for p in patterns)
|
|
1859
|
+
sections.append("\n".join(lines))
|
|
1860
|
+
|
|
1861
|
+
tech = agg.get("tech_stack_added", [])
|
|
1862
|
+
if tech:
|
|
1863
|
+
sections.append(f"### Tech Stack\n{', '.join(tech)}")
|
|
1864
|
+
|
|
1865
|
+
decisions = agg.get("key_decisions", [])
|
|
1866
|
+
if decisions:
|
|
1867
|
+
lines = ["### Key Decisions"]
|
|
1868
|
+
lines.extend(f"- {d}" for d in decisions)
|
|
1869
|
+
sections.append("\n".join(lines))
|
|
1870
|
+
|
|
1871
|
+
created = agg.get("key_files_created", [])
|
|
1872
|
+
modified = agg.get("key_files_modified", [])
|
|
1873
|
+
if created or modified:
|
|
1874
|
+
lines = ["### Key Files"]
|
|
1875
|
+
if created:
|
|
1876
|
+
lines.append("**Created:**")
|
|
1877
|
+
lines.extend(f"- `{f}`" for f in created)
|
|
1878
|
+
if modified:
|
|
1879
|
+
lines.append("**Modified:**")
|
|
1880
|
+
lines.extend(f"- `{f}`" for f in modified)
|
|
1881
|
+
sections.append("\n".join(lines))
|
|
1882
|
+
|
|
1883
|
+
debug = output.get("debug_learnings", [])
|
|
1884
|
+
if debug:
|
|
1885
|
+
lines = ["### Debug Learnings"]
|
|
1886
|
+
for d in debug:
|
|
1887
|
+
slug = d.get("slug", "unknown")
|
|
1888
|
+
sub = d.get("subsystem", "")
|
|
1889
|
+
rc = d.get("root_cause", "")
|
|
1890
|
+
res = d.get("resolution", "")
|
|
1891
|
+
lines.append(f"- **{slug}** ({sub}): {rc} — Fix: {res}")
|
|
1892
|
+
sections.append("\n".join(lines))
|
|
1893
|
+
|
|
1894
|
+
adhoc_entries = [a for a in output.get("adhoc_learnings", []) if a.get("learnings")]
|
|
1895
|
+
if adhoc_entries:
|
|
1896
|
+
lines = ["### Adhoc Learnings"]
|
|
1897
|
+
for a in adhoc_entries:
|
|
1898
|
+
sub = a.get("subsystem", "")
|
|
1899
|
+
path = a.get("path", "")
|
|
1900
|
+
label = sub or Path(path).stem if path else "unknown"
|
|
1901
|
+
lines.append(f"- **{label}**")
|
|
1902
|
+
for learning in a["learnings"]:
|
|
1903
|
+
lines.append(f" - {learning}")
|
|
1904
|
+
sections.append("\n".join(lines))
|
|
1905
|
+
|
|
1906
|
+
summaries = output.get("summaries", [])
|
|
1907
|
+
needs_read = [s for s in summaries if s.get("relevance") == "HIGH" and s.get("has_readiness_warnings")]
|
|
1908
|
+
other_relevant = [s for s in summaries if s.get("relevance") in ("HIGH", "MEDIUM") and not s.get("has_readiness_warnings")]
|
|
1909
|
+
|
|
1910
|
+
if needs_read:
|
|
1911
|
+
lines = ["### Summaries Needing Full Read"]
|
|
1912
|
+
lines.extend(f"- `{s['path']}`" for s in needs_read)
|
|
1913
|
+
sections.append("\n".join(lines))
|
|
1914
|
+
|
|
1915
|
+
if other_relevant:
|
|
1916
|
+
lines = ["### Other Relevant Summaries"]
|
|
1917
|
+
lines.extend(f"- `{s['path']}` [{s.get('relevance', '')}]" for s in other_relevant)
|
|
1918
|
+
sections.append("\n".join(lines))
|
|
1919
|
+
|
|
1920
|
+
matched_knowledge = [k for k in output.get("knowledge_files", []) if k.get("matched")]
|
|
1921
|
+
if matched_knowledge:
|
|
1922
|
+
lines = ["### Knowledge Files to Read"]
|
|
1923
|
+
lines.extend(f"- `{k['path']}`" for k in matched_knowledge)
|
|
1924
|
+
sections.append("\n".join(lines))
|
|
1925
|
+
|
|
1926
|
+
todos = output.get("pending_todos", [])
|
|
1927
|
+
if todos:
|
|
1928
|
+
lines = ["### Pending Todos"]
|
|
1929
|
+
for t in todos:
|
|
1930
|
+
title = t.get("title", "untitled")
|
|
1931
|
+
priority = t.get("priority", "")
|
|
1932
|
+
sub = t.get("subsystem", "")
|
|
1933
|
+
path = t.get("path", "")
|
|
1934
|
+
lines.append(f"- **{title}** [{priority}] ({sub}) — `{path}`")
|
|
1935
|
+
sections.append("\n".join(lines))
|
|
1936
|
+
|
|
1937
|
+
sources = output.get("sources", {})
|
|
1938
|
+
parse_errors = sources.get("parse_errors", [])
|
|
1939
|
+
info_lines = ["### Scanner Info"]
|
|
1940
|
+
for name, src in sources.items():
|
|
1941
|
+
if name == "parse_errors" or not isinstance(src, dict):
|
|
1942
|
+
continue
|
|
1943
|
+
scanned = src.get("scanned", 0)
|
|
1944
|
+
skipped = src.get("skipped")
|
|
1945
|
+
if skipped:
|
|
1946
|
+
info_lines.append(f"- {name}: skipped ({skipped})")
|
|
1947
|
+
else:
|
|
1948
|
+
info_lines.append(f"- {name}: {scanned} scanned")
|
|
1949
|
+
if parse_errors:
|
|
1950
|
+
info_lines.append("**Parse errors:**")
|
|
1951
|
+
for err in parse_errors:
|
|
1952
|
+
info_lines.append(f"- `{err.get('path', '')}`: {err.get('error', '')}")
|
|
1953
|
+
sections.append("\n".join(info_lines))
|
|
1954
|
+
|
|
1955
|
+
return "\n\n".join(sections)
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
def cmd_scan_planning_context(args: argparse.Namespace) -> None:
|
|
1959
|
+
"""Scan .planning/ artifacts and score relevance for plan-phase context assembly.
|
|
1960
|
+
|
|
1961
|
+
Contract:
|
|
1962
|
+
Args: --phase (str, required), --phase-name (str), --subsystem (repeatable), --keywords (csv), --json (flag)
|
|
1963
|
+
Output: JSON (--json) or markdown — scored summaries, learnings, todos, knowledge, aggregated context
|
|
1964
|
+
Exit codes: 0 = success (empty result if no .planning/)
|
|
1965
|
+
Side effects: read-only
|
|
1966
|
+
"""
|
|
1967
|
+
phase = normalize_phase(args.phase)
|
|
1968
|
+
phase_name = args.phase_name.strip() if args.phase_name else ""
|
|
1969
|
+
subsystems = [s for s in (args.subsystems or []) if s]
|
|
1970
|
+
keywords = [k.strip() for k in (args.keywords or "").split(",") if k.strip()]
|
|
1971
|
+
|
|
1972
|
+
if phase_name:
|
|
1973
|
+
name_words = [w for w in re.split(r"[-_\s]+", phase_name) if len(w) > 2]
|
|
1974
|
+
keywords.extend(name_words)
|
|
1975
|
+
|
|
1976
|
+
target_num = _extract_phase_number(phase)
|
|
1977
|
+
|
|
1978
|
+
planning = find_planning_dir_optional()
|
|
1979
|
+
if planning is None:
|
|
1980
|
+
if args.json:
|
|
1981
|
+
empty_src = {"dir": "", "scanned": 0, "skipped": ".planning/ not found"}
|
|
1982
|
+
output: dict[str, Any] = {
|
|
1983
|
+
"success": True,
|
|
1984
|
+
"target": {"phase": phase, "phase_name": phase_name, "subsystems": subsystems, "keywords": keywords},
|
|
1985
|
+
"sources": {
|
|
1986
|
+
"summaries": empty_src, "debug_docs": empty_src, "adhoc_summaries": empty_src,
|
|
1987
|
+
"completed_todos": empty_src, "pending_todos": empty_src, "knowledge_files": empty_src,
|
|
1988
|
+
"parse_errors": [],
|
|
1989
|
+
},
|
|
1990
|
+
"summaries": [], "debug_learnings": [], "adhoc_learnings": [],
|
|
1991
|
+
"completed_todos": [], "pending_todos": [], "knowledge_files": [],
|
|
1992
|
+
"aggregated": {
|
|
1993
|
+
"tech_stack_added": [], "patterns_established": [],
|
|
1994
|
+
"key_files_created": [], "key_files_modified": [], "key_decisions": [],
|
|
1995
|
+
},
|
|
1996
|
+
}
|
|
1997
|
+
json.dump(output, sys.stdout, indent=2, cls=_SafeEncoder)
|
|
1998
|
+
sys.stdout.write("\n")
|
|
1999
|
+
else:
|
|
2000
|
+
print("No .planning/ directory found. No prior context available.")
|
|
2001
|
+
return
|
|
2002
|
+
|
|
2003
|
+
parse_errors: list[dict[str, str]] = []
|
|
2004
|
+
|
|
2005
|
+
summaries, summaries_src = _scan_summaries(planning, phase, target_num, subsystems, keywords, parse_errors)
|
|
2006
|
+
debug_learnings, debug_src = _scan_debug_docs(planning, parse_errors)
|
|
2007
|
+
adhoc_learnings, adhoc_src = _scan_adhoc_summaries(planning, parse_errors)
|
|
2008
|
+
completed_todos, completed_src = _scan_todos(planning, "done", parse_errors)
|
|
2009
|
+
pending_todos, pending_src = _scan_todos(planning, "pending", parse_errors)
|
|
2010
|
+
knowledge_files, knowledge_src = _scan_knowledge_files(planning, subsystems)
|
|
2011
|
+
|
|
2012
|
+
aggregated = _aggregate_from_summaries(summaries)
|
|
2013
|
+
|
|
2014
|
+
output = {
|
|
2015
|
+
"success": True,
|
|
2016
|
+
"target": {"phase": phase, "phase_name": phase_name, "subsystems": subsystems, "keywords": keywords},
|
|
2017
|
+
"sources": {
|
|
2018
|
+
"summaries": summaries_src, "debug_docs": debug_src, "adhoc_summaries": adhoc_src,
|
|
2019
|
+
"completed_todos": completed_src, "pending_todos": pending_src,
|
|
2020
|
+
"knowledge_files": knowledge_src, "parse_errors": parse_errors,
|
|
2021
|
+
},
|
|
2022
|
+
"summaries": summaries,
|
|
2023
|
+
"debug_learnings": debug_learnings,
|
|
2024
|
+
"adhoc_learnings": adhoc_learnings,
|
|
2025
|
+
"completed_todos": completed_todos,
|
|
2026
|
+
"pending_todos": pending_todos,
|
|
2027
|
+
"knowledge_files": knowledge_files,
|
|
2028
|
+
"aggregated": aggregated,
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
if args.json:
|
|
2032
|
+
json.dump(output, sys.stdout, indent=2, cls=_SafeEncoder)
|
|
2033
|
+
sys.stdout.write("\n")
|
|
2034
|
+
else:
|
|
2035
|
+
print(_format_markdown(output))
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
# ===================================================================
|
|
2039
|
+
# Argument parser setup
|
|
2040
|
+
# ===================================================================
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2044
|
+
parser = argparse.ArgumentParser(
|
|
2045
|
+
prog="ms-tools",
|
|
2046
|
+
description="Mindsystem CLI tools — unified subcommands for mechanical operations.",
|
|
2047
|
+
)
|
|
2048
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
2049
|
+
|
|
2050
|
+
# --- update-state ---
|
|
2051
|
+
p = subparsers.add_parser("update-state", help="Update STATE.md plan progress")
|
|
2052
|
+
p.add_argument("completed", type=int, help="Number of completed plans")
|
|
2053
|
+
p.add_argument("total", type=int, help="Total number of plans")
|
|
2054
|
+
p.set_defaults(func=cmd_update_state)
|
|
2055
|
+
|
|
2056
|
+
# --- validate-execution-order ---
|
|
2057
|
+
p = subparsers.add_parser("validate-execution-order", help="Validate EXECUTION-ORDER.md against plan files")
|
|
2058
|
+
p.add_argument("phase_dir", help="Phase directory path")
|
|
2059
|
+
p.set_defaults(func=cmd_validate_execution_order)
|
|
2060
|
+
|
|
2061
|
+
# --- doctor-scan ---
|
|
2062
|
+
p = subparsers.add_parser("doctor-scan", help="Diagnostic scan of .planning/ tree")
|
|
2063
|
+
p.set_defaults(func=cmd_doctor_scan)
|
|
2064
|
+
|
|
2065
|
+
# --- gather-milestone-stats ---
|
|
2066
|
+
p = subparsers.add_parser("gather-milestone-stats", help="Gather milestone readiness and git statistics")
|
|
2067
|
+
p.add_argument("start_phase", type=int, help="Start phase number")
|
|
2068
|
+
p.add_argument("end_phase", type=int, help="End phase number")
|
|
2069
|
+
p.set_defaults(func=cmd_gather_milestone_stats)
|
|
2070
|
+
|
|
2071
|
+
# --- generate-phase-patch ---
|
|
2072
|
+
p = subparsers.add_parser("generate-phase-patch", help="Generate patch from phase commits")
|
|
2073
|
+
p.add_argument("phase", help="Phase number (e.g., 04 or 4)")
|
|
2074
|
+
p.add_argument("--suffix", default="", help="Filter commits and customize output filename")
|
|
2075
|
+
p.set_defaults(func=cmd_generate_phase_patch)
|
|
2076
|
+
|
|
2077
|
+
# --- generate-adhoc-patch ---
|
|
2078
|
+
p = subparsers.add_parser("generate-adhoc-patch", help="Generate patch from an adhoc commit")
|
|
2079
|
+
p.add_argument("commit", help="Commit hash")
|
|
2080
|
+
p.add_argument("output", help="Output path for the patch file")
|
|
2081
|
+
p.set_defaults(func=cmd_generate_adhoc_patch)
|
|
2082
|
+
|
|
2083
|
+
# --- archive-milestone-phases ---
|
|
2084
|
+
p = subparsers.add_parser("archive-milestone-phases", help="Archive phase dirs to milestone directory")
|
|
2085
|
+
p.add_argument("start_phase", type=int, help="Start phase number")
|
|
2086
|
+
p.add_argument("end_phase", type=int, help="End phase number")
|
|
2087
|
+
p.add_argument("milestone", help="Milestone slug (e.g., mvp, push-notifications)")
|
|
2088
|
+
p.set_defaults(func=cmd_archive_milestone_phases)
|
|
2089
|
+
|
|
2090
|
+
# --- archive-milestone-files ---
|
|
2091
|
+
p = subparsers.add_parser("archive-milestone-files", help="Archive optional milestone files")
|
|
2092
|
+
p.add_argument("milestone", help="Milestone slug (e.g., mvp, push-notifications)")
|
|
2093
|
+
p.set_defaults(func=cmd_archive_milestone_files)
|
|
2094
|
+
|
|
2095
|
+
# --- scan-artifact-subsystems ---
|
|
2096
|
+
p = subparsers.add_parser("scan-artifact-subsystems", help="Scan artifacts for subsystem values")
|
|
2097
|
+
p.add_argument("--values-only", action="store_true", help="Print only subsystem values")
|
|
2098
|
+
p.set_defaults(func=cmd_scan_artifact_subsystems)
|
|
2099
|
+
|
|
2100
|
+
# --- scan-milestone-naming ---
|
|
2101
|
+
p = subparsers.add_parser("scan-milestone-naming", help="Scan for version-based milestone naming needing migration")
|
|
2102
|
+
p.set_defaults(func=cmd_scan_milestone_naming)
|
|
2103
|
+
|
|
2104
|
+
# --- scan-planning-context ---
|
|
2105
|
+
p = subparsers.add_parser("scan-planning-context", help="Scan .planning/ and score relevance for plan-phase")
|
|
2106
|
+
p.add_argument("--phase", required=True, help='Phase number (e.g., "05" or "5" or "2.1")')
|
|
2107
|
+
p.add_argument("--phase-name", default="", help="Phase name for keyword matching")
|
|
2108
|
+
p.add_argument("--subsystem", action="append", default=[], dest="subsystems", help="Subsystem(s) for matching (repeatable)")
|
|
2109
|
+
p.add_argument("--keywords", default="", help="Comma-separated keywords for tag matching")
|
|
2110
|
+
p.add_argument("--json", action="store_true", help="Output raw JSON (default: formatted markdown)")
|
|
2111
|
+
p.set_defaults(func=cmd_scan_planning_context)
|
|
2112
|
+
|
|
2113
|
+
# --- find-phase ---
|
|
2114
|
+
p = subparsers.add_parser("find-phase", help="Find phase directory and validate against roadmap")
|
|
2115
|
+
p.add_argument("phase", help="Phase number (e.g., 5, 05, 2.1)")
|
|
2116
|
+
p.set_defaults(func=cmd_find_phase)
|
|
2117
|
+
|
|
2118
|
+
# --- list-artifacts ---
|
|
2119
|
+
p = subparsers.add_parser("list-artifacts", help="Count artifacts per phase")
|
|
2120
|
+
p.add_argument("phase", help="Phase number")
|
|
2121
|
+
p.set_defaults(func=cmd_list_artifacts)
|
|
2122
|
+
|
|
2123
|
+
# --- check-artifact ---
|
|
2124
|
+
p = subparsers.add_parser("check-artifact", help="Check if specific artifact exists")
|
|
2125
|
+
p.add_argument("phase", help="Phase number")
|
|
2126
|
+
p.add_argument("type", help="Artifact type (CONTEXT, DESIGN, RESEARCH, UAT, VERIFICATION, PLAN, SUMMARY, EXECUTION-ORDER)")
|
|
2127
|
+
p.set_defaults(func=cmd_check_artifact)
|
|
2128
|
+
|
|
2129
|
+
return parser
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
def main() -> None:
|
|
2133
|
+
parser = build_parser()
|
|
2134
|
+
args = parser.parse_args()
|
|
2135
|
+
args.func(args)
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
if __name__ == "__main__":
|
|
2139
|
+
main()
|