mindsystem-cc 4.1.2 → 4.2.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/agents/ms-browser-verifier.md +137 -0
- package/bin/install.js +2 -3
- package/commands/ms/add-todo.md +4 -4
- package/commands/ms/adhoc.md +2 -1
- package/commands/ms/audit-milestone.md +4 -3
- package/commands/ms/complete-milestone.md +2 -3
- package/commands/ms/config.md +72 -23
- package/commands/ms/create-roadmap.md +14 -7
- package/commands/ms/design-phase.md +3 -8
- package/commands/ms/doctor.md +7 -3
- package/commands/ms/execute-phase.md +50 -22
- package/commands/ms/map-codebase.md +1 -2
- package/commands/ms/new-milestone.md +3 -7
- package/commands/ms/new-project.md +6 -9
- package/commands/ms/remove-phase.md +2 -9
- package/commands/ms/research-milestone.md +3 -8
- package/commands/ms/research-phase.md +3 -8
- package/mindsystem/references/browser-verification.md +143 -0
- package/mindsystem/templates/config.json +4 -1
- package/mindsystem/workflows/adhoc.md +14 -15
- package/mindsystem/workflows/complete-milestone.md +1 -1
- package/mindsystem/workflows/compound.md +4 -11
- package/mindsystem/workflows/define-requirements.md +2 -1
- package/mindsystem/workflows/discuss-phase.md +5 -11
- package/mindsystem/workflows/doctor-fixes.md +32 -3
- package/mindsystem/workflows/execute-phase.md +84 -6
- package/mindsystem/workflows/map-codebase.md +4 -1
- package/mindsystem/workflows/verify-work.md +1 -1
- package/package.json +2 -2
- package/scripts/ms-tools.py +448 -52
package/scripts/ms-tools.py
CHANGED
|
@@ -109,13 +109,63 @@ def normalize_phase(phase_str: str) -> str:
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def find_phase_dir(planning: Path, phase: str) -> Path | None:
|
|
112
|
-
"""Find the phase directory matching a normalized phase number.
|
|
112
|
+
"""Find the phase directory matching a normalized phase number.
|
|
113
|
+
|
|
114
|
+
Tries three patterns in priority order:
|
|
115
|
+
1. Canonical padded: {phase}-* (e.g., 05-*)
|
|
116
|
+
2. Unpadded variant: {raw}-* (e.g., 5-*) — only when raw != phase
|
|
117
|
+
3. Bare directory: {phase}/ or {raw}/ — only directories
|
|
118
|
+
"""
|
|
113
119
|
phases_dir = planning / "phases"
|
|
114
120
|
if not phases_dir.is_dir():
|
|
115
121
|
return None
|
|
122
|
+
|
|
123
|
+
# Derive raw (unpadded) form: "05" -> "5", "02.1" -> "2.1"
|
|
124
|
+
raw_match = re.match(r"^0*(\d.*)", phase)
|
|
125
|
+
raw = raw_match.group(1) if raw_match else phase
|
|
126
|
+
|
|
127
|
+
# Tier 1: canonical padded glob
|
|
116
128
|
matches = sorted(phases_dir.glob(f"{phase}-*"))
|
|
117
129
|
dirs = [m for m in matches if m.is_dir()]
|
|
118
|
-
|
|
130
|
+
if dirs:
|
|
131
|
+
return dirs[0]
|
|
132
|
+
|
|
133
|
+
# Tier 2: unpadded variant glob (skip when raw == phase)
|
|
134
|
+
if raw != phase:
|
|
135
|
+
matches = sorted(phases_dir.glob(f"{raw}-*"))
|
|
136
|
+
dirs = [m for m in matches if m.is_dir()]
|
|
137
|
+
if dirs:
|
|
138
|
+
return dirs[0]
|
|
139
|
+
|
|
140
|
+
# Tier 3: bare directory
|
|
141
|
+
bare_padded = phases_dir / phase
|
|
142
|
+
if bare_padded.is_dir():
|
|
143
|
+
return bare_padded
|
|
144
|
+
if raw != phase:
|
|
145
|
+
bare_raw = phases_dir / raw
|
|
146
|
+
if bare_raw.is_dir():
|
|
147
|
+
return bare_raw
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_roadmap_phases(roadmap_path: Path) -> list[tuple[str, str]]:
|
|
153
|
+
"""Parse phase headers from ROADMAP.md.
|
|
154
|
+
|
|
155
|
+
Returns list of (phase_number, phase_name) tuples.
|
|
156
|
+
Strips markers like (INSERTED), (Generated) from names.
|
|
157
|
+
"""
|
|
158
|
+
if not roadmap_path.is_file():
|
|
159
|
+
return []
|
|
160
|
+
text = roadmap_path.read_text(encoding="utf-8")
|
|
161
|
+
results: list[tuple[str, str]] = []
|
|
162
|
+
for line in text.splitlines():
|
|
163
|
+
m = re.match(r"^###\s+Phase\s+(\d+(?:\.\d+)?)\s*:\s*(.+)$", line)
|
|
164
|
+
if m:
|
|
165
|
+
num = m.group(1)
|
|
166
|
+
name = re.sub(r"\s*\([A-Z][A-Za-z]*\)\s*$", "", m.group(2)).strip()
|
|
167
|
+
results.append((num, name))
|
|
168
|
+
return results
|
|
119
169
|
|
|
120
170
|
|
|
121
171
|
def run_git(*args: str) -> str:
|
|
@@ -297,6 +347,80 @@ def build_exclude_pathspecs() -> list[str]:
|
|
|
297
347
|
return [f":!{p}" for p in PATCH_EXCLUSIONS]
|
|
298
348
|
|
|
299
349
|
|
|
350
|
+
# -------------------------------------------------------------------
|
|
351
|
+
# Helper: find_phase_commit_hashes
|
|
352
|
+
# -------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def find_phase_commit_hashes(phase_input: str, suffix: str = "") -> list[str]:
|
|
356
|
+
"""Find commit hashes matching a phase's commit convention.
|
|
357
|
+
|
|
358
|
+
Contract:
|
|
359
|
+
Args: phase_input (str), suffix (str, optional)
|
|
360
|
+
Output: list of commit hash strings (newest first)
|
|
361
|
+
Side effects: none (reads git log only)
|
|
362
|
+
"""
|
|
363
|
+
padded_phase = normalize_phase(phase_input)
|
|
364
|
+
# Strip leading zeros from integer part, preserve decimal: "02.1" -> "2.1"
|
|
365
|
+
m = re.match(r"^(\d+)(.*)", padded_phase)
|
|
366
|
+
raw_phase = str(int(m.group(1))) + m.group(2) if m else padded_phase
|
|
367
|
+
|
|
368
|
+
# Build alternation pattern matching both padded and raw forms
|
|
369
|
+
if padded_phase == raw_phase:
|
|
370
|
+
phase_alt = re.escape(raw_phase)
|
|
371
|
+
else:
|
|
372
|
+
phase_alt = f"(?:0*{re.escape(raw_phase)})"
|
|
373
|
+
|
|
374
|
+
# Build commit pattern based on suffix
|
|
375
|
+
if suffix:
|
|
376
|
+
if suffix == "uat-fixes":
|
|
377
|
+
commit_pattern = f"\\({phase_alt}-uat\\):"
|
|
378
|
+
else:
|
|
379
|
+
commit_pattern = f"\\({phase_alt}-{re.escape(suffix)}\\):"
|
|
380
|
+
else:
|
|
381
|
+
commit_pattern = f"\\({phase_alt}-"
|
|
382
|
+
|
|
383
|
+
# Find matching commits
|
|
384
|
+
try:
|
|
385
|
+
log_output = run_git("log", "--oneline")
|
|
386
|
+
except subprocess.CalledProcessError:
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
hashes = []
|
|
390
|
+
for line in log_output.splitlines():
|
|
391
|
+
if re.search(commit_pattern, line):
|
|
392
|
+
hashes.append(line.split()[0])
|
|
393
|
+
return hashes
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# -------------------------------------------------------------------
|
|
397
|
+
# Subcommand: find-phase-commits
|
|
398
|
+
# -------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cmd_find_phase_commits(args: argparse.Namespace) -> None:
|
|
402
|
+
"""Print commit hashes matching a phase's commit convention.
|
|
403
|
+
|
|
404
|
+
Contract:
|
|
405
|
+
Args: phase (str), --suffix (str, optional)
|
|
406
|
+
Output: text — one commit hash per line (newest first), empty if no matches
|
|
407
|
+
Exit codes: 0 = success (including zero matches), 1 = git error
|
|
408
|
+
Side effects: none
|
|
409
|
+
"""
|
|
410
|
+
git_root = find_git_root()
|
|
411
|
+
import os
|
|
412
|
+
os.chdir(git_root)
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
hashes = find_phase_commit_hashes(args.phase, args.suffix)
|
|
416
|
+
except subprocess.CalledProcessError:
|
|
417
|
+
print("Error: Failed to read git log", file=sys.stderr)
|
|
418
|
+
sys.exit(1)
|
|
419
|
+
|
|
420
|
+
for h in hashes:
|
|
421
|
+
print(h)
|
|
422
|
+
|
|
423
|
+
|
|
300
424
|
# ===================================================================
|
|
301
425
|
# Subcommand: update-state
|
|
302
426
|
# ===================================================================
|
|
@@ -475,6 +599,166 @@ def cmd_validate_execution_order(args: argparse.Namespace) -> None:
|
|
|
475
599
|
print(f"PASS: {len(disk_plans)} plans across {wave_count} waves")
|
|
476
600
|
|
|
477
601
|
|
|
602
|
+
# ===================================================================
|
|
603
|
+
# Subcommand: browser-check
|
|
604
|
+
# ===================================================================
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _get_claude_config_dir() -> Path:
|
|
608
|
+
"""Cross-platform Claude Code config directory."""
|
|
609
|
+
if sys.platform == "win32":
|
|
610
|
+
appdata = os.environ.get("APPDATA")
|
|
611
|
+
if appdata:
|
|
612
|
+
return Path(appdata) / "Claude"
|
|
613
|
+
return Path.home() / "AppData" / "Roaming" / "Claude"
|
|
614
|
+
return Path.home() / ".claude"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
WEB_FRAMEWORK_DEPS = {
|
|
618
|
+
"react", "react-dom", "vue", "next", "nuxt", "@angular/core",
|
|
619
|
+
"svelte", "@sveltejs/kit", "solid-js", "astro", "@remix-run/react",
|
|
620
|
+
"gatsby", "preact",
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
WEB_CONFIG_FILES = [
|
|
624
|
+
"next.config.*", "nuxt.config.*", "vite.config.*", "angular.json",
|
|
625
|
+
"svelte.config.*", "astro.config.*", "remix.config.*",
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _detect_web_project(git_root: Path) -> tuple[bool, str]:
|
|
630
|
+
"""Detect if project is a web project. Returns (is_web, signal_description)."""
|
|
631
|
+
# Signal 1: package.json deps
|
|
632
|
+
pkg = git_root / "package.json"
|
|
633
|
+
if pkg.is_file():
|
|
634
|
+
try:
|
|
635
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
636
|
+
all_deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
637
|
+
found = WEB_FRAMEWORK_DEPS & set(all_deps.keys())
|
|
638
|
+
if found:
|
|
639
|
+
return True, f"{', '.join(sorted(found))} in package.json"
|
|
640
|
+
except (json.JSONDecodeError, OSError):
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
# Signal 2: Framework config files (glob at root)
|
|
644
|
+
for pattern in WEB_CONFIG_FILES:
|
|
645
|
+
if list(git_root.glob(pattern)):
|
|
646
|
+
return True, f"{pattern} found"
|
|
647
|
+
|
|
648
|
+
# Signal 3: One level deep package.json (monorepo)
|
|
649
|
+
for child_pkg in git_root.glob("*/package.json"):
|
|
650
|
+
if "node_modules" in str(child_pkg):
|
|
651
|
+
continue
|
|
652
|
+
try:
|
|
653
|
+
data = json.loads(child_pkg.read_text(encoding="utf-8"))
|
|
654
|
+
all_deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
655
|
+
found = WEB_FRAMEWORK_DEPS & set(all_deps.keys())
|
|
656
|
+
if found:
|
|
657
|
+
return True, f"{', '.join(sorted(found))} in {child_pkg.relative_to(git_root)}"
|
|
658
|
+
except (json.JSONDecodeError, OSError):
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
# Signal 4: PROJECT.md tech stack mentions
|
|
662
|
+
project_md = git_root / ".planning" / "PROJECT.md"
|
|
663
|
+
if project_md.is_file():
|
|
664
|
+
try:
|
|
665
|
+
content = project_md.read_text(encoding="utf-8").lower()
|
|
666
|
+
web_keywords = ["react", "vue", "next.js", "nextjs", "nuxt", "angular", "svelte", "sveltekit", "astro", "remix"]
|
|
667
|
+
for kw in web_keywords:
|
|
668
|
+
if kw in content:
|
|
669
|
+
return True, f"'{kw}' mentioned in PROJECT.md"
|
|
670
|
+
except OSError:
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
return False, "no web framework in package.json, no framework config files, no web stack in PROJECT.md"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _check_skill_installed(skill_name: str) -> tuple[bool, str]:
|
|
677
|
+
"""Check if a Claude Code skill is installed. Cross-platform."""
|
|
678
|
+
config_dir = _get_claude_config_dir()
|
|
679
|
+
locations = [
|
|
680
|
+
("user", config_dir / "skills" / skill_name / "SKILL.md"),
|
|
681
|
+
("project", Path.cwd() / ".claude" / "skills" / skill_name / "SKILL.md"),
|
|
682
|
+
]
|
|
683
|
+
for label, path in locations:
|
|
684
|
+
if path.is_file():
|
|
685
|
+
return True, f"{label}: {path}"
|
|
686
|
+
return False, "not found in ~/.claude/skills/ or .claude/skills/"
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def cmd_browser_check(args: argparse.Namespace) -> None:
|
|
690
|
+
"""Check browser verification prerequisites.
|
|
691
|
+
|
|
692
|
+
Contract:
|
|
693
|
+
Output: text — section-based status report
|
|
694
|
+
Exit codes: 0 = READY, 1 = MISSING_DEPS, 2 = SKIP
|
|
695
|
+
Side effects: read-only
|
|
696
|
+
"""
|
|
697
|
+
git_root = find_git_root()
|
|
698
|
+
planning = git_root / ".planning"
|
|
699
|
+
|
|
700
|
+
# 1. Config check
|
|
701
|
+
print("=== Browser Verification Config ===")
|
|
702
|
+
enabled = True # default
|
|
703
|
+
config_path = planning / "config.json" if planning.is_dir() else None
|
|
704
|
+
if config_path and config_path.is_file():
|
|
705
|
+
try:
|
|
706
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
707
|
+
bv = config.get("browser_verification", {})
|
|
708
|
+
if isinstance(bv, dict):
|
|
709
|
+
enabled = bv.get("enabled", True)
|
|
710
|
+
# else: malformed, default to enabled
|
|
711
|
+
except (json.JSONDecodeError, OSError):
|
|
712
|
+
pass # malformed config, default to enabled
|
|
713
|
+
print(f"Status: {'enabled' if enabled else 'disabled'}")
|
|
714
|
+
|
|
715
|
+
if not enabled:
|
|
716
|
+
print("\nResult: SKIP (disabled in config)")
|
|
717
|
+
sys.exit(2)
|
|
718
|
+
|
|
719
|
+
# 2. Web project check
|
|
720
|
+
print("\n=== Web Project Detection ===")
|
|
721
|
+
is_web, signal = _detect_web_project(git_root)
|
|
722
|
+
print(f"Web project: {is_web}")
|
|
723
|
+
print(f"Signal: {signal}")
|
|
724
|
+
|
|
725
|
+
if not is_web:
|
|
726
|
+
print("\nResult: SKIP (not a web project)")
|
|
727
|
+
sys.exit(2)
|
|
728
|
+
|
|
729
|
+
# 3. CLI check
|
|
730
|
+
print("\n=== Browser CLI ===")
|
|
731
|
+
cli_path = shutil.which("agent-browser")
|
|
732
|
+
if cli_path:
|
|
733
|
+
print(f"agent-browser: {cli_path}")
|
|
734
|
+
else:
|
|
735
|
+
print("agent-browser: not found")
|
|
736
|
+
print("Install: npm install -g agent-browser")
|
|
737
|
+
|
|
738
|
+
# 4. Skill check
|
|
739
|
+
print("\n=== Browser Skill ===")
|
|
740
|
+
skill_ok, skill_detail = _check_skill_installed("agent-browser")
|
|
741
|
+
if skill_ok:
|
|
742
|
+
print(f"agent-browser skill: {skill_detail}")
|
|
743
|
+
else:
|
|
744
|
+
print(f"agent-browser skill: {skill_detail}")
|
|
745
|
+
print("Install: add agent-browser skill to ~/.claude/skills/")
|
|
746
|
+
|
|
747
|
+
# Result
|
|
748
|
+
missing: list[str] = []
|
|
749
|
+
if not cli_path:
|
|
750
|
+
missing.append("agent-browser CLI")
|
|
751
|
+
if not skill_ok:
|
|
752
|
+
missing.append("agent-browser skill")
|
|
753
|
+
|
|
754
|
+
if missing:
|
|
755
|
+
print(f"\nResult: MISSING_DEPS ({', '.join(missing)})")
|
|
756
|
+
sys.exit(1)
|
|
757
|
+
|
|
758
|
+
print("\nResult: READY")
|
|
759
|
+
sys.exit(0)
|
|
760
|
+
|
|
761
|
+
|
|
478
762
|
# ===================================================================
|
|
479
763
|
# Subcommand: doctor-scan
|
|
480
764
|
# ===================================================================
|
|
@@ -530,12 +814,6 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
|
|
|
530
814
|
else:
|
|
531
815
|
skip_count += 1
|
|
532
816
|
|
|
533
|
-
def format_phase_prefix(phase: str) -> str:
|
|
534
|
-
if "." in phase:
|
|
535
|
-
int_part, dec_part = phase.split(".", 1)
|
|
536
|
-
return f"{int(int_part):02d}.{dec_part}"
|
|
537
|
-
return f"{int(phase):02d}"
|
|
538
|
-
|
|
539
817
|
def parse_phase_numbers(line: str) -> list[str]:
|
|
540
818
|
"""Parse phase numbers from a 'Phases completed' line."""
|
|
541
819
|
range_match = re.search(r"(\d+)-(\d+)", line)
|
|
@@ -629,7 +907,7 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
|
|
|
629
907
|
orphans: list[str] = []
|
|
630
908
|
for line in phase_lines:
|
|
631
909
|
for phase_num in parse_phase_numbers(line):
|
|
632
|
-
prefix =
|
|
910
|
+
prefix = normalize_phase(phase_num)
|
|
633
911
|
if phases_dir.is_dir():
|
|
634
912
|
for d in phases_dir.glob(f"{prefix}-*/"):
|
|
635
913
|
if d.is_dir():
|
|
@@ -728,7 +1006,7 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
|
|
|
728
1006
|
leftovers: list[str] = []
|
|
729
1007
|
for line in phase_lines:
|
|
730
1008
|
for phase_num in parse_phase_numbers(line):
|
|
731
|
-
prefix =
|
|
1009
|
+
prefix = normalize_phase(phase_num)
|
|
732
1010
|
if phases_dir.is_dir():
|
|
733
1011
|
for d in phases_dir.glob(f"{prefix}-*/"):
|
|
734
1012
|
if d.is_dir():
|
|
@@ -877,6 +1155,84 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
|
|
|
877
1155
|
record("WARN", "Research API Keys")
|
|
878
1156
|
print()
|
|
879
1157
|
|
|
1158
|
+
# ---- CHECK 10: Phase Directory Naming ----
|
|
1159
|
+
print("=== Phase Directory Naming ===")
|
|
1160
|
+
roadmap_path = planning / "ROADMAP.md"
|
|
1161
|
+
roadmap_phases = parse_roadmap_phases(roadmap_path)
|
|
1162
|
+
if not roadmap_phases:
|
|
1163
|
+
print("Status: SKIP")
|
|
1164
|
+
print("No ROADMAP.md or no phases found")
|
|
1165
|
+
record("SKIP", "Phase Directory Naming")
|
|
1166
|
+
else:
|
|
1167
|
+
non_canonical: list[str] = []
|
|
1168
|
+
missing_dirs: list[str] = []
|
|
1169
|
+
for num, name in roadmap_phases:
|
|
1170
|
+
padded = normalize_phase(num)
|
|
1171
|
+
slug = slugify(name)
|
|
1172
|
+
canonical = f"{padded}-{slug}"
|
|
1173
|
+
canonical_path = phases_dir / canonical if phases_dir.is_dir() else None
|
|
1174
|
+
if canonical_path and canonical_path.is_dir():
|
|
1175
|
+
continue
|
|
1176
|
+
found = find_phase_dir(planning, padded)
|
|
1177
|
+
if found is not None:
|
|
1178
|
+
non_canonical.append(f" {found.name} → git mv .planning/phases/{found.name} .planning/phases/{canonical}")
|
|
1179
|
+
else:
|
|
1180
|
+
missing_dirs.append(f" {canonical} (missing, run: ms-tools create-phase-dirs)")
|
|
1181
|
+
if non_canonical:
|
|
1182
|
+
print("Status: FAIL")
|
|
1183
|
+
print(f"Found {len(non_canonical)} non-canonical phase directory name(s):")
|
|
1184
|
+
for line in non_canonical:
|
|
1185
|
+
print(line)
|
|
1186
|
+
record("FAIL", "Phase Directory Naming")
|
|
1187
|
+
elif missing_dirs:
|
|
1188
|
+
print("Status: WARN")
|
|
1189
|
+
print(f"Found {len(missing_dirs)} missing phase directory(ies):")
|
|
1190
|
+
for line in missing_dirs:
|
|
1191
|
+
print(line)
|
|
1192
|
+
record("WARN", "Phase Directory Naming")
|
|
1193
|
+
else:
|
|
1194
|
+
print("Status: PASS")
|
|
1195
|
+
print("All phase directories use canonical naming")
|
|
1196
|
+
record("PASS", "Phase Directory Naming")
|
|
1197
|
+
print()
|
|
1198
|
+
|
|
1199
|
+
# ---- CHECK 11: Browser Verification ----
|
|
1200
|
+
print("=== Browser Verification ===")
|
|
1201
|
+
bv_enabled = True
|
|
1202
|
+
bv_config = config.get("browser_verification", {})
|
|
1203
|
+
if isinstance(bv_config, dict):
|
|
1204
|
+
bv_enabled = bv_config.get("enabled", True)
|
|
1205
|
+
|
|
1206
|
+
if not bv_enabled:
|
|
1207
|
+
print("Status: SKIP")
|
|
1208
|
+
print("Disabled in config.json")
|
|
1209
|
+
record("SKIP", "Browser Verification")
|
|
1210
|
+
else:
|
|
1211
|
+
is_web, web_signal = _detect_web_project(git_root)
|
|
1212
|
+
if not is_web:
|
|
1213
|
+
print("Status: SKIP")
|
|
1214
|
+
print(f"Not a web project ({web_signal})")
|
|
1215
|
+
record("SKIP", "Browser Verification")
|
|
1216
|
+
else:
|
|
1217
|
+
bv_missing: list[str] = []
|
|
1218
|
+
cli_path = shutil.which("agent-browser")
|
|
1219
|
+
if not cli_path:
|
|
1220
|
+
bv_missing.append("agent-browser CLI (npm install -g agent-browser)")
|
|
1221
|
+
skill_ok, _ = _check_skill_installed("agent-browser")
|
|
1222
|
+
if not skill_ok:
|
|
1223
|
+
bv_missing.append("agent-browser skill")
|
|
1224
|
+
if bv_missing:
|
|
1225
|
+
print("Status: WARN")
|
|
1226
|
+
print(f"Web project detected ({web_signal}) but missing:")
|
|
1227
|
+
for m in bv_missing:
|
|
1228
|
+
print(f" {m}")
|
|
1229
|
+
record("WARN", "Browser Verification")
|
|
1230
|
+
else:
|
|
1231
|
+
print("Status: PASS")
|
|
1232
|
+
print(f"Web project ({web_signal}), CLI and skill installed")
|
|
1233
|
+
record("PASS", "Browser Verification")
|
|
1234
|
+
print()
|
|
1235
|
+
|
|
880
1236
|
# ---- SUMMARY ----
|
|
881
1237
|
total = pass_count + warn_count + fail_count + skip_count
|
|
882
1238
|
print("=== Summary ===")
|
|
@@ -890,6 +1246,44 @@ def cmd_doctor_scan(args: argparse.Namespace) -> None:
|
|
|
890
1246
|
print("All checks passed")
|
|
891
1247
|
|
|
892
1248
|
|
|
1249
|
+
# ===================================================================
|
|
1250
|
+
# Subcommand: create-phase-dirs
|
|
1251
|
+
# ===================================================================
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def cmd_create_phase_dirs(args: argparse.Namespace) -> None:
|
|
1255
|
+
"""Create phase directories from ROADMAP.md headers.
|
|
1256
|
+
|
|
1257
|
+
Reads ### Phase N: Name headers, creates .planning/phases/{padded}-{slug}/
|
|
1258
|
+
for each. Skips phases that already have a directory (found via resilient
|
|
1259
|
+
find_phase_dir).
|
|
1260
|
+
"""
|
|
1261
|
+
planning = find_planning_dir()
|
|
1262
|
+
roadmap_path = planning / "ROADMAP.md"
|
|
1263
|
+
phases = parse_roadmap_phases(roadmap_path)
|
|
1264
|
+
if not phases:
|
|
1265
|
+
print("Error: No phases found in ROADMAP.md (or file missing)", file=sys.stderr)
|
|
1266
|
+
sys.exit(1)
|
|
1267
|
+
|
|
1268
|
+
phases_dir = planning / "phases"
|
|
1269
|
+
phases_dir.mkdir(parents=True, exist_ok=True)
|
|
1270
|
+
|
|
1271
|
+
created = 0
|
|
1272
|
+
skipped = 0
|
|
1273
|
+
for num, name in phases:
|
|
1274
|
+
padded = normalize_phase(num)
|
|
1275
|
+
if find_phase_dir(planning, padded) is not None:
|
|
1276
|
+
skipped += 1
|
|
1277
|
+
continue
|
|
1278
|
+
slug = slugify(name)
|
|
1279
|
+
dir_name = f"{padded}-{slug}"
|
|
1280
|
+
(phases_dir / dir_name).mkdir(parents=True, exist_ok=True)
|
|
1281
|
+
print(f"Created: {dir_name}")
|
|
1282
|
+
created += 1
|
|
1283
|
+
|
|
1284
|
+
print(f"\n{created} created, {skipped} skipped (already exist)")
|
|
1285
|
+
|
|
1286
|
+
|
|
893
1287
|
# ===================================================================
|
|
894
1288
|
# Subcommand: gather-milestone-stats
|
|
895
1289
|
# ===================================================================
|
|
@@ -982,28 +1376,32 @@ def cmd_gather_milestone_stats(args: argparse.Namespace) -> None:
|
|
|
982
1376
|
|
|
983
1377
|
all_commits: list[str] = []
|
|
984
1378
|
|
|
985
|
-
# Integer phases
|
|
1379
|
+
# Integer phases — try both padded and raw formats
|
|
986
1380
|
for i in range(start, end + 1):
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1381
|
+
padded = normalize_phase(str(i))
|
|
1382
|
+
raw = str(i)
|
|
1383
|
+
for p in ([padded, raw] if padded != raw else [padded]):
|
|
1384
|
+
try:
|
|
1385
|
+
out = run_git("log", "--all", "--format=%H %ai %s", f"--grep=({p}-")
|
|
1386
|
+
if out:
|
|
1387
|
+
all_commits.extend(out.splitlines())
|
|
1388
|
+
except subprocess.CalledProcessError:
|
|
1389
|
+
pass
|
|
994
1390
|
|
|
995
|
-
# Decimal phases
|
|
1391
|
+
# Decimal phases — try both directory-derived and padded forms
|
|
996
1392
|
for d in sorted(phases_dir.iterdir()):
|
|
997
1393
|
if not d.is_dir():
|
|
998
1394
|
continue
|
|
999
1395
|
phase_num = d.name.split("-", 1)[0]
|
|
1000
1396
|
if "." in phase_num and in_range(phase_num, start, end):
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1397
|
+
padded = normalize_phase(phase_num)
|
|
1398
|
+
for p in ([padded, phase_num] if padded != phase_num else [padded]):
|
|
1399
|
+
try:
|
|
1400
|
+
out = run_git("log", "--all", "--format=%H %ai %s", f"--grep=({p}-")
|
|
1401
|
+
if out:
|
|
1402
|
+
all_commits.extend(out.splitlines())
|
|
1403
|
+
except subprocess.CalledProcessError:
|
|
1404
|
+
pass
|
|
1007
1405
|
|
|
1008
1406
|
# Deduplicate and sort by date
|
|
1009
1407
|
seen: set[str] = set()
|
|
@@ -1071,38 +1469,24 @@ def cmd_generate_phase_patch(args: argparse.Namespace) -> None:
|
|
|
1071
1469
|
import os
|
|
1072
1470
|
os.chdir(git_root)
|
|
1073
1471
|
|
|
1074
|
-
|
|
1075
|
-
if re.match(r"^\d$", phase_input):
|
|
1076
|
-
phase_number = f"{int(phase_input):02d}"
|
|
1077
|
-
else:
|
|
1078
|
-
phase_number = phase_input
|
|
1472
|
+
padded_phase = normalize_phase(phase_input)
|
|
1079
1473
|
|
|
1080
|
-
# Determine commit pattern
|
|
1081
1474
|
if suffix:
|
|
1082
1475
|
if suffix == "uat-fixes":
|
|
1083
|
-
|
|
1084
|
-
print(f"Generating UAT fixes patch for phase {phase_number}...")
|
|
1476
|
+
print(f"Generating UAT fixes patch for phase {padded_phase}...")
|
|
1085
1477
|
else:
|
|
1086
|
-
|
|
1087
|
-
print(f"Generating {suffix} patch for phase {phase_number}...")
|
|
1478
|
+
print(f"Generating {suffix} patch for phase {padded_phase}...")
|
|
1088
1479
|
else:
|
|
1089
|
-
|
|
1090
|
-
print(f"Generating patch for phase {phase_number}...")
|
|
1480
|
+
print(f"Generating patch for phase {padded_phase}...")
|
|
1091
1481
|
|
|
1092
|
-
# Find matching commits
|
|
1093
1482
|
try:
|
|
1094
|
-
|
|
1483
|
+
phase_commits = find_phase_commit_hashes(phase_input, suffix)
|
|
1095
1484
|
except subprocess.CalledProcessError:
|
|
1096
1485
|
print("Error: Failed to read git log", file=sys.stderr)
|
|
1097
1486
|
sys.exit(1)
|
|
1098
1487
|
|
|
1099
|
-
phase_commits = []
|
|
1100
|
-
for line in log_output.splitlines():
|
|
1101
|
-
if re.search(commit_pattern, line):
|
|
1102
|
-
phase_commits.append(line.split()[0])
|
|
1103
|
-
|
|
1104
1488
|
if not phase_commits:
|
|
1105
|
-
print(
|
|
1489
|
+
print("No commits found matching phase convention")
|
|
1106
1490
|
print("Patch skipped")
|
|
1107
1491
|
return
|
|
1108
1492
|
|
|
@@ -1120,7 +1504,7 @@ def cmd_generate_phase_patch(args: argparse.Namespace) -> None:
|
|
|
1120
1504
|
|
|
1121
1505
|
# Find output directory
|
|
1122
1506
|
phases_dir = Path(".planning/phases")
|
|
1123
|
-
phase_dir_matches = sorted(phases_dir.glob(f"{
|
|
1507
|
+
phase_dir_matches = sorted(phases_dir.glob(f"{padded_phase}-*")) if phases_dir.is_dir() else []
|
|
1124
1508
|
phase_dir = str(phase_dir_matches[0]) if phase_dir_matches else str(phases_dir)
|
|
1125
1509
|
|
|
1126
1510
|
Path(phase_dir).mkdir(parents=True, exist_ok=True)
|
|
@@ -1128,9 +1512,9 @@ def cmd_generate_phase_patch(args: argparse.Namespace) -> None:
|
|
|
1128
1512
|
|
|
1129
1513
|
# Determine output filename
|
|
1130
1514
|
if suffix:
|
|
1131
|
-
patch_file = f"{phase_dir}/{
|
|
1515
|
+
patch_file = f"{phase_dir}/{padded_phase}-{suffix}.patch"
|
|
1132
1516
|
else:
|
|
1133
|
-
patch_file = f"{phase_dir}/{
|
|
1517
|
+
patch_file = f"{phase_dir}/{padded_phase}-changes.patch"
|
|
1134
1518
|
|
|
1135
1519
|
# Generate diff
|
|
1136
1520
|
exclude_args = build_exclude_pathspecs()
|
|
@@ -2776,10 +3160,8 @@ def cmd_uat_init(args: argparse.Namespace) -> None:
|
|
|
2776
3160
|
|
|
2777
3161
|
phase_dir = find_phase_dir(planning, phase)
|
|
2778
3162
|
if phase_dir is None:
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
phase_dir = phases_dir / phase
|
|
2782
|
-
phase_dir.mkdir(parents=True, exist_ok=True)
|
|
3163
|
+
print(f"Error: Phase directory not found for {phase}. Run: ms-tools create-phase-dirs", file=sys.stderr)
|
|
3164
|
+
sys.exit(1)
|
|
2783
3165
|
|
|
2784
3166
|
phase_name = phase_dir.name
|
|
2785
3167
|
uat = UATFile.from_init_json(data, phase_name)
|
|
@@ -3234,10 +3616,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3234
3616
|
p.add_argument("phase_dir", help="Phase directory path")
|
|
3235
3617
|
p.set_defaults(func=cmd_validate_execution_order)
|
|
3236
3618
|
|
|
3619
|
+
# --- browser-check ---
|
|
3620
|
+
p = subparsers.add_parser("browser-check", help="Check browser verification prerequisites")
|
|
3621
|
+
p.set_defaults(func=cmd_browser_check)
|
|
3622
|
+
|
|
3237
3623
|
# --- doctor-scan ---
|
|
3238
3624
|
p = subparsers.add_parser("doctor-scan", help="Diagnostic scan of .planning/ tree")
|
|
3239
3625
|
p.set_defaults(func=cmd_doctor_scan)
|
|
3240
3626
|
|
|
3627
|
+
# --- create-phase-dirs ---
|
|
3628
|
+
p = subparsers.add_parser("create-phase-dirs", help="Create phase directories from ROADMAP.md")
|
|
3629
|
+
p.set_defaults(func=cmd_create_phase_dirs)
|
|
3630
|
+
|
|
3241
3631
|
# --- gather-milestone-stats ---
|
|
3242
3632
|
p = subparsers.add_parser("gather-milestone-stats", help="Gather milestone readiness and git statistics")
|
|
3243
3633
|
p.add_argument("start_phase", type=int, help="Start phase number")
|
|
@@ -3250,6 +3640,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3250
3640
|
p.add_argument("--suffix", default="", help="Filter commits and customize output filename")
|
|
3251
3641
|
p.set_defaults(func=cmd_generate_phase_patch)
|
|
3252
3642
|
|
|
3643
|
+
# --- find-phase-commits ---
|
|
3644
|
+
p = subparsers.add_parser("find-phase-commits", help="Find commit hashes matching phase convention")
|
|
3645
|
+
p.add_argument("phase", help="Phase number (e.g., 04 or 4)")
|
|
3646
|
+
p.add_argument("--suffix", default="", help="Filter by suffix (e.g., uat)")
|
|
3647
|
+
p.set_defaults(func=cmd_find_phase_commits)
|
|
3648
|
+
|
|
3253
3649
|
# --- generate-adhoc-patch ---
|
|
3254
3650
|
p = subparsers.add_parser("generate-adhoc-patch", help="Generate patch from an adhoc commit or range")
|
|
3255
3651
|
p.add_argument("commit", help="Start commit hash")
|