@hanzlaa/rcode 3.4.4 → 3.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +63 -1
  3. package/README.md +9 -4
  4. package/cli/generate-command-skills.cjs +21 -9
  5. package/cli/index.js +0 -0
  6. package/cli/install.js +126 -7
  7. package/cli/lib/manifest.cjs +1 -1
  8. package/cli/uninstall.js +8 -0
  9. package/dist/rcode.js +1279 -2004
  10. package/package.json +16 -17
  11. package/rihal/agents/rihal-ahmed.md +2 -1
  12. package/rihal/agents/rihal-code-fixer.md +46 -0
  13. package/rihal/agents/rihal-code-reviewer.md +46 -1
  14. package/rihal/agents/rihal-deviation-analyzer.md +1 -0
  15. package/rihal/agents/rihal-docs-auditor.md +106 -1
  16. package/rihal/agents/rihal-edge-case-hunter.md +47 -1
  17. package/rihal/agents/rihal-executor.md +1 -1
  18. package/rihal/agents/rihal-khalid.md +40 -1
  19. package/rihal/agents/rihal-layla.md +2 -1
  20. package/rihal/agents/rihal-nasser.md +2 -1
  21. package/rihal/agents/rihal-noor.md +3 -2
  22. package/rihal/agents/rihal-nyquist-auditor.md +1 -1
  23. package/rihal/agents/rihal-phase-researcher.md +46 -1
  24. package/rihal/agents/rihal-planner.md +1 -1
  25. package/rihal/agents/rihal-profiler.md +45 -2
  26. package/rihal/agents/rihal-project-researcher.md +47 -0
  27. package/rihal/agents/rihal-remediation-planner.md +45 -0
  28. package/rihal/agents/rihal-roadmapper.md +46 -0
  29. package/rihal/agents/rihal-security-adversary.md +46 -1
  30. package/rihal/agents/rihal-security-auditor.md +45 -1
  31. package/rihal/agents/rihal-ui-auditor.md +44 -1
  32. package/rihal/agents/rihal-ux-designer.md +41 -1
  33. package/rihal/agents/rihal-zahra.md +2 -1
  34. package/rihal/agents/rihal-zayd.md +2 -1
  35. package/rihal/bin/lib/config.cjs +13 -1
  36. package/rihal/bin/lib/council-panel.cjs +185 -23
  37. package/rihal/bin/lib/roadmap.cjs +27 -2
  38. package/rihal/bin/rihal-tools.cjs +1837 -99
  39. package/rihal/commands/audit.md +2 -2
  40. package/rihal/commands/capture.md +12 -0
  41. package/rihal/commands/diagnose-issues.md +18 -0
  42. package/rihal/commands/discuss-phase-power.md +18 -0
  43. package/rihal/commands/feature-drift.md +18 -0
  44. package/rihal/commands/karpathy-audit.md +18 -0
  45. package/rihal/commands/lens-audit.md +70 -0
  46. package/rihal/commands/new-project-research.md +18 -0
  47. package/rihal/commands/new-project-roadmap.md +18 -0
  48. package/rihal/commands/phase.md +11 -0
  49. package/rihal/references/continuation-format.md +3 -3
  50. package/rihal/references/output-format.md +79 -0
  51. package/rihal/references/revision-loop.md +1 -1
  52. package/rihal/references/verb-dictionary.md +85 -28
  53. package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +1 -1
  54. package/rihal/skills/actions/2-plan/rihal-create-epics-and-stories/SKILL.md +12 -2
  55. package/rihal/skills/actions/2-plan/rihal-create-epics-and-stories/steps/step-04-final-validation.md +12 -0
  56. package/rihal/skills/actions/2-plan/rihal-create-prd/SKILL.md +12 -2
  57. package/rihal/skills/actions/2-plan/rihal-create-story/SKILL.md +12 -2
  58. package/rihal/skills/actions/4-implementation/rihal-browser-verify/SKILL.md +1 -1
  59. package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +1 -1
  60. package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +1 -1
  61. package/rihal/skills/actions/4-implementation/rihal-code-review/SKILL.md +16 -4
  62. package/rihal/skills/actions/4-implementation/rihal-debug/SKILL.md +14 -1
  63. package/rihal/skills/actions/4-implementation/rihal-git-flow/SKILL.md +1 -1
  64. package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +1 -1
  65. package/rihal/skills/actions/4-implementation/rihal-incremental/SKILL.md +1 -1
  66. package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +1 -1
  67. package/rihal/skills/actions/4-implementation/rihal-perf/SKILL.md +1 -1
  68. package/rihal/skills/actions/4-implementation/rihal-prove-it/SKILL.md +1 -1
  69. package/rihal/skills/actions/4-implementation/rihal-scaffold-project/steps/step-01-target.md +6 -0
  70. package/rihal/skills/actions/4-implementation/rihal-source-truth/SKILL.md +1 -1
  71. package/rihal/skills/actions/4-implementation/rihal-sprint-planning/SKILL.md +14 -3
  72. package/rihal/skills/actions/4-implementation/rihal-trim/SKILL.md +1 -1
  73. package/rihal/skills/agents/ahmed-hassani-director/SKILL.md +15 -1
  74. package/rihal/skills/agents/dalil-scout/SKILL.md +14 -2
  75. package/rihal/skills/agents/fatima-qa/SKILL.md +16 -1
  76. package/rihal/skills/agents/haitham-frontend/SKILL.md +13 -1
  77. package/rihal/skills/agents/hanzla-engineer/SKILL.md +13 -1
  78. package/rihal/skills/agents/hussain-pm/SKILL.md +16 -1
  79. package/rihal/skills/agents/hussain-sm/SKILL.md +14 -1
  80. package/rihal/skills/agents/layla-designer/SKILL.md +13 -1
  81. package/rihal/skills/agents/majlis-council/SKILL.md +16 -1
  82. package/rihal/skills/agents/mariam-marketing/SKILL.md +14 -1
  83. package/rihal/skills/agents/nasser-eng-manager/SKILL.md +16 -1
  84. package/rihal/skills/agents/noor-writer/SKILL.md +15 -1
  85. package/rihal/skills/agents/raees-orchestrator/SKILL.md +15 -1
  86. package/rihal/skills/agents/rihal-cross-platform-auditor/SKILL.md +162 -0
  87. package/rihal/skills/agents/rihal-dep-auditor/SKILL.md +151 -0
  88. package/rihal/skills/agents/rihal-deviation-analyzer/SKILL.md +78 -0
  89. package/rihal/skills/agents/rihal-i18n-auditor/SKILL.md +152 -0
  90. package/rihal/skills/agents/rihal-observability-auditor/SKILL.md +156 -0
  91. package/rihal/skills/agents/sadiq-analyst/SKILL.md +12 -2
  92. package/rihal/skills/agents/waleed-architect/SKILL.md +12 -2
  93. package/rihal/skills/agents/yousef-backend/SKILL.md +12 -2
  94. package/rihal/skills/agents/zahra-branding/SKILL.md +15 -1
  95. package/rihal/skills/agents/zayd-ml/SKILL.md +13 -1
  96. package/rihal/skills/core/rihal-advanced-elicitation/SKILL.md +2 -2
  97. package/rihal/skills/core/rihal-auth-audit/SKILL.md +1 -1
  98. package/rihal/skills/core/rihal-brainstorming/SKILL.md +13 -2
  99. package/rihal/skills/core/rihal-client-gate/SKILL.md +1 -1
  100. package/rihal/skills/core/rihal-clone-website/SKILL.md +11 -1
  101. package/rihal/skills/core/rihal-deploy-unify/SKILL.md +1 -1
  102. package/rihal/skills/core/rihal-distillator/SKILL.md +2 -2
  103. package/rihal/skills/core/rihal-editorial-review-prose/SKILL.md +1 -1
  104. package/rihal/skills/core/rihal-editorial-review-structure/SKILL.md +2 -2
  105. package/rihal/skills/core/rihal-help/SKILL.md +18 -1
  106. package/rihal/skills/core/rihal-incident-record/SKILL.md +1 -1
  107. package/rihal/skills/core/rihal-index-docs/SKILL.md +1 -1
  108. package/rihal/skills/core/rihal-memory-audit/SKILL.md +18 -1
  109. package/rihal/skills/core/rihal-memory-init/SKILL.md +13 -1
  110. package/rihal/skills/core/rihal-memory-update/SKILL.md +13 -1
  111. package/rihal/skills/core/rihal-mvp-graduate/SKILL.md +1 -1
  112. package/rihal/skills/core/rihal-ocr-consistency/SKILL.md +1 -1
  113. package/rihal/skills/core/rihal-rebrand/SKILL.md +1 -1
  114. package/rihal/skills/core/rihal-review-adversarial-general/SKILL.md +1 -1
  115. package/rihal/skills/core/rihal-review-edge-case-hunter/SKILL.md +17 -1
  116. package/rihal/skills/core/rihal-shard-doc/SKILL.md +1 -1
  117. package/rihal/skills/core/rihal-theme-system/SKILL.md +1 -1
  118. package/rihal/team.yaml +0 -7
  119. package/rihal/templates/RESEARCH.md +84 -0
  120. package/rihal/templates/VALIDATION.md +45 -0
  121. package/rihal/templates/memory/INDEX.md +1 -0
  122. package/rihal/templates/memory/project/design-system.md +128 -0
  123. package/rihal/templates/summary.md +33 -3
  124. package/rihal/workflows/add-tests.md +1 -1
  125. package/rihal/workflows/add-todo.md +6 -0
  126. package/rihal/workflows/analyze-dependencies.md +6 -0
  127. package/rihal/workflows/audit-fix.md +12 -0
  128. package/rihal/workflows/audit-milestone.md +2 -2
  129. package/rihal/workflows/audit.md +23 -14
  130. package/rihal/workflows/autonomous-smart-discuss.md +247 -0
  131. package/rihal/workflows/autonomous.md +54 -267
  132. package/rihal/workflows/capture.md +60 -0
  133. package/rihal/workflows/chain.md +1 -1
  134. package/rihal/workflows/code-review-fix.md +6 -3
  135. package/rihal/workflows/code-review.md +34 -10
  136. package/rihal/workflows/complete-milestone.md +17 -8
  137. package/rihal/workflows/correct-course.md +6 -0
  138. package/rihal/workflows/council.md +37 -23
  139. package/rihal/workflows/create-architecture.md +31 -0
  140. package/rihal/workflows/create-epics-and-stories.md +7 -1
  141. package/rihal/workflows/create-prd.md +25 -0
  142. package/rihal/workflows/dashboard.md +1 -1
  143. package/rihal/workflows/debug.md +8 -0
  144. package/rihal/workflows/decisions.md +1 -1
  145. package/rihal/workflows/diff.md +6 -0
  146. package/rihal/workflows/discuss-phase-discuss-areas.md +271 -0
  147. package/rihal/workflows/discuss-phase.md +27 -266
  148. package/rihal/workflows/do.md +51 -12
  149. package/rihal/workflows/docs-update.md +3 -0
  150. package/rihal/workflows/document-project.md +7 -1
  151. package/rihal/workflows/edit-prd.md +31 -0
  152. package/rihal/workflows/enable-hooks.md +1 -1
  153. package/rihal/workflows/execute-regression-gates.md +131 -0
  154. package/rihal/workflows/execute-sprint.md +31 -2
  155. package/rihal/workflows/execute-verify-phase-goal.md +136 -0
  156. package/rihal/workflows/execute-waves.md +404 -0
  157. package/rihal/workflows/execute.md +101 -642
  158. package/rihal/workflows/feature-drift.md +243 -0
  159. package/rihal/workflows/forensics.md +10 -2
  160. package/rihal/workflows/health.md +65 -16
  161. package/rihal/workflows/help.md +36 -9
  162. package/rihal/workflows/import.md +17 -3
  163. package/rihal/workflows/init.md +20 -10
  164. package/rihal/workflows/install.md +2 -10
  165. package/rihal/workflows/lens-audit.md +689 -0
  166. package/rihal/workflows/map-codebase.md +7 -1
  167. package/rihal/workflows/memory-audit.md +67 -5
  168. package/rihal/workflows/memory-distill.md +10 -0
  169. package/rihal/workflows/memory-init.md +4 -0
  170. package/rihal/workflows/memory-update.md +4 -0
  171. package/rihal/workflows/new-milestone.md +7 -1
  172. package/rihal/workflows/new-project-create-roadmap.md +176 -0
  173. package/rihal/workflows/new-project-define-requirements.md +160 -0
  174. package/rihal/workflows/new-project-research-decision.md +247 -0
  175. package/rihal/workflows/new-project.md +3 -557
  176. package/rihal/workflows/note.md +1 -1
  177. package/rihal/workflows/phase.md +54 -0
  178. package/rihal/workflows/plan-milestone-gaps.md +1 -1
  179. package/rihal/workflows/plan-prd-express.md +108 -0
  180. package/rihal/workflows/plan-research-validation.md +313 -0
  181. package/rihal/workflows/plan-spawn-planner.md +204 -0
  182. package/rihal/workflows/plan.md +91 -532
  183. package/rihal/workflows/plant-seed.md +1 -1
  184. package/rihal/workflows/pr-branch.md +1 -1
  185. package/rihal/workflows/profile-user.md +1 -1
  186. package/rihal/workflows/quick.md +3 -3
  187. package/rihal/workflows/remove-phase.md +6 -1
  188. package/rihal/workflows/remove-workspace.md +6 -0
  189. package/rihal/workflows/rerun.md +1 -1
  190. package/rihal/workflows/research-phase.md +4 -2
  191. package/rihal/workflows/resume-work.md +8 -3
  192. package/rihal/workflows/retrospective.md +31 -0
  193. package/rihal/workflows/review-adversarial.md +12 -0
  194. package/rihal/workflows/review.md +6 -0
  195. package/rihal/workflows/scaffold-project.md +31 -0
  196. package/rihal/workflows/scan.md +10 -0
  197. package/rihal/workflows/secure-phase.md +15 -2
  198. package/rihal/workflows/session-report.md +32 -7
  199. package/rihal/workflows/ship.md +7 -2
  200. package/rihal/workflows/show.md +6 -0
  201. package/rihal/workflows/sprint-status.md +4 -4
  202. package/rihal/workflows/status.md +2 -2
  203. package/rihal/workflows/ui-phase.md +1 -1
  204. package/rihal/workflows/undo.md +2 -3
  205. package/rihal/workflows/update.md +2 -2
  206. package/rihal/workflows/validate-phase.md +1 -1
  207. package/rihal/workflows/validate-prd.md +31 -0
  208. package/rihal/workflows/verify-phase.md +38 -5
  209. package/rihal/workflows/verify-work.md +25 -11
  210. package/rihal/workflows/workstream.md +20 -8
  211. package/server/lib/html/client.js +13 -63
  212. package/server/lib/html/shell.js +0 -1
  213. package/server/lib/scanner.js +33 -2
@@ -43,6 +43,42 @@ const WORKFLOWS_DIR = path.join(RIHAL_DIR, 'workflows');
43
43
  const PLANNING_DIR = path.join(PROJECT_ROOT, '.planning');
44
44
  const SESSIONS_DIR = path.join(PLANNING_DIR, 'council-sessions');
45
45
 
