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.
Files changed (83) hide show
  1. package/README.md +5 -6
  2. package/agents/ms-designer.md +5 -2
  3. package/agents/ms-mockup-designer.md +1 -1
  4. package/agents/ms-plan-writer.md +8 -1
  5. package/agents/ms-product-researcher.md +69 -0
  6. package/agents/ms-research-synthesizer.md +1 -1
  7. package/agents/ms-researcher.md +8 -8
  8. package/agents/ms-roadmapper.md +9 -13
  9. package/bin/install.js +68 -5
  10. package/commands/ms/add-phase.md +30 -18
  11. package/commands/ms/adhoc.md +1 -1
  12. package/commands/ms/audit-milestone.md +12 -12
  13. package/commands/ms/complete-milestone.md +25 -22
  14. package/commands/ms/config.md +202 -0
  15. package/commands/ms/design-phase.md +34 -29
  16. package/commands/ms/discuss-phase.md +26 -22
  17. package/commands/ms/doctor.md +22 -202
  18. package/commands/ms/execute-phase.md +18 -7
  19. package/commands/ms/help.md +46 -39
  20. package/commands/ms/insert-phase.md +29 -17
  21. package/commands/ms/new-milestone.md +42 -19
  22. package/commands/ms/new-project.md +88 -103
  23. package/commands/ms/plan-milestone-gaps.md +4 -5
  24. package/commands/ms/plan-phase.md +5 -3
  25. package/commands/ms/progress.md +2 -4
  26. package/commands/ms/research-phase.md +7 -12
  27. package/commands/ms/research-project.md +12 -12
  28. package/mindsystem/references/continuation-format.md +3 -3
  29. package/mindsystem/references/plan-format.md +11 -1
  30. package/mindsystem/references/principles.md +1 -1
  31. package/mindsystem/references/questioning.md +50 -8
  32. package/mindsystem/references/routing/audit-result-routing.md +12 -11
  33. package/mindsystem/references/routing/between-milestones-routing.md +2 -2
  34. package/mindsystem/references/routing/milestone-complete-routing.md +1 -1
  35. package/mindsystem/references/routing/next-phase-routing.md +4 -2
  36. package/mindsystem/templates/context.md +7 -6
  37. package/mindsystem/templates/milestone-archive.md +5 -5
  38. package/mindsystem/templates/milestone-context.md +1 -1
  39. package/mindsystem/templates/milestone.md +9 -9
  40. package/mindsystem/templates/project.md +70 -64
  41. package/mindsystem/templates/research-subagent-prompt.md +3 -3
  42. package/mindsystem/templates/roadmap-milestone.md +14 -14
  43. package/mindsystem/templates/roadmap.md +9 -7
  44. package/mindsystem/workflows/adhoc.md +1 -1
  45. package/mindsystem/workflows/complete-milestone.md +66 -107
  46. package/mindsystem/workflows/discuss-phase.md +137 -65
  47. package/mindsystem/workflows/doctor-fixes.md +273 -0
  48. package/mindsystem/workflows/execute-phase.md +7 -3
  49. package/mindsystem/workflows/execute-plan.md +6 -5
  50. package/mindsystem/workflows/map-codebase.md +2 -2
  51. package/mindsystem/workflows/mockup-generation.md +1 -1
  52. package/mindsystem/workflows/plan-phase.md +28 -3
  53. package/mindsystem/workflows/transition.md +20 -25
  54. package/mindsystem/workflows/verify-work.md +1 -1
  55. package/package.json +1 -1
  56. package/scripts/__pycache__/ms-tools.cpython-314.pyc +0 -0
  57. package/scripts/__pycache__/test_ms_tools.cpython-314-pytest-9.0.2.pyc +0 -0
  58. package/scripts/fixtures/scan-context/.planning/ROADMAP.md +16 -0
  59. package/scripts/fixtures/scan-context/.planning/adhoc/20260220-fix-token-SUMMARY.md +12 -0
  60. package/scripts/fixtures/scan-context/.planning/config.json +3 -0
  61. package/scripts/fixtures/scan-context/.planning/debug/resolved/token-bug.md +11 -0
  62. package/scripts/fixtures/scan-context/.planning/knowledge/auth.md +11 -0
  63. package/scripts/fixtures/scan-context/.planning/phases/02-infra/02-1-SUMMARY.md +20 -0
  64. package/scripts/fixtures/scan-context/.planning/phases/04-setup/04-1-SUMMARY.md +21 -0
  65. package/scripts/fixtures/scan-context/.planning/phases/05-auth/05-1-SUMMARY.md +28 -0
  66. package/scripts/fixtures/scan-context/.planning/todos/done/setup-db.md +10 -0
  67. package/scripts/fixtures/scan-context/.planning/todos/pending/add-logout.md +10 -0
  68. package/scripts/fixtures/scan-context/expected-output.json +257 -0
  69. package/scripts/ms-tools.py +2139 -0
  70. package/scripts/test_ms_tools.py +836 -0
  71. package/commands/ms/list-phase-assumptions.md +0 -56
  72. package/mindsystem/workflows/list-phase-assumptions.md +0 -178
  73. package/scripts/__pycache__/compare_mockups.cpython-314.pyc +0 -0
  74. package/scripts/archive-milestone-files.sh +0 -68
  75. package/scripts/archive-milestone-phases.sh +0 -138
  76. package/scripts/doctor-scan.sh +0 -379
  77. package/scripts/gather-milestone-stats.sh +0 -179
  78. package/scripts/generate-adhoc-patch.sh +0 -79
  79. package/scripts/generate-phase-patch.sh +0 -169
  80. package/scripts/scan-artifact-subsystems.sh +0 -55
  81. package/scripts/scan-planning-context.py +0 -839
  82. package/scripts/update-state.sh +0 -59
  83. 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()