mindsystem-cc 3.21.0 → 3.22.1

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 (87) hide show
  1. package/README.md +4 -12
  2. package/agents/ms-debugger.md +196 -880
  3. package/agents/ms-plan-checker.md +30 -30
  4. package/agents/ms-plan-writer.md +1 -1
  5. package/agents/ms-product-researcher.md +4 -2
  6. package/agents/ms-verifier.md +25 -117
  7. package/commands/ms/add-phase.md +3 -4
  8. package/commands/ms/add-todo.md +3 -4
  9. package/commands/ms/adhoc.md +3 -4
  10. package/commands/ms/audit-milestone.md +4 -3
  11. package/commands/ms/complete-milestone.md +2 -2
  12. package/commands/ms/config.md +36 -9
  13. package/commands/ms/create-roadmap.md +3 -4
  14. package/commands/ms/debug.md +27 -28
  15. package/commands/ms/design-phase.md +8 -5
  16. package/commands/ms/discuss-phase.md +2 -2
  17. package/commands/ms/doctor.md +9 -6
  18. package/commands/ms/execute-phase.md +2 -5
  19. package/commands/ms/help.md +2 -2
  20. package/commands/ms/insert-phase.md +3 -4
  21. package/commands/ms/map-codebase.md +1 -2
  22. package/commands/ms/new-milestone.md +1 -3
  23. package/commands/ms/new-project.md +3 -5
  24. package/commands/ms/plan-milestone-gaps.md +3 -4
  25. package/commands/ms/plan-phase.md +2 -3
  26. package/commands/ms/progress.md +1 -0
  27. package/commands/ms/remove-phase.md +3 -4
  28. package/commands/ms/research-phase.md +4 -4
  29. package/commands/ms/research-project.md +9 -16
  30. package/commands/ms/review-design.md +4 -2
  31. package/commands/ms/verify-work.md +6 -8
  32. package/mindsystem/templates/config.json +2 -1
  33. package/mindsystem/templates/roadmap.md +1 -1
  34. package/mindsystem/templates/state.md +2 -2
  35. package/mindsystem/templates/verification-report.md +3 -26
  36. package/mindsystem/workflows/diagnose-issues.md +0 -1
  37. package/mindsystem/workflows/discuss-phase.md +7 -3
  38. package/mindsystem/workflows/execute-phase.md +2 -18
  39. package/mindsystem/workflows/map-codebase.md +6 -12
  40. package/mindsystem/workflows/mockup-generation.md +46 -22
  41. package/mindsystem/workflows/plan-phase.md +12 -5
  42. package/mindsystem/workflows/verify-work.md +96 -69
  43. package/package.json +1 -1
  44. package/scripts/__pycache__/ms-tools.cpython-314.pyc +0 -0
  45. package/scripts/__pycache__/test_ms_tools.cpython-314-pytest-9.0.2.pyc +0 -0
  46. package/scripts/ms-tools.py +751 -6
  47. package/scripts/test_ms_tools.py +786 -0
  48. package/skills/senior-review/AGENTS.md +531 -0
  49. package/skills/{flutter-senior-review → senior-review}/SKILL.md +47 -36
  50. package/skills/senior-review/principles/dependencies-api-boundary-design.md +32 -0
  51. package/skills/senior-review/principles/dependencies-data-not-flags.md +32 -0
  52. package/skills/senior-review/principles/dependencies-temporal-coupling.md +32 -0
  53. package/skills/senior-review/principles/pragmatism-consistent-error-handling.md +32 -0
  54. package/skills/senior-review/principles/pragmatism-speculative-generality.md +32 -0
  55. package/skills/senior-review/principles/state-invalid-states.md +33 -0
  56. package/skills/senior-review/principles/state-single-source-of-truth.md +32 -0
  57. package/skills/senior-review/principles/state-type-hierarchies.md +32 -0
  58. package/skills/senior-review/principles/structure-composition-over-config.md +32 -0
  59. package/skills/senior-review/principles/structure-feature-isolation.md +32 -0
  60. package/skills/senior-review/principles/structure-module-cohesion.md +32 -0
  61. package/agents/ms-flutter-code-quality.md +0 -169
  62. package/agents/ms-flutter-reviewer.md +0 -211
  63. package/agents/ms-flutter-simplifier.md +0 -79
  64. package/mindsystem/references/debugging/debugging-mindset.md +0 -11
  65. package/mindsystem/references/debugging/hypothesis-testing.md +0 -11
  66. package/mindsystem/references/debugging/investigation-techniques.md +0 -11
  67. package/mindsystem/references/debugging/verification-patterns.md +0 -11
  68. package/mindsystem/references/debugging/when-to-research.md +0 -11
  69. package/mindsystem/references/git-integration.md +0 -254
  70. package/mindsystem/references/verification-patterns.md +0 -595
  71. package/mindsystem/workflows/debug.md +0 -14
  72. package/mindsystem/workflows/verify-phase.md +0 -625
  73. package/skills/flutter-code-quality/SKILL.md +0 -143
  74. package/skills/flutter-code-simplification/SKILL.md +0 -102
  75. package/skills/flutter-senior-review/AGENTS.md +0 -869
  76. package/skills/flutter-senior-review/principles/dependencies-data-not-callbacks.md +0 -75
  77. package/skills/flutter-senior-review/principles/dependencies-provider-tree.md +0 -85
  78. package/skills/flutter-senior-review/principles/dependencies-temporal-coupling.md +0 -97
  79. package/skills/flutter-senior-review/principles/pragmatism-consistent-error-handling.md +0 -130
  80. package/skills/flutter-senior-review/principles/pragmatism-speculative-generality.md +0 -91
  81. package/skills/flutter-senior-review/principles/state-data-clumps.md +0 -64
  82. package/skills/flutter-senior-review/principles/state-invalid-states.md +0 -53
  83. package/skills/flutter-senior-review/principles/state-single-source-of-truth.md +0 -68
  84. package/skills/flutter-senior-review/principles/state-type-hierarchies.md +0 -75
  85. package/skills/flutter-senior-review/principles/structure-composition-over-config.md +0 -105
  86. package/skills/flutter-senior-review/principles/structure-shared-visual-patterns.md +0 -107
  87. package/skills/flutter-senior-review/principles/structure-wrapper-pattern.md +0 -90
