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.
- package/README.md +4 -12
- package/agents/ms-debugger.md +196 -880
- package/agents/ms-plan-checker.md +30 -30
- package/agents/ms-plan-writer.md +1 -1
- package/agents/ms-product-researcher.md +4 -2
- package/agents/ms-verifier.md +25 -117
- package/commands/ms/add-phase.md +3 -4
- package/commands/ms/add-todo.md +3 -4
- package/commands/ms/adhoc.md +3 -4
- package/commands/ms/audit-milestone.md +4 -3
- package/commands/ms/complete-milestone.md +2 -2
- package/commands/ms/config.md +36 -9
- package/commands/ms/create-roadmap.md +3 -4
- package/commands/ms/debug.md +27 -28
- package/commands/ms/design-phase.md +8 -5
- package/commands/ms/discuss-phase.md +2 -2
- package/commands/ms/doctor.md +9 -6
- package/commands/ms/execute-phase.md +2 -5
- package/commands/ms/help.md +2 -2
- package/commands/ms/insert-phase.md +3 -4
- package/commands/ms/map-codebase.md +1 -2
- package/commands/ms/new-milestone.md +1 -3
- package/commands/ms/new-project.md +3 -5
- package/commands/ms/plan-milestone-gaps.md +3 -4
- package/commands/ms/plan-phase.md +2 -3
- package/commands/ms/progress.md +1 -0
- package/commands/ms/remove-phase.md +3 -4
- package/commands/ms/research-phase.md +4 -4
- package/commands/ms/research-project.md +9 -16
- package/commands/ms/review-design.md +4 -2
- package/commands/ms/verify-work.md +6 -8
- package/mindsystem/templates/config.json +2 -1
- package/mindsystem/templates/roadmap.md +1 -1
- package/mindsystem/templates/state.md +2 -2
- package/mindsystem/templates/verification-report.md +3 -26
- package/mindsystem/workflows/diagnose-issues.md +0 -1
- package/mindsystem/workflows/discuss-phase.md +7 -3
- package/mindsystem/workflows/execute-phase.md +2 -18
- package/mindsystem/workflows/map-codebase.md +6 -12
- package/mindsystem/workflows/mockup-generation.md +46 -22
- package/mindsystem/workflows/plan-phase.md +12 -5
- package/mindsystem/workflows/verify-work.md +96 -69
- package/package.json +1 -1
- package/scripts/__pycache__/ms-tools.cpython-314.pyc +0 -0
- package/scripts/__pycache__/test_ms_tools.cpython-314-pytest-9.0.2.pyc +0 -0
- package/scripts/ms-tools.py +751 -6
- package/scripts/test_ms_tools.py +786 -0
- package/skills/senior-review/AGENTS.md +531 -0
- package/skills/{flutter-senior-review → senior-review}/SKILL.md +47 -36
- package/skills/senior-review/principles/dependencies-api-boundary-design.md +32 -0
- package/skills/senior-review/principles/dependencies-data-not-flags.md +32 -0
- package/skills/senior-review/principles/dependencies-temporal-coupling.md +32 -0
- package/skills/senior-review/principles/pragmatism-consistent-error-handling.md +32 -0
- package/skills/senior-review/principles/pragmatism-speculative-generality.md +32 -0
- package/skills/senior-review/principles/state-invalid-states.md +33 -0
- package/skills/senior-review/principles/state-single-source-of-truth.md +32 -0
- package/skills/senior-review/principles/state-type-hierarchies.md +32 -0
- package/skills/senior-review/principles/structure-composition-over-config.md +32 -0
- package/skills/senior-review/principles/structure-feature-isolation.md +32 -0
- package/skills/senior-review/principles/structure-module-cohesion.md +32 -0
- package/agents/ms-flutter-code-quality.md +0 -169
- package/agents/ms-flutter-reviewer.md +0 -211
- package/agents/ms-flutter-simplifier.md +0 -79
- package/mindsystem/references/debugging/debugging-mindset.md +0 -11
- package/mindsystem/references/debugging/hypothesis-testing.md +0 -11
- package/mindsystem/references/debugging/investigation-techniques.md +0 -11
- package/mindsystem/references/debugging/verification-patterns.md +0 -11
- package/mindsystem/references/debugging/when-to-research.md +0 -11
- package/mindsystem/references/git-integration.md +0 -254
- package/mindsystem/references/verification-patterns.md +0 -595
- package/mindsystem/workflows/debug.md +0 -14
- package/mindsystem/workflows/verify-phase.md +0 -625
- package/skills/flutter-code-quality/SKILL.md +0 -143
- package/skills/flutter-code-simplification/SKILL.md +0 -102
- package/skills/flutter-senior-review/AGENTS.md +0 -869
- package/skills/flutter-senior-review/principles/dependencies-data-not-callbacks.md +0 -75
- package/skills/flutter-senior-review/principles/dependencies-provider-tree.md +0 -85
- package/skills/flutter-senior-review/principles/dependencies-temporal-coupling.md +0 -97
- package/skills/flutter-senior-review/principles/pragmatism-consistent-error-handling.md +0 -130
- package/skills/flutter-senior-review/principles/pragmatism-speculative-generality.md +0 -91
- package/skills/flutter-senior-review/principles/state-data-clumps.md +0 -64
- package/skills/flutter-senior-review/principles/state-invalid-states.md +0 -53
- package/skills/flutter-senior-review/principles/state-single-source-of-truth.md +0 -68
- package/skills/flutter-senior-review/principles/state-type-hierarchies.md +0 -75
- package/skills/flutter-senior-review/principles/structure-composition-over-config.md +0 -105
- package/skills/flutter-senior-review/principles/structure-shared-visual-patterns.md +0 -107
- package/skills/flutter-senior-review/principles/structure-wrapper-pattern.md +0 -90
package/scripts/ms-tools.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|