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
@@ -1,839 +0,0 @@
1
- #!/usr/bin/env python3
2
- # /// script
3
- # requires-python = ">=3.10"
4
- # dependencies = ["pyyaml"]
5
- # ///
6
- """Scan .planning/ artifacts and score relevance for plan-phase context assembly.
7
-
8
- Deterministic collection and scoring of planning artifacts so the LLM
9
- receives structured JSON and focuses on interpretation and judgment.
10
- """
11
-
12
- import argparse
13
- import datetime
14
- import json
15
- import re
16
- import subprocess
17
- import sys
18
- from pathlib import Path
19
- from typing import Any
20
-
21
- import yaml
22
-
23
-
24
- class _SafeEncoder(json.JSONEncoder):
25
- """Handle YAML types that json.dump can't serialize (date, datetime)."""
26
-
27
- def default(self, o: object) -> Any:
28
- if isinstance(o, (datetime.date, datetime.datetime)):
29
- return o.isoformat()
30
- return super().default(o)
31
-
32
-
33
- # ---------------------------------------------------------------------------
34
- # Git root / .planning discovery
35
- # ---------------------------------------------------------------------------
36
-
37
-
38
- def find_planning_dir() -> Path | None:
39
- """Find .planning/ from git root."""
40
- try:
41
- result = subprocess.run(
42
- ["git", "rev-parse", "--show-toplevel"],
43
- capture_output=True,
44
- text=True,
45
- check=True,
46
- )
47
- git_root = Path(result.stdout.strip())
48
- planning = git_root / ".planning"
49
- return planning if planning.is_dir() else None
50
- except (subprocess.CalledProcessError, FileNotFoundError):
51
- return None
52
-
53
-
54
- # ---------------------------------------------------------------------------
55
- # YAML frontmatter parsing
56
- # ---------------------------------------------------------------------------
57
-
58
- _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?\n)---\s*\n", re.DOTALL)
59
-
60
-
61
- def parse_frontmatter(path: Path) -> dict[str, Any] | None:
62
- """Extract YAML frontmatter from a markdown file.
63
-
64
- Returns parsed dict or None if no frontmatter found.
65
- """
66
- try:
67
- text = path.read_text(encoding="utf-8", errors="replace")
68
- except OSError:
69
- return None
70
-
71
- match = _FRONTMATTER_RE.match(text)
72
- if not match:
73
- return None
74
-
75
- try:
76
- return yaml.safe_load(match.group(1)) or {}
77
- except yaml.YAMLError:
78
- return None
79
-
80
-
81
- def has_readiness_section(path: Path) -> bool:
82
- """Check if file has a non-empty '## Next Phase Readiness' section."""
83
- try:
84
- text = path.read_text(encoding="utf-8", errors="replace")
85
- except OSError:
86
- return False
87
-
88
- idx = text.find("## Next Phase Readiness")
89
- if idx == -1:
90
- return False
91
-
92
- # Grab content until next ## heading or end of file
93
- after = text[idx + len("## Next Phase Readiness") :]
94
- next_heading = re.search(r"\n## ", after)
95
- section = after[: next_heading.start()] if next_heading else after
96
- # Non-empty = has more than whitespace / dashes
97
- stripped = section.strip().strip("-").strip()
98
- return len(stripped) > 0
99
-
100
-
101
- # ---------------------------------------------------------------------------
102
- # Phase number helpers
103
- # ---------------------------------------------------------------------------
104
-
105
-
106
- def normalize_phase(phase_str: str) -> str:
107
- """Normalize phase input: '5' -> '05', '05' -> '05', '2.1' -> '02.1'."""
108
- match = re.match(r"^(\d+)(?:\.(\d+))?$", phase_str)
109
- if not match:
110
- return phase_str
111
- integer = int(match.group(1))
112
- decimal = match.group(2)
113
- if decimal:
114
- return f"{integer:02d}.{decimal}"
115
- return f"{integer:02d}"
116
-
117
-
118
- def extract_phase_number(phase_str: str) -> int | None:
119
- """Extract integer phase number from phase string like '05-auth' or '05'."""
120
- match = re.match(r"^(\d+)", str(phase_str))
121
- return int(match.group(1)) if match else None
122
-
123
-
124
- def is_adjacent_phase(target_num: int, candidate_num: int) -> bool:
125
- """Check if candidate is within 2 phases before target (N-1, N-2)."""
126
- diff = target_num - candidate_num
127
- return 1 <= diff <= 2
128
-
129
-
130
- # ---------------------------------------------------------------------------
131
- # Relevance scoring
132
- # ---------------------------------------------------------------------------
133
-
134
-
135
- def score_summary(
136
- fm: dict[str, Any],
137
- target_phase: str,
138
- target_num: int | None,
139
- subsystems: list[str],
140
- keywords: list[str],
141
- ) -> tuple[str, list[str]]:
142
- """Score a SUMMARY's relevance to the target phase.
143
-
144
- Returns (relevance, match_reasons) where relevance is HIGH/MEDIUM/LOW.
145
- """
146
- reasons: list[str] = []
147
- is_high = False
148
- is_medium = False
149
-
150
- # --- HIGH signals ---
151
-
152
- # Target phase appears in affects list
153
- affects = fm.get("affects", []) or []
154
- if isinstance(affects, str):
155
- affects = [affects]
156
- for a in affects:
157
- if target_phase in str(a):
158
- reasons.append(f"affects contains '{target_phase}'")
159
- is_high = True
160
-
161
- # Same subsystem
162
- fm_subsystem = fm.get("subsystem", "")
163
- if fm_subsystem and fm_subsystem in subsystems:
164
- reasons.append(f"same subsystem '{fm_subsystem}'")
165
- is_high = True
166
-
167
- # In requires chain (direct — transitive computed at caller level)
168
- requires = fm.get("requires", []) or []
169
- if isinstance(requires, list):
170
- for req in requires:
171
- if isinstance(req, dict):
172
- req_phase = str(req.get("phase", ""))
173
- else:
174
- req_phase = str(req)
175
- if target_phase in req_phase:
176
- reasons.append(f"requires references '{target_phase}'")
177
- is_high = True
178
-
179
- # --- MEDIUM signals ---
180
-
181
- # Overlapping tags with keywords
182
- fm_tags = fm.get("tags", []) or []
183
- if isinstance(fm_tags, str):
184
- fm_tags = [fm_tags]
185
- fm_tags_lower = {str(t).lower() for t in fm_tags}
186
- keywords_lower = {k.lower() for k in keywords}
187
- overlap = fm_tags_lower & keywords_lower
188
- if overlap:
189
- reasons.append(f"overlapping tags: {sorted(overlap)}")
190
- is_medium = True
191
-
192
- # Adjacent phase (N-1, N-2)
193
- fm_phase = fm.get("phase", "")
194
- candidate_num = extract_phase_number(str(fm_phase))
195
- if target_num is not None and candidate_num is not None:
196
- if is_adjacent_phase(target_num, candidate_num):
197
- reasons.append(f"adjacent phase (N-{target_num - candidate_num})")
198
- is_medium = True
199
-
200
- if is_high:
201
- return ("HIGH", reasons)
202
- if is_medium:
203
- return ("MEDIUM", reasons)
204
- return ("LOW", reasons)
205
-
206
-
207
- # ---------------------------------------------------------------------------
208
- # Transitive requires resolution
209
- # ---------------------------------------------------------------------------
210
-
211
-
212
- def resolve_transitive_requires(
213
- summaries: list[dict[str, Any]],
214
- target_phase: str,
215
- ) -> set[str]:
216
- """Find all phases transitively required by the target phase.
217
-
218
- Build a reverse lookup: which phases provide things the target needs.
219
- """
220
- # Build provides index: phase_name -> list of provides
221
- provides_index: dict[str, list[str]] = {}
222
- for s in summaries:
223
- fm = s.get("frontmatter", {})
224
- phase_name = str(fm.get("phase", ""))
225
- provides = fm.get("provides", []) or []
226
- if isinstance(provides, str):
227
- provides = [provides]
228
- provides_index[phase_name] = [str(p) for p in provides]
229
-
230
- # Find which summaries have target in their affects
231
- required: set[str] = set()
232
- for s in summaries:
233
- fm = s.get("frontmatter", {})
234
- phase_name = str(fm.get("phase", ""))
235
- affects = fm.get("affects", []) or []
236
- if isinstance(affects, str):
237
- affects = [affects]
238
- if any(target_phase in str(a) for a in affects):
239
- required.add(phase_name)
240
- # Also add anything this phase requires (one hop)
241
- requires = fm.get("requires", []) or []
242
- if isinstance(requires, list):
243
- for req in requires:
244
- if isinstance(req, dict):
245
- req_phase = str(req.get("phase", ""))
246
- else:
247
- req_phase = str(req)
248
- if req_phase:
249
- required.add(req_phase)
250
-
251
- return required
252
-
253
-
254
- # ---------------------------------------------------------------------------
255
- # Directory scanners
256
- # ---------------------------------------------------------------------------
257
-
258
-
259
- def scan_summaries(
260
- planning: Path,
261
- target_phase: str,
262
- target_num: int | None,
263
- subsystems: list[str],
264
- keywords: list[str],
265
- parse_errors: list[dict[str, str]],
266
- ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
267
- """Scan phase summary files and score relevance."""
268
- phases_dir = planning / "phases"
269
- source_info: dict[str, Any] = {
270
- "dir": str(phases_dir),
271
- "scanned": 0,
272
- "skipped": None,
273
- }
274
-
275
- if not phases_dir.is_dir():
276
- source_info["skipped"] = "directory not found"
277
- return [], source_info
278
-
279
- summary_files = sorted(phases_dir.glob("*/*-SUMMARY.md"))
280
- if not summary_files:
281
- source_info["skipped"] = "no SUMMARY.md files found"
282
- return [], source_info
283
-
284
- results: list[dict[str, Any]] = []
285
- for path in summary_files:
286
- source_info["scanned"] += 1
287
- fm = parse_frontmatter(path)
288
- if fm is None:
289
- parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
290
- continue
291
-
292
- relevance, match_reasons = score_summary(
293
- fm, target_phase, target_num, subsystems, keywords
294
- )
295
- readiness = has_readiness_section(path)
296
-
297
- results.append(
298
- {
299
- "path": str(path),
300
- "frontmatter": fm,
301
- "relevance": relevance,
302
- "match_reasons": match_reasons,
303
- "has_readiness_warnings": readiness,
304
- }
305
- )
306
-
307
- # Resolve transitive requires and upgrade scores
308
- transitive = resolve_transitive_requires(results, target_phase)
309
- for entry in results:
310
- fm = entry["frontmatter"]
311
- phase_name = str(fm.get("phase", ""))
312
- if phase_name in transitive and entry["relevance"] != "HIGH":
313
- entry["relevance"] = "HIGH"
314
- entry["match_reasons"].append(f"in transitive requires chain")
315
-
316
- return results, source_info
317
-
318
-
319
- def scan_debug_docs(
320
- planning: Path,
321
- parse_errors: list[dict[str, str]],
322
- ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
323
- """Scan resolved debug documents for learnings."""
324
- resolved_dir = planning / "debug" / "resolved"
325
- source_info: dict[str, Any] = {
326
- "dir": str(resolved_dir),
327
- "scanned": 0,
328
- "skipped": None,
329
- }
330
-
331
- if not resolved_dir.is_dir():
332
- source_info["skipped"] = "directory not found"
333
- return [], source_info
334
-
335
- results: list[dict[str, Any]] = []
336
- for path in sorted(resolved_dir.glob("*.md")):
337
- source_info["scanned"] += 1
338
- fm = parse_frontmatter(path)
339
- if fm is None:
340
- parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
341
- continue
342
-
343
- results.append(
344
- {
345
- "path": str(path),
346
- "slug": path.stem,
347
- "subsystem": fm.get("subsystem", ""),
348
- "root_cause": fm.get("root_cause", ""),
349
- "resolution": fm.get("resolution", ""),
350
- "tags": fm.get("tags", []) or [],
351
- "phase": fm.get("phase", ""),
352
- }
353
- )
354
-
355
- return results, source_info
356
-
357
-
358
- def scan_adhoc_summaries(
359
- planning: Path,
360
- parse_errors: list[dict[str, str]],
361
- ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
362
- """Scan adhoc summary files for learnings."""
363
- adhoc_dir = planning / "adhoc"
364
- source_info: dict[str, Any] = {
365
- "dir": str(adhoc_dir),
366
- "scanned": 0,
367
- "skipped": None,
368
- }
369
-
370
- if not adhoc_dir.is_dir():
371
- source_info["skipped"] = "directory not found"
372
- return [], source_info
373
-
374
- summary_files = sorted(adhoc_dir.glob("*-SUMMARY.md"))
375
- if not summary_files:
376
- source_info["skipped"] = "no adhoc SUMMARY.md files found"
377
- return [], source_info
378
-
379
- results: list[dict[str, Any]] = []
380
- for path in summary_files:
381
- source_info["scanned"] += 1
382
- fm = parse_frontmatter(path)
383
- if fm is None:
384
- parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
385
- continue
386
-
387
- learnings = fm.get("learnings", []) or []
388
- if isinstance(learnings, str):
389
- learnings = [learnings]
390
-
391
- results.append(
392
- {
393
- "path": str(path),
394
- "subsystem": fm.get("subsystem", ""),
395
- "learnings": learnings,
396
- "related_phase": fm.get("related_phase", ""),
397
- "tags": fm.get("tags", []) or [],
398
- }
399
- )
400
-
401
- return results, source_info
402
-
403
-
404
- def scan_todos(
405
- planning: Path,
406
- subdir: str,
407
- parse_errors: list[dict[str, str]],
408
- ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
409
- """Scan todo files (done/ or pending/) for metadata."""
410
- todo_dir = planning / "todos" / subdir
411
- source_info: dict[str, Any] = {
412
- "dir": str(todo_dir),
413
- "scanned": 0,
414
- "skipped": None,
415
- }
416
-
417
- if not todo_dir.is_dir():
418
- source_info["skipped"] = "directory not found"
419
- return [], source_info
420
-
421
- md_files = sorted(todo_dir.glob("*.md"))
422
- if not md_files:
423
- source_info["skipped"] = f"no .md files in {subdir}/"
424
- return [], source_info
425
-
426
- results: list[dict[str, Any]] = []
427
- for path in md_files:
428
- source_info["scanned"] += 1
429
- fm = parse_frontmatter(path)
430
- if fm is None:
431
- parse_errors.append({"path": str(path), "error": "no valid frontmatter"})
432
- continue
433
-
434
- results.append(
435
- {
436
- "path": str(path),
437
- "title": fm.get("title", path.stem),
438
- "subsystem": fm.get("subsystem", ""),
439
- "priority": fm.get("priority", ""),
440
- "phase_origin": fm.get("phase_origin", ""),
441
- }
442
- )
443
-
444
- return results, source_info
445
-
446
-
447
- def scan_knowledge_files(
448
- planning: Path,
449
- subsystems: list[str],
450
- ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
451
- """List knowledge files and match by subsystem."""
452
- knowledge_dir = planning / "knowledge"
453
- source_info: dict[str, Any] = {
454
- "dir": str(knowledge_dir),
455
- "scanned": 0,
456
- "skipped": None,
457
- }
458
-
459
- if not knowledge_dir.is_dir():
460
- source_info["skipped"] = "directory not found"
461
- return [], source_info
462
-
463
- md_files = sorted(knowledge_dir.glob("*.md"))
464
- if not md_files:
465
- source_info["skipped"] = "no .md files in knowledge/"
466
- return [], source_info
467
-
468
- subsystems_lower = {s.lower() for s in subsystems}
469
- results: list[dict[str, Any]] = []
470
- for path in md_files:
471
- source_info["scanned"] += 1
472
- # Knowledge files use filename as subsystem identifier
473
- file_subsystem = path.stem.lower()
474
- matched = file_subsystem in subsystems_lower
475
-
476
- results.append(
477
- {
478
- "path": str(path),
479
- "subsystem": path.stem,
480
- "matched": matched,
481
- }
482
- )
483
-
484
- return results, source_info
485
-
486
-
487
- # ---------------------------------------------------------------------------
488
- # Aggregation
489
- # ---------------------------------------------------------------------------
490
-
491
-
492
- def aggregate_from_summaries(
493
- summaries: list[dict[str, Any]],
494
- ) -> dict[str, list[str]]:
495
- """Aggregate tech stack, patterns, key files, decisions from HIGH+MEDIUM summaries."""
496
- tech_added: list[str] = []
497
- patterns: list[str] = []
498
- key_files_created: list[str] = []
499
- key_files_modified: list[str] = []
500
- key_decisions: list[str] = []
501
-
502
- for entry in summaries:
503
- if entry["relevance"] == "LOW":
504
- continue
505
-
506
- fm = entry["frontmatter"]
507
-
508
- # tech-stack.added
509
- ts = fm.get("tech-stack", {}) or {}
510
- if isinstance(ts, dict):
511
- added = ts.get("added", []) or []
512
- if isinstance(added, str):
513
- added = [added]
514
- tech_added.extend(str(a) for a in added)
515
-
516
- # tech-stack.patterns
517
- pat = ts.get("patterns", []) or []
518
- if isinstance(pat, str):
519
- pat = [pat]
520
- patterns.extend(str(p) for p in pat)
521
-
522
- # patterns-established
523
- pe = fm.get("patterns-established", []) or []
524
- if isinstance(pe, str):
525
- pe = [pe]
526
- patterns.extend(str(p) for p in pe)
527
-
528
- # key-files
529
- kf = fm.get("key-files", {}) or {}
530
- if isinstance(kf, dict):
531
- created = kf.get("created", []) or []
532
- if isinstance(created, str):
533
- created = [created]
534
- key_files_created.extend(str(f) for f in created)
535
-
536
- modified = kf.get("modified", []) or []
537
- if isinstance(modified, str):
538
- modified = [modified]
539
- key_files_modified.extend(str(f) for f in modified)
540
-
541
- # key-decisions
542
- kd = fm.get("key-decisions", []) or []
543
- if isinstance(kd, str):
544
- kd = [kd]
545
- key_decisions.extend(str(d) for d in kd)
546
-
547
- return {
548
- "tech_stack_added": sorted(set(tech_added)),
549
- "patterns_established": sorted(set(patterns)),
550
- "key_files_created": sorted(set(key_files_created)),
551
- "key_files_modified": sorted(set(key_files_modified)),
552
- "key_decisions": list(dict.fromkeys(key_decisions)), # dedupe, preserve order
553
- }
554
-
555
-
556
- # ---------------------------------------------------------------------------
557
- # Markdown formatting
558
- # ---------------------------------------------------------------------------
559
-
560
-
561
- def format_markdown(output: dict[str, Any]) -> str:
562
- """Format scanner output as readable markdown for LLM consumption."""
563
- sections: list[str] = []
564
- agg = output.get("aggregated", {})
565
-
566
- # --- Established Patterns ---
567
- patterns = agg.get("patterns_established", [])
568
- if patterns:
569
- lines = ["### Established Patterns"]
570
- lines.extend(f"- {p}" for p in patterns)
571
- sections.append("\n".join(lines))
572
-
573
- # --- Tech Stack ---
574
- tech = agg.get("tech_stack_added", [])
575
- if tech:
576
- sections.append(f"### Tech Stack\n{', '.join(tech)}")
577
-
578
- # --- Key Decisions ---
579
- decisions = agg.get("key_decisions", [])
580
- if decisions:
581
- lines = ["### Key Decisions"]
582
- lines.extend(f"- {d}" for d in decisions)
583
- sections.append("\n".join(lines))
584
-
585
- # --- Key Files ---
586
- created = agg.get("key_files_created", [])
587
- modified = agg.get("key_files_modified", [])
588
- if created or modified:
589
- lines = ["### Key Files"]
590
- if created:
591
- lines.append("**Created:**")
592
- lines.extend(f"- `{f}`" for f in created)
593
- if modified:
594
- lines.append("**Modified:**")
595
- lines.extend(f"- `{f}`" for f in modified)
596
- sections.append("\n".join(lines))
597
-
598
- # --- Debug Learnings ---
599
- debug = output.get("debug_learnings", [])
600
- if debug:
601
- lines = ["### Debug Learnings"]
602
- for d in debug:
603
- slug = d.get("slug", "unknown")
604
- sub = d.get("subsystem", "")
605
- rc = d.get("root_cause", "")
606
- res = d.get("resolution", "")
607
- lines.append(f"- **{slug}** ({sub}): {rc} — Fix: {res}")
608
- sections.append("\n".join(lines))
609
-
610
- # --- Adhoc Learnings ---
611
- adhoc_entries = [
612
- a for a in output.get("adhoc_learnings", []) if a.get("learnings")
613
- ]
614
- if adhoc_entries:
615
- lines = ["### Adhoc Learnings"]
616
- for a in adhoc_entries:
617
- sub = a.get("subsystem", "")
618
- path = a.get("path", "")
619
- label = sub or Path(path).stem if path else "unknown"
620
- lines.append(f"- **{label}**")
621
- for learning in a["learnings"]:
622
- lines.append(f" - {learning}")
623
- sections.append("\n".join(lines))
624
-
625
- # --- Summaries ---
626
- summaries = output.get("summaries", [])
627
- needs_read = [
628
- s
629
- for s in summaries
630
- if s.get("relevance") == "HIGH" and s.get("has_readiness_warnings")
631
- ]
632
- other_relevant = [
633
- s
634
- for s in summaries
635
- if s.get("relevance") in ("HIGH", "MEDIUM")
636
- and not s.get("has_readiness_warnings")
637
- ]
638
-
639
- if needs_read:
640
- lines = ["### Summaries Needing Full Read"]
641
- for s in needs_read:
642
- lines.append(f"- `{s['path']}`")
643
- sections.append("\n".join(lines))
644
-
645
- if other_relevant:
646
- lines = ["### Other Relevant Summaries"]
647
- for s in other_relevant:
648
- lines.append(f"- `{s['path']}` [{s.get('relevance', '')}]")
649
- sections.append("\n".join(lines))
650
-
651
- # --- Knowledge Files ---
652
- matched_knowledge = [
653
- k for k in output.get("knowledge_files", []) if k.get("matched")
654
- ]
655
- if matched_knowledge:
656
- lines = ["### Knowledge Files to Read"]
657
- for k in matched_knowledge:
658
- lines.append(f"- `{k['path']}`")
659
- sections.append("\n".join(lines))
660
-
661
- # --- Pending Todos ---
662
- todos = output.get("pending_todos", [])
663
- if todos:
664
- lines = ["### Pending Todos"]
665
- for t in todos:
666
- title = t.get("title", "untitled")
667
- priority = t.get("priority", "")
668
- sub = t.get("subsystem", "")
669
- path = t.get("path", "")
670
- lines.append(f"- **{title}** [{priority}] ({sub}) — `{path}`")
671
- sections.append("\n".join(lines))
672
-
673
- # --- Scanner Info ---
674
- sources = output.get("sources", {})
675
- parse_errors = sources.get("parse_errors", [])
676
- info_lines = ["### Scanner Info"]
677
- for name, src in sources.items():
678
- if name == "parse_errors" or not isinstance(src, dict):
679
- continue
680
- scanned = src.get("scanned", 0)
681
- skipped = src.get("skipped")
682
- if skipped:
683
- info_lines.append(f"- {name}: skipped ({skipped})")
684
- else:
685
- info_lines.append(f"- {name}: {scanned} scanned")
686
- if parse_errors:
687
- info_lines.append("**Parse errors:**")
688
- for err in parse_errors:
689
- info_lines.append(f"- `{err.get('path', '')}`: {err.get('error', '')}")
690
- sections.append("\n".join(info_lines))
691
-
692
- return "\n\n".join(sections)
693
-
694
-
695
- # ---------------------------------------------------------------------------
696
- # Main
697
- # ---------------------------------------------------------------------------
698
-
699
-
700
- def build_parser() -> argparse.ArgumentParser:
701
- parser = argparse.ArgumentParser(
702
- description="Scan .planning/ artifacts and score relevance for plan-phase context assembly.",
703
- )
704
- parser.add_argument(
705
- "--phase",
706
- required=True,
707
- help='Phase number (e.g., "05" or "5" or "2.1")',
708
- )
709
- parser.add_argument(
710
- "--phase-name",
711
- default="",
712
- help="Phase name for keyword matching",
713
- )
714
- parser.add_argument(
715
- "--subsystem",
716
- action="append",
717
- default=[],
718
- dest="subsystems",
719
- help="Subsystem(s) for matching (repeatable)",
720
- )
721
- parser.add_argument(
722
- "--keywords",
723
- default="",
724
- help="Comma-separated keywords for tag matching",
725
- )
726
- parser.add_argument(
727
- "--json",
728
- action="store_true",
729
- help="Output raw JSON (default: formatted markdown)",
730
- )
731
- return parser
732
-
733
-
734
- def main() -> None:
735
- parser = build_parser()
736
- args = parser.parse_args()
737
-
738
- phase = normalize_phase(args.phase)
739
- phase_name = args.phase_name.strip()
740
- subsystems = [s for s in args.subsystems if s]
741
- keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
742
-
743
- # Add phase name words as keywords
744
- if phase_name:
745
- name_words = [w for w in re.split(r"[-_\s]+", phase_name) if len(w) > 2]
746
- keywords.extend(name_words)
747
-
748
- target_num = extract_phase_number(phase)
749
-
750
- planning = find_planning_dir()
751
- if planning is None:
752
- if args.json:
753
- output: dict[str, Any] = {
754
- "success": True,
755
- "target": {
756
- "phase": phase,
757
- "phase_name": phase_name,
758
- "subsystems": subsystems,
759
- "keywords": keywords,
760
- },
761
- "sources": {
762
- "summaries": {"dir": "", "scanned": 0, "skipped": ".planning/ not found"},
763
- "debug_docs": {"dir": "", "scanned": 0, "skipped": ".planning/ not found"},
764
- "adhoc_summaries": {"dir": "", "scanned": 0, "skipped": ".planning/ not found"},
765
- "completed_todos": {"dir": "", "scanned": 0, "skipped": ".planning/ not found"},
766
- "pending_todos": {"dir": "", "scanned": 0, "skipped": ".planning/ not found"},
767
- "knowledge_files": {"dir": "", "scanned": 0, "skipped": ".planning/ not found"},
768
- "parse_errors": [],
769
- },
770
- "summaries": [],
771
- "debug_learnings": [],
772
- "adhoc_learnings": [],
773
- "completed_todos": [],
774
- "pending_todos": [],
775
- "knowledge_files": [],
776
- "aggregated": {
777
- "tech_stack_added": [],
778
- "patterns_established": [],
779
- "key_files_created": [],
780
- "key_files_modified": [],
781
- "key_decisions": [],
782
- },
783
- }
784
- json.dump(output, sys.stdout, indent=2, cls=_SafeEncoder)
785
- sys.stdout.write("\n")
786
- else:
787
- print("No .planning/ directory found. No prior context available.")
788
- return
789
-
790
- parse_errors: list[dict[str, str]] = []
791
-
792
- # Scan all sources
793
- summaries, summaries_src = scan_summaries(
794
- planning, phase, target_num, subsystems, keywords, parse_errors
795
- )
796
- debug_learnings, debug_src = scan_debug_docs(planning, parse_errors)
797
- adhoc_learnings, adhoc_src = scan_adhoc_summaries(planning, parse_errors)
798
- completed_todos, completed_src = scan_todos(planning, "done", parse_errors)
799
- pending_todos, pending_src = scan_todos(planning, "pending", parse_errors)
800
- knowledge_files, knowledge_src = scan_knowledge_files(planning, subsystems)
801
-
802
- # Aggregate from HIGH+MEDIUM summaries
803
- aggregated = aggregate_from_summaries(summaries)
804
-
805
- output = {
806
- "success": True,
807
- "target": {
808
- "phase": phase,
809
- "phase_name": phase_name,
810
- "subsystems": subsystems,
811
- "keywords": keywords,
812
- },
813
- "sources": {
814
- "summaries": summaries_src,
815
- "debug_docs": debug_src,
816
- "adhoc_summaries": adhoc_src,
817
- "completed_todos": completed_src,
818
- "pending_todos": pending_src,
819
- "knowledge_files": knowledge_src,
820
- "parse_errors": parse_errors,
821
- },
822
- "summaries": summaries,
823
- "debug_learnings": debug_learnings,
824
- "adhoc_learnings": adhoc_learnings,
825
- "completed_todos": completed_todos,
826
- "pending_todos": pending_todos,
827
- "knowledge_files": knowledge_files,
828
- "aggregated": aggregated,
829
- }
830
-
831
- if args.json:
832
- json.dump(output, sys.stdout, indent=2, cls=_SafeEncoder)
833
- sys.stdout.write("\n")
834
- else:
835
- print(format_markdown(output))
836
-
837
-
838
- if __name__ == "__main__":
839
- main()