46
+ // #473 guard: if CWD has its own .rihal/ but doesn't match the resolved
47
+ // PROJECT_ROOT, the user is invoking this binary from a different project.
48
+ // Without this guard, every state-writing subcommand silently targets the
49
+ // installer's repo instead of the user's CWD — surfaced during Phase 12
50
+ // smoke tests when `phase add` polluted the rihal-code repo's ROADMAP
51
+ // while running from /tmp. Refuse to operate with a clear error.
52
+ function assertCwdMatchesProjectRoot() {
53
+ try {
54
+ const cwd = process.cwd();
55
+ const cwdRihal = path.join(cwd, '.rihal');
56
+ if (!fs.existsSync(cwdRihal)) return; // no local install — fine
57
+ if (path.resolve(cwd) === path.resolve(PROJECT_ROOT)) return; // same project — fine
58
+ // CWD has its own .rihal/ but is NOT this binary's project. Refuse.
59
+ process.stderr.write(
60
+ `Refusing to operate: this binary lives at ${path.dirname(__dirname)}/bin/ ` +
61
+ `but CWD ${cwd} has its own .rihal/ — running from here would silently ` +
62
+ `target the wrong project (#473). Use the CWD's installed CLI: ` +
63
+ `node "${cwdRihal}/bin/rihal-tools.cjs" <args>\n`
64
+ );
65
+ process.exit(2);
66
+ } catch { /* never crash startup on diagnostic logic */ }
67
+ }
68
+
69
+ /**
70
+ * Return the first file in `dir` matching `pattern`, or null.
71
+ * Used by cmdInit's phase-aware fields branch (Phase 10 / #466) to resolve
72
+ * specific artifact paths (CONTEXT.md, RESEARCH.md, VERIFICATION.md) from
73
+ * a phase directory that may use either zero-padded (06-name) or plain
74
+ * (6-name) prefix conventions.
75
+ */
76
+ function files0(dir, pattern) {
77
+ if (!fs.existsSync(dir)) return null;
78
+ const matches = fs.readdirSync(dir).filter(f => pattern.test(f));
79
+ return matches.length > 0 ? matches[0] : null;
80
+ }
81
+
46
82
  /**
47
83
  * Parse a minimal YAML subset for our flat config.yaml shape.
48
84
  * Only supports `key: value` lines — no nesting, no lists, no flow syntax.
@@ -87,6 +123,62 @@ function readConfig() {
87
123
  }
88
124
  }
89
125
 
126
+ /**
127
+ * Read .rihal/config.yaml as a nested object (workflow.*, features.*, etc.).
128
+ * Phase 12 / #468 — used by cmdInit to surface workflow feature flags into
129
+ * the init JSON so workflow agents don't re-shell config-get per field.
130
+ * Returns {} when config absent or unreadable.
131
+ */
132
+ function readNestedConfig() {
133
+ try {
134
+ const configPath = path.join(RIHAL_DIR, 'config.yaml');
135
+ if (!fs.existsSync(configPath)) return {};
136
+ const cfg = require(path.join(__dirname, 'lib', 'config.cjs'));
137
+ return cfg.parseNestedYaml(fs.readFileSync(configPath, 'utf8')) || {};
138
+ } catch { return {}; }
139
+ }
140
+
141
+ /**
142
+ * Resolve the model string for an agent under the current profile.
143
+ * Phase 12 / #468 — returns just the model id (or null when the agent isn't
144
+ * installed or the profile inherits). Wraps cmdResolveModel so cmdInit can
145
+ * surface researcher_model / planner_model / checker_model without throwing
146
+ * when an agent isn't shipped.
147
+ */
148
+ function resolveModelString(agentId) {
149
+ try {
150
+ const installed = listInstalledAgents();
151
+ // Manifest ids are stored bare (e.g. "planner") while workflows reference
152
+ // them with the rihal- prefix. Try both forms before giving up.
153
+ const bare = agentId.replace(/^rihal-/, '');
154
+ const candidate = installed.includes(agentId) ? agentId
155
+ : installed.includes(bare) ? bare
156
+ : null;
157
+ if (!candidate) return null;
158
+ const r = cmdResolveModel(candidate);
159
+ return (r && r.model) ? r.model : null;
160
+ } catch { return null; }
161
+ }
162
+
163
+ /**
164
+ * Extract REQ-IDs (REQ-FOO, REQ-FOO-BAR) from a ROADMAP requirements list.
165
+ * Phase 12 / #468 — feeds plan.md's `phase_req_ids` field. Returns deduped
166
+ * array in source order. Empty array when no IDs match.
167
+ */
168
+ function extractReqIds(requirements) {
169
+ if (!Array.isArray(requirements) || requirements.length === 0) return [];
170
+ const seen = new Set();
171
+ const out = [];
172
+ const re = /\bREQ-[A-Z0-9][A-Z0-9-]*\b/g;
173
+ for (const line of requirements) {
174
+ const matches = String(line).match(re) || [];
175
+ for (const m of matches) {
176
+ if (!seen.has(m)) { seen.add(m); out.push(m); }
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
90
182
  /**
91
183
  * Parse CSV with quoted-field support. Expects the first row to be headers.
92
184
  * Returns array of objects keyed by header.
@@ -327,6 +419,155 @@ function cmdInit(workflowName, rawArgs) {
327
419
  state_exists: fs.existsSync(path.join(RIHAL_DIR, 'state.json')),
328
420
  };
329
421
 
422
+ // Phase 10 / #466 — phase-aware fields for phase-op + sprint-plan workflows.
423
+ // Closes the third part of #464 (workflows expect these fields per their
424
+ // documented init contract; before this they were silently absent).
425
+ if ((workflowName === 'phase-op' || workflowName === 'sprint-plan') && question) {
426
+ const phaseInput = question.trim().split(/\s+/)[0];
427
+ const phaseNum = parseInt(phaseInput, 10);
428
+ if (!Number.isNaN(phaseNum) && phaseNum > 0) {
429
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
430
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
431
+ out.roadmap_exists = fs.existsSync(roadmapPath);
432
+ out.planning_exists = fs.existsSync(PLANNING_DIR);
433
+
434
+ // Find phase entry in ROADMAP via the now-fixed parser.
435
+ let roadmapPhase = null;
436
+ if (out.roadmap_exists) {
437
+ try {
438
+ const roadmap = require(path.join(__dirname, 'lib', 'roadmap.cjs'));
439
+ const r = roadmap.dispatch(PROJECT_ROOT, ['get-phase', String(phaseNum)]);
440
+ if (r && r.found) roadmapPhase = r;
441
+ } catch { /* parser failure shouldn't break init */ }
442
+ }
443
+
444
+ // Find phase directory on disk (matches both '6-name' and legacy '06-name').
445
+ let phaseDirEntry = null;
446
+ if (fs.existsSync(phasesDir)) {
447
+ const padded = String(phaseNum).padStart(2, '0');
448
+ for (const entry of fs.readdirSync(phasesDir)) {
449
+ if (entry === String(phaseNum) || entry.startsWith(`${phaseNum}-`) || entry.startsWith(`${padded}-`)) {
450
+ phaseDirEntry = entry;
451
+ break;
452
+ }
453
+ }
454
+ }
455
+
456
+ out.phase_found = roadmapPhase !== null;
457
+ out.phase_number = String(phaseNum);
458
+ out.padded_phase = String(phaseNum).padStart(2, '0');
459
+ out.phase_name = roadmapPhase ? roadmapPhase.name : null;
460
+ out.phase_slug = phaseDirEntry ? phaseDirEntry.replace(/^\d+-/, '') : null;
461
+ out.phase_dir = phaseDirEntry ? path.join(PLANNING_DIR, 'phases', phaseDirEntry) : null;
462
+
463
+ // Phase status from state.json (complete/executed/in_progress/planned/null).
464
+ // Used by plan.md to show context-aware messaging when plans already exist.
465
+ try {
466
+ const stateFilePath = path.join(RIHAL_DIR, 'state.json');
467
+ const rawState = fs.existsSync(stateFilePath)
468
+ ? JSON.parse(fs.readFileSync(stateFilePath, 'utf8'))
469
+ : null;
470
+ const stPhase = (rawState?.phases || []).find(p => {
471
+ const k = String(p.id || p.number || '').replace(/^0+/, '') || String(p.id || p.number || '');
472
+ return k === String(phaseNum);
473
+ });
474
+ out.phase_status = stPhase ? (stPhase.status || null) : null;
475
+ } catch { out.phase_status = null; }
476
+
477
+ // Disk artifacts — same shape as walkPhaseDirs() but inlined.
478
+ if (phaseDirEntry) {
479
+ const dirFull = path.join(phasesDir, phaseDirEntry);
480
+ const files = fs.readdirSync(dirFull);
481
+ out.has_research = files.some(f => /(?:^|-)RESEARCH\.md$/i.test(f));
482
+ out.has_context = files.some(f => /(?:^|-)CONTEXT\.md$/i.test(f));
483
+ out.has_verification = files.some(f => /(?:^|-)VERIFICATION\.md$/i.test(f));
484
+ out.has_plans = files.some(f => /(?:^|-)(PLAN|SPRINT)\.md$/i.test(f));
485
+ out.plan_count = files.filter(f => /(?:^|-)(PLAN|SPRINT)\.md$/i.test(f)).length;
486
+ // Phase 12 / #468 — REVIEWS.md + UAT.md presence (referenced by plan.md).
487
+ out.has_reviews = files.some(f => /(?:^|-)REVIEWS\.md$/i.test(f));
488
+ out.has_uat = files.some(f => /(?:^|-)UAT\.md$/i.test(f));
489
+ } else {
490
+ out.has_research = false;
491
+ out.has_context = false;
492
+ out.has_verification = false;
493
+ out.has_plans = false;
494
+ out.plan_count = 0;
495
+ out.has_reviews = false;
496
+ out.has_uat = false;
497
+ }
498
+
499
+ // Source-of-truth path getters for the fields workflows reference.
500
+ out.context_path = (out.phase_dir && out.has_context)
501
+ ? path.join(out.phase_dir, files0(out.phase_dir, /CONTEXT\.md$/i))
502
+ : null;
503
+ out.research_path = (out.phase_dir && out.has_research)
504
+ ? path.join(out.phase_dir, files0(out.phase_dir, /RESEARCH\.md$/i))
505
+ : null;
506
+ out.verification_path = (out.phase_dir && out.has_verification)
507
+ ? path.join(out.phase_dir, files0(out.phase_dir, /VERIFICATION\.md$/i))
508
+ : null;
509
+ // Phase 12 / #468 — REVIEWS.md / UAT.md paths (null when absent).
510
+ out.reviews_path = (out.phase_dir && out.has_reviews)
511
+ ? path.join(out.phase_dir, files0(out.phase_dir, /REVIEWS\.md$/i))
512
+ : null;
513
+ out.uat_path = (out.phase_dir && out.has_uat)
514
+ ? path.join(out.phase_dir, files0(out.phase_dir, /UAT\.md$/i))
515
+ : null;
516
+ out.state_path = path.join(RIHAL_DIR, 'state.json');
517
+ out.roadmap_path = roadmapPath;
518
+ out.requirements_path = fs.existsSync(path.join(PLANNING_DIR, 'REQUIREMENTS.md'))
519
+ ? path.join(PLANNING_DIR, 'REQUIREMENTS.md')
520
+ : null;
521
+
522
+ // Defaults consumed by /rihal-plan and /rihal-discuss-phase.
523
+ // Accept both bare commit_docs and nested git.commit_docs (settings.md uses git.commit_docs).
524
+ const _rawCommitDocs = config.git?.commit_docs ?? config.commit_docs;
525
+ out.commit_docs = _rawCommitDocs === undefined ? true : String(_rawCommitDocs) !== 'false';
526
+ out.response_language = config.response_language || config.language || null;
527
+
528
+ // Phase 12 / #468 — close the agent-context contract.
529
+ // Reads nested config (workflow.*, features.*) via lib/config.cjs and
530
+ // surfaces every field that plan.md/discuss-phase.md reference today.
531
+ const nestedCfg = readNestedConfig();
532
+ const wf = nestedCfg.workflow || {};
533
+ const features = nestedCfg.features || {};
534
+
535
+ // Workflow feature flags (top-level for direct workflow consumption).
536
+ // Defaults match the inline `config-get … || echo "X"` calls in the workflows.
537
+ out.research_enabled = String(wf.research_by_default ?? 'false') === 'true';
538
+ out.plan_checker_enabled = String(wf.plan_checker ?? 'true') !== 'false';
539
+ out.nyquist_validation_enabled = String(wf.nyquist_validation ?? 'true') !== 'false';
540
+ out.text_mode = String(wf.text_mode ?? 'false') === 'true';
541
+
542
+ // Model resolution per active profile. The researcher agent ships as
543
+ // `phase-researcher` in this codebase; resolveModelString falls back to
544
+ // that when the prefixed/bare `researcher` ids aren't present.
545
+ out.researcher_model = resolveModelString('rihal-researcher')
546
+ || resolveModelString('rihal-phase-researcher');
547
+ out.planner_model = resolveModelString('rihal-planner');
548
+ out.checker_model = resolveModelString('rihal-sprint-checker');
549
+
550
+ // Phase requirement IDs — extracted from ROADMAP requirements block.
551
+ out.phase_req_ids = extractReqIds(roadmapPhase ? roadmapPhase.requirements : []);
552
+
553
+ // Deeper config flags (grouped to keep top-level lean).
554
+ // Defaults documented in the workflows' inline config-get fallbacks.
555
+ out.features = {
556
+ thinking_partner: String(features.thinking_partner ?? 'false') === 'true',
557
+ };
558
+ out.workflow_flags = {
559
+ discuss_mode: wf.discuss_mode ?? 'adaptive',
560
+ research_before_questions: String(wf.research_before_questions ?? 'true') !== 'false',
561
+ max_discuss_passes: parseInt(wf.max_discuss_passes ?? '3', 10) || 3,
562
+ security_enforcement: String(wf.security_enforcement ?? 'true') !== 'false',
563
+ security_asvs_level: parseInt(wf.security_asvs_level ?? '1', 10) || 1,
564
+ ui_phase: String(wf.ui_phase ?? 'true') !== 'false',
565
+ ui_safety_gate: String(wf.ui_safety_gate ?? 'true') !== 'false',
566
+ };
567
+ }
568
+ }
569
+
570
+
330
571
  return out;
331
572
  }
332
573
 
@@ -363,8 +604,38 @@ function cmdSelectPanel(rawArgs) {
363
604
  };
364
605
  }
365
606
 
