@hanzlaa/rcode 3.4.4 → 3.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CONTRIBUTING.md +63 -1
- package/README.md +9 -4
- package/cli/generate-command-skills.cjs +5 -5
- package/cli/index.js +0 -0
- package/cli/install.js +112 -3
- package/cli/lib/manifest.cjs +1 -1
- package/cli/uninstall.js +8 -0
- package/dist/rcode.js +19 -1
- package/package.json +16 -17
- package/rihal/agents/rihal-ahmed.md +2 -1
- package/rihal/agents/rihal-code-fixer.md +46 -0
- package/rihal/agents/rihal-code-reviewer.md +46 -1
- package/rihal/agents/rihal-deviation-analyzer.md +1 -0
- package/rihal/agents/rihal-docs-auditor.md +106 -1
- package/rihal/agents/rihal-edge-case-hunter.md +47 -1
- package/rihal/agents/rihal-executor.md +1 -1
- package/rihal/agents/rihal-khalid.md +40 -1
- package/rihal/agents/rihal-layla.md +2 -1
- package/rihal/agents/rihal-nasser.md +2 -1
- package/rihal/agents/rihal-noor.md +3 -2
- package/rihal/agents/rihal-nyquist-auditor.md +1 -1
- package/rihal/agents/rihal-phase-researcher.md +46 -1
- package/rihal/agents/rihal-planner.md +1 -1
- package/rihal/agents/rihal-profiler.md +45 -2
- package/rihal/agents/rihal-project-researcher.md +47 -0
- package/rihal/agents/rihal-remediation-planner.md +45 -0
- package/rihal/agents/rihal-roadmapper.md +46 -0
- package/rihal/agents/rihal-security-adversary.md +46 -1
- package/rihal/agents/rihal-security-auditor.md +45 -1
- package/rihal/agents/rihal-ui-auditor.md +44 -1
- package/rihal/agents/rihal-ux-designer.md +41 -1
- package/rihal/agents/rihal-zahra.md +2 -1
- package/rihal/agents/rihal-zayd.md +2 -1
- package/rihal/bin/lib/config.cjs +13 -1
- package/rihal/bin/lib/council-panel.cjs +185 -23
- package/rihal/bin/lib/roadmap.cjs +27 -2
- package/rihal/bin/rihal-tools.cjs +1837 -99
- package/rihal/commands/audit.md +2 -2
- package/rihal/commands/capture.md +12 -0
- package/rihal/commands/diagnose-issues.md +18 -0
- package/rihal/commands/discuss-phase-power.md +18 -0
- package/rihal/commands/feature-drift.md +18 -0
- package/rihal/commands/karpathy-audit.md +18 -0
- package/rihal/commands/lens-audit.md +70 -0
- package/rihal/commands/new-project-research.md +18 -0
- package/rihal/commands/new-project-roadmap.md +18 -0
- package/rihal/commands/phase.md +11 -0
- package/rihal/references/continuation-format.md +3 -3
- package/rihal/references/output-format.md +79 -0
- package/rihal/references/revision-loop.md +1 -1
- package/rihal/references/verb-dictionary.md +85 -28
- package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +1 -1
- package/rihal/skills/actions/2-plan/rihal-create-epics-and-stories/SKILL.md +12 -2
- package/rihal/skills/actions/2-plan/rihal-create-epics-and-stories/steps/step-04-final-validation.md +12 -0
- package/rihal/skills/actions/2-plan/rihal-create-prd/SKILL.md +12 -2
- package/rihal/skills/actions/2-plan/rihal-create-story/SKILL.md +12 -2
- package/rihal/skills/actions/4-implementation/rihal-browser-verify/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-code-review/SKILL.md +16 -4
- package/rihal/skills/actions/4-implementation/rihal-debug/SKILL.md +14 -1
- package/rihal/skills/actions/4-implementation/rihal-git-flow/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-incremental/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-perf/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-prove-it/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-scaffold-project/steps/step-01-target.md +6 -0
- package/rihal/skills/actions/4-implementation/rihal-source-truth/SKILL.md +1 -1
- package/rihal/skills/actions/4-implementation/rihal-sprint-planning/SKILL.md +14 -3
- package/rihal/skills/actions/4-implementation/rihal-trim/SKILL.md +1 -1
- package/rihal/skills/agents/ahmed-hassani-director/SKILL.md +15 -1
- package/rihal/skills/agents/dalil-scout/SKILL.md +14 -2
- package/rihal/skills/agents/fatima-qa/SKILL.md +16 -1
- package/rihal/skills/agents/haitham-frontend/SKILL.md +13 -1
- package/rihal/skills/agents/hanzla-engineer/SKILL.md +13 -1
- package/rihal/skills/agents/hussain-pm/SKILL.md +16 -1
- package/rihal/skills/agents/hussain-sm/SKILL.md +14 -1
- package/rihal/skills/agents/layla-designer/SKILL.md +13 -1
- package/rihal/skills/agents/majlis-council/SKILL.md +16 -1
- package/rihal/skills/agents/mariam-marketing/SKILL.md +14 -1
- package/rihal/skills/agents/nasser-eng-manager/SKILL.md +16 -1
- package/rihal/skills/agents/noor-writer/SKILL.md +15 -1
- package/rihal/skills/agents/raees-orchestrator/SKILL.md +15 -1
- package/rihal/skills/agents/rihal-cross-platform-auditor/SKILL.md +162 -0
- package/rihal/skills/agents/rihal-dep-auditor/SKILL.md +151 -0
- package/rihal/skills/agents/rihal-deviation-analyzer/SKILL.md +78 -0
- package/rihal/skills/agents/rihal-i18n-auditor/SKILL.md +152 -0
- package/rihal/skills/agents/rihal-observability-auditor/SKILL.md +156 -0
- package/rihal/skills/agents/sadiq-analyst/SKILL.md +12 -2
- package/rihal/skills/agents/waleed-architect/SKILL.md +12 -2
- package/rihal/skills/agents/yousef-backend/SKILL.md +12 -2
- package/rihal/skills/agents/zahra-branding/SKILL.md +15 -1
- package/rihal/skills/agents/zayd-ml/SKILL.md +13 -1
- package/rihal/skills/core/rihal-advanced-elicitation/SKILL.md +2 -2
- package/rihal/skills/core/rihal-auth-audit/SKILL.md +1 -1
- package/rihal/skills/core/rihal-brainstorming/SKILL.md +13 -2
- package/rihal/skills/core/rihal-client-gate/SKILL.md +1 -1
- package/rihal/skills/core/rihal-clone-website/SKILL.md +11 -1
- package/rihal/skills/core/rihal-deploy-unify/SKILL.md +1 -1
- package/rihal/skills/core/rihal-distillator/SKILL.md +2 -2
- package/rihal/skills/core/rihal-editorial-review-prose/SKILL.md +1 -1
- package/rihal/skills/core/rihal-editorial-review-structure/SKILL.md +2 -2
- package/rihal/skills/core/rihal-help/SKILL.md +18 -1
- package/rihal/skills/core/rihal-incident-record/SKILL.md +1 -1
- package/rihal/skills/core/rihal-index-docs/SKILL.md +1 -1
- package/rihal/skills/core/rihal-memory-audit/SKILL.md +18 -1
- package/rihal/skills/core/rihal-memory-init/SKILL.md +13 -1
- package/rihal/skills/core/rihal-memory-update/SKILL.md +13 -1
- package/rihal/skills/core/rihal-mvp-graduate/SKILL.md +1 -1
- package/rihal/skills/core/rihal-ocr-consistency/SKILL.md +1 -1
- package/rihal/skills/core/rihal-rebrand/SKILL.md +1 -1
- package/rihal/skills/core/rihal-review-adversarial-general/SKILL.md +1 -1
- package/rihal/skills/core/rihal-review-edge-case-hunter/SKILL.md +17 -1
- package/rihal/skills/core/rihal-shard-doc/SKILL.md +1 -1
- package/rihal/skills/core/rihal-theme-system/SKILL.md +1 -1
- package/rihal/team.yaml +0 -7
- package/rihal/templates/RESEARCH.md +84 -0
- package/rihal/templates/VALIDATION.md +45 -0
- package/rihal/templates/memory/INDEX.md +1 -0
- package/rihal/templates/memory/project/design-system.md +128 -0
- package/rihal/templates/summary.md +33 -3
- package/rihal/workflows/add-tests.md +1 -1
- package/rihal/workflows/add-todo.md +6 -0
- package/rihal/workflows/analyze-dependencies.md +6 -0
- package/rihal/workflows/audit-fix.md +12 -0
- package/rihal/workflows/audit-milestone.md +2 -2
- package/rihal/workflows/audit.md +23 -14
- package/rihal/workflows/autonomous-smart-discuss.md +247 -0
- package/rihal/workflows/autonomous.md +54 -267
- package/rihal/workflows/capture.md +60 -0
- package/rihal/workflows/chain.md +1 -1
- package/rihal/workflows/code-review-fix.md +6 -3
- package/rihal/workflows/code-review.md +34 -10
- package/rihal/workflows/complete-milestone.md +17 -8
- package/rihal/workflows/correct-course.md +6 -0
- package/rihal/workflows/council.md +37 -23
- package/rihal/workflows/create-architecture.md +31 -0
- package/rihal/workflows/create-epics-and-stories.md +7 -1
- package/rihal/workflows/create-prd.md +25 -0
- package/rihal/workflows/dashboard.md +1 -1
- package/rihal/workflows/debug.md +8 -0
- package/rihal/workflows/decisions.md +1 -1
- package/rihal/workflows/diff.md +6 -0
- package/rihal/workflows/discuss-phase-discuss-areas.md +271 -0
- package/rihal/workflows/discuss-phase.md +27 -266
- package/rihal/workflows/do.md +51 -12
- package/rihal/workflows/docs-update.md +3 -0
- package/rihal/workflows/document-project.md +7 -1
- package/rihal/workflows/edit-prd.md +31 -0
- package/rihal/workflows/enable-hooks.md +1 -1
- package/rihal/workflows/execute-regression-gates.md +131 -0
- package/rihal/workflows/execute-sprint.md +31 -2
- package/rihal/workflows/execute-verify-phase-goal.md +136 -0
- package/rihal/workflows/execute-waves.md +404 -0
- package/rihal/workflows/execute.md +101 -642
- package/rihal/workflows/feature-drift.md +243 -0
- package/rihal/workflows/forensics.md +10 -2
- package/rihal/workflows/health.md +65 -16
- package/rihal/workflows/help.md +36 -9
- package/rihal/workflows/import.md +17 -3
- package/rihal/workflows/init.md +20 -10
- package/rihal/workflows/install.md +2 -10
- package/rihal/workflows/lens-audit.md +689 -0
- package/rihal/workflows/map-codebase.md +7 -1
- package/rihal/workflows/memory-audit.md +67 -5
- package/rihal/workflows/memory-distill.md +10 -0
- package/rihal/workflows/memory-init.md +4 -0
- package/rihal/workflows/memory-update.md +4 -0
- package/rihal/workflows/new-milestone.md +7 -1
- package/rihal/workflows/new-project-create-roadmap.md +176 -0
- package/rihal/workflows/new-project-define-requirements.md +160 -0
- package/rihal/workflows/new-project-research-decision.md +247 -0
- package/rihal/workflows/new-project.md +3 -557
- package/rihal/workflows/note.md +1 -1
- package/rihal/workflows/phase.md +54 -0
- package/rihal/workflows/plan-milestone-gaps.md +1 -1
- package/rihal/workflows/plan-prd-express.md +108 -0
- package/rihal/workflows/plan-research-validation.md +313 -0
- package/rihal/workflows/plan-spawn-planner.md +204 -0
- package/rihal/workflows/plan.md +91 -532
- package/rihal/workflows/plant-seed.md +1 -1
- package/rihal/workflows/pr-branch.md +1 -1
- package/rihal/workflows/profile-user.md +1 -1
- package/rihal/workflows/quick.md +3 -3
- package/rihal/workflows/remove-phase.md +6 -1
- package/rihal/workflows/remove-workspace.md +6 -0
- package/rihal/workflows/rerun.md +1 -1
- package/rihal/workflows/research-phase.md +4 -2
- package/rihal/workflows/resume-work.md +8 -3
- package/rihal/workflows/retrospective.md +31 -0
- package/rihal/workflows/review-adversarial.md +12 -0
- package/rihal/workflows/review.md +6 -0
- package/rihal/workflows/scaffold-project.md +31 -0
- package/rihal/workflows/scan.md +10 -0
- package/rihal/workflows/secure-phase.md +15 -2
- package/rihal/workflows/session-report.md +32 -7
- package/rihal/workflows/ship.md +7 -2
- package/rihal/workflows/show.md +6 -0
- package/rihal/workflows/sprint-status.md +4 -4
- package/rihal/workflows/status.md +2 -2
- package/rihal/workflows/ui-phase.md +1 -1
- package/rihal/workflows/undo.md +2 -3
- package/rihal/workflows/update.md +2 -2
- package/rihal/workflows/validate-phase.md +1 -1
- package/rihal/workflows/validate-prd.md +31 -0
- package/rihal/workflows/verify-phase.md +38 -5
- package/rihal/workflows/verify-work.md +25 -11
- package/rihal/workflows/workstream.md +20 -8
- package/server/lib/html/client.js +13 -63
- package/server/lib/html/shell.js +0 -1
- 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()
|
|
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',
|
|
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', '
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
1427
|
-
if (!phaseMatch) throw new Error(`Invalid phase ID format: ${phaseId}. Expected
|
|
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
|
|
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
|
|
1748
|
+
// If no phase dir found, default to 1st plan
|
|
1446
1749
|
if (!phaseDir) {
|
|
1447
|
-
return { ok: true, next_plan_id: `${phasePart}.
|
|
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
|
|
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)
|
|
1462
|
-
// First plan in empty phase gets .
|
|
1463
|
-
return { ok: true, next_plan_id: maxPlanNum === 0 ? `${phasePart}.
|
|
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
|
|
1471
|
-
if (!match) throw new Error(`Invalid plan ID format: ${planId}. Expected
|
|
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
|
|
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
|
|
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 ===
|
|
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)
|
|
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
|
|
1840
|
+
} else if (/^\d+$/.test(id)) {
|
|
1538
1841
|
idType = 'phase';
|
|
1539
1842
|
phaseId = id;
|
|
1540
|
-
} else if (/^\d
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1619
|
-
if (!result.path && planNum ===
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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}.
|
|
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: "
|
|
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}.
|
|
1805
|
-
const minimal = `---\nid: "${assignedId}"\nphase: "${phaseIdStr}"\nplan: "
|
|
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
|
|
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)
|
|
1829
|
-
const planIdStr = String(planNum)
|
|
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
|
|
1965
|
-
throw new Error(`Target must be
|
|
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
|
|
2025
|
-
//
|
|
2026
|
-
//
|
|
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
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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 ||
|
|
2442
|
+
String(p.number) === phaseNum ||
|
|
2443
|
+
String(p.id) === phaseNum ||
|
|
2444
|
+
p.name === phaseName
|
|
2043
2445
|
);
|
|
2044
2446
|
if (existingIdx >= 0) {
|
|
2045
|
-
//
|
|
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]
|
|
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
|
|
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.
|
|
2185
|
-
* 2.
|
|
2186
|
-
* 3.
|
|
2187
|
-
* 4.
|
|
2188
|
-
* 5.
|
|
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
|
-
//
|
|
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-
|
|
2541
|
-
'rihal-waleed': 'claude-
|
|
2542
|
-
'rihal-planner': 'claude-
|
|
2543
|
-
'rihal-sprint-checker': 'claude-
|
|
2544
|
-
'rihal-fatima': 'claude-
|
|
2545
|
-
'rihal-executor': 'claude-
|
|
2546
|
-
'rihal-verifier': 'claude-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|