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.
@@ -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
- return dirs[0] if dirs else None
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 = format_phase_prefix(phase_num)
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 = format_phase_prefix(phase_num)
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
- phase = f"{i:02d}"
988
- try:
989
- out = run_git("log", "--all", "--format=%H %ai %s", f"--grep=({phase}-")
990
- if out:
991
- all_commits.extend(out.splitlines())
992
- except subprocess.CalledProcessError:
993
- pass
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
- try:
1002
- out = run_git("log", "--all", "--format=%H %ai %s", f"--grep=({phase_num}-")
1003
- if out:
1004
- all_commits.extend(out.splitlines())
1005
- except subprocess.CalledProcessError:
1006
- pass
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
- # Normalize phase number
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
- commit_pattern = f"\\({phase_number}-uat\\):"
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
- commit_pattern = f"\\({phase_number}-{suffix}\\):"
1087
- print(f"Generating {suffix} patch for phase {phase_number}...")
1478
+ print(f"Generating {suffix} patch for phase {padded_phase}...")
1088
1479
  else:
1089
- commit_pattern = f"\\({phase_number}-"
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
- log_output = run_git("log", "--oneline")
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(f"No commits found matching pattern: {commit_pattern}")
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"{phase_number}-*")) if phases_dir.is_dir() else []
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}/{phase_number}-{suffix}.patch"
1515
+ patch_file = f"{phase_dir}/{padded_phase}-{suffix}.patch"
1132
1516
  else:
1133
- patch_file = f"{phase_dir}/{phase_number}-changes.patch"
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
- phases_dir = planning / "phases"
2780
- phases_dir.mkdir(parents=True, exist_ok=True)
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")