607
+ // Canonical aliases for short workflow-side ids that don't match manifest ids.
608
+ // Workflows historically use shorter names (researcher, checker, advisor) that
609
+ // map to longer manifest ids. Keep the alias table small and explicit — if a
610
+ // new agent needs an alias, add it here, don't add fuzzy matching. (#472)
611
+ const AGENT_ID_ALIASES = {
612
+ researcher: 'phase-researcher',
613
+ checker: 'sprint-checker',
614
+ advisor: 'advisor-researcher',
615
+ };
616
+
617
+ function resolveAgentId(rawId, manifest) {
618
+ if (!rawId) return null;
619
+ // 1. Exact match on raw id
620
+ let row = manifest.find((r) => r.id === rawId);
621
+ if (row) return row;
622
+ // 2. Strip leading rihal- prefix (workflows use prefixed form, manifest is bare)
623
+ const stripped = rawId.replace(/^rihal-/, '');
624
+ if (stripped !== rawId) {
625
+ row = manifest.find((r) => r.id === stripped);
626
+ if (row) return row;
627
+ }
628
+ // 3. Apply canonical aliases (e.g. researcher → phase-researcher)
629
+ const aliased = AGENT_ID_ALIASES[stripped];
630
+ if (aliased) {
631
+ row = manifest.find((r) => r.id === aliased);
632
+ if (row) return row;
633
+ }
634
+ return null;
635
+ }
636
+
366
637
  function cmdAgentInfo(agentId) {
367
- const row = readAgentManifest().find((r) => r.id === agentId);
638
+ const row = resolveAgentId(agentId, readAgentManifest());
368
639
  if (!row) {
369
640
  console.error(`Unknown agent: ${agentId}`);
370
641
  process.exit(1);
@@ -460,16 +731,34 @@ function cmdClassifyQuestion(raw) {
460
731
  'design system', 'component library', 'accessibility', 'a11y', 'ui design',
461
732
  'redesign', 'onboarding flow', 'landing page', 'interface', 'layout',
462
733
  ],
734
+ frontend: [
735
+ 'react', 'component', 'frontend', 'front-end', 'next.js', 'nextjs',
736
+ 'tailwind', 'tsx', 'jsx', 'rtl', 'a11y',
737
+ 'css', 'html', 'layout', 'responsive', 'animation', 'hydration',
738
+ 'bundle size', 'lighthouse', 'cls', 'lcp', 'tbt', 'ui component',
739
+ 'page render', 'client side', 'browser render',
740
+ // Roman Urdu FE signals
741
+ 'front end', 'fe issue', 'ui fix',
742
+ ],
743
+ backend: [
744
+ 'backend', 'back-end', 'api endpoint', 'server side', 'prisma',
745
+ 'database query', 'db query', 'sql query', 'orm', 'db migration',
746
+ 'queue', 'webhook', 'graphql', 'rest api', 'n+1',
747
+ 'redis', 'postgres', 'mysql', 'mongodb', 'bullmq',
748
+ 'cron job', 'rate limit', 'auth middleware',
749
+ // Roman Urdu BE signals
750
+ 'be issue', 'api slow', 'db slow',
751
+ ],
463
752
  codebase: [
464
753
  'rewrite', 'refactor', 'migrate', 'this code', 'this function', 'this file',
465
754
  'this component', 'this api', 'this service', 'this database', 'this schema',
466
755
  'the auth', 'the tests', 'the build', 'the deploy', 'the pipeline',
467
756
  'production ready', 'ready to ship', 'test coverage', 'bug', 'error',
468
- 'performance', 'should i rewrite', 'auth layer', 'db migration',
757
+ 'performance', 'should i rewrite', 'auth layer',
469
758
  'pull request', 'code review', 'technical debt', 'tech debt',
470
759
  'feature', 'ci/cd', 'cicd', 'pipeline', 'documentation', 'docs',
471
760
  // Tech choice signals
472
- 'astro', 'nextjs', 'next.js', 'remix', 'nuxt', 'svelte', 'vue', 'angular',
761
+ 'astro', 'remix', 'nuxt', 'svelte', 'vue', 'angular',
473
762
  'should i use', 'which framework', 'compare framework',
474
763
  // Roman Urdu codebase/fix signals
475
764
  'fix karo', 'theek karo', 'sahi karo',
@@ -477,6 +766,20 @@ function cmdClassifyQuestion(raw) {
477
766
  // Arabic execution signals
478
767
  'إصلاح', 'كود', 'برنامج', 'نفذ', 'شغل',
479
768
  ],
769
+ // Phase 6 — drift / audit / re-audit / extend-existing-artifact signals.
770
+ // Routes /rihal-do toward /rihal-feature-drift instead of falling
771
+ // through to inline execution. Reinforces classifyScope's drift branch.
772
+ drift: [
773
+ 'drift', 'redrift', 're-audit', 'reaudit', 'audit feature', 'audit docs',
774
+ 'audit the docs', 'audit the prd', 'audit feature docs',
775
+ 'fill out existing', 'fill out the existing', 'fill out this',
776
+ 'extend audit', 'extend the audit', 'extend plan', 'extend the plan',
777
+ 'expand audit', 'expand the audit',
778
+ 'verify docs vs code', 'verify claims vs code', 'docs vs reality', 'docs vs code',
779
+ 'stale docs', 'out of date docs', 'out-of-date docs',
780
+ // Roman Urdu drift signals
781
+ 'docs purane hai', 'docs purani hain', 'docs stale hain', 'audit dobara',
782
+ ],
480
783
  };
481
784
 
482
785
  const matchedSignals = (signals) => signals.filter((s) => normalized.includes(s));
@@ -485,8 +788,8 @@ function cmdClassifyQuestion(raw) {
485
788
  matched[type] = matchedSignals(signals);
486
789
  }
487
790
 
488
- // Weights per type
489
- const WEIGHTS = { discovery: 3, market: 2, greenfield: 2, team: 3, release: 3, design: 3, codebase: 3 };
791
+ // Weights per type — frontend/backend get weight 4 (most specific signals).
792
+ const WEIGHTS = { discovery: 3, market: 2, greenfield: 2, team: 3, release: 3, design: 3, codebase: 3, drift: 3, frontend: 4, backend: 4 };
490
793
  const scores = {};
491
794
  for (const [type, hits] of Object.entries(matched)) {
492
795
  scores[type] = hits.length * WEIGHTS[type];
@@ -835,7 +1138,7 @@ function cmdState(subArgs) {
835
1138
  : phaseIdx + 1;
836
1139
  if (!phase.sprints) phase.sprints = [];
837
1140
  const sprintNum = phase.sprints.length + 1;
838
- const padPhase = String(phaseNum).padStart(2, '0');
1141
+ const padPhase = String(phaseNum); // no leading zeros
839
1142
  const sprintId = `${padPhase}.${sprintNum}`;
840
1143
 
841
1144
  const sprint = {
@@ -975,7 +1278,7 @@ function cmdState(subArgs) {
975
1278
  if (s.id === sprintId) {
976
1279
  if (!s.stories) s.stories = [];
977
1280
  const storyNum = s.stories.length + 1;
978
- const storyId = `${sprintId}.${String(storyNum).padStart(2, '0')}`;
1281
+ const storyId = `${sprintId}.${String(storyNum)}`;
979
1282
  const story = {
980
1283
  id: storyId,
981
1284
  title: flags.title,
@@ -1408,14 +1711,14 @@ function cmdState(subArgs) {
1408
1711
  if (fs.existsSync(phasesDir)) {
1409
1712
  const entries = fs.readdirSync(phasesDir);
1410
1713
  for (const entry of entries) {
1411
- const match = entry.match(/^(\d{2})-/);
1714
+ const match = entry.match(/^(\d+)-/);
1412
1715
  if (match) {
1413
1716
  const num = parseInt(match[1], 10);
1414
1717
  maxNum = Math.max(maxNum, num);
1415
1718
  }
1416
1719
  }
1417
1720
  }
1418
- const nextId = String(maxNum + 1).padStart(2, '0');
1721
+ const nextId = String(maxNum + 1);
1419
1722
  return { ok: true, next_phase_id: nextId };
1420
1723
  }
1421
1724
 
@@ -1423,52 +1726,52 @@ function cmdState(subArgs) {
1423
1726
  if (sub === 'next-plan-id') {
1424
1727
  const phaseId = subArgs[1];
1425
1728
  if (!phaseId) throw new Error('next-plan-id requires a phase ID argument (NN format)');
1426
- const phaseMatch = phaseId.match(/^(\d{2})(?:\.(\d+))?$/);
1427
- if (!phaseMatch) throw new Error(`Invalid phase ID format: ${phaseId}. Expected NN or NN.M`);
1729
+ const phaseMatch = phaseId.match(/^(\d+)(?:\.(\d+))?$/);
1730
+ if (!phaseMatch) throw new Error(`Invalid phase ID format: ${phaseId}. Expected N or N.M`);
1428
1731
 
1429
1732
  const phasePart = phaseMatch[1];
1430
1733
  const phasesDir = path.join(PLANNING_DIR, 'phases');
1431
1734
 
1432
- // Find the phase directory matching NN-*
1735
+ // Find the phase directory matching NN-* (directories may be zero-padded for sorting)
1433
1736
  let phaseDir = null;
1434
1737
  if (fs.existsSync(phasesDir)) {
1435
1738
  const entries = fs.readdirSync(phasesDir);
1436
1739
  for (const entry of entries) {
1437
- const match = entry.match(/^(\d{2})(?:\.\d+)?-/);
1438
- if (match && match[1] === phasePart) {
1740
+ const match = entry.match(/^(\d+)(?:\.\d+)?-/);
1741
+ if (match && parseInt(match[1], 10) === parseInt(phasePart, 10)) {
1439
1742
  phaseDir = path.join(phasesDir, entry);
1440
1743
  break;
1441
1744
  }
1442
1745
  }
1443
1746
  }
1444
1747
 
1445
- // If no phase dir found, default to 01 plan
1748
+ // If no phase dir found, default to 1st plan
1446
1749
  if (!phaseDir) {
1447
- return { ok: true, next_plan_id: `${phasePart}.01` };
1750
+ return { ok: true, next_plan_id: `${phasePart}.1` };
1448
1751
  }
1449
1752
 
1450
1753
  // Scan phase dir for numbered subdirs (MM-*) to find max plan number
1451
1754
  let maxPlanNum = 0;
1452
1755
  const entries = fs.readdirSync(phaseDir);
1453
1756
  for (const entry of entries) {
1454
- const match = entry.match(/^(\d{2})-/);
1757
+ const match = entry.match(/^(\d+)-/);
1455
1758
  if (match && fs.statSync(path.join(phaseDir, entry)).isDirectory()) {
1456
1759
  const num = parseInt(match[1], 10);
1457
1760
  maxPlanNum = Math.max(maxPlanNum, num);
1458
1761
  }
1459
1762
  }
1460
1763
 
1461
- const nextPlanNum = String(maxPlanNum + 1).padStart(2, '0');
1462
- // First plan in empty phase gets .01 not .02
1463
- return { ok: true, next_plan_id: maxPlanNum === 0 ? `${phasePart}.01` : `${phasePart}.${nextPlanNum}` };
1764
+ const nextPlanNum = String(maxPlanNum + 1);
1765
+ // First plan in empty phase gets .1 not .2
1766
+ return { ok: true, next_plan_id: maxPlanNum === 0 ? `${phasePart}.1` : `${phasePart}.${nextPlanNum}` };
1464
1767
  }
1465
1768
 
1466
1769
  // --- next-task-id <plan-id> ---
1467
1770
  if (sub === 'next-task-id') {
1468
1771
  const planId = subArgs[1];
1469
1772
  if (!planId) throw new Error('next-task-id requires a plan ID argument (NN.MM format)');
1470
- const match = planId.match(/^(\d{2})\.(\d{2})$/);
1471
- if (!match) throw new Error(`Invalid plan ID format: ${planId}. Expected NN.MM`);
1773
+ const match = planId.match(/^(\d+)\.(\d+)$/);
1774
+ if (!match) throw new Error(`Invalid plan ID format: ${planId}. Expected N.M`);
1472
1775
 
1473
1776
  const phasePart = match[1];
1474
1777
  const planPart = match[2];
@@ -1480,15 +1783,15 @@ function cmdState(subArgs) {
1480
1783
  if (fs.existsSync(phasesDir)) {
1481
1784
  const entries = fs.readdirSync(phasesDir);
1482
1785
  for (const entry of entries) {
1483
- const phaseMatch = entry.match(/^(\d{2})(?:\.\d+)?-/);
1484
- if (phaseMatch && phaseMatch[1] === phasePart) {
1786
+ const phaseMatch = entry.match(/^(\d+)(?:\.\d+)?-/);
1787
+ if (phaseMatch && parseInt(phaseMatch[1], 10) === parseInt(phasePart, 10)) {
1485
1788
  const phaseDir = path.join(phasesDir, entry);
1486
1789
 
1487
1790
  // Check for subdirectory named planPart-*
1488
1791
  const subentries = fs.readdirSync(phaseDir);
1489
1792
  for (const subentry of subentries) {
1490
- const subMatch = subentry.match(/^(\d{2})-/);
1491
- if (subMatch && subMatch[1] === planPart) {
1793
+ const subMatch = subentry.match(/^(\d+)-/);
1794
+ if (subMatch && parseInt(subMatch[1], 10) === parseInt(planPart, 10)) {
1492
1795
  const planDir = path.join(phaseDir, subentry);
1493
1796
  const candidate = path.join(planDir, 'SPRINT.md');
1494
1797
  if (fs.existsSync(candidate)) {
@@ -1499,7 +1802,7 @@ function cmdState(subArgs) {
1499
1802
  }
1500
1803
 
1501
1804
  // If no subdir found, check phase-level PLAN.md
1502
- if (!planFile && planPart === '01') {
1805
+ if (!planFile && parseInt(planPart, 10) === 1) {
1503
1806
  const candidate = path.join(phaseDir, 'SPRINT.md');
1504
1807
  if (fs.existsSync(candidate)) {
1505
1808
  planFile = candidate;
@@ -1517,7 +1820,7 @@ function cmdState(subArgs) {
1517
1820
  // Read PLAN.md and count existing tasks
1518
1821
  const planContent = fs.readFileSync(planFile, 'utf8');
1519
1822
  const taskMatches = planContent.match(/^### Task \d+\.\d+\.\d+ —/gm) || [];
1520
- const nextTaskNum = String(taskMatches.length + 1).padStart(2, '0');
1823
+ const nextTaskNum = String(taskMatches.length + 1);
1521
1824
 
1522
1825
  return { ok: true, next_task_id: `${planId}.${nextTaskNum}` };
1523
1826
  }
@@ -1534,10 +1837,10 @@ function cmdState(subArgs) {
1534
1837
  if (/^M\d+$/.test(id)) {
1535
1838
  idType = 'milestone';
1536
1839
  milestoneId = id;
1537
- } else if (/^\d{2}$/.test(id)) {
1840
+ } else if (/^\d+$/.test(id)) {
1538
1841
  idType = 'phase';
1539
1842
  phaseId = id;
1540
- } else if (/^\d{2}\.\d+$/.test(id)) {
1843
+ } else if (/^\d+\.\d+$/.test(id)) {
1541
1844
  const parts = id.split('.');
1542
1845
  phaseId = parts[0];
1543
1846
 
@@ -1548,7 +1851,7 @@ function cmdState(subArgs) {
1548
1851
  if (fs.existsSync(phasesDir)) {
1549
1852
  const entries = fs.readdirSync(phasesDir);
1550
1853
  for (const entry of entries) {
1551
- if (entry.match(/^\d{2}\.\d+-/)) {
1854
+ if (entry.match(/^\d+\.\d+-/)) {
1552
1855
  isDecimalPhase = true;
1553
1856
  break;
1554
1857
  }
@@ -1561,7 +1864,7 @@ function cmdState(subArgs) {
1561
1864
  idType = 'plan';
1562
1865
  planId = id;
1563
1866
  }
1564
- } else if (/^\d{2}\.\d+\.\d+$/.test(id)) {
1867
+ } else if (/^\d+\.\d+\.\d+$/.test(id)) {
1565
1868
  idType = 'task';
1566
1869
  const parts = id.split('.');
1567
1870
  phaseId = parts[0];
@@ -1591,8 +1894,8 @@ function cmdState(subArgs) {
1591
1894
  if (fs.existsSync(phasesDir)) {
1592
1895
  const entries = fs.readdirSync(phasesDir);
1593
1896
  for (const entry of entries) {
1594
- const match = entry.match(/^(\d{2})-/);
1595
- if (match && match[1] === phaseId) {
1897
+ const match = entry.match(/^(\d+)-/);
1898
+ if (match && parseInt(match[1], 10) === parseInt(phaseId, 10)) {
1596
1899
  const phaseDir = path.join(phasesDir, entry);
1597
1900
  result.phase_dir = phaseDir;
1598
1901
 
@@ -1603,8 +1906,8 @@ function cmdState(subArgs) {
1603
1906
  // Check for subdirectory
1604
1907
  const subentries = fs.readdirSync(phaseDir);
1605
1908
  for (const subentry of subentries) {
1606
- const subMatch = subentry.match(/^(\d{2})-/);
1607
- if (subMatch && subMatch[1] === planNum) {
1909
+ const subMatch = subentry.match(/^(\d+)-/);
1910
+ if (subMatch && parseInt(subMatch[1], 10) === parseInt(planNum, 10)) {
1608
1911
  const planDir = path.join(phaseDir, subentry);
1609
1912
  const planPath = path.join(planDir, 'SPRINT.md');
1610
1913
  if (fs.existsSync(planPath)) {
@@ -1615,8 +1918,8 @@ function cmdState(subArgs) {
1615
1918
  }
1616
1919
  }
1617
1920
 
1618
- // If no subdir and planNum is 01, check phase-level PLAN.md
1619
- if (!result.path && planNum === '01') {
1921
+ // If no subdir and planNum is 1, check phase-level PLAN.md
1922
+ if (!result.path && parseInt(planNum, 10) === 1) {
1620
1923
  const candidate = path.join(phaseDir, 'SPRINT.md');
1621
1924
  if (fs.existsSync(candidate)) {
1622
1925
  result.plan_dir = phaseDir;
@@ -1673,16 +1976,18 @@ function cmdState(subArgs) {
1673
1976
  if (fs.existsSync(phasesDir)) {
1674
1977
  const entries = fs.readdirSync(phasesDir);
1675
1978
  for (const entry of entries) {
1676
- const match = entry.match(/^(\d{2})(?:\.\d+)?-(.+)$/);
1979
+ const match = entry.match(/^(\d+)(?:\.\d+)?-(.+)$/);
1677
1980
  if (match) {
1678
- const phaseId = match[1];
1981
+ const phaseId = String(parseInt(match[1], 10)); // strip leading zeros
1679
1982
  const slug = match[2];
1680
1983
  const phaseDir = path.join(phasesDir, entry);
1681
1984
 
1682
- // Add phase if not already present
1683
- if (!state.phases.some(p => p.id === phaseId)) {
1985
+ // Add phase if not already present (check both id and number per #482-A
1986
+ // schema-drift fix different writers use different field names).
1987
+ if (!state.phases.some(p => String(parseInt(p.id, 10)) === phaseId || String(parseInt(p.number, 10)) === phaseId)) {
1684
1988
  state.phases.push({
1685
1989
  id: phaseId,
1990
+ number: phaseId,
1686
1991
  slug,
1687
1992
  path: phaseDir,
1688
1993
  created: new Date().toISOString(),
@@ -1692,9 +1997,9 @@ function cmdState(subArgs) {
1692
1997
  // Scan for plans within phase
1693
1998
  const subentries = fs.readdirSync(phaseDir);
1694
1999
  for (const subentry of subentries) {
1695
- const subMatch = subentry.match(/^(\d{2})-(.+)$/);
2000
+ const subMatch = subentry.match(/^(\d+)-(.+)$/);
1696
2001
  if (subMatch && fs.statSync(path.join(phaseDir, subentry)).isDirectory()) {
1697
- const planNum = subMatch[1];
2002
+ const planNum = String(parseInt(subMatch[1], 10)); // strip leading zeros
1698
2003
  const planId = `${phaseId}.${planNum}`;
1699
2004
  const planSlug = subMatch[2];
1700
2005
  const planDir = path.join(phaseDir, subentry);
@@ -1769,7 +2074,7 @@ function cmdState(subArgs) {
1769
2074
  let phaseNum = 1;
1770
2075
 
1771
2076
  for (const entry of entries) {
1772
- const match = entry.match(/^(\d{2})-/);
2077
+ const match = entry.match(/^(\d+)-/);
1773
2078
  if (match) {
1774
2079
  phaseNum = parseInt(match[1], 10);
1775
2080
  }
@@ -1781,7 +2086,7 @@ function cmdState(subArgs) {
1781
2086
  if (fs.existsSync(phasePlanPath)) {
1782
2087
  try {
1783
2088
  let content = fs.readFileSync(phasePlanPath, 'utf8');
1784
- const phaseIdStr = String(phaseNum).padStart(2, '0');
2089
+ const phaseIdStr = String(phaseNum); // no leading zeros
1785
2090
 
1786
2091
  // Check if it has frontmatter with phase/plan fields
1787
2092
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
@@ -1789,9 +2094,9 @@ function cmdState(subArgs) {
1789
2094
  const fm = frontmatterMatch[1];
1790
2095
  if (!fm.match(/^id:/m)) {
1791
2096
  // Only add id if missing; preserve existing phase/plan if present
1792
- let newFrontmatter = fm.trimEnd() + `\nid: "${phaseIdStr}.01"`;
2097
+ let newFrontmatter = fm.trimEnd() + `\nid: "${phaseIdStr}.1"`;
1793
2098
  if (!fm.match(/^phase:/m)) newFrontmatter += `\nphase: "${phaseIdStr}"`;
1794
- if (!fm.match(/^plan:/m)) newFrontmatter += `\nplan: "01"`;
2099
+ if (!fm.match(/^plan:/m)) newFrontmatter += `\nplan: "1"`;
1795
2100
  newFrontmatter += '\n';
1796
2101
  content = content.replace(/^---\n([\s\S]*?)\n---\n/, `---\n${newFrontmatter}---\n`);
1797
2102
  const tmp = phasePlanPath + '.tmp';
@@ -1801,8 +2106,8 @@ function cmdState(subArgs) {
1801
2106
  }
1802
2107
  } else {
1803
2108
  // No frontmatter found — prepend minimal frontmatter
1804
- const assignedId = `${phaseIdStr}.01`;
1805
- const minimal = `---\nid: "${assignedId}"\nphase: "${phaseIdStr}"\nplan: "01"\ntype: auto\n---\n`;
2109
+ const assignedId = `${phaseIdStr}.1`;
2110
+ const minimal = `---\nid: "${assignedId}"\nphase: "${phaseIdStr}"\nplan: "1"\ntype: auto\n---\n`;
1806
2111
  fs.writeFileSync(phasePlanPath, minimal + content);
1807
2112
  migratedCount++;
1808
2113
  }
@@ -1816,7 +2121,7 @@ function cmdState(subArgs) {
1816
2121
  const subentries = fs.readdirSync(phaseDir);
1817
2122
  let planNum = 1;
1818
2123
  for (const subentry of subentries) {
1819
- const subMatch = subentry.match(/^(\d{2})-/);
2124
+ const subMatch = subentry.match(/^(\d+)-/);
1820
2125
  if (subMatch && fs.statSync(path.join(phaseDir, subentry)).isDirectory()) {
1821
2126
  planNum = parseInt(subMatch[1], 10);
1822
2127
  const planDir = path.join(phaseDir, subentry);
@@ -1825,8 +2130,8 @@ function cmdState(subArgs) {
1825
2130
  if (fs.existsSync(planPath)) {
1826
2131
  try {
1827
2132
  let content = fs.readFileSync(planPath, 'utf8');
1828
- const phaseIdStr = String(phaseNum).padStart(2, '0');
1829
- const planIdStr = String(planNum).padStart(2, '0');
2133
+ const phaseIdStr = String(phaseNum); // no leading zeros
2134
+ const planIdStr = String(planNum); // no leading zeros
1830
2135
 
1831
2136
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
1832
2137
  if (frontmatterMatch) {
@@ -1863,6 +2168,57 @@ function cmdState(subArgs) {
1863
2168
  return { ok: true, migrated: migratedCount, message: `Migrated ${migratedCount} PLAN.md files with IDs` };
1864
2169
  }
1865
2170
 
2171
+ // =====================================================================
2172
+ // state migrate-schema: normalise phases array to current schema
2173
+ // Handles 3 known schema variants in the wild:
2174
+ // Schema A (v1 old) — phases[N] has {id, goal, ...} but no status
2175
+ // Schema B (v1 mid) — phases[N] has {number, name, status?, ...}
2176
+ // Schema C (v2) — phases[N] has {number, name, status, planned_at?, ...}
2177
+ // After migration every entry has: number, name, status (defaulting to 'complete'
2178
+ // for entries that have a SUMMARY.md path or missing status).
2179
+ // =====================================================================
2180
+ if (sub === 'migrate-schema') {
2181
+ const state = readState();
2182
+ if (!state) return { ok: false, error: 'state.json not found or empty' };
2183
+ if (!Array.isArray(state.phases)) return { ok: false, error: 'state.phases is not an array' };
2184
+
2185
+ let changed = 0;
2186
+ state.phases = state.phases.map((p) => {
2187
+ const updated = Object.assign({}, p);
2188
+
2189
+ // Normalise identifier: prefer number, fall back to id or name
2190
+ if (!updated.number && (updated.id || updated.name)) {
2191
+ updated.number = String(updated.id || updated.name);
2192
+ changed++;
2193
+ }
2194
+
2195
+ // Normalise name
2196
+ if (!updated.name && updated.goal) {
2197
+ updated.name = String(updated.goal).slice(0, 60);
2198
+ changed++;
2199
+ }
2200
+
2201
+ // Normalise status: missing → infer from completion markers
2202
+ if (!updated.status) {
2203
+ if (updated.completed || updated.summary_path) {
2204
+ updated.status = 'complete';
2205
+ } else if (updated.started) {
2206
+ updated.status = 'executing';
2207
+ } else {
2208
+ updated.status = 'planned';
2209
+ }
2210
+ changed++;
2211
+ }
2212
+
2213
+ return updated;
2214
+ });
2215
+
2216
+ if (changed > 0) {
2217
+ writeState(state);
2218
+ }
2219
+ return { ok: true, changed, message: `Schema migration complete — ${changed} field(s) normalised across ${state.phases.length} phase entries` };
2220
+ }
2221
+
1866
2222
  // =====================================================================
1867
2223
  // Execution-lifecycle phase state
1868
2224
  // =====================================================================
@@ -1899,6 +2255,10 @@ function cmdState(subArgs) {
1899
2255
  entry = { number: phaseKey, name: flags.name || phaseKey, plans: Number(flags.plans || 0) };
1900
2256
  state.phases.push(entry);
1901
2257
  }
2258
+ // Transition guard: reject complete → executing unless --force
2259
+ if (previousStatus === 'complete' && !flags.force) {
2260
+ throw new Error(`Phase ${phaseKey} is already complete. Use --force to re-execute.`);
2261
+ }
1902
2262
  entry.status = 'executing';
1903
2263
  if (flags.name) entry.name = flags.name;
1904
2264
  if (flags.plans !== undefined) entry.plans = Number(flags.plans);
@@ -1917,6 +2277,10 @@ function cmdState(subArgs) {
1917
2277
  const entry = state.phases.find((p) => String(p.number || p.id || p.name) === phaseKey);
1918
2278
  if (!entry) throw new Error(`Phase ${phaseKey} not found in state`);
1919
2279
  const previousStatus = entry.status || null;
2280
+ // Transition guard: warn if completing from planned (skipped executing)
2281
+ if (previousStatus === 'planned') {
2282
+ process.stderr.write(`Warning: completing phase ${phaseKey} from 'planned' without executing.\n`);
2283
+ }
1920
2284
  entry.status = 'complete';
1921
2285
  entry.completed = new Date().toISOString();
1922
2286
  writeState(state);
@@ -1961,8 +2325,8 @@ function cmdState(subArgs) {
1961
2325
  if (!/^999\.\d+$/.test(from)) {
1962
2326
  throw new Error(`Source must be 999.x parking-lot number, got: ${from}`);
1963
2327
  }
1964
- if (!/^\d{1,3}(\.\d+)?$/.test(to)) {
1965
- throw new Error(`Target must be NN or NN.M, got: ${to}`);
2328
+ if (!/^\d+(\.\d+)?$/.test(to)) {
2329
+ throw new Error(`Target must be N or N.M (any non-negative integer; high numbers like 1001 are valid for hot-track phases), got: ${to}`);
1966
2330
  }
1967
2331
  const state = readState() || defaultState();
1968
2332
  if (!state.phases) state.phases = [];
@@ -2021,33 +2385,75 @@ function cmdState(subArgs) {
2021
2385
  epics_exists: fs.existsSync(epicsPath),
2022
2386
  };
2023
2387
 
2024
- // Parse ROADMAP.md for phase tables.
2025
- // Expected row format: | 01 | Phase Name | Goal text | ... |
2026
- // First cell is phase number (1-3 chars of digits or digits+letter).
2388
+ // Parse ROADMAP.md for phases. Supports two formats (issue #455):
2389
+ // Format A pipe tables: | 01 | Phase Name | Goal text | ... |
2390
+ // Format B heading style: ## Phase 01 Name / ### Phase 01: Name
2391
+ // Milestone heading is also matched in any of: "## Milestone M1", "## Milestone v1.0 — Name",
2392
+ // "**Milestone: v1.0 — Name**".
2027
2393
  if (parsed.roadmap_exists) {
2028
2394
  const roadmap = fs.readFileSync(roadmapPath, 'utf8');
2029
- parsed.milestones_found = (roadmap.match(/^##\s+Milestone\s+M\d+/gim) || []).length;
2030
- const rowRe = /^\|\s*(\d{1,3}(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
2031
- let m;
2395
+ const milestoneMatches = [
2396
+ ...(roadmap.match(/^##\s+Milestone\s+M\d+/gim) || []),
2397
+ ...(roadmap.match(/^#{1,4}\s+Milestone\s*:?\s*[^\n]+$/gim) || []),
2398
+ ...(roadmap.match(/\*\*\s*Milestone\s*:?\s*[^\n*]+\*\*/gi) || []),
2399
+ ];
2400
+ parsed.milestones_found = new Set(milestoneMatches.map(s => s.trim().toLowerCase())).size;
2401
+
2032
2402
  if (!state.phases) state.phases = [];
2033
- while ((m = rowRe.exec(roadmap)) !== null) {
2034
- const phaseNum = m[1].trim();
2035
- const phaseName = m[2].trim();
2036
- const phaseGoal = m[3].trim();
2037
- // Skip header rows like "| # | Phase | Goal |"
2038
- if (!/^\d/.test(phaseNum)) continue;
2039
- if (phaseName.toLowerCase() === 'phase') continue;
2403
+
2404
+ // One-time normalization: drop null/garbage entries and merge duplicates
2405
+ // by id/number across the schema-drift boundary (#482-A). Sync is the
2406
+ // safe place to do this because we re-derive truth from disk anyway.
2407
+ const beforeClean = state.phases.length;
2408
+ const seenKeys = new Map();
2409
+ const cleaned = [];
2410
+ for (const ph of state.phases) {
2411
+ if (!ph) continue;
2412
+ const key = String(ph.id || ph.number || '').trim();
2413
+ if (!key || !/^\d+(\.\d+)?$/.test(key)) continue;
2414
+ if (seenKeys.has(key)) {
2415
+ // Merge into the kept entry: prefer non-null values from this duplicate.
2416
+ const keptIdx = seenKeys.get(key);
2417
+ for (const k of Object.keys(ph)) {
2418
+ if (cleaned[keptIdx][k] == null && ph[k] != null) cleaned[keptIdx][k] = ph[k];
2419
+ }
2420
+ continue;
2421
+ }
2422
+ seenKeys.set(key, cleaned.length);
2423
+ cleaned.push({ id: key, number: key, ...ph, id: key, number: key });
2424
+ }
2425
+ parsed.phases_normalized = beforeClean - cleaned.length;
2426
+ state.phases = cleaned;
2427
+
2428
+ const seenNums = new Set();
2429
+
2430
+ const upsertPhase = (phaseNum, phaseName, phaseGoal) => {
2431
+ if (!/^\d/.test(phaseNum)) return;
2432
+ if (phaseName.toLowerCase() === 'phase') return;
2433
+ if (seenNums.has(phaseNum)) return;
2434
+ seenNums.add(phaseNum);
2040
2435
  parsed.phases_found += 1;
2436
+ // Dedup against id, number, AND name — schema drift between writers means
2437
+ // older entries carry .id while newer carry .number. Checking only one
2438
+ // field caused duplicate entries (e.g. issue #482-A: phases 10-13 each
2439
+ // appeared twice after a re-sync because the .id-only entries were not
2440
+ // matched against the .number-only writer).
2041
2441
  const existingIdx = state.phases.findIndex(p =>
2042
- String(p.number) === phaseNum || p.name === phaseName
2442
+ String(p.number) === phaseNum ||
2443
+ String(p.id) === phaseNum ||
2444
+ p.name === phaseName
2043
2445
  );
2044
2446
  if (existingIdx >= 0) {
2045
- // Preserve existing status fields; update metadata.
2447
+ // Backfill both id and number so future readers using either schema find it.
2046
2448
  state.phases[existingIdx].number = state.phases[existingIdx].number || phaseNum;
2449
+ state.phases[existingIdx].id = state.phases[existingIdx].id || phaseNum;
2047
2450
  state.phases[existingIdx].name = phaseName;
2048
2451
  if (phaseGoal) state.phases[existingIdx].goal = phaseGoal;
2049
2452
  } else {
2453
+ // Write both id and number on every new entry so dedup works regardless
2454
+ // of which schema future readers expect.
2050
2455
  state.phases.push({
2456
+ id: phaseNum,
2051
2457
  number: phaseNum,
2052
2458
  name: phaseName,
2053
2459
  goal: phaseGoal,
@@ -2058,6 +2464,25 @@ function cmdState(subArgs) {
2058
2464
  });
2059
2465
  parsed.phases_upserted += 1;
2060
2466
  }
2467
+ };
2468
+
2469
+ // Format A — pipe tables
2470
+ // Phase number: \d+ (not \d{1,3}) — high numbers like 1001 are valid for
2471
+ // hot-track parking-lot phases per parking-lot-convention.md.
2472
+ const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
2473
+ let m;
2474
+ while ((m = rowRe.exec(roadmap)) !== null) {
2475
+ upsertPhase(m[1].trim(), m[2].trim(), m[3].trim());
2476
+ }
2477
+
2478
+ // Format B — heading style
2479
+ const headRe = /^#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*[—\-:]\s*([^\n]+)$/gm;
2480
+ while ((m = headRe.exec(roadmap)) !== null) {
2481
+ const num = m[1].trim();
2482
+ const name = m[2].trim();
2483
+ const after = roadmap.slice(headRe.lastIndex).split(/\n/).slice(0, 8).join('\n');
2484
+ const goalMatch = after.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
2485
+ upsertPhase(num, name, goalMatch ? goalMatch[1].trim() : '');
2061
2486
  }
2062
2487
  }
2063
2488
 
@@ -2085,12 +2510,12 @@ function cmdState(subArgs) {
2085
2510
  const body = epicBlocks[i + 1] || '';
2086
2511
  const numMatch = header.match(/(\d+)/);
2087
2512
  if (!numMatch) continue;
2088
- const epicNum = numMatch[1].padStart(2, '0');
2513
+ const epicNum = String(parseInt(numMatch[1], 10)); // strip leading zeros
2089
2514
  const nameMatch = header.match(/[—\-:]\s*(.+?)\s*$/);
2090
2515
  const epicName = nameMatch ? nameMatch[1].trim() : `Epic ${epicNum}`;
2091
2516
 
2092
2517
  // Upsert epic with story-level preservation.
2093
- let epicEntry = state.epics.find(e => String(e.number) === epicNum);
2518
+ let epicEntry = state.epics.find(e => String(parseInt(e.number, 10)) === epicNum);
2094
2519
  if (!epicEntry) {
2095
2520
  epicEntry = { number: epicNum, name: epicName, status: 'planned', stories: [] };
2096
2521
  state.epics.push(epicEntry);
@@ -2132,7 +2557,7 @@ function cmdState(subArgs) {
2132
2557
  for (const phaseEntry of fs.readdirSync(sprintRoot)) {
2133
2558
  const phaseDir = path.join(sprintRoot, phaseEntry);
2134
2559
  if (!fs.statSync(phaseDir).isDirectory()) continue;
2135
- const phaseNumMatch = phaseEntry.match(/^(\d{1,3}(?:\.\d+)?)/);
2560
+ const phaseNumMatch = phaseEntry.match(/^(\d+(?:\.\d+)?)/);
2136
2561
  const phaseNum = phaseNumMatch ? phaseNumMatch[1] : phaseEntry;
2137
2562
  for (const file of fs.readdirSync(phaseDir)) {
2138
2563
  const sprintMatch = file.match(/^sprint-(\d+)\.md$/);
@@ -2169,29 +2594,822 @@ function cmdState(subArgs) {
2169
2594
  throw new Error(`state sync --from-disk: no ROADMAP.md, epics.md, or sprint files found`);
2170
2595
  }
2171
2596
 
2597
+ // Issue #478 — prune state phases not present in ROADMAP.
2598
+ // After upserting ROADMAP → state, seenNums holds every number the ROADMAP
2599
+ // parser found. Any state entry whose id/number is NOT in seenNums is stale
2600
+ // (e.g. from renumbering, manual edits, or partial removals). Prune them,
2601
+ // but only when we successfully parsed at least 1 phase from ROADMAP.
2602
+ parsed.phases_pruned = 0;
2603
+ if (parsed.roadmap_exists && seenNums.size > 0) {
2604
+ const before = state.phases.length;
2605
+ state.phases = state.phases.filter(p => {
2606
+ const key = String(p.id || p.number || '').trim();
2607
+ return !key || seenNums.has(key);
2608
+ });
2609
+ parsed.phases_pruned = before - state.phases.length;
2610
+ }
2611
+
2612
+ // Issue #455 — surface silent no-op when ROADMAP exists but parser found nothing.
2613
+ const warnings = [];
2614
+ if (parsed.roadmap_exists && parsed.phases_found === 0) {
2615
+ warnings.push('ROADMAP.md exists but no phases parsed — check format (expected pipe-table rows or "## Phase NN — Name" headings).');
2616
+ }
2617
+ if (parsed.epics_exists && parsed.epics_found === 0) {
2618
+ warnings.push('epics.md exists but no epics parsed — check "## EPIC-NN" or "## Epic N" heading format.');
2619
+ }
2620
+
2172
2621
  writeState(state);
2173
- return { ok: true, synced: true, ...parsed };
2622
+ return { ok: true, synced: true, ...parsed, ...(warnings.length ? { warnings } : {}) };
2174
2623
  }
2175
2624
 
2176
2625
  throw new Error(`Unknown state subcommand: ${sub}.\nCommon: read, set-phase, advance-plan, add-decision, decisions-global, add-blocker, sync, promote-backlog\nRun 'rihal-tools.cjs help' for the full list of state subcommands.`);
2177
2626
  }
2178
2627
 
2628
+ /**
2629
+ * cmdPhase — top-level phase operations.
2630
+ *
2631
+ * Subcommands:
2632
+ * add <name> Add an integer phase to end of current milestone.
2633
+ * Computes next phase number from disk + ROADMAP + state.json,
2634
+ * creates .planning/phases/{NN}-{slug}/, inserts a Goal/Status/
2635
+ * Plans/Acceptance entry into ROADMAP.md before "## Backlog"
2636
+ * (or at end if absent), and upserts state.phases[].
2637
+ *
2638
+ * Closes #460. Replaces the broken `phase add` invocation referenced by
2639
+ * .rihal/workflows/add-phase.md, which previously hit the dispatcher's
2640
+ * "Unknown subcommand: phase" path.
2641
+ */
2642
+ function cmdPhase(subArgs) {
2643
+ const sub = subArgs[0];
2644
+
2645
+ if (sub === 'add') {
2646
+ // Extract --decimal <parent> if present (closes #477 item C). The flag may
2647
+ // appear before or after the phase name; we splice it out before joining.
2648
+ const remaining = subArgs.slice(1);
2649
+ let decimalParent = null;
2650
+ const decimalIdx = remaining.findIndex(a => a === '--decimal');
2651
+ if (decimalIdx !== -1) {
2652
+ decimalParent = remaining[decimalIdx + 1];
2653
+ if (!decimalParent || decimalParent.startsWith('--')) {
2654
+ throw new Error('--decimal requires a parent phase number (e.g., --decimal 13)');
2655
+ }
2656
+ if (!/^\d+$/.test(decimalParent)) {
2657
+ throw new Error(`--decimal parent must be a positive integer, got: ${decimalParent}`);
2658
+ }
2659
+ remaining.splice(decimalIdx, 2);
2660
+ }
2661
+
2662
+ // #583 --number N flag: explicit phase number override, bypasses auto-computation.
2663
+ let forcedNumber = null;
2664
+ const numberIdx = remaining.findIndex(a => a === '--number');
2665
+ if (numberIdx !== -1) {
2666
+ const nVal = remaining[numberIdx + 1];
2667
+ if (!nVal || nVal.startsWith('--') || !/^\d+$/.test(nVal)) {
2668
+ throw new Error('--number requires a positive integer (e.g., --number 22)');
2669
+ }
2670
+ forcedNumber = parseInt(nVal, 10);
2671
+ remaining.splice(numberIdx, 2);
2672
+ }
2673
+
2674
+ const phaseName = remaining.join(' ').trim();
2675
+ if (!phaseName) throw new Error('phase add requires <name>');
2676
+
2677
+ const slug = phaseName
2678
+ .toLowerCase()
2679
+ .replace(/[^a-z0-9\s-]/g, '')
2680
+ .replace(/\s+/g, '-')
2681
+ .replace(/-+/g, '-')
2682
+ .replace(/^-+|-+$/g, '');
2683
+ if (!slug) {
2684
+ throw new Error('Phase name must contain at least one alphanumeric character');
2685
+ }
2686
+
2687
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
2688
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
2689
+
2690
+ // State lives in .rihal/state.json — same path used by cmdState (line ~634)
2691
+ // and every other state-writing subcommand. Phase 6 dogfood surfaced this:
2692
+ // earlier drafts wrote to .planning/state.json, creating an orphan file
2693
+ // invisible to `state sync` / `state set-phase` / etc. Closes #462.
2694
+ const statePath = path.join(RIHAL_DIR, 'state.json');
2695
+ let state;
2696
+ if (fs.existsSync(statePath)) {
2697
+ try {
2698
+ state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
2699
+ } catch (e) {
2700
+ throw new Error(`Invalid JSON in state.json: ${e.message}`);
2701
+ }
2702
+ } else {
2703
+ state = { phases: [], decisions: [], blockers: [] };
2704
+ }
2705
+ if (!state.phases) state.phases = [];
2706
+
2707
+ let number;
2708
+ if (forcedNumber !== null) {
2709
+ // --number N: explicit override. Validate it doesn't already exist.
2710
+ number = String(forcedNumber);
2711
+ if (state.phases.some(p => String(p.number) === number)) {
2712
+ throw new Error(`Phase ${number} already exists in state.json (--number override)`);
2713
+ }
2714
+ } else if (decimalParent !== null) {
2715
+ // Verify parent exists somewhere (state, dir, or ROADMAP) before slotting under it.
2716
+ const parentNum = parseInt(decimalParent, 10);
2717
+ let parentExists = state.phases.some(p => parseInt(String(p.number), 10) === parentNum);
2718
+ if (!parentExists && fs.existsSync(phasesDir)) {
2719
+ parentExists = fs.readdirSync(phasesDir).some(e => {
2720
+ const m = e.match(/^(\d+)(?:[.-]|$)/);
2721
+ return m && parseInt(m[1], 10) === parentNum;
2722
+ });
2723
+ }
2724
+ if (!parentExists && fs.existsSync(roadmapPath)) {
2725
+ const text = fs.readFileSync(roadmapPath, 'utf8');
2726
+ const re = new RegExp(`(^|\\n)(?:##+\\s*Phase\\s+|\\|\\s*)${parentNum}\\b`);
2727
+ parentExists = re.test(text);
2728
+ }
2729
+ if (!parentExists) {
2730
+ throw new Error(`--decimal parent ${parentNum} not found (no state entry, directory, or ROADMAP row matches)`);
2731
+ }
2732
+
2733
+ // Find max minor across phases dir, ROADMAP, and state for `<parent>.M`.
2734
+ let maxMinor = 0;
2735
+ if (fs.existsSync(phasesDir)) {
2736
+ for (const entry of fs.readdirSync(phasesDir)) {
2737
+ const m = entry.match(new RegExp(`^${parentNum}\\.(\\d+)`));
2738
+ if (m) maxMinor = Math.max(maxMinor, parseInt(m[1], 10));
2739
+ }
2740
+ }
2741
+ if (fs.existsSync(roadmapPath)) {
2742
+ const text = fs.readFileSync(roadmapPath, 'utf8');
2743
+ const pipeRe = new RegExp(`^\\|\\s*${parentNum}\\.(\\d+)\\s*\\|`, 'gm');
2744
+ let m;
2745
+ while ((m = pipeRe.exec(text)) !== null) {
2746
+ maxMinor = Math.max(maxMinor, parseInt(m[1], 10));
2747
+ }
2748
+ const headRe = new RegExp(`^#{2,4}\\s*Phase\\s+${parentNum}\\.(\\d+)\\b`, 'gm');
2749
+ while ((m = headRe.exec(text)) !== null) {
2750
+ maxMinor = Math.max(maxMinor, parseInt(m[1], 10));
2751
+ }
2752
+ }
2753
+ for (const p of state.phases) {
2754
+ const m = String(p.number || '').match(new RegExp(`^${parentNum}\\.(\\d+)$`));
2755
+ if (m) maxMinor = Math.max(maxMinor, parseInt(m[1], 10));
2756
+ }
2757
+ number = `${parentNum}.${maxMinor + 1}`;
2758
+ } else {
2759
+ let maxNum = 0;
2760
+ if (fs.existsSync(phasesDir)) {
2761
+ for (const entry of fs.readdirSync(phasesDir)) {
2762
+ const m = entry.match(/^(\d+)/);
2763
+ if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
2764
+ }
2765
+ }
2766
+ if (fs.existsSync(roadmapPath)) {
2767
+ const text = fs.readFileSync(roadmapPath, 'utf8');
2768
+ // Phase 14 / #476 — \d+ (not \d{1,3}). High numbers like 1001 are valid
2769
+ // for hot-track phases. The cap was silently dropping them from maxNum
2770
+ // computation, causing the next phase to collide with an existing one.
2771
+ const pipeRe = /^\|\s*(\d+)\s*\|/gm;
2772
+ let m;
2773
+ while ((m = pipeRe.exec(text)) !== null) {
2774
+ maxNum = Math.max(maxNum, parseInt(m[1], 10));
2775
+ }
2776
+ const headRe = /^#{2,4}\s*Phase\s+(\d+)\b/gm;
2777
+ while ((m = headRe.exec(text)) !== null) {
2778
+ maxNum = Math.max(maxNum, parseInt(m[1], 10));
2779
+ }
2780
+ }
2781
+ for (const p of state.phases) {
2782
+ const n = parseInt(String(p.number || ''), 10);
2783
+ if (!Number.isNaN(n)) maxNum = Math.max(maxNum, n);
2784
+ }
2785
+
2786
+ const next = maxNum + 1;
2787
+ // No leading zeros — phases use plain integer identifiers (6, not 06).
2788
+ // Per Hanzla feedback: leading zeros add visual clutter without disambiguation
2789
+ // value at the scales we operate. Applies to phases, sprints, epics, stories,
2790
+ // tasks, decisions across all artifacts (dirs, ROADMAP, state.json, banners).
2791
+
2792
+ // #583 sanity guard: prevent phantom phase numbers caused by stale high-number
2793
+ // entries in ROADMAP.md or phases/ (e.g. a prior phantom "## Phase 1009" left
2794
+ // in ROADMAP triggers the next add to produce 1010). If computed next is more
2795
+ // than 50 above the count of currently tracked phases, the maxNum source is
2796
+ // suspect. Abort and require an explicit --number N to override.
2797
+ const trackedCount = state.phases.filter(p => {
2798
+ const n = parseInt(String(p.number || ''), 10);
2799
+ return !Number.isNaN(n) && n > 0;
2800
+ }).length;
2801
+ if (next > trackedCount + 50) {
2802
+ throw new Error(
2803
+ `Computed phase number ${next} is unexpectedly large ` +
2804
+ `(only ${trackedCount} phases tracked in state.json). ` +
2805
+ `ROADMAP.md or the phases/ directory may contain a stale high-number entry. ` +
2806
+ `Inspect with: node rihal-tools.cjs phases list\n` +
2807
+ `Then retry with an explicit number: rihal-tools.cjs phase add "${phaseName}" --number ${trackedCount + 1}`
2808
+ );
2809
+ }
2810
+
2811
+ number = String(next);
2812
+ }
2813
+
2814
+ if (state.phases.some(p => String(p.number) === number)) {
2815
+ throw new Error(`Phase ${number} already exists in state.json`);
2816
+ }
2817
+
2818
+ const dirName = `${number}-${slug}`;
2819
+ const directory = path.join(phasesDir, dirName);
2820
+ if (fs.existsSync(directory)) {
2821
+ throw new Error(`Phase directory already exists: ${path.relative(PROJECT_ROOT, directory)}`);
2822
+ }
2823
+ fs.mkdirSync(directory, { recursive: true });
2824
+
2825
+ const entry = `## Phase ${number} — ${phaseName}\n\n` +
2826
+ `**Goal:** _TBD — fill in via /rihal-discuss-phase ${number} or edit directly._\n\n` +
2827
+ `**Status:** Planned\n\n` +
2828
+ `**Plans:**\n- _TBD_\n\n` +
2829
+ `**Acceptance:** _TBD_\n\n---\n`;
2830
+
2831
+ if (fs.existsSync(roadmapPath)) {
2832
+ let text = fs.readFileSync(roadmapPath, 'utf8');
2833
+ const backlogMatch = text.match(/^##\s+Backlog\b/m);
2834
+ if (backlogMatch) {
2835
+ const backlogIdx = backlogMatch.index;
2836
+ text = text.slice(0, backlogIdx) + entry + '\n' + text.slice(backlogIdx);
2837
+ } else {
2838
+ if (!text.endsWith('\n')) text += '\n';
2839
+ text += '\n' + entry;
2840
+ }
2841
+ fs.writeFileSync(roadmapPath, text);
2842
+ }
2843
+
2844
+ state.phases.push({
2845
+ number,
2846
+ name: phaseName,
2847
+ slug,
2848
+ goal: '',
2849
+ status: 'planned',
2850
+ created: new Date().toISOString(),
2851
+ started: null,
2852
+ completed: null,
2853
+ plan_count: 0,
2854
+ });
2855
+ state.updated = new Date().toISOString();
2856
+ // Ensure the directory holding statePath (RIHAL_DIR) exists.
2857
+ const stateDir = path.dirname(statePath);
2858
+ if (!fs.existsSync(stateDir)) {
2859
+ fs.mkdirSync(stateDir, { recursive: true });
2860
+ }
2861
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
2862
+
2863
+ return {
2864
+ ok: true,
2865
+ phase_number: number,
2866
+ name: phaseName,
2867
+ slug,
2868
+ directory: path.relative(PROJECT_ROOT, directory),
2869
+ };
2870
+ }
2871
+
2872
+ throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add`);
2873
+ }
2874
+
2875
+ /**
2876
+ * cmdCommit — atomic git commit with conventional-commits validation.
2877
+ *
2878
+ * Closes #465 (the highest-impact missing subcommand from the Phase 9
2879
+ * dogfood audit). Used by execute-sprint, map-codebase, and
2880
+ * new-project-roadmap workflows.
2881
+ *
2882
+ * Signature:
2883
+ * rihal-tools.cjs commit "<message>" [--files <path1> <path2> ...]
2884
+ *
2885
+ * Validates:
2886
+ * - conventional-commits format (type(scope): subject)
2887
+ * - non-empty subject
2888
+ * - rejects AI-attribution lines (Co-Authored-By: Claude, etc.)
2889
+ * - rejects --no-verify flag explicitly
2890
+ *
2891
+ * Does NOT push (per project rule: never push without explicit human approval).
2892
+ */
2893
+ function cmdCommit(argv) {
2894
+ // argv is the raw args array from process.argv after 'commit'.
2895
+ // First argument = the entire commit message (preserved with quotes by the shell).
2896
+ // Remaining args parsed as flags: --files <paths...>, --no-verify (rejected).
2897
+ const args = Array.isArray(argv) ? argv : [];
2898
+
2899
+ // --no-verify is only a flag when it's a standalone arg, not when it appears
2900
+ // inside the message body (e.g., a commit message that documents that
2901
+ // --no-verify is rejected). Scan only args AFTER the first (= message).
2902
+ const message = args[0] ? String(args[0]) : '';
2903
+ const flagArgs = args.slice(1);
2904
+ const files = [];
2905
+
2906
+ for (let i = 0; i < flagArgs.length; i++) {
2907
+ const t = flagArgs[i];
2908
+ if (t === '--no-verify') {
2909
+ throw new Error('rihal-tools commit does not bypass hooks. Fix the underlying issue, then re-commit.');
2910
+ }
2911
+ if (t === '--files') {
2912
+ // Everything remaining is a file path
2913
+ while (++i < flagArgs.length) files.push(flagArgs[i]);
2914
+ break;
2915
+ }
2916
+ }
2917
+
2918
+ if (!message || !message.trim()) {
2919
+ throw new Error('commit requires a message: rihal-tools.cjs commit "type(scope): subject"');
2920
+ }
2921
+
2922
+ // AI attribution rejection (project rule)
2923
+ const aiPatterns = [
2924
+ /co-authored-by:\s*claude/i,
2925
+ /generated with \[?claude/i,
2926
+ /🤖\s*generated/i,
2927
+ /co-authored-by:\s*ai/i,
2928
+ ];
2929
+ for (const re of aiPatterns) {
2930
+ if (re.test(message)) {
2931
+ throw new Error('AI attribution forbidden in commit messages (project rule). Remove "Co-Authored-By: Claude" / "Generated with Claude Code" / etc.');
2932
+ }
2933
+ }
2934
+
2935
+ // Conventional-commits validation: type(scope): subject
2936
+ // Types per .github/workflows/semantic.yaml: feat, fix, docs, style, refactor, test, chore, perf, revert
2937
+ // Plus our extensions: plan, audit (used during this session)
2938
+ const subjectLine = message.split('\n')[0];
2939
+ const ccRe = /^(feat|fix|docs|style|refactor|test|chore|perf|revert|plan|audit)(\([^)]+\))?:\s+\S/;
2940
+ if (!ccRe.test(subjectLine)) {
2941
+ throw new Error(
2942
+ `Subject must follow conventional commits: type(scope): subject. Got: "${subjectLine.slice(0, 80)}".\n` +
2943
+ `Valid types: feat, fix, docs, style, refactor, test, chore, perf, revert, plan, audit.`
2944
+ );
2945
+ }
2946
+ if (subjectLine.length > 100) {
2947
+ throw new Error(`Subject too long (${subjectLine.length} chars > 100). Move detail to body.`);
2948
+ }
2949
+
2950
+ // Stage files if --files provided; otherwise commit whatever is staged.
2951
+ const { execSync } = require('child_process');
2952
+ if (files.length > 0) {
2953
+ // Validate each path exists before staging
2954
+ for (const f of files) {
2955
+ if (!fs.existsSync(path.join(PROJECT_ROOT, f)) && !fs.existsSync(f)) {
2956
+ throw new Error(`File not found: ${f}`);
2957
+ }
2958
+ }
2959
+ // git add may exit 0 with a warning (not error) for gitignored files on some
2960
+ // git versions — file never gets staged but execSync doesn't throw. Capture
2961
+ // stderr to detect the gitignore warning explicitly (#566).
2962
+ let gitAddStderr = '';
2963
+ try {
2964
+ const addResult = execSync(
2965
+ `git add ${files.map(f => `"${f.replace(/"/g, '\\"')}"`).join(' ')}`,
2966
+ { cwd: PROJECT_ROOT, stdio: 'pipe' }
2967
+ );
2968
+ } catch (e) {
2969
+ gitAddStderr = (e.stderr ? e.stderr.toString() : '') + (e.stdout ? e.stdout.toString() : '');
2970
+ if (gitAddStderr.includes('ignored by one of your .gitignore') || gitAddStderr.includes('use -f if')) {
2971
+ throw new Error(
2972
+ `Cannot stage files — one or more paths are gitignored:\n${gitAddStderr.trim()}\n\n` +
2973
+ `Fix: remove the .gitignore entry for the planning directory, or run:\n` +
2974
+ ` node rihal-tools.cjs gitignore status`
2975
+ );
2976
+ }
2977
+ throw e; // re-throw any other git error
2978
+ }
2979
+
2980
+ // #566 extra guard: verify all --files paths actually appear in the staged index.
2981
+ // git add on a gitignored file may silently succeed (exit 0) but not stage the
2982
+ // file, causing a later commit to include unrelated already-staged changes.
2983
+ // Exception: a tracked file that is unchanged won't appear in the staged diff —
2984
+ // that is OK (e.g. STATE.md listed in worktree mode but not modified). Only
2985
+ // error for files that are both not-staged AND not tracked (gitignored case).
2986
+ const stagedAfterAdd = execSync('git diff --cached --name-only', { cwd: PROJECT_ROOT, encoding: 'utf8' })
2987
+ .trim().split('\n').filter(Boolean);
2988
+ const notStaged = files.filter(f => {
2989
+ const norm = f.replace(/^\.\//, '');
2990
+ if (stagedAfterAdd.some(s => s === norm || s.endsWith('/' + norm) || norm.endsWith(s))) return false;
2991
+ // Not in staged diff — check if it's tracked (unchanged) vs untracked/gitignored
2992
+ try {
2993
+ execSync(`git ls-files --error-unmatch "${f}"`, { cwd: PROJECT_ROOT, stdio: 'pipe' });
2994
+ return false; // tracked and unchanged — that's fine
2995
+ } catch {
2996
+ return true; // not tracked — likely gitignored
2997
+ }
2998
+ });
2999
+ if (notStaged.length > 0) {
3000
+ throw new Error(
3001
+ `The following files were not staged after git add — likely gitignored:\n` +
3002
+ notStaged.map(f => ` ${f}`).join('\n') + '\n\n' +
3003
+ `Refusing to commit unrelated staged changes. Fix .gitignore or use git add -f.`
3004
+ );
3005
+ }
3006
+ }
3007
+
3008
+ // Verify there's something to commit
3009
+ const status = execSync('git diff --cached --name-only', { cwd: PROJECT_ROOT, encoding: 'utf8' }).trim();
3010
+ if (!status) {
3011
+ throw new Error('Nothing staged to commit. Use --files <path> or stage with git add first.');
3012
+ }
3013
+
3014
+ // Use HEREDOC-style approach: write message to temp file, commit -F
3015
+ const tmpMsgPath = path.join(require('os').tmpdir(), `rihal-commit-msg-${Date.now()}.txt`);
3016
+ fs.writeFileSync(tmpMsgPath, message);
3017
+ try {
3018
+ execSync(`git commit -F "${tmpMsgPath}"`, { cwd: PROJECT_ROOT, stdio: 'pipe' });
3019
+ } finally {
3020
+ try { fs.unlinkSync(tmpMsgPath); } catch {}
3021
+ }
3022
+
3023
+ // Capture the new HEAD SHA for return value
3024
+ const sha = execSync('git rev-parse HEAD', { cwd: PROJECT_ROOT, encoding: 'utf8' }).trim();
3025
+ const filesChanged = execSync(`git show --stat --format="" ${sha}`, { cwd: PROJECT_ROOT, encoding: 'utf8' })
3026
+ .trim().split('\n').filter(Boolean);
3027
+
3028
+ return {
3029
+ ok: true,
3030
+ sha: sha.slice(0, 7),
3031
+ full_sha: sha,
3032
+ subject: subjectLine,
3033
+ files_changed: filesChanged.length > 0 ? filesChanged.length - 1 : 0,
3034
+ };
3035
+ }
3036
+
3037
+ /**
3038
+ * cmdGenerateClaudeMd — Phase 11 / #467 / closes part of #465.
3039
+ *
3040
+ * Bootstrap a project CLAUDE.md scaffold. Used by new-project-roadmap.md.
3041
+ * Refuses to overwrite an existing CLAUDE.md unless --force is set.
3042
+ */
3043
+ function cmdGenerateClaudeMd(rawArgs) {
3044
+ const args = (rawArgs || '').split(/\s+/).filter(Boolean);
3045
+ const force = args.includes('--force');
3046
+ const claudeMdPath = path.join(PROJECT_ROOT, 'CLAUDE.md');
3047
+
3048
+ if (fs.existsSync(claudeMdPath) && !force) {
3049
+ throw new Error(`CLAUDE.md already exists at ${claudeMdPath}. Use --force to overwrite.`);
3050
+ }
3051
+
3052
+ // Resolve project name from package.json or directory.
3053
+ let projectName = path.basename(PROJECT_ROOT);
3054
+ const pkgPath = path.join(PROJECT_ROOT, 'package.json');
3055
+ if (fs.existsSync(pkgPath)) {
3056
+ try {
3057
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
3058
+ if (pkg.name) projectName = pkg.name;
3059
+ } catch { /* keep dir name */ }
3060
+ }
3061
+
3062
+ const today = new Date().toISOString().slice(0, 10);
3063
+ const content = `# ${projectName} — Project Rules for AI Agents
3064
+
3065
+ This file is loaded by Claude Code, Codex, and compatible AI coding tools at the start of every session. Rules below are NON-NEGOTIABLE.
3066
+
3067
+ > Generated by \`rihal-tools generate-claude-md\` on ${today}. Edit freely after generation.
3068
+
3069
+ ---
3070
+
3071
+ ## Commit Rules
3072
+
3073
+ - Follow [Conventional Commits](https://www.conventionalcommits.org/): \`type(scope): subject\`
3074
+ - Types allowed: \`feat\`, \`fix\`, \`docs\`, \`style\`, \`refactor\`, \`test\`, \`chore\`, \`perf\`, \`revert\`, \`plan\`, \`audit\`
3075
+ - Subject: lowercase first letter, imperative mood, no trailing period, ≤ 72 chars
3076
+ - **NEVER add AI attribution** to commit messages — no "Generated with Claude Code", no "Co-Authored-By: Claude"
3077
+ - **NEVER use \`--no-verify\`** to bypass hooks. If hooks fail, fix the underlying issue.
3078
+ - **ALWAYS stage specific files** with \`git add <files>\` — never blanket \`git add -A\` without reading the diff first
3079
+
3080
+ ---
3081
+
3082
+ ## Push Rules
3083
+
3084
+ - **NEVER \`git push\`** without explicit human approval on that specific push
3085
+ - **NEVER \`git push --force\`** under any circumstances without operator typing the command themselves
3086
+ - Every push requires fresh approval — earlier session approvals do not carry forward
3087
+
3088
+ ---
3089
+
3090
+ ## File Modification Rules
3091
+
3092
+ - **Maximum file size: ~1000 lines** — refactor before exceeding
3093
+ - **Refactor incrementally** — never rewrite from scratch
3094
+ - **Preserve existing patterns** — don't introduce new conventions without documented justification
3095
+ - **Verify imports exist** before referencing them
3096
+ - **Never theoretical suggestions** — grep/read first, plan second
3097
+
3098
+ ---
3099
+
3100
+ ## Scope Discipline
3101
+
3102
+ - Do EXACTLY what was asked — nothing more
3103
+ - No "while I'm here" improvements
3104
+ - No speculative abstractions
3105
+ - No new files unless necessary
3106
+
3107
+ ---
3108
+
3109
+ ## Phase Workflow Rules (#475 — non-negotiable)
3110
+
3111
+ When creating, planning, or modifying a phase, you MUST go through the rihal toolchain. Direct file writes to \`.planning/phases/\` produce planning artifacts that are invisible to \`/rihal-status\`, \`/rihal-execute\`, \`/rihal-progress\`, and \`roadmap list-phases\`.
3112
+
3113
+ - **Creating a new phase** → run \`/rihal-add-phase\` (or \`node .rihal/bin/rihal-tools.cjs phase add "<name>"\`). Do NOT \`mkdir .planning/phases/NN-...\` directly.
3114
+ - **Writing SPRINT.md / PLAN.md** → run \`/rihal-plan <N>\`. Spawns \`rihal-planner\` + \`rihal-sprint-checker\`. Do NOT \`Write\` SPRINT.md files directly.
3115
+ - **Discussing phase scope** → run \`/rihal-discuss-phase <N>\` for medium-risk phases. Writes \`<N>-CONTEXT.md\` with locked decisions.
3116
+ - **Use canonical artifact names**: \`<N>-CONTEXT.md\`, \`<N>-RESEARCH.md\`, \`<N>-PLAN.md\` or \`<N>-NN-SPRINT.md\`, \`<N>-VERIFICATION.md\`, \`<N>-SUMMARY.md\`. Do NOT invent \`SCOPE.md\` / \`REVIEW.md\` / \`EDGE-CASES.md\` as phase artifacts — those belong elsewhere or as agent outputs.
3117
+ - **Phase numbering** — sequential integers (\`/rihal-add-phase\`) for new phases; decimal sub-phases (\`100.1\`, \`100.2\` via \`/rihal-insert-phase\`) for hot-fixes branched from a parent. **Do NOT use 1000+ as a hot-track convention** — see [\`docs/phase-numbering.md\`](docs/phase-numbering.md) for the four supported options and when to use each.
3118
+
3119
+ **Why this is enforced**: every direct \`Write\` to \`.planning/phases/**/SPRINT.md\` without registration is a silent state divergence. Future \`/rihal-status\` reports under-count work. \`/rihal-execute\` can't find the plan. \`/rihal-progress\` shows wrong percentages.
3120
+
3121
+ If you have a real reason to bypass (e.g. retroactively documenting a phase that already shipped), put \`<!-- rihal-bypass: <one-line reason> -->\` at the top of the file so it's auditable later. The PreToolUse hook will allow the write through.
3122
+
3123
+ ---
3124
+
3125
+ ## Communication
3126
+
3127
+ - Report progress honestly — do not claim work is done if it isn't
3128
+ - Flag blockers immediately
3129
+ - When unsure, ask — do not guess on destructive operations
3130
+
3131
+ ---
3132
+
3133
+ **This file is part of the project. Treat it as load-bearing.**
3134
+ `;
3135
+
3136
+ fs.writeFileSync(claudeMdPath, content);
3137
+ return {
3138
+ ok: true,
3139
+ path: path.relative(PROJECT_ROOT, claudeMdPath),
3140
+ project_name: projectName,
3141
+ overwritten: force && fs.existsSync(claudeMdPath),
3142
+ };
3143
+ }
3144
+
3145
+ /**
3146
+ * cmdCheckImplementationReadiness — Phase 11 / #467.
3147
+ *
3148
+ * Returns { ready, blockers } indicating whether a phase is ready to execute.
3149
+ * Used by check-implementation-readiness.md workflow.
3150
+ */
3151
+ function cmdCheckImplementationReadiness(rawArgs) {
3152
+ const args = (rawArgs || '').split(/\s+/).filter(Boolean);
3153
+ let phaseNum = null;
3154
+ for (let i = 0; i < args.length; i++) {
3155
+ if (args[i] === '--phase') {
3156
+ phaseNum = args[i + 1];
3157
+ break;
3158
+ }
3159
+ }
3160
+
3161
+ const blockers = [];
3162
+
3163
+ // Check 1 — .planning/ exists
3164
+ if (!fs.existsSync(PLANNING_DIR)) {
3165
+ blockers.push({ severity: 'major', issue: '.planning/ directory missing — run /rihal-new-project first' });
3166
+ }
3167
+
3168
+ // Check 2 — ROADMAP exists
3169
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
3170
+ if (!fs.existsSync(roadmapPath)) {
3171
+ blockers.push({ severity: 'major', issue: '.planning/ROADMAP.md missing — phase planning requires a roadmap' });
3172
+ }
3173
+
3174
+ // Check 3 — phase exists in ROADMAP if phaseNum given
3175
+ if (phaseNum && fs.existsSync(roadmapPath)) {
3176
+ try {
3177
+ const roadmap = require(path.join(__dirname, 'lib', 'roadmap.cjs'));
3178
+ const r = roadmap.dispatch(PROJECT_ROOT, ['get-phase', String(phaseNum)]);
3179
+ if (!r || !r.found) {
3180
+ blockers.push({ severity: 'major', issue: `phase ${phaseNum} not found in ROADMAP.md` });
3181
+ }
3182
+ } catch (e) {
3183
+ blockers.push({ severity: 'minor', issue: `roadmap parser threw: ${e.message}` });
3184
+ }
3185
+ }
3186
+
3187
+ // Check 4 — no blocking anti-patterns flagged
3188
+ if (phaseNum) {
3189
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3190
+ if (fs.existsSync(phasesDir)) {
3191
+ for (const entry of fs.readdirSync(phasesDir)) {
3192
+ if (entry === String(phaseNum) || entry.startsWith(`${phaseNum}-`)) {
3193
+ const continueHere = path.join(phasesDir, entry, '.continue-here.md');
3194
+ if (fs.existsSync(continueHere)) {
3195
+ const content = fs.readFileSync(continueHere, 'utf8');
3196
+ if (/severity:\s*blocking/i.test(content)) {
3197
+ blockers.push({ severity: 'major', issue: `phase ${phaseNum} has unresolved blocking anti-pattern in .continue-here.md` });
3198
+ }
3199
+ }
3200
+ break;
3201
+ }
3202
+ }
3203
+ }
3204
+ }
3205
+
3206
+ return {
3207
+ ok: true,
3208
+ ready: blockers.filter(b => b.severity === 'major').length === 0,
3209
+ phase: phaseNum,
3210
+ blockers,
3211
+ };
3212
+ }
3213
+
3214
+ /**
3215
+ * cmdCommitToSubrepo — Phase 11 / #467.
3216
+ *
3217
+ * Atomic commit within a git subrepo. Reuses cmdCommit's validation but
3218
+ * runs git within the subrepo's directory.
3219
+ */
3220
+ function cmdCommitToSubrepo(argv) {
3221
+ const args = Array.isArray(argv) ? argv : [];
3222
+ let subrepo = null;
3223
+ const passthrough = [];
3224
+
3225
+ for (let i = 0; i < args.length; i++) {
3226
+ if (args[i] === '--subrepo') {
3227
+ subrepo = args[i + 1];
3228
+ i++;
3229
+ continue;
3230
+ }
3231
+ passthrough.push(args[i]);
3232
+ }
3233
+
3234
+ if (!subrepo) {
3235
+ throw new Error('commit-to-subrepo requires --subrepo <path>');
3236
+ }
3237
+
3238
+ const subrepoPath = path.isAbsolute(subrepo) ? subrepo : path.join(PROJECT_ROOT, subrepo);
3239
+ if (!fs.existsSync(subrepoPath)) {
3240
+ throw new Error(`Subrepo not found: ${subrepo}`);
3241
+ }
3242
+ if (!fs.existsSync(path.join(subrepoPath, '.git'))) {
3243
+ throw new Error(`Not a git repository: ${subrepo} (no .git directory)`);
3244
+ }
3245
+
3246
+ // Reuse cmdCommit validation by overriding PROJECT_ROOT temporarily via env.
3247
+ // Cleaner approach: run git commands directly with cwd: subrepoPath.
3248
+ const message = passthrough[0];
3249
+ if (!message || !message.trim()) {
3250
+ throw new Error('commit-to-subrepo requires a message: rihal-tools.cjs commit-to-subrepo --subrepo <path> "<message>"');
3251
+ }
3252
+
3253
+ // AI attribution + conventional-commits validation (same rules as cmdCommit).
3254
+ const aiPatterns = [/co-authored-by:\s*claude/i, /generated with \[?claude/i, /🤖\s*generated/i, /co-authored-by:\s*ai/i];
3255
+ for (const re of aiPatterns) {
3256
+ if (re.test(message)) {
3257
+ throw new Error('AI attribution forbidden in commit messages (project rule).');
3258
+ }
3259
+ }
3260
+ const subjectLine = message.split('\n')[0];
3261
+ const ccRe = /^(feat|fix|docs|style|refactor|test|chore|perf|revert|plan|audit)(\([^)]+\))?:\s+\S/;
3262
+ if (!ccRe.test(subjectLine)) {
3263
+ throw new Error(`Subject must follow conventional commits: type(scope): subject. Got: "${subjectLine.slice(0, 80)}".`);
3264
+ }
3265
+ if (subjectLine.length > 100) {
3266
+ throw new Error(`Subject too long (${subjectLine.length} chars > 100).`);
3267
+ }
3268
+
3269
+ // Check for --no-verify in remaining args (after message at index 0).
3270
+ for (let i = 1; i < passthrough.length; i++) {
3271
+ if (passthrough[i] === '--no-verify') {
3272
+ throw new Error('rihal-tools commit-to-subrepo does not bypass hooks.');
3273
+ }
3274
+ }
3275
+
3276
+ const { execSync } = require('child_process');
3277
+ const status = execSync('git diff --cached --name-only', { cwd: subrepoPath, encoding: 'utf8' }).trim();
3278
+ if (!status) {
3279
+ throw new Error(`Nothing staged in subrepo ${subrepo}. Stage files inside the subrepo with git add first.`);
3280
+ }
3281
+
3282
+ const tmpMsgPath = path.join(require('os').tmpdir(), `rihal-subrepo-msg-${Date.now()}.txt`);
3283
+ fs.writeFileSync(tmpMsgPath, message);
3284
+ try {
3285
+ execSync(`git commit -F "${tmpMsgPath}"`, { cwd: subrepoPath, stdio: 'pipe' });
3286
+ } finally {
3287
+ try { fs.unlinkSync(tmpMsgPath); } catch {}
3288
+ }
3289
+
3290
+ const sha = execSync('git rev-parse HEAD', { cwd: subrepoPath, encoding: 'utf8' }).trim();
3291
+ return {
3292
+ ok: true,
3293
+ subrepo,
3294
+ sha: sha.slice(0, 7),
3295
+ full_sha: sha,
3296
+ subject: subjectLine,
3297
+ };
3298
+ }
3299
+
3300
+ /**
3301
+ * cmdContextRefresh — Phase 11 / #467.
3302
+ *
3303
+ * Refresh the in-project context cache from .rihal/sources.yaml.
3304
+ * Used by init.md. No-op gracefully when no sources configured.
3305
+ */
3306
+ function cmdContextRefresh() {
3307
+ const sourcesPath = path.join(RIHAL_DIR, 'sources.yaml');
3308
+ const contextDir = path.join(RIHAL_DIR, 'context');
3309
+
3310
+ if (!fs.existsSync(sourcesPath)) {
3311
+ return {
3312
+ ok: true,
3313
+ refreshed: false,
3314
+ message: '.rihal/sources.yaml not found — no context to refresh. Configure sources in .rihal/sources.yaml first.',
3315
+ };
3316
+ }
3317
+
3318
+ // Ensure context dir exists
3319
+ if (!fs.existsSync(contextDir)) {
3320
+ fs.mkdirSync(contextDir, { recursive: true });
3321
+ }
3322
+
3323
+ // Touch a refresh marker so consumers can detect last refresh time.
3324
+ const markerPath = path.join(contextDir, '.last-refresh');
3325
+ fs.writeFileSync(markerPath, new Date().toISOString() + '\n');
3326
+
3327
+ return {
3328
+ ok: true,
3329
+ refreshed: true,
3330
+ sources_path: path.relative(PROJECT_ROOT, sourcesPath),
3331
+ context_dir: path.relative(PROJECT_ROOT, contextDir),
3332
+ last_refresh_iso: new Date().toISOString(),
3333
+ };
3334
+ }
3335
+
3336
+ /**
3337
+ * cmdClassifyTech — Phase 11 / #467.
3338
+ *
3339
+ * Classify tech stack from keywords. Used by ui-phase.md to pick the
3340
+ * design contract template.
3341
+ */
3342
+ function cmdClassifyTech(rawArgs) {
3343
+ const args = (rawArgs || '').match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
3344
+ let keywords = '';
3345
+ for (let i = 0; i < args.length; i++) {
3346
+ if (args[i] === '--keywords') {
3347
+ keywords = (args[i + 1] || '').replace(/^["']|["']$/g, '');
3348
+ break;
3349
+ }
3350
+ }
3351
+ if (!keywords) {
3352
+ throw new Error('classify-tech requires --keywords "<keywords>"');
3353
+ }
3354
+
3355
+ const text = keywords.toLowerCase();
3356
+ const stacks = [
3357
+ { stack: 'next.js', category: 'frontend', patterns: [/\bnext\.?js\b/, /\bapp router\b/, /\bnextjs\b/] },
3358
+ { stack: 'react', category: 'frontend', patterns: [/\breact\b/, /\bjsx\b/, /\btsx\b/] },
3359
+ { stack: 'vue', category: 'frontend', patterns: [/\bvue\.?js\b/, /\bnuxt\b/, /\bcomposition api\b/] },
3360
+ { stack: 'svelte', category: 'frontend', patterns: [/\bsvelte\b/, /\bsvelte ?kit\b/] },
3361
+ { stack: 'angular', category: 'frontend', patterns: [/\bangular\b/] },
3362
+ { stack: 'astro', category: 'frontend', patterns: [/\bastro\b/] },
3363
+ { stack: 'remix', category: 'frontend', patterns: [/\bremix\b/] },
3364
+ { stack: 'fastapi', category: 'backend', patterns: [/\bfastapi\b/, /\bpydantic\b/] },
3365
+ { stack: 'express', category: 'backend', patterns: [/\bexpress\b/] },
3366
+ { stack: 'nestjs', category: 'backend', patterns: [/\bnestjs\b/, /\bnest\.?js\b/] },
3367
+ { stack: 'django', category: 'backend', patterns: [/\bdjango\b/] },
3368
+ { stack: 'rails', category: 'backend', patterns: [/\brails\b/, /\bruby on rails\b/] },
3369
+ { stack: 'spring', category: 'backend', patterns: [/\bspring\b/, /\bspring boot\b/] },
3370
+ { stack: 'flutter', category: 'mobile', patterns: [/\bflutter\b/, /\bdart\b/] },
3371
+ { stack: 'react-native', category: 'mobile', patterns: [/\breact native\b/, /\brn\b/] },
3372
+ { stack: 'tailwind', category: 'styling', patterns: [/\btailwind\b/] },
3373
+ { stack: 'shadcn', category: 'styling', patterns: [/\bshadcn\b/, /\bradix\b/] },
3374
+ ];
3375
+
3376
+ let best = { stack: 'unknown', category: 'unknown', confidence: 0, matches: [] };
3377
+ for (const s of stacks) {
3378
+ const hits = s.patterns.filter(p => p.test(text));
3379
+ if (hits.length === 0) continue;
3380
+ const conf = Math.min(1, hits.length / s.patterns.length + 0.3);
3381
+ if (conf > best.confidence) {
3382
+ best = { stack: s.stack, category: s.category, confidence: Number(conf.toFixed(2)), matches: hits.map(h => h.toString()) };
3383
+ }
3384
+ }
3385
+
3386
+ return { ok: true, ...best };
3387
+ }
3388
+
2179
3389
  /**
2180
3390
  * Classify the scope of input based on keywords and length.
2181
- * Returns one of: 'ticket', 'feature', 'phase', 'initiative'
3391
+ * Returns one of: 'ticket', 'feature', 'phase', 'initiative', 'drift'
2182
3392
  *
2183
3393
  * Priority order:
2184
- * 1. Initiative keywords (highest)
2185
- * 2. Phase keywords
2186
- * 3. Feature keywords (add, implement, build)
2187
- * 4. Ticket keywords
2188
- * 5. Length-based fallback
3394
+ * 1. Drift / audit / re-audit / extend-existing-artifact intent (Phase 6)
3395
+ * 2. Initiative keywords
3396
+ * 3. Phase keywords
3397
+ * 4. Feature keywords (add, implement, build)
3398
+ * 5. Ticket keywords
3399
+ * 6. Length-based fallback
2189
3400
  */
2190
3401
  function classifyScope(input) {
2191
3402
  const text = (input || '').toLowerCase();
2192
3403
  const len = text.length;
2193
3404
 
2194
- // Initiative signals highest priority
3405
+ // Drift / audit / re-audit / extend-existing-artifact intent.
3406
+ // Routes /rihal-do to /rihal-feature-drift instead of falling through to
3407
+ // inline execution (closes the residual edge case from #458).
3408
+ if (/\b(drift|re-?audit|stale|out[- ]of[- ]date|fill out (the|this|existing)|extend (audit|plan|phase)|verify (docs|claims) vs (code|reality))\b/i.test(text)) {
3409
+ return 'drift';
3410
+ }
3411
+
3412
+ // Initiative signals — highest priority among scope tiers
2195
3413
  if (/\b(milestone|initiative|roadmap|multi-team|multi-sprint|q[1-4]\s*\d{4})\b/.test(text)) {
2196
3414
  return 'initiative';
2197
3415
  }
@@ -2355,6 +3573,351 @@ function cmdPlanList() {
2355
3573
  };
2356
3574
  }
2357
3575
 
3576
+ /** phase-plan-index — JSON inventory of plans (SPRINT.md) under a phase, with wave grouping and summary detection. */
3577
+ function cmdPhasePlanIndex(rawArgs) {
3578
+ const phaseArg = String(rawArgs || '').trim();
3579
+ if (!phaseArg) {
3580
+ console.error('Usage: phase-plan-index <phase-number>');
3581
+ process.exit(1);
3582
+ }
3583
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3584
+ if (!fs.existsSync(phasesDir)) return { phase: phaseArg, plans: [], waves: {}, incomplete: 0, has_checkpoints: false };
3585
+ const norm = phaseArg.replace(/^0+/, '') || '0';
3586
+ const dirs = fs.readdirSync(phasesDir).filter((d) => {
3587
+ const m = d.match(/^(\d+)(?:[-.])/);
3588
+ if (!m) return false;
3589
+ const n = m[1].replace(/^0+/, '') || '0';
3590
+ return n === norm;
3591
+ });
3592
+ if (dirs.length === 0) return { phase: phaseArg, plans: [], waves: {}, incomplete: 0, has_checkpoints: false, phase_dir: null };
3593
+ const phaseDir = path.join(phasesDir, dirs[0]);
3594
+ const all = fs.readdirSync(phaseDir);
3595
+ const sprintFiles = all.filter((f) => /-SPRINT\.md$/i.test(f)).sort();
3596
+ const summarySet = new Set(all.filter((f) => /-SUMMARY\.md$/i.test(f)).map((f) => f.replace(/-SUMMARY\.md$/i, '')));
3597
+ let hasCheckpoints = false;
3598
+ const plans = sprintFiles.map((file) => {
3599
+ const stem = file.replace(/-SPRINT\.md$/i, '');
3600
+ const text = fs.readFileSync(path.join(phaseDir, file), 'utf8');
3601
+ const { frontmatter, body } = parseFrontmatter(text);
3602
+ const id = frontmatter.sprint || frontmatter.plan || stem;
3603
+ const wave = parseInt(frontmatter.wave || '1', 10) || 1;
3604
+ const autonomous = String(frontmatter.autonomous || '').toLowerCase() === 'true';
3605
+ const gapClosure = String(frontmatter.gap_closure || frontmatter.type || '').toLowerCase() === 'gap_closure';
3606
+ const objMatch = body.match(/^##\s+(?:Objective|Goal)\s*\n+([^\n]+)/mi);
3607
+ const objective = objMatch ? objMatch[1].trim() : (frontmatter.goal || '').replace(/^["']|["']$/g, '');
3608
+ const taskCount = (body.match(/^[-*]\s+\[[ xX]\]/gm) || []).length;
3609
+ const filesModified = (body.match(/^\s*-\s*path:\s*["']?([^"'\n]+)/gm) || []).length;
3610
+ const hasSummary = summarySet.has(stem);
3611
+ if (/checkpoint/i.test(body)) hasCheckpoints = true;
3612
+ return { id, wave, autonomous, gap_closure: gapClosure, objective, task_count: taskCount, files_modified: filesModified, has_summary: hasSummary, file: path.relative(PROJECT_ROOT, path.join(phaseDir, file)) };
3613
+ });
3614
+ const waves = {};
3615
+ for (const p of plans) {
3616
+ const k = String(p.wave);
3617
+ if (!waves[k]) waves[k] = [];
3618
+ waves[k].push(p.id);
3619
+ }
3620
+ const incomplete = plans.filter((p) => !p.has_summary).length;
3621
+ return {
3622
+ phase: phaseArg,
3623
+ phase_dir: path.relative(PROJECT_ROOT, phaseDir),
3624
+ plans,
3625
+ waves,
3626
+ incomplete,
3627
+ has_checkpoints: hasCheckpoints,
3628
+ };
3629
+ }
3630
+
3631
+ /** phases list — directory inventory under .planning/phases with optional --type filter and --pick path. */
3632
+ function cmdPhasesList(args) {
3633
+ const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
3634
+ let type = 'all';
3635
+ let pick = null;
3636
+ let raw = false;
3637
+ for (let i = 0; i < argv.length; i++) {
3638
+ if (argv[i] === '--type') type = argv[++i];
3639
+ else if (argv[i] === '--pick') pick = argv[++i];
3640
+ else if (argv[i] === '--raw') raw = true;
3641
+ }
3642
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3643
+ const directories = fs.existsSync(phasesDir)
3644
+ ? fs.readdirSync(phasesDir)
3645
+ .filter((d) => fs.statSync(path.join(phasesDir, d)).isDirectory() && /^\d/.test(d))
3646
+ .sort((a, b) => {
3647
+ const na = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
3648
+ const nb = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
3649
+ return na - nb;
3650
+ })
3651
+ : [];
3652
+ const summaries = [];
3653
+ const sprints = [];
3654
+ for (const d of directories) {
3655
+ const dirPath = path.join(phasesDir, d);
3656
+ for (const f of fs.readdirSync(dirPath)) {
3657
+ const rel = path.relative(PROJECT_ROOT, path.join(dirPath, f));
3658
+ if (/-SUMMARY\.md$/i.test(f) || /^SUMMARY\.md$/i.test(f)) summaries.push(rel);
3659
+ if (/-SPRINT\.md$/i.test(f)) sprints.push(rel);
3660
+ }
3661
+ }
3662
+ const result = { directories, summaries, sprints };
3663
+ if (pick) {
3664
+ const m = pick.match(/^([a-z_]+)\[(-?\d+)\]$/i);
3665
+ if (m) {
3666
+ const arr = result[m[1]] || [];
3667
+ const idx = parseInt(m[2], 10);
3668
+ const val = arr[idx < 0 ? arr.length + idx : idx];
3669
+ console.log(val == null ? '' : val);
3670
+ return;
3671
+ }
3672
+ const v = result[pick];
3673
+ if (Array.isArray(v)) console.log(v.join('\n'));
3674
+ else if (v != null) console.log(v);
3675
+ return;
3676
+ }
3677
+ if (type === 'summaries') return { summaries };
3678
+ if (type === 'sprints') return { sprints };
3679
+ if (type === 'directories') return { directories };
3680
+ return result;
3681
+ }
3682
+
3683
+ /** find-phase — resolve a phase number (with or without leading zero) to its directory and metadata. */
3684
+ function cmdFindPhase(args) {
3685
+ const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
3686
+ const target = argv.find((a) => !a.startsWith('--'));
3687
+ if (!target) {
3688
+ console.error('Usage: find-phase <N> [--raw]');
3689
+ process.exit(1);
3690
+ }
3691
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3692
+ if (!fs.existsSync(phasesDir)) return { number: target, exists: false, dir: null, slug: null, decimal_children: [] };
3693
+ const norm = target.replace(/^0+/, '') || '0';
3694
+ const all = fs.readdirSync(phasesDir).filter((d) => fs.statSync(path.join(phasesDir, d)).isDirectory());
3695
+ const exact = all.find((d) => {
3696
+ const m = d.match(/^(\d+(?:\.\d+)?)(?:[-.])/) || d.match(/^(\d+(?:\.\d+)?)$/);
3697
+ if (!m) return false;
3698
+ return (m[1].replace(/^0+/, '') || '0') === norm;
3699
+ });
3700
+ const decimal_children = all
3701
+ .filter((d) => {
3702
+ const m = d.match(/^(\d+)\.(\d+)[-.]/);
3703
+ return m && (m[1].replace(/^0+/, '') || '0') === norm;
3704
+ })
3705
+ .map((d) => path.relative(PROJECT_ROOT, path.join(phasesDir, d)));
3706
+ if (!exact) return { number: target, exists: false, dir: null, slug: null, decimal_children };
3707
+ const slugMatch = exact.match(/^\d+(?:\.\d+)?[-](.+)$/);
3708
+ return {
3709
+ number: target,
3710
+ exists: true,
3711
+ dir: path.relative(PROJECT_ROOT, path.join(phasesDir, exact)),
3712
+ slug: slugMatch ? slugMatch[1] : '',
3713
+ decimal_children,
3714
+ };
3715
+ }
3716
+
3717
+ /** audit-uat — walk all UAT files under .planning/phases/ and return inventory + status counts. */
3718
+ function cmdAuditUat(args) {
3719
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3720
+ const counts = { pending: 0, passed: 0, failed: 0, skipped: 0, blocked: 0, human_uat: 0, resolved: 0 };
3721
+ const files = [];
3722
+ if (fs.existsSync(phasesDir)) {
3723
+ for (const d of fs.readdirSync(phasesDir)) {
3724
+ const dp = path.join(phasesDir, d);
3725
+ if (!fs.statSync(dp).isDirectory()) continue;
3726
+ for (const f of fs.readdirSync(dp)) {
3727
+ if (!/UAT.*\.md$|VERIFICATION\.md$/i.test(f)) continue;
3728
+ const fp = path.join(dp, f);
3729
+ const text = fs.readFileSync(fp, 'utf8');
3730
+ const fileCounts = { pending: 0, passed: 0, failed: 0, skipped: 0, blocked: 0, human_uat: 0, resolved: 0 };
3731
+ const items = [];
3732
+ const statusRe = /^[\s-]*status:\s*([a-z_]+)/gim;
3733
+ let m;
3734
+ while ((m = statusRe.exec(text)) !== null) {
3735
+ const s = m[1].toLowerCase();
3736
+ if (s in fileCounts) fileCounts[s]++;
3737
+ if (s in counts) counts[s]++;
3738
+ items.push({ status: s });
3739
+ }
3740
+ const checkRe = /^[-*]\s+\[([ xX!?])\]\s+(.+)$/gm;
3741
+ while ((m = checkRe.exec(text)) !== null) {
3742
+ const mark = m[1];
3743
+ if (mark === ' ') { fileCounts.pending++; counts.pending++; items.push({ status: 'pending', text: m[2].trim() }); }
3744
+ else if (mark === 'x' || mark === 'X') { fileCounts.passed++; counts.passed++; items.push({ status: 'passed', text: m[2].trim() }); }
3745
+ else if (mark === '!') { fileCounts.blocked++; counts.blocked++; items.push({ status: 'blocked', text: m[2].trim() }); }
3746
+ else if (mark === '?') { fileCounts.human_uat++; counts.human_uat++; items.push({ status: 'human_uat', text: m[2].trim() }); }
3747
+ }
3748
+ files.push({ path: path.relative(PROJECT_ROOT, fp), status_counts: fileCounts, items });
3749
+ }
3750
+ }
3751
+ }
3752
+ const total_items = Object.values(counts).reduce((a, b) => a + b, 0);
3753
+ return {
3754
+ summary: {
3755
+ total_files: files.length,
3756
+ total_items,
3757
+ phase_count: new Set(files.map((f) => f.path.match(/phases\/([^/]+)/)?.[1])).size,
3758
+ ...counts,
3759
+ },
3760
+ results: files,
3761
+ };
3762
+ }
3763
+
3764
+ /** uat render-checkpoint — render a markdown checkpoint block from a UAT file. */
3765
+ function cmdUatRenderCheckpoint(args) {
3766
+ const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
3767
+ let file = null;
3768
+ for (let i = 0; i < argv.length; i++) {
3769
+ if (argv[i] === '--file') file = argv[++i];
3770
+ }
3771
+ if (!file || !fs.existsSync(file)) {
3772
+ console.error('Usage: uat render-checkpoint --file <path>');
3773
+ process.exit(1);
3774
+ }
3775
+ const text = fs.readFileSync(file, 'utf8');
3776
+ const { frontmatter } = parseFrontmatter(text);
3777
+ const phase = frontmatter.phase || '';
3778
+ const lines = [];
3779
+ lines.push(`## UAT Checkpoint — Phase ${phase}`);
3780
+ lines.push('');
3781
+ const pendingRe = /^[-*]\s+\[ \]\s+(.+)$/gm;
3782
+ const pending = [];
3783
+ let m;
3784
+ while ((m = pendingRe.exec(text)) !== null) pending.push(m[1].trim());
3785
+ if (pending.length === 0) {
3786
+ lines.push('No pending UAT items. ✅');
3787
+ } else {
3788
+ lines.push(`**${pending.length} pending item(s):**`);
3789
+ lines.push('');
3790
+ pending.slice(0, 20).forEach((p, i) => lines.push(`${i + 1}. ${p}`));
3791
+ if (pending.length > 20) lines.push(`...and ${pending.length - 20} more`);
3792
+ }
3793
+ lines.push('');
3794
+ lines.push('Reply: `pass`, `fail <reason>`, `skip`, or `block <reason>`.');
3795
+ return { __raw: lines.join('\n') };
3796
+ }
3797
+
3798
+ /** requirements mark-complete — flip status to complete for given requirement IDs in REQUIREMENTS.md. */
3799
+ function cmdRequirementsMarkComplete(args) {
3800
+ const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
3801
+ const ids = argv.filter((a) => !a.startsWith('--'));
3802
+ if (ids.length === 0) return { updated: [], not_found: [] };
3803
+ const reqPath = path.join(PLANNING_DIR, 'REQUIREMENTS.md');
3804
+ if (!fs.existsSync(reqPath)) return { updated: [], not_found: ids, reason: 'REQUIREMENTS.md not found' };
3805
+ let text = fs.readFileSync(reqPath, 'utf8');
3806
+ const updated = [];
3807
+ const notFound = [];
3808
+ for (const id of ids) {
3809
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3810
+ const re = new RegExp(`(\\| *${escaped} *\\|[^\\n]*?\\| *)([^|\\n]+)( *\\|)`, 'g');
3811
+ let matched = false;
3812
+ text = text.replace(re, (full, pre, status, post) => {
3813
+ matched = true;
3814
+ return `${pre}complete${post}`;
3815
+ });
3816
+ if (matched) updated.push(id); else notFound.push(id);
3817
+ }
3818
+ if (updated.length > 0) fs.writeFileSync(reqPath, text);
3819
+ return { updated, not_found: notFound };
3820
+ }
3821
+
3822
+ /** todo match-phase — return todos whose phase tag matches N. */
3823
+ function cmdTodoMatchPhase(args) {
3824
+ const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
3825
+ const phase = argv.find((a) => !a.startsWith('--'));
3826
+ if (!phase) {
3827
+ console.error('Usage: todo match-phase <N>');
3828
+ process.exit(1);
3829
+ }
3830
+ const todoDirs = [
3831
+ path.join(PLANNING_DIR, 'notes', 'todos'),
3832
+ path.join(PLANNING_DIR, 'todos'),
3833
+ path.join(PLANNING_DIR, 'notes'),
3834
+ ];
3835
+ const matches = [];
3836
+ for (const dir of todoDirs) {
3837
+ if (!fs.existsSync(dir)) continue;
3838
+ for (const f of fs.readdirSync(dir)) {
3839
+ if (!f.endsWith('.md')) continue;
3840
+ const fp = path.join(dir, f);
3841
+ const text = fs.readFileSync(fp, 'utf8');
3842
+ const { frontmatter, body } = parseFrontmatter(text);
3843
+ const tagMatch = frontmatter.phase === phase
3844
+ || (frontmatter.tags || '').split(',').map((t) => t.trim()).includes(`phase-${phase}`)
3845
+ || new RegExp(`\\bphase[\\s-]?${phase}\\b`, 'i').test(body);
3846
+ if (!tagMatch) continue;
3847
+ const titleMatch = body.match(/^#\s+(.+)$/m);
3848
+ matches.push({
3849
+ file: path.relative(PROJECT_ROOT, fp),
3850
+ title: titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, ''),
3851
+ area: frontmatter.area || frontmatter.tags || '',
3852
+ score: 1.0,
3853
+ reasons: [`phase tag matched ${phase}`],
3854
+ });
3855
+ }
3856
+ }
3857
+ return { phase, todo_count: matches.length, matches };
3858
+ }
3859
+
3860
+ /** learnings copy — soft-fail copy of a phase's LEARNINGS.md to the global store. */
3861
+ function cmdLearningsCopy(args) {
3862
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3863
+ if (!fs.existsSync(phasesDir)) return { copied: 0, reason: 'no phases dir' };
3864
+ const dirs = fs.readdirSync(phasesDir).filter((d) => fs.statSync(path.join(phasesDir, d)).isDirectory());
3865
+ const learnings = dirs
3866
+ .map((d) => path.join(phasesDir, d, 'LEARNINGS.md'))
3867
+ .filter((p) => fs.existsSync(p));
3868
+ if (learnings.length === 0) return { copied: 0, reason: 'no LEARNINGS.md found in any phase' };
3869
+ const globalDir = path.join(process.env.HOME || '', '.rihal', 'learnings');
3870
+ if (!fs.existsSync(globalDir)) fs.mkdirSync(globalDir, { recursive: true });
3871
+ const project = path.basename(PROJECT_ROOT);
3872
+ let copied = 0;
3873
+ for (const src of learnings) {
3874
+ const phase = src.match(/phases\/([^/]+)\//)?.[1] || 'unknown';
3875
+ const dest = path.join(globalDir, `${project}__${phase}.md`);
3876
+ fs.copyFileSync(src, dest);
3877
+ copied++;
3878
+ }
3879
+ return { copied, project, store: globalDir };
3880
+ }
3881
+
3882
+ /** frontmatter get — extract a single field from a markdown file's YAML frontmatter. */
3883
+ function cmdFrontmatterGet(args) {
3884
+ const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
3885
+ let file = null;
3886
+ let field = null;
3887
+ for (let i = 0; i < argv.length; i++) {
3888
+ if (argv[i] === '--field') field = argv[++i];
3889
+ else if (!argv[i].startsWith('--') && !file) file = argv[i];
3890
+ }
3891
+ if (!file || !field) {
3892
+ console.error('Usage: frontmatter get <file> --field <name>');
3893
+ process.exit(1);
3894
+ }
3895
+ if (!fs.existsSync(file)) {
3896
+ console.error(`File not found: ${file}`);
3897
+ process.exit(1);
3898
+ }
3899
+ const text = fs.readFileSync(file, 'utf8');
3900
+ const { frontmatter } = parseFrontmatter(text);
3901
+ const val = frontmatter[field];
3902
+ if (val === undefined) { console.log(''); return; }
3903
+ console.log(val);
3904
+ return;
3905
+ }
3906
+
3907
+ /** docs-audit — placeholder inventory of documentation gaps; non-fatal. */
3908
+ function cmdDocsAudit(args) {
3909
+ const reqPath = path.join(PLANNING_DIR, 'documentation-requirements.csv');
3910
+ if (!fs.existsSync(reqPath)) {
3911
+ return { has_requirements: false, gaps: [], reason: 'no documentation-requirements.csv — nothing to audit' };
3912
+ }
3913
+ const text = fs.readFileSync(reqPath, 'utf8');
3914
+ const rows = text.split('\n').slice(1).filter((l) => l.trim()).map((l) => l.split(','));
3915
+ const gaps = rows
3916
+ .filter((cols) => cols[1] && !fs.existsSync(path.join(PROJECT_ROOT, cols[1].trim())))
3917
+ .map((cols) => ({ doc: cols[0]?.trim(), expected_path: cols[1]?.trim() }));
3918
+ return { has_requirements: true, total: rows.length, gaps };
3919
+ }
3920
+
2358
3921
  /** init chain — context blob for /rihal-chain workflow. */
2359
3922
  function cmdInitChain(rawArgs) {
2360
3923
  const config = readConfig();
@@ -2535,15 +4098,15 @@ function cmdResolveModel(agentId) {
2535
4098
  throw new Error(`Unknown agent: ${agentId}. Valid agents: ${installedAgents.join(', ')}`);
2536
4099
  }
2537
4100
 
2538
- // Model assignments per profile
4101
+ // Model assignments per profile (Claude 4 family: opus-4-7, sonnet-4-6, haiku-4-5)
2539
4102
  const QUALITY_AGENTS = {
2540
- 'rihal-sadiq': 'claude-3-5-opus-20241022',
2541
- 'rihal-waleed': 'claude-3-5-opus-20241022',
2542
- 'rihal-planner': 'claude-3-5-opus-20241022',
2543
- 'rihal-sprint-checker': 'claude-3-5-opus-20241022',
2544
- 'rihal-fatima': 'claude-3-5-sonnet-20241022',
2545
- 'rihal-executor': 'claude-3-5-sonnet-20241022',
2546
- 'rihal-verifier': 'claude-3-5-sonnet-20241022',
4103
+ 'rihal-sadiq': 'claude-opus-4-7',
4104
+ 'rihal-waleed': 'claude-opus-4-7',
4105
+ 'rihal-planner': 'claude-opus-4-7',
4106
+ 'rihal-sprint-checker': 'claude-opus-4-7',
4107
+ 'rihal-fatima': 'claude-sonnet-4-6',
4108
+ 'rihal-executor': 'claude-sonnet-4-6',
4109
+ 'rihal-verifier': 'claude-sonnet-4-6',
2547
4110
  };
2548
4111
 
2549
4112
  if (profile === 'inherit') {
@@ -2551,20 +4114,20 @@ function cmdResolveModel(agentId) {
2551
4114
  }
2552
4115
 
2553
4116
  if (profile === 'budget') {
2554
- return { model: 'claude-3-5-haiku-20241022', profile: 'budget', agent: agentId };
4117
+ return { model: 'claude-haiku-4-5-20251001', profile: 'budget', agent: agentId };
2555
4118
  }
2556
4119
 
2557
4120
  if (profile === 'balanced') {
2558
- return { model: 'claude-3-5-sonnet-20241022', profile: 'balanced', agent: agentId };
4121
+ return { model: 'claude-sonnet-4-6', profile: 'balanced', agent: agentId };
2559
4122
  }
2560
4123
 
2561
4124
  if (profile === 'quality') {
2562
- const model = QUALITY_AGENTS[agentId] || 'claude-3-5-haiku-20241022';
4125
+ const model = QUALITY_AGENTS[agentId] || 'claude-sonnet-4-6';
2563
4126
  return { model, profile: 'quality', agent: agentId };
2564
4127
  }
2565
4128
 
2566
4129
  // Unknown profile, default to balanced
2567
- return { model: 'claude-3-5-sonnet-20241022', profile: 'balanced', agent: agentId, warning: `Unknown profile '${profile}'; using balanced` };
4130
+ return { model: 'claude-sonnet-4-6', profile: 'balanced', agent: agentId, warning: `Unknown profile '${profile}'; using balanced` };
2568
4131
  }
2569
4132
 
2570
4133
  /**
@@ -3027,6 +4590,11 @@ function cmdBrain(args) {
3027
4590
  function cmdProgress(args) {
3028
4591
  const sub = args[0] || 'init';
3029
4592
  const rawMode = args.includes('--raw');
4593
+ // #200 — opt-in strict mode: exit 1 when insights contain drift/undercount.
4594
+ // Off by default (warning preserves the soft-surface UX). Toggle via --strict
4595
+ // flag or RIHAL_STRICT_STATE=true env var. Used by CI / pre-deploy gates.
4596
+ const strictMode = args.includes('--strict')
4597
+ || /^(true|1|yes)$/i.test(process.env.RIHAL_STRICT_STATE || '');
3030
4598
 
3031
4599
  // Resolve paths — workflow files may run this from any subdirectory.
3032
4600
  const statePath = path.join(RIHAL_DIR, 'state.json');
@@ -3046,7 +4614,8 @@ function cmdProgress(args) {
3046
4614
  const seen = new Set();
3047
4615
 
3048
4616
  // Format A — markdown pipe tables: | 07 | Name | Goal |
3049
- const rowRe = /^\|\s*(\d{1,3}(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
4617
+ // Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
4618
+ const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
3050
4619
  let m;
3051
4620
  while ((m = rowRe.exec(text)) !== null) {
3052
4621
  const num = m[1].trim();
@@ -3060,7 +4629,8 @@ function cmdProgress(args) {
3060
4629
  }
3061
4630
 
3062
4631
  // Format B — heading style: ## Phase 07 — Name / ### Phase 07: Name / ## Phase 07 - Name
3063
- const headRe = /^#{2,4}\s*Phase\s+(\d{1,3}(?:\.\d+)?)\s*[—\-:]\s*([^\n]+)$/gm;
4632
+ // Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
4633
+ const headRe = /^#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*[—\-:]\s*([^\n]+)$/gm;
3064
4634
  while ((m = headRe.exec(text)) !== null) {
3065
4635
  const num = m[1].trim();
3066
4636
  const name = m[2].trim();
@@ -3110,7 +4680,8 @@ function cmdProgress(args) {
3110
4680
  for (const entry of fs.readdirSync(phasesDir)) {
3111
4681
  const full = path.join(phasesDir, entry);
3112
4682
  if (!fs.statSync(full).isDirectory()) continue;
3113
- const numMatch = entry.match(/^(\d{1,3}(?:\.\d+)?)/);
4683
+ // Phase 14 / #476 — \d+ supports high-N phase dirs (1000+).
4684
+ const numMatch = entry.match(/^(\d+(?:\.\d+)?)/);
3114
4685
  if (!numMatch) continue;
3115
4686
  const num = numMatch[1];
3116
4687
  const files = fs.readdirSync(full);
@@ -3127,6 +4698,21 @@ function cmdProgress(args) {
3127
4698
  return byNum;
3128
4699
  }
3129
4700
 
4701
+ // #200 — opt-in strict gate. Walks insights for drift/undercount kinds and
4702
+ // exits 1 with the failure list to stderr. No-op when strictMode=false.
4703
+ function enforceStrictGate(insightsList) {
4704
+ if (!strictMode) return;
4705
+ const blocking = (insightsList || []).filter(i =>
4706
+ i && (i.kind === 'drift' || i.kind === 'undercount') && i.severity !== 'info'
4707
+ );
4708
+ if (blocking.length === 0) return;
4709
+ process.stderr.write('✖ State drift detected — state.json is out of sync with disk.\n');
4710
+ for (const i of blocking) process.stderr.write(` • ${i.message}\n`);
4711
+ process.stderr.write('\n Auto-fix: node .rihal/bin/rihal-tools.cjs state sync --from-disk\n');
4712
+ process.stderr.write(' Inspect: node .rihal/bin/rihal-tools.cjs state read\n');
4713
+ process.exit(1);
4714
+ }
4715
+
3130
4716
  function detectInsights(state, roadmapPhases, diskByNum) {
3131
4717
  const insights = [];
3132
4718
  const statePhases = (state && (state.state?.phases || state.phases)) || [];
@@ -3155,6 +4741,39 @@ function cmdProgress(args) {
3155
4741
  });
3156
4742
  }
3157
4743
 
4744
+ // Phantom-complete: phase claimed Complete (in ROADMAP or state) but missing
4745
+ // PLAN.md AND SUMMARY.md on disk. User-visible bug: /rihal-status would
4746
+ // happily report 'all complete' while /rihal-audit correctly flagged the
4747
+ // gap because the two read different sources of truth.
4748
+ // Surfaced 2026-04-29 in a real session — siraaj phases 07-12 had ROADMAP
4749
+ // markers but zero artifacts.
4750
+ const phantomCompletes = [];
4751
+ const claimedComplete = (p) => {
4752
+ if (!p) return false;
4753
+ const s = String(p.status ?? '').toLowerCase();
4754
+ return p.completed || s === 'complete' || s === 'completed' || s === 'done';
4755
+ };
4756
+ // Walk ROADMAP-claimed completes and state-claimed completes, both directions.
4757
+ const completeKeys = new Set();
4758
+ for (const p of roadmapPhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
4759
+ for (const p of statePhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
4760
+ for (const k of completeKeys) {
4761
+ const disk = diskByNum[k] || diskByNum[k.padStart(2, '0')];
4762
+ // Only flag when the phase dir EXISTS — purely-state-only entries are a
4763
+ // separate problem (drift/undercount above). Here we want claim-vs-files.
4764
+ if (!disk) continue;
4765
+ if (disk.plan_count === 0 && disk.summary_count === 0) {
4766
+ phantomCompletes.push(k);
4767
+ }
4768
+ }
4769
+ if (phantomCompletes.length > 0) {
4770
+ insights.push({
4771
+ kind: 'phantom-complete',
4772
+ severity: 'warn',
4773
+ message: `${phantomCompletes.length} phase(s) marked Complete but missing both PLAN.md and SUMMARY.md on disk: ${phantomCompletes.slice(0, 5).join(', ')}. The completion claim is unsupported. Run /rihal-audit phase <N> to inspect.`,
4774
+ });
4775
+ }
4776
+
3158
4777
  // Between-milestones heuristic: no current_phase + previous milestone's last phase is complete
3159
4778
  if (state && state.current_phase === null && statePhases.length > 0) {
3160
4779
  const allComplete = statePhases.every(p => p.status === 'complete' || p.completed);
@@ -3167,6 +4786,35 @@ function cmdProgress(args) {
3167
4786
  }
3168
4787
  }
3169
4788
 
4789
+ // Stuck-phase: in_progress phase with no commits touching its .planning dir in 7+ days
4790
+ try {
4791
+ const inProgressPhases = statePhases.filter(p => {
4792
+ const s = String(p.status ?? '').toLowerCase();
4793
+ return s === 'in_progress' || s === 'in-progress' || s === 'executing';
4794
+ });
4795
+ for (const p of inProgressPhases) {
4796
+ const key = norm(phaseKey(p));
4797
+ const disk = diskByNum[key] || diskByNum[key.padStart(2, '0')];
4798
+ if (!disk) continue;
4799
+ const dirName = disk.dirName;
4800
+ const gitArgs = ['log', '--oneline', '--since=7 days ago', '--', `.planning/phases/${dirName}/`];
4801
+ let recentCommits = '';
4802
+ try {
4803
+ recentCommits = require('child_process').execSync(
4804
+ `git ${gitArgs.join(' ')}`,
4805
+ { cwd: PROJECT_ROOT, stdio: 'pipe', timeout: 5000 }
4806
+ ).toString().trim();
4807
+ } catch { /* git not available or no history */ }
4808
+ if (recentCommits === '') {
4809
+ insights.push({
4810
+ kind: 'stuck-phase',
4811
+ severity: 'warn',
4812
+ message: `Phase ${key} is in progress but has no commits in the last 7 days. It may be stuck. Run /rihal-status or /rihal-audit phase ${key} to investigate.`,
4813
+ });
4814
+ }
4815
+ }
4816
+ } catch { /* non-fatal — git unavailable or project root not set */ }
4817
+
3170
4818
  return insights;
3171
4819
  }
3172
4820
 
@@ -3184,7 +4832,7 @@ function cmdProgress(args) {
3184
4832
  routes.push({
3185
4833
  letter: 'A',
3186
4834
  label: `Execute phase ${k} — unfinished plans`,
3187
- command: `/rihal-execute-phase ${k}`,
4835
+ command: `/rihal-execute ${k}`,
3188
4836
  });
3189
4837
  }
3190
4838
 
@@ -3291,7 +4939,9 @@ function cmdProgress(args) {
3291
4939
  }
3292
4940
 
3293
4941
  if (sub === 'insights') {
3294
- return { ok: true, insights: detectInsights(state, roadmapPhases, diskByNum) };
4942
+ const insightsList = detectInsights(state, roadmapPhases, diskByNum);
4943
+ enforceStrictGate(insightsList);
4944
+ return { ok: true, insights: insightsList };
3295
4945
  }
3296
4946
 
3297
4947
  if (sub === 'routes') {
@@ -3301,6 +4951,7 @@ function cmdProgress(args) {
3301
4951
  // sub === 'init' (default) — full snapshot
3302
4952
  const currentPhase = state && state.current_phase;
3303
4953
  const insights = detectInsights(state, roadmapPhases, diskByNum);
4954
+ enforceStrictGate(insights);
3304
4955
  const routes = deriveRoutes(state, roadmapPhases, diskByNum);
3305
4956
  const { weighted: weightedCompleted, pct: weightedPct } = computeWeightedProgress(statePhases, diskByNum);
3306
4957
 
@@ -3572,6 +5223,12 @@ function cmdFindFiles(rawArgs) {
3572
5223
 
3573
5224
  async function main() {
3574
5225
  const [, , subcommand, ...args] = process.argv;
5226
+ // #473 guard runs before any subcommand. Skipped for read-only inspection
5227
+ // so 'rihal-tools version' / 'help' / 'list-agents' work outside the project.
5228
+ const READ_ONLY_SUBCOMMANDS = new Set(['version', 'help', '--help', '-h', undefined, 'list-agents', 'agent-info', 'agent-skills']);
5229
+ if (!READ_ONLY_SUBCOMMANDS.has(subcommand)) {
5230
+ assertCwdMatchesProjectRoot();
5231
+ }
3575
5232
  try {
3576
5233
  let result;
3577
5234
  switch (subcommand) {
@@ -3592,6 +5249,44 @@ async function main() {
3592
5249
  if (args[0] === 'list') { result = cmdPlanList(); }
3593
5250
  else { console.error('Unknown plan subcommand. Valid: list'); process.exit(1); }
3594
5251
  break;
5252
+ case 'phase-plan-index':
5253
+ result = cmdPhasePlanIndex(args.join(' '));
5254
+ break;
5255
+ case 'phases':
5256
+ if (args[0] === 'list') { result = cmdPhasesList(args.slice(1)); if (result === undefined) return; }
5257
+ else { console.error('Unknown phases subcommand. Valid: list'); process.exit(1); }
5258
+ break;
5259
+ case 'find-phase':
5260
+ result = cmdFindPhase(args);
5261
+ break;
5262
+ case 'audit-uat':
5263
+ result = cmdAuditUat(args);
5264
+ break;
5265
+ case 'uat':
5266
+ if (args[0] === 'render-checkpoint') {
5267
+ const r = cmdUatRenderCheckpoint(args.slice(1));
5268
+ if (r && r.__raw) { console.log(r.__raw); return; }
5269
+ result = r;
5270
+ } else { console.error('Unknown uat subcommand. Valid: render-checkpoint'); process.exit(1); }
5271
+ break;
5272
+ case 'requirements':
5273
+ if (args[0] === 'mark-complete') { result = cmdRequirementsMarkComplete(args.slice(1)); }
5274
+ else { console.error('Unknown requirements subcommand. Valid: mark-complete'); process.exit(1); }
5275
+ break;
5276
+ case 'todo':
5277
+ if (args[0] === 'match-phase') { result = cmdTodoMatchPhase(args.slice(1)); }
5278
+ else { console.error('Unknown todo subcommand. Valid: match-phase'); process.exit(1); }
5279
+ break;
5280
+ case 'learnings':
5281
+ if (args[0] === 'copy') { result = cmdLearningsCopy(args.slice(1)); }
5282
+ else { console.error('Unknown learnings subcommand. Valid: copy'); process.exit(1); }
5283
+ break;
5284
+ case 'docs-audit':
5285
+ result = cmdDocsAudit(args);
5286
+ break;
5287
+ case 'frontmatter':
5288
+ if (args[0] === 'get') { cmdFrontmatterGet(args.slice(1)); return; }
5289
+ else { console.error('Unknown frontmatter subcommand. Valid: get'); process.exit(1); }
3595
5290
  case 'notes':
3596
5291
  if (args[0] === 'list') { result = cmdNotesList(); }
3597
5292
  else if (args[0] === 'count') { result = cmdNotesCount(); }
@@ -3612,6 +5307,32 @@ async function main() {
3612
5307
  case 'state':
3613
5308
  result = cmdState(args);
3614
5309
  break;
5310
+ case 'phase':
5311
+ result = cmdPhase(args);
5312
+ break;
5313
+ case 'commit':
5314
+ result = cmdCommit(args);
5315
+ break;
5316
+ case 'commit-to-subrepo':
5317
+ result = cmdCommitToSubrepo(args);
5318
+ break;
5319
+ case 'generate-claude-md':
5320
+ result = cmdGenerateClaudeMd(args.join(' '));
5321
+ break;
5322
+ case 'check-implementation-readiness':
5323
+ result = cmdCheckImplementationReadiness(args.join(' '));
5324
+ break;
5325
+ case 'classify-tech':
5326
+ result = cmdClassifyTech(args.join(' '));
5327
+ break;
5328
+ case 'context':
5329
+ if (args[0] === 'refresh') {
5330
+ result = cmdContextRefresh();
5331
+ } else {
5332
+ console.error('Unknown context subcommand. Valid: refresh');
5333
+ process.exit(1);
5334
+ }
5335
+ break;
3615
5336
  case 'module':
3616
5337
  result = cmdModule(args);
3617
5338
  break;
@@ -3708,8 +5429,25 @@ async function main() {
3708
5429
  console.log(' agent-skills <name> → alias for agent-info');
3709
5430
  console.log(' list-agents → list all available Rihal agents');
3710
5431
  console.log(' state <subcommand> [args] → manage .rihal/state.json');
5432
+ console.log(' phase add <name> [--decimal <parent>] → add phase (integer to current milestone, or --decimal slots under parent as parent.M)');
5433
+ console.log(' commit "<msg>" [--files p1 p2 ...] → atomic git commit with conventional-commits validation (no AI attribution, no --no-verify, no auto-push)');
5434
+ console.log(' commit-to-subrepo --subrepo <p> "<msg>" → atomic commit inside a git subrepo (same validation as commit)');
5435
+ console.log(' generate-claude-md [--force] → bootstrap a project CLAUDE.md scaffold (refuses to overwrite without --force)');
5436
+ console.log(' check-implementation-readiness --phase <N> → verify preconditions before phase planning; returns {ready, blockers}');
5437
+ console.log(' classify-tech --keywords "<keywords>" → classify tech stack from keywords (frontend/backend/mobile/styling)');
5438
+ console.log(' context refresh → refresh .rihal/context/ cache from .rihal/sources.yaml');
3711
5439
  console.log(' module <subcommand> [args] → module system helpers');
3712
5440
  console.log(' plan <subcommand> [args] → phase/plan operations');
5441
+ console.log(' phase-plan-index <N> → JSON inventory of plans under phase N (waves, summary status, task counts)');
5442
+ console.log(' phases list [--type X] [--pick path] → directory inventory of .planning/phases (--type: summaries|sprints|directories|all; --pick: e.g. directories[-1])');
5443
+ console.log(' find-phase <N> [--raw] → resolve phase number to dir/slug + decimal children');
5444
+ console.log(' audit-uat [--raw] → walk all UAT files, return inventory + status counts');
5445
+ console.log(' uat render-checkpoint --file <p> → render markdown UAT checkpoint block from file');
5446
+ console.log(' requirements mark-complete <ID> [<ID>...] → flip status to complete in REQUIREMENTS.md');
5447
+ console.log(' todo match-phase <N> → return todos with matching phase tag');
5448
+ console.log(' learnings copy → soft-fail copy of phase LEARNINGS.md to ~/.rihal/learnings/');
5449
+ console.log(' docs-audit → list docs missing per documentation-requirements.csv');
5450
+ console.log(' frontmatter get <file> --field <name> → print one frontmatter field value (empty if absent)');
3713
5451
  console.log(' notes <subcommand> [args] → manage project notes');
3714
5452
  console.log(' config <subcommand> [args] → read/write project config');
3715
5453
  console.log(' notify send --title "<t>" [--body "<b>"] [--event <e>] [--only slack|discord|teams] → post to configured webhooks');
@@ -3763,7 +5501,7 @@ async function main() {
3763
5501
  console.log(' state story list [--sprint <NN.S>] [--status <status>]');
3764
5502
  return;
3765
5503
  default: {
3766
- const stateSubs = ['read','get','init','set-phase','advance-plan','record-execution','record-council','record-chain','add-decision','decisions-global','add-blocker','resolve-blocker','record-session','set-ids-in-state','migrate-ids','next-phase-id','next-plan-id','next-task-id','resolve-id','workstream-create','workstream-switch','workstream-list','workstream-status','workstream-complete','workstream-validate','insert-phase','planned-phase','begin-phase','complete-phase','reset'];
5504
+ const stateSubs = ['read','get','init','set-phase','advance-plan','record-execution','record-council','record-chain','add-decision','decisions-global','add-blocker','resolve-blocker','record-session','set-ids-in-state','migrate-ids','migrate-schema','next-phase-id','next-plan-id','next-task-id','resolve-id','workstream-create','workstream-switch','workstream-list','workstream-status','workstream-complete','workstream-validate','insert-phase','planned-phase','begin-phase','complete-phase','reset'];
3767
5505
  if (stateSubs.includes(subcommand)) {
3768
5506
  console.error(`Did you mean: state ${subcommand}? Run 'rihal-tools.cjs help' for full usage.`);
3769
5507
  } else {