@@ -13,6 +13,7 @@ patch generation, archival, and planning context scanning.
13
13
  import argparse
14
14
  import datetime
15
15
  import json
16
+ import os
16
17
  import re
17
18
  import shutil
18
19
  import subprocess
@@ -250,6 +251,49 @@ def cmd_update_state(args: argparse.Namespace) -> None:
250
251
  print(f"STATE.md updated: {completed} of {total} plans complete")
251
252
 
252
253
 
254
+ # ===================================================================
255
+ # Subcommand: set-last-command
256
+ # ===================================================================
257
+
258
+
259
+ def cmd_set_last_command(args: argparse.Namespace) -> None:
260
+ """Update .planning/STATE.md Last Command field with timestamp.
261
+
262
+ Contract:
263
+ Args: command_string (str) — e.g. "ms:plan-phase 10"
264
+ Output: text — confirmation or warning
265
+ Exit codes: 0 always (bookkeeping, not critical path)
266
+ Side effects: writes STATE.md (if it exists)
267
+ """
268
+ command_string = args.command_string
269
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
270
+ new_line = f"Last Command: {command_string} | {now}"
271
+
272
+ state_file = find_git_root() / ".planning" / "STATE.md"
273
+ if not state_file.is_file():
274
+ print("Warning: STATE.md not found, skipping Last Command update", file=sys.stderr)
275
+ return
276
+
277
+ text = state_file.read_text(encoding="utf-8")
278
+
279
+ # Try replacing existing Last Command line
280
+ updated, count = re.subn(
281
+ r"^Last Command:.*$", new_line, text, count=1, flags=re.MULTILINE,
282
+ )
283
+
284
+ if count == 0:
285
+ # Insert after Status: line
286
+ updated, count = re.subn(
287
+ r"^(Status:.*)$", rf"\1\n{new_line}", text, count=1, flags=re.MULTILINE,
288
+ )
289
+ if count == 0:
290
+ print("Warning: No 'Last Command:' or 'Status:' line found in STATE.md", file=sys.stderr)
291
+ return
292
+
293
+ state_file.write_text(updated, encoding="utf-8")
294
+ print(f"STATE.md Last Command: {command_string} | {now}")
295
+
296
+
253
297
  # ===================================================================
254
298
  # Subcommand: validate-execution-order
255
299
  # ===================================================================
@@ -349,7 +393,7 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
349
393
  Contract:
350
394
  Args: (none)
351
395
  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
396
+ Exit codes: 0 = scan completed, 2 = missing .planning/ or config.json
353
397
  Side effects: read-only
354
398
  """
355
399
  git_root = find_git_root()
@@ -376,14 +420,17 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
376
420
  knowledge_dir = planning / "knowledge"
377
421
 
378
422
  pass_count = 0
423
+ warn_count = 0
379
424
  fail_count = 0
380
425
  skip_count = 0
381
426
  failed_checks: list[str] = []
382
427
 
383
428
  def record(status: str, name: str) -> None:
384
- nonlocal pass_count, fail_count, skip_count
429
+ nonlocal pass_count, warn_count, fail_count, skip_count
385
430
  if status == "PASS":
386
431
  pass_count += 1
432
+ elif status == "WARN":
433
+ warn_count += 1
387
434
  elif status == "FAIL":
388
435
  fail_count += 1
389
436
  failed_checks.append(name)
@@ -663,14 +710,41 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
663
710
  record("PASS", "Milestone Naming Convention")
664
711
  print()
665
712
 
713
+ # ---- CHECK 9: Research API Keys ----
714
+ print("=== Research API Keys ===")
715
+ c7_key = os.environ.get("CONTEXT7_API_KEY", "")
716
+ pplx_key = os.environ.get("PERPLEXITY_API_KEY", "")
717
+ if c7_key and pplx_key:
718
+ print("Status: PASS")
719
+ print("All research API keys configured")
720
+ record("PASS", "Research API Keys")
721
+ else:
722
+ print("Status: WARN")
723
+ missing_keys: list[str] = []
724
+ if not c7_key:
725
+ missing_keys.append("CONTEXT7_API_KEY")
726
+ print("CONTEXT7_API_KEY: not set")
727
+ print(" Enables: library documentation lookup via Context7")
728
+ print(" Without: falls back to WebSearch/WebFetch (less authoritative)")
729
+ print(" Set up: https://context7.com → copy API key → export CONTEXT7_API_KEY=<key>")
730
+ if not pplx_key:
731
+ missing_keys.append("PERPLEXITY_API_KEY")
732
+ print("PERPLEXITY_API_KEY: not set")
733
+ print(" Enables: deep research via Perplexity AI")
734
+ print(" Without: falls back to WebSearch/WebFetch (less comprehensive)")
735
+ print(" Set up: https://perplexity.ai/settings/api → copy API key → export PERPLEXITY_API_KEY=<key>")
736
+ record("WARN", "Research API Keys")
737
+ print()
738
+
666
739
  # ---- SUMMARY ----
667
- total = pass_count + fail_count + skip_count
740
+ total = pass_count + warn_count + fail_count + skip_count
668
741
  print("=== Summary ===")
669
- print(f"Checks: {total} total, {pass_count} passed, {fail_count} failed, {skip_count} skipped")
742
+ print(f"Checks: {total} total, {pass_count} passed, {warn_count} warned, {fail_count} failed, {skip_count} skipped")
670
743
 
671
744
  if fail_count > 0:
672
- print(f"Issues: {' '.join(failed_checks)}")
673
- sys.exit(1)
745
+ print(f"Issues: {', '.join(failed_checks)}")
746
+ elif warn_count > 0:
747
+ print("No failures — warnings are informational")
674
748
  else:
675
749
  print("All checks passed")
676
750
 
@@ -2035,6 +2109,650 @@ def cmd_scan_planning_context(args: argparse.Namespace) -> None:
2035
2109
  print(_format_markdown(output))
2036
2110
 
2037
2111
 
2112
+ # ===================================================================
2113
+ # UAT File Management
2114
+ # ===================================================================
2115
+
2116
+ _TEST_HEADER_RE = re.compile(r"^###\s+(\d+)\.\s+(.+)$")
2117
+ _BATCH_HEADER_RE = re.compile(r"^###\s+Batch\s+(\d+):\s+(.+)$")
2118
+ _SECTION_HEADER_RE = re.compile(r"^##\s+(.+)$")
2119
+ _KV_LINE_RE = re.compile(r"^(\w[\w_]*)\s*:\s*(.*)$")
2120
+ _LIST_ITEM_START_RE = re.compile(r"^-\s+(\w[\w_]*)\s*:\s*(.*)$")
2121
+ _LIST_ITEM_CONT_RE = re.compile(r"^\s+(\w[\w_]*)\s*:\s*(.*)$")
2122
+
2123
+ # Fields that get quoted in serialization per section
2124
+ _QUOTED_FIELDS = {
2125
+ "current_batch": {"name"},
2126
+ "fixes": {"description"},
2127
+ "assumptions": {"name", "expected", "reason"},
2128
+ "tests": {"reported"},
2129
+ }
2130
+
2131
+
2132
+ def _ensure_quoted(value: str) -> str:
2133
+ """Add surrounding quotes if not already quoted."""
2134
+ if value.startswith('"') and value.endswith('"'):
2135
+ return value
2136
+ return f'"{value}"'
2137
+
2138
+
2139
+ class UATFile:
2140
+ """Internal representation of UAT.md for programmatic manipulation."""
2141
+
2142
+ def __init__(self) -> None:
2143
+ self.frontmatter: dict[str, Any] = {}
2144
+ self.progress: dict[str, str] = {}
2145
+ self.current_batch: dict[str, str] = {}
2146
+ self.tests: list[dict[str, str]] = []
2147
+ self.fixes: list[dict[str, str]] = []
2148
+ self.batches: list[dict[str, str]] = []
2149
+ self.assumptions: list[dict[str, str]] = []
2150
+
2151
+ # --- Parsing ---
2152
+
2153
+ @classmethod
2154
+ def parse(cls, text: str) -> "UATFile":
2155
+ """Parse UAT.md text into structured representation."""
2156
+ uat = cls()
2157
+
2158
+ # Parse frontmatter
2159
+ fm_match = _FRONTMATTER_RE.match(text)
2160
+ if fm_match:
2161
+ try:
2162
+ uat.frontmatter = yaml.safe_load(fm_match.group(1)) or {}
2163
+ except yaml.YAMLError:
2164
+ uat.frontmatter = {}
2165
+ body = text[fm_match.end():]
2166
+ else:
2167
+ body = text
2168
+
2169
+ # Split into sections by ## headers
2170
+ sections = cls._split_sections(body)
2171
+
2172
+ for name, content in sections.items():
2173
+ if name == "Progress":
2174
+ uat.progress = cls._parse_kv_block(content)
2175
+ elif name == "Current Batch":
2176
+ uat.current_batch = cls._parse_kv_block(content)
2177
+ elif name == "Tests":
2178
+ uat.tests = cls._parse_tests(content)
2179
+ elif name == "Fixes Applied":
2180
+ uat.fixes = cls._parse_list_items(content)
2181
+ elif name == "Batches":
2182
+ uat.batches = cls._parse_batches(content)
2183
+ elif name == "Assumptions":
2184
+ uat.assumptions = cls._parse_list_items(content)
2185
+
2186
+ return uat
2187
+
2188
+ @staticmethod
2189
+ def _split_sections(body: str) -> dict[str, str]:
2190
+ """Split body text into {section_name: content} dict."""
2191
+ sections: dict[str, str] = {}
2192
+ current_name: str | None = None
2193
+ current_lines: list[str] = []
2194
+
2195
+ for line in body.splitlines():
2196
+ m = _SECTION_HEADER_RE.match(line)
2197
+ if m:
2198
+ if current_name is not None:
2199
+ sections[current_name] = "\n".join(current_lines)
2200
+ current_name = m.group(1).strip()
2201
+ current_lines = []
2202
+ else:
2203
+ current_lines.append(line)
2204
+
2205
+ if current_name is not None:
2206
+ sections[current_name] = "\n".join(current_lines)
2207
+
2208
+ return sections
2209
+
2210
+ @staticmethod
2211
+ def _parse_kv_block(text: str) -> dict[str, str]:
2212
+ """Parse a block of key: value lines."""
2213
+ result: dict[str, str] = {}
2214
+ for line in text.splitlines():
2215
+ m = _KV_LINE_RE.match(line.strip())
2216
+ if m:
2217
+ result[m.group(1)] = m.group(2).strip()
2218
+ return result
2219
+
2220
+ @staticmethod
2221
+ def _parse_tests(text: str) -> list[dict[str, str]]:
2222
+ """Parse ### N. Name sections into test dicts."""
2223
+ tests: list[dict[str, str]] = []
2224
+ current: dict[str, str] | None = None
2225
+
2226
+ for line in text.splitlines():
2227
+ m = _TEST_HEADER_RE.match(line)
2228
+ if m:
2229
+ if current is not None:
2230
+ tests.append(current)
2231
+ current = {"num": m.group(1), "name": m.group(2).strip()}
2232
+ elif current is not None:
2233
+ kv = _KV_LINE_RE.match(line.strip())
2234
+ if kv:
2235
+ current[kv.group(1)] = kv.group(2).strip()
2236
+
2237
+ if current is not None:
2238
+ tests.append(current)
2239
+
2240
+ return tests
2241
+
2242
+ @staticmethod
2243
+ def _parse_batches(text: str) -> list[dict[str, str]]:
2244
+ """Parse ### Batch N: Name sections into batch dicts."""
2245
+ batches: list[dict[str, str]] = []
2246
+ current: dict[str, str] | None = None
2247
+
2248
+ for line in text.splitlines():
2249
+ m = _BATCH_HEADER_RE.match(line)
2250
+ if m:
2251
+ if current is not None:
2252
+ batches.append(current)
2253
+ current = {"num": m.group(1), "name": m.group(2).strip()}
2254
+ elif current is not None:
2255
+ kv = _KV_LINE_RE.match(line.strip())
2256
+ if kv:
2257
+ current[kv.group(1)] = kv.group(2).strip()
2258
+
2259
+ if current is not None:
2260
+ batches.append(current)
2261
+
2262
+ return batches
2263
+
2264
+ @staticmethod
2265
+ def _parse_list_items(text: str) -> list[dict[str, str]]:
2266
+ """Parse - key: value list items (fixes, assumptions)."""
2267
+ items: list[dict[str, str]] = []
2268
+ current: dict[str, str] | None = None
2269
+
2270
+ for line in text.splitlines():
2271
+ m = _LIST_ITEM_START_RE.match(line)
2272
+ if m:
2273
+ if current is not None:
2274
+ items.append(current)
2275
+ current = {m.group(1): m.group(2).strip()}
2276
+ elif current is not None:
2277
+ m2 = _LIST_ITEM_CONT_RE.match(line)
2278
+ if m2:
2279
+ current[m2.group(1)] = m2.group(2).strip()
2280
+
2281
+ if current is not None:
2282
+ items.append(current)
2283
+
2284
+ return items
2285
+
2286
+ # --- Construction ---
2287
+
2288
+ @classmethod
2289
+ def from_init_json(cls, data: dict, phase_name: str) -> "UATFile":
2290
+ """Construct from structured JSON input."""
2291
+ uat = cls()
2292
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
2293
+
2294
+ tests = data.get("tests", [])
2295
+ batches = data.get("batches", [])
2296
+ source = data.get("source", [])
2297
+
2298
+ uat.frontmatter = {
2299
+ "status": "testing",
2300
+ "phase": phase_name,
2301
+ "source": source,
2302
+ "started": now,
2303
+ "updated": now,
2304
+ "current_batch": 1,
2305
+ "mocked_files": [],
2306
+ "pre_work_stash": None,
2307
+ }
2308
+
2309
+ # Build tests
2310
+ for i, t in enumerate(tests, 1):
2311
+ uat.tests.append({
2312
+ "num": str(i),
2313
+ "name": t["name"],
2314
+ "expected": t["expected"],
2315
+ "mock_required": str(t.get("mock_required", False)).lower(),
2316
+ "mock_type": t.get("mock_type") or "null",
2317
+ "result": "[pending]",
2318
+ })
2319
+
2320
+ # Build batches
2321
+ for i, b in enumerate(batches, 1):
2322
+ test_nums = "[" + ", ".join(str(n) for n in b["tests"]) + "]"
2323
+ uat.batches.append({
2324
+ "num": str(i),
2325
+ "name": b["name"],
2326
+ "tests": test_nums,
2327
+ "status": "pending",
2328
+ "mock_type": b.get("mock_type") or "null",
2329
+ })
2330
+
2331
+ # Set first batch as current
2332
+ if batches:
2333
+ first = batches[0]
2334
+ test_nums = "[" + ", ".join(str(n) for n in first["tests"]) + "]"
2335
+ uat.current_batch = {
2336
+ "batch": f"1 of {len(batches)}",
2337
+ "name": _ensure_quoted(first["name"]),
2338
+ "mock_type": first.get("mock_type") or "null",
2339
+ "tests": test_nums,
2340
+ "status": "pending",
2341
+ }
2342
+
2343
+ uat.recalc_progress()
2344
+ return uat
2345
+
2346
+ # --- Mutations ---
2347
+
2348
+ def update_test(self, num: int, fields: dict[str, str]) -> None:
2349
+ """Update fields on test N."""
2350
+ for t in self.tests:
2351
+ if t["num"] == str(num):
2352
+ t.update(fields)
2353
+ return
2354
+ raise ValueError(f"Test {num} not found")
2355
+
2356
+ def update_batch(self, num: int, fields: dict[str, str]) -> None:
2357
+ """Update fields on batch N."""
2358
+ for b in self.batches:
2359
+ if b["num"] == str(num):
2360
+ b.update(fields)
2361
+ return
2362
+ raise ValueError(f"Batch {num} not found")
2363
+
2364
+ def update_session(self, fields: dict[str, str]) -> None:
2365
+ """Update frontmatter fields."""
2366
+ for k, v in fields.items():
2367
+ if v == "":
2368
+ # Empty string means clear/null
2369
+ if k == "mocked_files":
2370
+ self.frontmatter[k] = []
2371
+ else:
2372
+ self.frontmatter[k] = None
2373
+ elif k == "mocked_files":
2374
+ self.frontmatter[k] = [f.strip() for f in v.split(",") if f.strip()]
2375
+ elif k == "current_batch":
2376
+ try:
2377
+ batch_num = int(v)
2378
+ self.frontmatter[k] = batch_num
2379
+ self._sync_current_batch(batch_num)
2380
+ except ValueError:
2381
+ self.frontmatter[k] = v
2382
+ else:
2383
+ self.frontmatter[k] = v
2384
+
2385
+ def _sync_current_batch(self, batch_num: int) -> None:
2386
+ """Sync Current Batch section when frontmatter current_batch changes."""
2387
+ total = len(self.batches)
2388
+ for b in self.batches:
2389
+ if b["num"] == str(batch_num):
2390
+ name = b["name"]
2391
+ if not name.startswith('"'):
2392
+ name = _ensure_quoted(name)
2393
+ self.current_batch = {
2394
+ "batch": f"{batch_num} of {total}",
2395
+ "name": name,
2396
+ "mock_type": b.get("mock_type", "null"),
2397
+ "tests": b.get("tests", "[]"),
2398
+ "status": b.get("status", "pending"),
2399
+ }
2400
+ return
2401
+
2402
+ def append_fix(self, fix_dict: dict) -> None:
2403
+ """Append to fixes. Update in-place if same test already has a fix."""
2404
+ test_num = str(fix_dict.get("test", ""))
2405
+ converted: dict[str, str] = {}
2406
+ for k, v in fix_dict.items():
2407
+ if isinstance(v, list):
2408
+ converted[k] = "[" + ", ".join(str(x) for x in v) + "]"
2409
+ elif k == "description":
2410
+ converted[k] = _ensure_quoted(str(v))
2411
+ else:
2412
+ converted[k] = str(v)
2413
+
2414
+ for i, f in enumerate(self.fixes):
2415
+ if f.get("test") == test_num:
2416
+ self.fixes[i] = converted
2417
+ return
2418
+ self.fixes.append(converted)
2419
+
2420
+ def append_assumption(self, assumption_dict: dict) -> None:
2421
+ """Append to assumptions."""
2422
+ converted: dict[str, str] = {}
2423
+ for k, v in assumption_dict.items():
2424
+ s = str(v)
2425
+ if k in ("name", "expected", "reason"):
2426
+ s = _ensure_quoted(s)
2427
+ converted[k] = s
2428
+ self.assumptions.append(converted)
2429
+
2430
+ # --- Progress ---
2431
+
2432
+ def recalc_progress(self) -> None:
2433
+ """Derive all progress counters from test results."""
2434
+ total = len(self.tests)
2435
+ pending = 0
2436
+ passed = 0
2437
+ issues = 0
2438
+ fixing = 0
2439
+ skipped = 0
2440
+
2441
+ for t in self.tests:
2442
+ result = t.get("result", "[pending]")
2443
+ fix_status = t.get("fix_status", "")
2444
+
2445
+ if result in ("[pending]", "blocked"):
2446
+ pending += 1
2447
+ elif result == "pass":
2448
+ passed += 1
2449
+ elif result == "issue":
2450
+ if fix_status == "verified":
2451
+ passed += 1
2452
+ elif fix_status in ("investigating", "applied"):
2453
+ fixing += 1
2454
+ else:
2455
+ issues += 1
2456
+ elif result == "skipped":
2457
+ skipped += 1
2458
+
2459
+ tested = total - pending
2460
+ self.progress = {
2461
+ "total": str(total),
2462
+ "tested": str(tested),
2463
+ "passed": str(passed),
2464
+ "issues": str(issues),
2465
+ "fixing": str(fixing),
2466
+ "pending": str(pending),
2467
+ "skipped": str(skipped),
2468
+ }
2469
+
2470
+ def progress_summary(self) -> str:
2471
+ """One-line summary for stdout."""
2472
+ p = self.progress
2473
+ return (
2474
+ f"{p.get('tested', '0')}/{p.get('total', '0')} "
2475
+ f"({p.get('passed', '0')} pass, {p.get('issues', '0')} issue, "
2476
+ f"{p.get('fixing', '0')} fixing, {p.get('skipped', '0')} skip)"
2477
+ )
2478
+
2479
+ # --- Serialization ---
2480
+
2481
+ def serialize(self) -> str:
2482
+ """Rebuild full file from internal state."""
2483
+ self.recalc_progress()
2484
+ self.frontmatter["updated"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
2485
+
2486
+ lines: list[str] = []
2487
+
2488
+ # Frontmatter
2489
+ lines.append("---")
2490
+ fm_text = yaml.dump(
2491
+ self.frontmatter, default_flow_style=False, sort_keys=False,
2492
+ ).rstrip()
2493
+ lines.append(fm_text)
2494
+ lines.append("---")
2495
+ lines.append("")
2496
+
2497
+ # Progress
2498
+ lines.append("## Progress")
2499
+ lines.append("")
2500
+ for k in ("total", "tested", "passed", "issues", "fixing", "pending", "skipped"):
2501
+ if k in self.progress:
2502
+ lines.append(f"{k}: {self.progress[k]}")
2503
+ lines.append("")
2504
+
2505
+ # Current Batch
2506
+ lines.append("## Current Batch")
2507
+ lines.append("")
2508
+ for k in ("batch", "name", "mock_type", "tests", "status"):
2509
+ if k in self.current_batch:
2510
+ lines.append(f"{k}: {self.current_batch[k]}")
2511
+ lines.append("")
2512
+
2513
+ # Tests
2514
+ lines.append("## Tests")
2515
+ lines.append("")
2516
+ test_field_order = (
2517
+ "expected", "mock_required", "mock_type", "result",
2518
+ "reported", "severity", "fix_status", "fix_commit",
2519
+ "retry_count", "reason",
2520
+ )
2521
+ for t in self.tests:
2522
+ lines.append(f"### {t['num']}. {t['name']}")
2523
+ for k in test_field_order:
2524
+ if k in t:
2525
+ val = t[k]
2526
+ if k in _QUOTED_FIELDS.get("tests", set()):
2527
+ val = _ensure_quoted(val)
2528
+ lines.append(f"{k}: {val}")
2529
+ lines.append("")
2530
+
2531
+ # Fixes Applied
2532
+ lines.append("## Fixes Applied")
2533
+ lines.append("")
2534
+ fix_field_order = ("commit", "test", "description", "files")
2535
+ for fix in self.fixes:
2536
+ first = True
2537
+ for k in fix_field_order:
2538
+ if k in fix:
2539
+ val = fix[k]
2540
+ if k in _QUOTED_FIELDS.get("fixes", set()):
2541
+ val = _ensure_quoted(val)
2542
+ prefix = "- " if first else " "
2543
+ lines.append(f"{prefix}{k}: {val}")
2544
+ first = False
2545
+ lines.append("")
2546
+
2547
+ # Batches
2548
+ lines.append("## Batches")
2549
+ lines.append("")
2550
+ batch_field_order = ("tests", "status", "mock_type", "passed", "issues")
2551
+ for b in self.batches:
2552
+ lines.append(f"### Batch {b['num']}: {b['name']}")
2553
+ for k in batch_field_order:
2554
+ if k in b:
2555
+ lines.append(f"{k}: {b[k]}")
2556
+ lines.append("")
2557
+
2558
+ # Assumptions
2559
+ lines.append("## Assumptions")
2560
+ lines.append("")
2561
+ assumption_field_order = ("test", "name", "expected", "reason")
2562
+ for a in self.assumptions:
2563
+ first = True
2564
+ for k in assumption_field_order:
2565
+ if k in a:
2566
+ val = a[k]
2567
+ if k in _QUOTED_FIELDS.get("assumptions", set()):
2568
+ val = _ensure_quoted(val)
2569
+ prefix = "- " if first else " "
2570
+ lines.append(f"{prefix}{k}: {val}")
2571
+ first = False
2572
+ lines.append("")
2573
+
2574
+ return "\n".join(lines)
2575
+
2576
+
2577
+ # ===================================================================
2578
+ # Subcommand: uat-init
2579
+ # ===================================================================
2580
+
2581
+
2582
+ def cmd_uat_init(args: argparse.Namespace) -> None:
2583
+ """Create UAT.md from JSON stdin.
2584
+
2585
+ Contract:
2586
+ Args: phase (str) — phase number
2587
+ Input: JSON on stdin with source, tests, batches
2588
+ Output: text — confirmation with path and counts
2589
+ Exit codes: 0 = success, 1 = invalid JSON
2590
+ Side effects: creates UAT.md file
2591
+ """
2592
+ phase = normalize_phase(args.phase)
2593
+ planning = find_planning_dir()
2594
+
2595
+ try:
2596
+ data = json.loads(sys.stdin.read())
2597
+ except json.JSONDecodeError as e:
2598
+ print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
2599
+ sys.exit(1)
2600
+
2601
+ phase_dir = find_phase_dir(planning, phase)
2602
+ if phase_dir is None:
2603
+ phases_dir = planning / "phases"
2604
+ phases_dir.mkdir(parents=True, exist_ok=True)
2605
+ phase_dir = phases_dir / phase
2606
+ phase_dir.mkdir(parents=True, exist_ok=True)
2607
+
2608
+ phase_name = phase_dir.name
2609
+ uat = UATFile.from_init_json(data, phase_name)
2610
+
2611
+ out_path = phase_dir / f"{phase_name}-UAT.md"
2612
+ out_path.write_text(uat.serialize(), encoding="utf-8")
2613
+
2614
+ n_tests = len(uat.tests)
2615
+ n_batches = len(uat.batches)
2616
+ print(f"Created {out_path} with {n_tests} tests in {n_batches} batches")
2617
+
2618
+
2619
+ # ===================================================================
2620
+ # Subcommand: uat-update
2621
+ # ===================================================================
2622
+
2623
+
2624
+ def cmd_uat_update(args: argparse.Namespace) -> None:
2625
+ """Update UAT.md fields.
2626
+
2627
+ Contract:
2628
+ Args: phase (str), mutually exclusive target flag, key=value pairs or JSON stdin
2629
+ Output: text — update label + progress summary
2630
+ Exit codes: 0 = success, 1 = file not found or invalid input
2631
+ Side effects: writes UAT.md
2632
+ """
2633
+ phase = normalize_phase(args.phase)
2634
+ planning = find_planning_dir()
2635
+
2636
+ phase_dir = find_phase_dir(planning, phase)
2637
+ if phase_dir is None:
2638
+ print(f"Error: Phase directory not found for {phase}", file=sys.stderr)
2639
+ sys.exit(1)
2640
+
2641
+ uat_path = phase_dir / f"{phase_dir.name}-UAT.md"
2642
+ if not uat_path.is_file():
2643
+ print(f"Error: UAT file not found: {uat_path}", file=sys.stderr)
2644
+ sys.exit(1)
2645
+
2646
+ uat = UATFile.parse(uat_path.read_text(encoding="utf-8"))
2647
+
2648
+ # Parse key=value pairs from remaining args
2649
+ fields: dict[str, str] = {}
2650
+ for kv in (args.fields or []):
2651
+ if "=" in kv:
2652
+ k, v = kv.split("=", 1)
2653
+ fields[k] = v
2654
+
2655
+ label = ""
2656
+
2657
+ if args.test is not None:
2658
+ uat.update_test(args.test, fields)
2659
+ label = f"Updated test {args.test}: " + ", ".join(f"{k}={v}" for k, v in fields.items())
2660
+ elif args.batch is not None:
2661
+ uat.update_batch(args.batch, fields)
2662
+ label = f"Updated batch {args.batch}: " + ", ".join(f"{k}={v}" for k, v in fields.items())
2663
+ elif args.session:
2664
+ uat.update_session(fields)
2665
+ label = f"Updated session: " + ", ".join(f"{k}={v}" for k, v in fields.items())
2666
+ elif args.append_fix:
2667
+ try:
2668
+ fix_data = json.loads(sys.stdin.read())
2669
+ except json.JSONDecodeError as e:
2670
+ print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
2671
+ sys.exit(1)
2672
+ uat.append_fix(fix_data)
2673
+ label = f"Appended fix for test {fix_data.get('test', '?')}"
2674
+ elif args.append_assumption:
2675
+ try:
2676
+ assumption_data = json.loads(sys.stdin.read())
2677
+ except json.JSONDecodeError as e:
2678
+ print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
2679
+ sys.exit(1)
2680
+ uat.append_assumption(assumption_data)
2681
+ label = f"Appended assumption for test {assumption_data.get('test', '?')}"
2682
+
2683
+ uat_path.write_text(uat.serialize(), encoding="utf-8")
2684
+ print(f"{label} | Progress: {uat.progress_summary()}")
2685
+
2686
+
2687
+ # ===================================================================
2688
+ # Subcommand: uat-status
2689
+ # ===================================================================
2690
+
2691
+
2692
+ def cmd_uat_status(args: argparse.Namespace) -> None:
2693
+ """Output UAT status as JSON.
2694
+
2695
+ Contract:
2696
+ Args: phase (str) — phase number
2697
+ Output: JSON — compact status for LLM resume
2698
+ Exit codes: 0 = success, 1 = file not found
2699
+ Side effects: read-only
2700
+ """
2701
+ phase = normalize_phase(args.phase)
2702
+ planning = find_planning_dir()
2703
+
2704
+ phase_dir = find_phase_dir(planning, phase)
2705
+ if phase_dir is None:
2706
+ print(f"Error: Phase directory not found for {phase}", file=sys.stderr)
2707
+ sys.exit(1)
2708
+
2709
+ uat_path = phase_dir / f"{phase_dir.name}-UAT.md"
2710
+ if not uat_path.is_file():
2711
+ print(f"Error: UAT file not found: {uat_path}", file=sys.stderr)
2712
+ sys.exit(1)
2713
+
2714
+ uat = UATFile.parse(uat_path.read_text(encoding="utf-8"))
2715
+ uat.recalc_progress()
2716
+
2717
+ fixing_tests = []
2718
+ pending_tests = []
2719
+ blocked_tests = []
2720
+
2721
+ for t in uat.tests:
2722
+ num = int(t["num"])
2723
+ result = t.get("result", "[pending]")
2724
+ fix_status = t.get("fix_status", "")
2725
+
2726
+ if fix_status in ("investigating", "applied"):
2727
+ fixing_tests.append({
2728
+ "num": num,
2729
+ "name": t["name"],
2730
+ "fix_status": fix_status,
2731
+ "fix_commit": t.get("fix_commit", ""),
2732
+ "retry_count": int(t.get("retry_count", "0")),
2733
+ })
2734
+ if result == "[pending]":
2735
+ pending_tests.append(num)
2736
+ elif result == "blocked":
2737
+ blocked_tests.append(num)
2738
+
2739
+ output = {
2740
+ "status": uat.frontmatter.get("status", ""),
2741
+ "current_batch": uat.frontmatter.get("current_batch"),
2742
+ "total_batches": len(uat.batches),
2743
+ "progress": {k: int(v) for k, v in uat.progress.items()},
2744
+ "mocked_files": uat.frontmatter.get("mocked_files", []),
2745
+ "fixing_tests": fixing_tests,
2746
+ "pending_tests": pending_tests,
2747
+ "blocked_tests": blocked_tests,
2748
+ "pre_work_stash": uat.frontmatter.get("pre_work_stash"),
2749
+ "path": str(uat_path),
2750
+ }
2751
+
2752
+ json.dump(output, sys.stdout, cls=_SafeEncoder)
2753
+ sys.stdout.write("\n")
2754
+
2755
+
2038
2756
  # ===================================================================
2039
2757
  # Argument parser setup
2040
2758
  # ===================================================================
@@ -2053,6 +2771,11 @@ def build_parser() -> argparse.ArgumentParser:
2053
2771
  p.add_argument("total", type=int, help="Total number of plans")
2054
2772
  p.set_defaults(func=cmd_update_state)
2055
2773
 
2774
+ # --- set-last-command ---
2775
+ p = subparsers.add_parser("set-last-command", help="Update STATE.md Last Command with timestamp")
2776
+ p.add_argument("command_string", help='Command that was run (e.g. "ms:plan-phase 10")')
2777
+ p.set_defaults(func=cmd_set_last_command)
2778
+
2056
2779
  # --- validate-execution-order ---
2057
2780
  p = subparsers.add_parser("validate-execution-order", help="Validate EXECUTION-ORDER.md against plan files")
2058
2781
  p.add_argument("phase_dir", help="Phase directory path")
@@ -2126,6 +2849,28 @@ def build_parser() -> argparse.ArgumentParser:
2126
2849
  p.add_argument("type", help="Artifact type (CONTEXT, DESIGN, RESEARCH, UAT, VERIFICATION, PLAN, SUMMARY, EXECUTION-ORDER)")
2127
2850
  p.set_defaults(func=cmd_check_artifact)
2128
2851
 
2852
+ # --- uat-init ---
2853
+ p = subparsers.add_parser("uat-init", help="Create UAT.md from JSON stdin")
2854
+ p.add_argument("phase", help="Phase number (e.g., 5, 05, 2.1)")
2855
+ p.set_defaults(func=cmd_uat_init)
2856
+
2857
+ # --- uat-update ---
2858
+ p = subparsers.add_parser("uat-update", help="Update UAT.md fields")
2859
+ p.add_argument("phase", help="Phase number")
2860
+ group = p.add_mutually_exclusive_group(required=True)
2861
+ group.add_argument("--test", type=int, help="Test number to update")
2862
+ group.add_argument("--batch", type=int, help="Batch number to update")
2863
+ group.add_argument("--session", action="store_true", help="Update session/frontmatter fields")
2864
+ group.add_argument("--append-fix", action="store_true", help="Append fix (JSON from stdin)")
2865
+ group.add_argument("--append-assumption", action="store_true", help="Append assumption (JSON from stdin)")
2866
+ p.add_argument("fields", nargs="*", help="key=value pairs")
2867
+ p.set_defaults(func=cmd_uat_update)
2868
+
2869
+ # --- uat-status ---
2870
+ p = subparsers.add_parser("uat-status", help="Output UAT status as JSON")
2871
+ p.add_argument("phase", help="Phase number")
2872
+ p.set_defaults(func=cmd_uat_status)
2873
+
2129
2874
  return parser
2130
2875
 
2131
2876