@hanzlaa/rcode 3.4.32 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/AGENTS.md +6 -6
  2. package/CONTRIBUTING.md +2 -0
  3. package/LICENSE +21 -0
  4. package/README.md +66 -403
  5. package/cli/agent.js +3 -2
  6. package/cli/doctor.js +87 -1
  7. package/cli/install.js +122 -31
  8. package/cli/lib/schemas.cjs +318 -0
  9. package/cli/postinstall.js +19 -3
  10. package/dist/rcode.js +318 -24
  11. package/package.json +8 -4
  12. package/rihal/agents/rihal-cross-platform-auditor.md +15 -0
  13. package/rihal/agents/rihal-dep-auditor.md +15 -0
  14. package/rihal/agents/rihal-docs-auditor.md +3 -145
  15. package/rihal/agents/rihal-i18n-auditor.md +16 -0
  16. package/rihal/agents/rihal-nyquist-auditor.md +4 -156
  17. package/rihal/agents/rihal-observability-auditor.md +16 -0
  18. package/rihal/agents/rihal-phase-researcher.md +1 -1
  19. package/rihal/agents/rihal-planner.md +1 -1
  20. package/rihal/bin/rihal-hooks.cjs +394 -4
  21. package/rihal/bin/rihal-tools.cjs +891 -24
  22. package/rihal/commands/create-prd.md +18 -0
  23. package/rihal/commands/execute-milestone.md +18 -0
  24. package/rihal/commands/plan-milestone.md +18 -0
  25. package/rihal/commands/scaffold-milestone.md +18 -0
  26. package/rihal/commands/scaffold-skill.md +18 -0
  27. package/rihal/references/REFERENCES_INDEX.md +49 -7
  28. package/rihal/references/agent-contracts.md +10 -0
  29. package/rihal/references/design-tokens.md +98 -0
  30. package/rihal/references/docs-auditor-playbook.md +148 -0
  31. package/rihal/references/git-preflight.md +117 -0
  32. package/rihal/references/iterative-retrieval.md +85 -0
  33. package/rihal/references/nyquist-auditor-playbook.md +157 -0
  34. package/rihal/references/workstream-flag.md +2 -2
  35. package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +9 -0
  36. package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +9 -0
  37. package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +4 -0
  38. package/rihal/skills/actions/4-implementation/rihal-code-review/steps/step-02-review.md +7 -3
  39. package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +4 -0
  40. package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +4 -0
  41. package/rihal/skills/agents/haitham-frontend/SKILL.md +2 -0
  42. package/rihal/skills/agents/majlis-council/SKILL.md +1 -1
  43. package/rihal/team.yaml +32 -0
  44. package/rihal/templates/settings-hooks.json +39 -0
  45. package/rihal/workflows/check-todos.md +4 -0
  46. package/rihal/workflows/code-review-fix.md +4 -3
  47. package/rihal/workflows/code-review.md +1 -1
  48. package/rihal/workflows/debug.md +1 -1
  49. package/rihal/workflows/dev-story.md +4 -0
  50. package/rihal/workflows/diff.md +2 -2
  51. package/rihal/workflows/do.md +16 -8
  52. package/rihal/workflows/docs-update.md +2 -2
  53. package/rihal/workflows/enable-hooks.md +6 -1
  54. package/rihal/workflows/execute-milestone.md +139 -0
  55. package/rihal/workflows/execute-regression-gates.md +1 -1
  56. package/rihal/workflows/execute-sprint.md +54 -2
  57. package/rihal/workflows/execute-verify-phase-goal.md +31 -4
  58. package/rihal/workflows/execute-waves.md +33 -5
  59. package/rihal/workflows/execute.md +40 -6
  60. package/rihal/workflows/help.md +1 -1
  61. package/rihal/workflows/import.md +1 -1
  62. package/rihal/workflows/lens-audit.md +39 -23
  63. package/rihal/workflows/list-workspaces.md +1 -1
  64. package/rihal/workflows/map-codebase.md +4 -4
  65. package/rihal/workflows/new-milestone.md +18 -1
  66. package/rihal/workflows/new-project-research.md +53 -1
  67. package/rihal/workflows/new-workspace.md +1 -1
  68. package/rihal/workflows/plan-milestone.md +105 -0
  69. package/rihal/workflows/plan-research-validation.md +1 -1
  70. package/rihal/workflows/plan-spawn-planner.md +1 -1
  71. package/rihal/workflows/plan.md +31 -3
  72. package/rihal/workflows/plant-seed.md +6 -0
  73. package/rihal/workflows/quick.md +11 -5
  74. package/rihal/workflows/research-phase.md +24 -0
  75. package/rihal/workflows/scaffold-milestone.md +60 -0
  76. package/rihal/workflows/scaffold-skill.md +137 -0
  77. package/rihal/workflows/scan.md +1 -1
  78. package/rihal/workflows/session-report.md +43 -3
  79. package/rihal/workflows/verify-work.md +3 -3
  80. package/server/dashboard.js +53 -6
  81. package/server/lib/api.js +7 -0
  82. package/server/lib/html/client.js +725 -13
  83. package/server/lib/html/css.js +2046 -466
  84. package/server/lib/html/shell.js +227 -134
  85. package/server/lib/scanner.js +33 -0
  86. package/server/orchestrator.js +438 -0
@@ -901,6 +901,11 @@ function cmdInitExecute(rawArgs) {
901
901
  plan_path: planPath,
902
902
  phase_dir: phaseDir,
903
903
  plans,
904
+ // Surface response_language at top level so workflows don't have to drill
905
+ // into config — matches cmdInit's contract (#721). null means English.
906
+ response_language: config.response_language || config.language || null,
907
+ executor_model: config.executor_model || config.model_profile || null,
908
+ verifier_model: config.verifier_model || config.model_profile || null,
904
909
  config,
905
910
  paths: {
906
911
  project_root: PROJECT_ROOT,
@@ -989,6 +994,11 @@ function cmdState(subArgs) {
989
994
  function isProcessAlive(pid) {
990
995
  try { process.kill(pid, 0); return true; } catch { return false; }
991
996
  }
997
+ // #8 — stamp schema_version on every write so legacy state files
998
+ // (no field) auto-gain the explicit tag. Never demotes an existing
999
+ // higher version — only fills the missing case. Bumping the version
1000
+ // is the migrator's job, not this helper.
1001
+ if (typeof state.schema_version !== 'number') state.schema_version = 1;
992
1002
 
993
1003
  // Issue #681: auto-clear the install-time _seeded_stub marker once the
994
1004
  // state has graduated to a real project (project field set + at least one
@@ -1048,6 +1058,11 @@ function cmdState(subArgs) {
1048
1058
  const now = new Date().toISOString();
1049
1059
  return {
1050
1060
  version: '1',
1061
+ // #8 — explicit schema_version field for future migration framework.
1062
+ // Bump when the shape changes. `state schema-status` / `state migrate-schema`
1063
+ // read this. Existing state files without the field are treated as v1
1064
+ // (backwards-compat — never crash on legacy state).
1065
+ schema_version: 1,
1051
1066
  project: projectName || path.basename(PROJECT_ROOT),
1052
1067
  created: now,
1053
1068
  updated: now,
@@ -1137,7 +1152,21 @@ function cmdState(subArgs) {
1137
1152
  state.current_plan = 0;
1138
1153
  if (!state.phases) state.phases = [];
1139
1154
  if (!state.phases.some(p => p.name === name)) {
1140
- state.phases.push({ name, started: new Date().toISOString(), completed: null, plan_count: 0 });
1155
+ // Derive a stable number from the name's leading digits (e.g., "20-foo" 20)
1156
+ // so downstream lookups by p.number / p.id in sprint add etc. resolve correctly.
1157
+ // Falls back to next sequential position when name has no leading digit.
1158
+ const leadingNum = String(name).match(/^(\d+)/);
1159
+ const number = leadingNum
1160
+ ? parseInt(leadingNum[1], 10)
1161
+ : (state.phases.length + 1);
1162
+ state.phases.push({
1163
+ number,
1164
+ id: String(number),
1165
+ name,
1166
+ started: new Date().toISOString(),
1167
+ completed: null,
1168
+ plan_count: 0,
1169
+ });
1141
1170
  }
1142
1171
  return writeState(state);
1143
1172
  }
@@ -1203,6 +1232,178 @@ function cmdState(subArgs) {
1203
1232
  return writeStateCompact(state, { sprint_id: sprintId, phase: padPhase });
1204
1233
  }
1205
1234
 
1235
+ // --- logs prune [--dir <path>] [--older-than <days>] [--dry-run] ---
1236
+ // Prune dated session-* artifacts (#13). Defaults:
1237
+ // dir = .rihal/progress/
1238
+ // pattern = session-*.md
1239
+ // older-than = 90 days
1240
+ // dry-run = true (so accidental invocation never deletes)
1241
+ // No-op if the directory doesn't exist — prints a friendly message.
1242
+ // File age is determined by mtime, not filename.
1243
+ if (sub === 'logs' && subArgs[1] === 'prune') {
1244
+ const flags = parseFlags(2);
1245
+ const dryRun = ('dry-run' in flags) || !subArgs.includes('--no-dry-run');
1246
+ const dir = flags.dir
1247
+ ? path.resolve(PROJECT_ROOT, flags.dir)
1248
+ : path.join(RIHAL_DIR, 'progress');
1249
+ const olderDays = parseInt(flags['older-than'] || '90', 10);
1250
+ const pattern = flags.pattern || 'session-*.md';
1251
+ const cutoff = Date.now() - olderDays * 24 * 60 * 60 * 1000;
1252
+
1253
+ if (!fs.existsSync(dir)) {
1254
+ return {
1255
+ ok: true,
1256
+ dry_run: dryRun,
1257
+ pruned: 0,
1258
+ message: `No logs directory at ${path.relative(PROJECT_ROOT, dir)} — nothing to prune.`,
1259
+ };
1260
+ }
1261
+
1262
+ // Translate the glob pattern to a RegExp (only the * wildcard for safety).
1263
+ const reSrc = '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$';
1264
+ const fileRe = new RegExp(reSrc);
1265
+
1266
+ const toPrune = [];
1267
+ for (const entry of fs.readdirSync(dir)) {
1268
+ if (!fileRe.test(entry)) continue;
1269
+ const full = path.join(dir, entry);
1270
+ let stat;
1271
+ try { stat = fs.statSync(full); } catch (_) { continue; }
1272
+ if (!stat.isFile()) continue;
1273
+ if (stat.mtimeMs < cutoff) {
1274
+ toPrune.push({
1275
+ file: path.relative(PROJECT_ROOT, full),
1276
+ age_days: Math.floor((Date.now() - stat.mtimeMs) / (24 * 60 * 60 * 1000)),
1277
+ bytes: stat.size,
1278
+ });
1279
+ }
1280
+ }
1281
+
1282
+ if (!dryRun) {
1283
+ for (const item of toPrune) {
1284
+ try { fs.unlinkSync(path.join(PROJECT_ROOT, item.file)); }
1285
+ catch (e) { item.error = e.message; }
1286
+ }
1287
+ }
1288
+
1289
+ return {
1290
+ ok: true,
1291
+ dry_run: dryRun,
1292
+ dir: path.relative(PROJECT_ROOT, dir),
1293
+ pattern,
1294
+ older_than_days: olderDays,
1295
+ pruned: dryRun ? 0 : toPrune.filter(t => !t.error).length,
1296
+ would_prune: dryRun ? toPrune.length : 0,
1297
+ details: toPrune,
1298
+ };
1299
+ }
1300
+
1301
+ // --- sprint init-all [--file <path>] [--dry-run] ---
1302
+ // Bulk-initialize sprints by parsing .planning/sprints.md (#11).
1303
+ // Supported formats: markdown table with `| Sprint | Phase | Goal |` columns,
1304
+ // OR a simple "## Sprint N — Phase X — Goal" heading list. Skips rows whose
1305
+ // sprint id already exists for that phase (idempotent). No-op when the
1306
+ // file is absent — prints a helpful message rather than failing.
1307
+ if (sub === 'sprint' && subArgs[1] === 'init-all') {
1308
+ const flags = parseFlags(2);
1309
+ const dryRun = ('dry-run' in flags) || subArgs.includes('--dry-run');
1310
+ const filePath = flags.file || path.join(PLANNING_DIR, 'sprints.md');
1311
+ if (!fs.existsSync(filePath)) {
1312
+ return {
1313
+ ok: true,
1314
+ created: 0,
1315
+ message: `No sprints.md found at ${path.relative(PROJECT_ROOT, filePath)}. Write one with rows like '| 1 | 3 | Migrate auth module |' to bulk-initialize sprints.`,
1316
+ };
1317
+ }
1318
+ const text = fs.readFileSync(filePath, 'utf8');
1319
+ const rows = [];
1320
+
1321
+ // Parse markdown-table form: skip header + separator rows, accept any
1322
+ // row with at least 3 pipe-delimited cells (sprint, phase, goal).
1323
+ const lines = text.split(/\r?\n/);
1324
+ let inTable = false;
1325
+ for (const line of lines) {
1326
+ const trimmed = line.trim();
1327
+ if (!trimmed.startsWith('|')) { inTable = false; continue; }
1328
+ // Separator row like |---|---|---|
1329
+ if (/^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?$/.test(trimmed)) { inTable = true; continue; }
1330
+ const cells = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
1331
+ if (cells.length < 3) continue;
1332
+ // Header detection: skip if first cell is non-numeric AND we haven't seen separator yet
1333
+ if (!inTable && !/^\d/.test(cells[0])) continue;
1334
+ // Tolerate extra columns; first three are sprint, phase, goal.
1335
+ const sprintNum = parseInt(cells[0], 10);
1336
+ const phaseRef = cells[1];
1337
+ const goal = cells[2];
1338
+ if (!Number.isFinite(sprintNum) || !phaseRef || !goal) continue;
1339
+ rows.push({ sprint: sprintNum, phase: phaseRef, goal });
1340
+ }
1341
+
1342
+ // Fallback: parse "## Sprint N — Phase X — Goal" heading form.
1343
+ if (rows.length === 0) {
1344
+ const hRe = /^#{2,3}\s*Sprint\s+(\d+)\s*[—\-:]\s*Phase\s+([\d.]+)\s*[—\-:]\s*(.+)$/gim;
1345
+ let m;
1346
+ while ((m = hRe.exec(text)) !== null) {
1347
+ rows.push({ sprint: parseInt(m[1], 10), phase: m[2], goal: m[3].trim() });
1348
+ }
1349
+ }
1350
+
1351
+ if (rows.length === 0) {
1352
+ return {
1353
+ ok: true,
1354
+ created: 0,
1355
+ message: `sprints.md parsed but no rows recognized. Expected '| sprint | phase | goal |' table or '## Sprint N — Phase X — Goal' headings.`,
1356
+ };
1357
+ }
1358
+
1359
+ const state = readState() || defaultState();
1360
+ const created = [];
1361
+ const skipped = [];
1362
+ for (const row of rows) {
1363
+ const phaseIdx = state.phases.findIndex(p =>
1364
+ String(p.number) === String(row.phase) ||
1365
+ String(p.id) === String(row.phase) ||
1366
+ p.name === row.phase
1367
+ );
1368
+ if (phaseIdx === -1) {
1369
+ skipped.push({ ...row, reason: `phase ${row.phase} not found` });
1370
+ continue;
1371
+ }
1372
+ const phase = state.phases[phaseIdx];
1373
+ if (!phase.sprints) phase.sprints = [];
1374
+ const phaseNum = phase.number != null ? phase.number
1375
+ : phase.id != null ? (parseInt(phase.id, 10) || (phaseIdx + 1))
1376
+ : phaseIdx + 1;
1377
+ const sprintId = `${phaseNum}.${row.sprint}`;
1378
+ if (phase.sprints.some(s => s.id === sprintId || s.number === row.sprint)) {
1379
+ skipped.push({ ...row, reason: `sprint ${sprintId} already exists` });
1380
+ continue;
1381
+ }
1382
+ const sprint = {
1383
+ id: sprintId,
1384
+ number: row.sprint,
1385
+ goal: row.goal,
1386
+ status: 'planned',
1387
+ velocity_target: null,
1388
+ velocity_actual: null,
1389
+ started_at: null,
1390
+ completed_at: null,
1391
+ stories: [],
1392
+ };
1393
+ if (!dryRun) phase.sprints.push(sprint);
1394
+ created.push({ sprint_id: sprintId, phase: String(phaseNum), goal: row.goal });
1395
+ }
1396
+ if (!dryRun && created.length > 0) writeState(state);
1397
+ return {
1398
+ ok: true,
1399
+ dry_run: dryRun,
1400
+ created: created.length,
1401
+ skipped: skipped.length,
1402
+ file: path.relative(PROJECT_ROOT, filePath),
1403
+ details: { created, skipped },
1404
+ };
1405
+ }
1406
+
1206
1407
  // --- sprint list [--phase NN] ---
1207
1408
  if (sub === 'sprint' && subArgs[1] === 'list') {
1208
1409
  const flags = parseFlags(2);
@@ -2276,6 +2477,112 @@ function cmdState(subArgs) {
2276
2477
  return { ok: true, migrated: migratedCount, message: `Migrated ${migratedCount} PLAN.md files with IDs` };
2277
2478
  }
2278
2479
 
2480
+ // =====================================================================
2481
+ // state migrate-plan-names: normalise plan filenames to no-leading-zeros (#657)
2482
+ //
2483
+ // Renames <N>-0K-SPRINT.md → <N>-K-SPRINT.md so the K (plan index) honours
2484
+ // the project's no-leading-zeros rule. The N (phase prefix) is preserved
2485
+ // because phase directories use leading zeros for ls sort order.
2486
+ //
2487
+ // Reports planned actions and exits without touching disk when --dry-run.
2488
+ // Updates state.json plan IDs if the renamed file is referenced there.
2489
+ // Does NOT rewrite ROADMAP / SUMMARY backrefs — workflows glob *-SPRINT.md
2490
+ // which still matches. Backref cleanup is a follow-up if needed.
2491
+ // =====================================================================
2492
+ if (sub === 'migrate-plan-names') {
2493
+ const flags = parseFlags(1);
2494
+ // parseFlags sets valueless flags to '' (empty string). Detect presence
2495
+ // by key existence, not truthiness, so --dry-run works as a bare flag.
2496
+ const dryRun = ('dry-run' in flags) || subArgs.includes('--dry-run');
2497
+ const renames = [];
2498
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
2499
+ if (!fs.existsSync(phasesDir)) {
2500
+ return { ok: true, renamed: 0, dry_run: dryRun, message: '.planning/phases not found' };
2501
+ }
2502
+ for (const entry of fs.readdirSync(phasesDir)) {
2503
+ const phaseDir = path.join(phasesDir, entry);
2504
+ if (!fs.statSync(phaseDir).isDirectory()) continue;
2505
+ for (const file of fs.readdirSync(phaseDir)) {
2506
+ // Match: <N>-0K-SPRINT.md where K starts with '0' AND has at least one
2507
+ // more digit (so single-digit "0" wouldn't match — there is no plan 0).
2508
+ const m = file.match(/^(\d+)-0(\d+)-SPRINT\.md$/);
2509
+ if (!m) continue;
2510
+ const phasePrefix = m[1];
2511
+ const planNum = m[2]; // already stripped leading zero
2512
+ const oldName = file;
2513
+ const newName = `${phasePrefix}-${planNum}-SPRINT.md`;
2514
+ const oldPath = path.join(phaseDir, oldName);
2515
+ const newPath = path.join(phaseDir, newName);
2516
+ if (fs.existsSync(newPath)) {
2517
+ renames.push({ phase_dir: entry, from: oldName, to: newName, status: 'skip-target-exists' });
2518
+ continue;
2519
+ }
2520
+ renames.push({ phase_dir: entry, from: oldName, to: newName, status: dryRun ? 'would-rename' : 'renamed' });
2521
+ if (!dryRun) fs.renameSync(oldPath, newPath);
2522
+ }
2523
+ }
2524
+ // Update state.json plan IDs (e.g., "20.01" → "20.1") if the entries exist.
2525
+ let stateUpdates = 0;
2526
+ if (!dryRun) {
2527
+ const state = readState();
2528
+ if (state && Array.isArray(state.phases)) {
2529
+ for (const phase of state.phases) {
2530
+ if (!Array.isArray(phase.plans)) continue;
2531
+ for (const plan of phase.plans) {
2532
+ if (typeof plan.id !== 'string') continue;
2533
+ const newId = plan.id.replace(/^(\d+)\.0(\d+)$/, '$1.$2');
2534
+ if (newId !== plan.id) {
2535
+ plan.id = newId;
2536
+ if (plan.plan) plan.plan = String(plan.plan).replace(/^0(\d+)$/, '$1');
2537
+ stateUpdates++;
2538
+ }
2539
+ }
2540
+ }
2541
+ if (stateUpdates > 0) writeState(state);
2542
+ }
2543
+ }
2544
+ return {
2545
+ ok: true,
2546
+ dry_run: dryRun,
2547
+ renamed: renames.filter(r => r.status === 'renamed').length,
2548
+ would_rename: renames.filter(r => r.status === 'would-rename').length,
2549
+ skipped: renames.filter(r => r.status === 'skip-target-exists').length,
2550
+ state_plan_ids_updated: stateUpdates,
2551
+ details: renames,
2552
+ };
2553
+ }
2554
+
2555
+ // =====================================================================
2556
+ // state schema-status: report current vs expected schema_version (#8).
2557
+ // Read-only. Surfaces stale state files so users know when to run
2558
+ // `state migrate-schema`.
2559
+ // =====================================================================
2560
+ if (sub === 'schema-status') {
2561
+ const CURRENT_SCHEMA_VERSION = 1;
2562
+ if (!fs.existsSync(statePath)) {
2563
+ return { ok: false, error: 'state.json not found' };
2564
+ }
2565
+ let state;
2566
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
2567
+ catch (e) { return { ok: false, error: `Invalid JSON: ${e.message}` }; }
2568
+ const recorded = state.schema_version;
2569
+ // Treat missing schema_version as v1 (legacy state files). Never crash.
2570
+ const effective = typeof recorded === 'number' ? recorded : 1;
2571
+ return {
2572
+ ok: true,
2573
+ file: path.relative(PROJECT_ROOT, statePath),
2574
+ schema_version: effective,
2575
+ current_version: CURRENT_SCHEMA_VERSION,
2576
+ drift: effective !== CURRENT_SCHEMA_VERSION,
2577
+ explicit: typeof recorded === 'number',
2578
+ message: typeof recorded === 'number'
2579
+ ? (effective === CURRENT_SCHEMA_VERSION
2580
+ ? 'Up to date.'
2581
+ : `state.json is at v${effective}, current is v${CURRENT_SCHEMA_VERSION}. Run: rihal-tools state migrate-schema`)
2582
+ : 'state.json has no schema_version field — treated as v1. Next write will stamp the explicit field.',
2583
+ };
2584
+ }
2585
+
2279
2586
  // =====================================================================
2280
2587
  // state migrate-schema: normalise phases array to current schema
2281
2588
  // Handles 3 known schema variants in the wild:
@@ -2286,11 +2593,16 @@ function cmdState(subArgs) {
2286
2593
  // for entries that have a SUMMARY.md path or missing status).
2287
2594
  // =====================================================================
2288
2595
  if (sub === 'migrate-schema') {
2596
+ // Closes #735. Full normalizer: phases array + all top-level array fields.
2289
2597
  const state = readState();
2290
2598
  if (!state) return { ok: false, error: 'state.json not found or empty' };
2291
- if (!Array.isArray(state.phases)) return { ok: false, error: 'state.phases is not an array' };
2599
+ if (!Array.isArray(state.phases)) {
2600
+ state.phases = [];
2601
+ }
2292
2602
 
2293
2603
  let changed = 0;
2604
+
2605
+ // 1. Normalize phases entries
2294
2606
  state.phases = state.phases.map((p) => {
2295
2607
  const updated = Object.assign({}, p);
2296
2608
 
@@ -2321,10 +2633,42 @@ function cmdState(subArgs) {
2321
2633
  return updated;
2322
2634
  });
2323
2635
 
2636
+ // 2. Ensure all required top-level arrays are present (never crash on legacy state).
2637
+ const requiredArrays = [
2638
+ 'velocity_history', 'executions', 'decisions',
2639
+ 'blockers', 'council_sessions', 'workstreams',
2640
+ ];
2641
+ for (const key of requiredArrays) {
2642
+ if (!Array.isArray(state[key])) {
2643
+ state[key] = [];
2644
+ changed++;
2645
+ }
2646
+ }
2647
+
2648
+ // 3. Ensure required scalar fields
2649
+ if (!state.project) { state.project = path.basename(PROJECT_ROOT); changed++; }
2650
+ if (!state.created) { state.created = state.updated || new Date().toISOString(); changed++; }
2651
+ if (state.current_phase === undefined) { state.current_phase = null; changed++; }
2652
+ if (state.current_plan === undefined) { state.current_plan = 0; changed++; }
2653
+ if (state.current_sprint === undefined) { state.current_sprint = null; changed++; }
2654
+ if (state.last_session === undefined) { state.last_session = null; changed++; }
2655
+ if (state.active_workstream === undefined) { state.active_workstream = null; changed++; }
2656
+
2657
+ // 4. Bump schema_version if still at implicit v1 and we made structural changes
2658
+ if (typeof state.schema_version !== 'number') {
2659
+ state.schema_version = 1;
2660
+ changed++;
2661
+ }
2662
+
2324
2663
  if (changed > 0) {
2325
2664
  writeState(state);
2326
2665
  }
2327
- return { ok: true, changed, message: `Schema migration complete — ${changed} field(s) normalised across ${state.phases.length} phase entries` };
2666
+ return {
2667
+ ok: true, changed,
2668
+ schema_version: state.schema_version,
2669
+ phase_count: state.phases.length,
2670
+ message: `Schema migration complete — ${changed} field(s) normalised (${state.phases.length} phases)`,
2671
+ };
2328
2672
  }
2329
2673
 
2330
2674
  // =====================================================================
@@ -2980,7 +3324,205 @@ function cmdPhase(subArgs) {
2980
3324
  };
2981
3325
  }
2982
3326
 
2983
- throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add`);
3327
+ if (sub === 'set-status') {
3328
+ const phaseRef = subArgs[1];
3329
+ const newStatus = subArgs[2];
3330
+ if (!phaseRef) throw new Error('phase set-status requires <phase_number> <status>');
3331
+ if (!newStatus) throw new Error('phase set-status requires <status> (e.g., executed, complete, blocked)');
3332
+ const validStatuses = ['planned', 'in_progress', 'executed', 'complete', 'blocked'];
3333
+ if (!validStatuses.includes(newStatus)) {
3334
+ throw new Error(`Invalid status "${newStatus}". Valid: ${validStatuses.join(', ')}`);
3335
+ }
3336
+
3337
+ const statePath = path.join(RIHAL_DIR, 'state.json');
3338
+ if (!fs.existsSync(statePath)) {
3339
+ throw new Error(`state.json not found at ${statePath} — run 'rihal-tools state init' first`);
3340
+ }
3341
+ let state;
3342
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
3343
+ catch (e) { throw new Error(`Invalid JSON in state.json: ${e.message}`); }
3344
+ if (!state.phases) state.phases = [];
3345
+
3346
+ const phaseIdx = state.phases.findIndex(p =>
3347
+ String(p.number) === String(phaseRef) ||
3348
+ String(p.id) === String(phaseRef) ||
3349
+ p.name === phaseRef
3350
+ );
3351
+ if (phaseIdx === -1) {
3352
+ throw new Error(`Phase "${phaseRef}" not found in state.phases (looked up by number, id, and name)`);
3353
+ }
3354
+ const previous = state.phases[phaseIdx].status || null;
3355
+ state.phases[phaseIdx].status = newStatus;
3356
+ state.phases[phaseIdx].status_updated = new Date().toISOString();
3357
+
3358
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
3359
+ return { ok: true, phase: phaseRef, previous_status: previous, new_status: newStatus };
3360
+ }
3361
+
3362
+ // =====================================================================
3363
+ // phase next-range [count] — return next N contiguous free phase numbers.
3364
+ // Closes #730. Enables bulk-scaffold and parallel planning workflows
3365
+ // to reserve a block of numbers atomically before creating directories.
3366
+ // =====================================================================
3367
+ if (sub === 'next-range') {
3368
+ const count = Math.max(1, parseInt(subArgs[1] || '1', 10));
3369
+ if (Number.isNaN(count) || count < 1 || count > 200) {
3370
+ throw new Error('phase next-range count must be a positive integer ≤ 200');
3371
+ }
3372
+
3373
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3374
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
3375
+ const statePath = path.join(RIHAL_DIR, 'state.json');
3376
+
3377
+ let maxNum = 0;
3378
+ if (fs.existsSync(phasesDir)) {
3379
+ for (const entry of fs.readdirSync(phasesDir)) {
3380
+ const m = entry.match(/^(\d+)/);
3381
+ if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
3382
+ }
3383
+ }
3384
+ if (fs.existsSync(roadmapPath)) {
3385
+ const text = fs.readFileSync(roadmapPath, 'utf8');
3386
+ const pipeRe = /^\|\s*(\d+)\s*\|/gm;
3387
+ let m;
3388
+ while ((m = pipeRe.exec(text)) !== null) maxNum = Math.max(maxNum, parseInt(m[1], 10));
3389
+ const headRe = /^#{2,4}\s*Phase\s+(\d+)\b/gm;
3390
+ while ((m = headRe.exec(text)) !== null) maxNum = Math.max(maxNum, parseInt(m[1], 10));
3391
+ }
3392
+ if (fs.existsSync(statePath)) {
3393
+ try {
3394
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
3395
+ for (const p of (state.phases || [])) {
3396
+ const n = parseInt(String(p.number || ''), 10);
3397
+ if (!Number.isNaN(n)) maxNum = Math.max(maxNum, n);
3398
+ }
3399
+ } catch {}
3400
+ }
3401
+
3402
+ const first = maxNum + 1;
3403
+ const last = maxNum + count;
3404
+ const range = [];
3405
+ for (let i = first; i <= last; i++) range.push(i);
3406
+ return { ok: true, first, last, count, range };
3407
+ }
3408
+
3409
+ // =====================================================================
3410
+ // phase scaffold-milestone --names "n1|n2|n3" [--start N]
3411
+ // Closes #731. Bulk-creates phase folders for a milestone in one call.
3412
+ // Names are pipe-separated (| avoids shell quoting issues with commas).
3413
+ // --start N overrides the computed first number (defaults to next-range).
3414
+ // =====================================================================
3415
+ if (sub === 'scaffold-milestone') {
3416
+ const remaining = subArgs.slice(1);
3417
+ let rawNames = null;
3418
+ let startOverride = null;
3419
+
3420
+ for (let i = 0; i < remaining.length; i++) {
3421
+ if (remaining[i] === '--names' && remaining[i + 1]) {
3422
+ rawNames = remaining[++i];
3423
+ } else if (remaining[i] === '--start' && remaining[i + 1]) {
3424
+ startOverride = parseInt(remaining[++i], 10);
3425
+ if (Number.isNaN(startOverride)) throw new Error('--start requires an integer');
3426
+ }
3427
+ }
3428
+ if (!rawNames) throw new Error('phase scaffold-milestone requires --names "name1|name2|..."');
3429
+
3430
+ const names = rawNames.split('|').map(n => n.trim()).filter(Boolean);
3431
+ if (!names.length) throw new Error('--names must contain at least one non-empty name');
3432
+
3433
+ // Compute starting number via same logic as next-range / phase add
3434
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
3435
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
3436
+ const statePath = path.join(RIHAL_DIR, 'state.json');
3437
+
3438
+ let maxNum = 0;
3439
+ if (fs.existsSync(phasesDir)) {
3440
+ for (const entry of fs.readdirSync(phasesDir)) {
3441
+ const m = entry.match(/^(\d+)/);
3442
+ if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
3443
+ }
3444
+ }
3445
+ if (fs.existsSync(roadmapPath)) {
3446
+ const text = fs.readFileSync(roadmapPath, 'utf8');
3447
+ const pipeRe = /^\|\s*(\d+)\s*\|/gm;
3448
+ let m;
3449
+ while ((m = pipeRe.exec(text)) !== null) maxNum = Math.max(maxNum, parseInt(m[1], 10));
3450
+ const headRe = /^#{2,4}\s*Phase\s+(\d+)\b/gm;
3451
+ while ((m = headRe.exec(text)) !== null) maxNum = Math.max(maxNum, parseInt(m[1], 10));
3452
+ }
3453
+ let state = { phases: [] };
3454
+ if (fs.existsSync(statePath)) {
3455
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch {}
3456
+ }
3457
+ if (!Array.isArray(state.phases)) state.phases = [];
3458
+ for (const p of state.phases) {
3459
+ const n = parseInt(String(p.number || ''), 10);
3460
+ if (!Number.isNaN(n)) maxNum = Math.max(maxNum, n);
3461
+ }
3462
+
3463
+ const firstNum = startOverride !== null ? startOverride : maxNum + 1;
3464
+ const created = [];
3465
+
3466
+ for (let i = 0; i < names.length; i++) {
3467
+ const phaseName = names[i];
3468
+ const number = String(firstNum + i);
3469
+ const slug = phaseName
3470
+ .toLowerCase()
3471
+ .replace(/[^a-z0-9\s-]/g, '')
3472
+ .replace(/\s+/g, '-')
3473
+ .replace(/-+/g, '-')
3474
+ .replace(/^-+|-+$/g, '');
3475
+ if (!slug) {
3476
+ throw new Error(`Name at index ${i} ("${phaseName}") produces an empty slug`);
3477
+ }
3478
+ if (state.phases.some(p => String(p.number) === number)) {
3479
+ throw new Error(`Phase ${number} already exists in state.json (would collide at index ${i})`);
3480
+ }
3481
+
3482
+ const dirName = `${number}-${slug}`;
3483
+ const directory = path.join(phasesDir, dirName);
3484
+ if (fs.existsSync(directory)) {
3485
+ throw new Error(`Directory already exists: ${path.relative(PROJECT_ROOT, directory)}`);
3486
+ }
3487
+ fs.mkdirSync(directory, { recursive: true });
3488
+
3489
+ // Append ROADMAP entry
3490
+ if (fs.existsSync(roadmapPath)) {
3491
+ const entry = `## Phase ${number} — ${phaseName}\n\n` +
3492
+ `**Goal:** _TBD — fill in via /rihal-discuss-phase ${number} or edit directly._\n\n` +
3493
+ `**Status:** Planned\n\n` +
3494
+ `**Plans:**\n- _TBD_\n\n` +
3495
+ `**Acceptance:** _TBD_\n\n---\n`;
3496
+ let text = fs.readFileSync(roadmapPath, 'utf8');
3497
+ const backlogMatch = text.match(/^##\s+Backlog\b/m);
3498
+ if (backlogMatch) {
3499
+ text = text.slice(0, backlogMatch.index) + entry + '\n' + text.slice(backlogMatch.index);
3500
+ } else {
3501
+ if (!text.endsWith('\n')) text += '\n';
3502
+ text += '\n' + entry;
3503
+ }
3504
+ fs.writeFileSync(roadmapPath, text);
3505
+ }
3506
+
3507
+ state.phases.push({
3508
+ number, name: phaseName, slug,
3509
+ goal: '', status: 'planned',
3510
+ created: new Date().toISOString(),
3511
+ started: null, completed: null, plan_count: 0,
3512
+ });
3513
+ created.push({ number, name: phaseName, directory: path.relative(PROJECT_ROOT, directory) });
3514
+ }
3515
+
3516
+ state.updated = new Date().toISOString();
3517
+ if (typeof state.schema_version !== 'number') state.schema_version = 1;
3518
+ const stateDir = path.dirname(statePath);
3519
+ if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
3520
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
3521
+
3522
+ return { ok: true, count: created.length, phases: created };
3523
+ }
3524
+
3525
+ throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add, set-status, next-range, scaffold-milestone`);
2984
3526
  }
2985
3527
 
2986
3528
  /**
@@ -3059,7 +3601,7 @@ function cmdCommit(argv) {
3059
3601
  }
3060
3602
 
3061
3603
  // Stage files if --files provided; otherwise commit whatever is staged.
3062
- const { execSync } = require('child_process');
3604
+ const { execSync, execFileSync } = require('child_process');
3063
3605
  if (files.length > 0) {
3064
3606
  // Validate each path exists before staging
3065
3607
  for (const f of files) {
@@ -3072,10 +3614,9 @@ function cmdCommit(argv) {
3072
3614
  // stderr to detect the gitignore warning explicitly (#566).
3073
3615
  let gitAddStderr = '';
3074
3616
  try {
3075
- const addResult = execSync(
3076
- `git add ${files.map(f => `"${f.replace(/"/g, '\\"')}"`).join(' ')}`,
3077
- { cwd: PROJECT_ROOT, stdio: 'pipe' }
3078
- );
3617
+ // execFileSync argument array: filenames pass as literal argv entries — no
3618
+ // shell, so a filename with ; $() backticks or spaces cannot inject (#754).
3619
+ execFileSync('git', ['add', ...files], { cwd: PROJECT_ROOT, stdio: 'pipe' });
3079
3620
  } catch (e) {
3080
3621
  gitAddStderr = (e.stderr ? e.stderr.toString() : '') + (e.stdout ? e.stdout.toString() : '');
3081
3622
  if (gitAddStderr.includes('ignored by one of your .gitignore') || gitAddStderr.includes('use -f if')) {
@@ -3101,7 +3642,7 @@ function cmdCommit(argv) {
3101
3642
  if (stagedAfterAdd.some(s => s === norm || s.endsWith('/' + norm) || norm.endsWith(s))) return false;
3102
3643
  // Not in staged diff — check if it's tracked (unchanged) vs untracked/gitignored
3103
3644
  try {
3104
- execSync(`git ls-files --error-unmatch "${f}"`, { cwd: PROJECT_ROOT, stdio: 'pipe' });
3645
+ execFileSync('git', ['ls-files', '--error-unmatch', f], { cwd: PROJECT_ROOT, stdio: 'pipe' });
3105
3646
  return false; // tracked and unchanged — that's fine
3106
3647
  } catch {
3107
3648
  return true; // not tracked — likely gitignored
@@ -4183,6 +4724,53 @@ function cmdDocsAudit(args) {
4183
4724
  return { has_requirements: true, total: rows.length, gaps };
4184
4725
  }
4185
4726
 
4727
+ /**
4728
+ * cmdWorkflowConfigAudit — scan workflow files for stale config.json refs.
4729
+ * Closes #733. Reports every workflow that references .planning/config.json
4730
+ * (legacy location) instead of .rihal/config.yaml (current location).
4731
+ * Read-only. Fix guidance is printed per-file.
4732
+ */
4733
+ function cmdWorkflowConfigAudit() {
4734
+ // Check both installed (.rihal/workflows) and source (rihal/workflows) locations
4735
+ const candidates = [
4736
+ path.join(RIHAL_DIR, 'workflows'),
4737
+ path.join(PROJECT_ROOT, 'rihal', 'workflows'),
4738
+ ];
4739
+ const workflowsDir = candidates.find(d => fs.existsSync(d));
4740
+ if (!workflowsDir) {
4741
+ return { ok: true, audited: 0, hits: [], message: 'No workflows directory found (checked .rihal/workflows and rihal/workflows)' };
4742
+ }
4743
+ const files = fs.readdirSync(workflowsDir).filter(f => f.endsWith('.md'));
4744
+ const JSON_RE = /\.planning\/config\.json|planning\/config\.json/g;
4745
+ const hits = [];
4746
+
4747
+ for (const fname of files) {
4748
+ const fpath = path.join(workflowsDir, fname);
4749
+ const text = fs.readFileSync(fpath, 'utf8');
4750
+ const lines = text.split('\n');
4751
+ const matches = [];
4752
+ lines.forEach((line, idx) => {
4753
+ if (JSON_RE.test(line)) {
4754
+ matches.push({ line: idx + 1, content: line.trim().slice(0, 120) });
4755
+ }
4756
+ JSON_RE.lastIndex = 0; // reset stateful regex
4757
+ });
4758
+ if (matches.length) {
4759
+ hits.push({ file: fname, count: matches.length, refs: matches });
4760
+ }
4761
+ }
4762
+
4763
+ return {
4764
+ ok: true,
4765
+ audited: files.length,
4766
+ stale_count: hits.length,
4767
+ hits,
4768
+ fix_guidance: hits.length > 0
4769
+ ? 'Replace .planning/config.json with .rihal/config.yaml. Use `node rihal-tools.cjs config-get <key>` or readConfig() to read values.'
4770
+ : 'No stale config.json references found.',
4771
+ };
4772
+ }
4773
+
4186
4774
  /** init chain — context blob for /rihal-chain workflow. */
4187
4775
  function cmdInitChain(rawArgs) {
4188
4776
  const config = readConfig();
@@ -4611,6 +5199,95 @@ function cmdNotesCount() {
4611
5199
  * Placeholder URLs (containing `<PLACEHOLDER`) are skipped with a clear
4612
5200
  * message — useful in v2.0 before M5 lands real Rihal repo URLs.
4613
5201
  */
5202
+
5203
+ /**
5204
+ * cmdHandoff — cross-skill continuation token system. Closes #741.
5205
+ *
5206
+ * Enables one workflow to write a structured "where I left off" token
5207
+ * that the next skill/workflow reads at startup — bridging the context
5208
+ * gap between chained agents.
5209
+ *
5210
+ * Subcommands:
5211
+ * handoff write --from <skill> --to <skill> --phase <N> [--plan <M>] [--context "..."]
5212
+ * Write a handoff token to ~/.rihal/handoffs/{from}-{to}-{date}.json
5213
+ * and also to .rihal/handoff-latest.json for easy pickup by the next agent.
5214
+ *
5215
+ * handoff read [--from <skill>]
5216
+ * Read the most recent handoff targeting the current (or specified) skill.
5217
+ * Returns JSON with: from, to, phase, plan, context, written_at.
5218
+ * Exits 0 even when no handoff exists (returns {found: false}).
5219
+ *
5220
+ * handoff clear
5221
+ * Remove .rihal/handoff-latest.json (signal that the handoff was consumed).
5222
+ */
5223
+ function cmdHandoff(args) {
5224
+ const os_mod = require('os');
5225
+ const sub = (args[0] || 'help').trim();
5226
+ const handoffsDir = path.join(os_mod.homedir(), '.rihal', 'handoffs');
5227
+ const latestPath = path.join(RIHAL_DIR, 'handoff-latest.json');
5228
+
5229
+ if (sub === 'write') {
5230
+ const fromVal = args[args.indexOf('--from') + 1] || null;
5231
+ const toVal = args[args.indexOf('--to') + 1] || null;
5232
+ const phaseVal = args[args.indexOf('--phase') + 1] || null;
5233
+ const planVal = args[args.indexOf('--plan') + 1] || null;
5234
+ const ctxIdx = args.indexOf('--context');
5235
+ const contextVal = ctxIdx !== -1 ? args.slice(ctxIdx + 1).join(' ') : null;
5236
+ if (!fromVal || !toVal) throw new Error('handoff write requires --from <skill> and --to <skill>');
5237
+
5238
+ const token = {
5239
+ from: fromVal, to: toVal,
5240
+ phase: phaseVal || null, plan: planVal || null,
5241
+ context: contextVal || null,
5242
+ written_at: new Date().toISOString(),
5243
+ };
5244
+
5245
+ try { fs.mkdirSync(handoffsDir, { recursive: true }); } catch {}
5246
+ const date = new Date().toISOString().slice(0, 10);
5247
+ const fname = `${fromVal}-${toVal}-${date}.json`;
5248
+ const fpath = path.join(handoffsDir, fname);
5249
+ fs.writeFileSync(fpath, JSON.stringify(token, null, 2) + '\n');
5250
+ // Also write the "latest" shortcut into the project .rihal dir
5251
+ try {
5252
+ fs.mkdirSync(RIHAL_DIR, { recursive: true });
5253
+ fs.writeFileSync(latestPath, JSON.stringify(token, null, 2) + '\n');
5254
+ } catch {}
5255
+
5256
+ return { ok: true, token, written_to: [path.relative(PROJECT_ROOT, fpath), path.relative(PROJECT_ROOT, latestPath)] };
5257
+ }
5258
+
5259
+ if (sub === 'read') {
5260
+ const fromFilter = args[args.indexOf('--from') + 1] || null;
5261
+ // Prefer .rihal/handoff-latest.json (written by the most recent handoff write)
5262
+ if (fs.existsSync(latestPath)) {
5263
+ try {
5264
+ const token = JSON.parse(fs.readFileSync(latestPath, 'utf8'));
5265
+ if (!fromFilter || token.from === fromFilter) {
5266
+ return { found: true, token, source: path.relative(PROJECT_ROOT, latestPath) };
5267
+ }
5268
+ } catch {}
5269
+ }
5270
+ // Fallback: scan ~/.rihal/handoffs/ for most recent matching file
5271
+ try {
5272
+ const files = fs.readdirSync(handoffsDir)
5273
+ .filter(f => f.endsWith('.json') && (!fromFilter || f.startsWith(fromFilter + '-')))
5274
+ .sort().reverse();
5275
+ if (files.length) {
5276
+ const token = JSON.parse(fs.readFileSync(path.join(handoffsDir, files[0]), 'utf8'));
5277
+ return { found: true, token, source: files[0] };
5278
+ }
5279
+ } catch {}
5280
+ return { found: false, token: null };
5281
+ }
5282
+
5283
+ if (sub === 'clear') {
5284
+ try { fs.unlinkSync(latestPath); } catch {}
5285
+ return { ok: true, cleared: path.relative(PROJECT_ROOT, latestPath) };
5286
+ }
5287
+
5288
+ return { ok: false, error: `Unknown handoff subcommand: ${sub}. Valid: write, read, clear` };
5289
+ }
5290
+
4614
5291
  function cmdBrain(args) {
4615
5292
  const sub = args[0] || 'help';
4616
5293
  // sources.yaml lives under .rihal/brain/ in user installs (v2.2+).
@@ -4803,32 +5480,103 @@ function cmdBrain(args) {
4803
5480
  }
4804
5481
 
4805
5482
  // External git source — use sparse checkout into a tmp dir then copy.
5483
+ // #170 — global brain cache at ~/.rihal/brain-cache/<sha1(repo+branch+paths)>/.
5484
+ // Same source pulled from N projects = N clones today, 1 clone + N copies
5485
+ // after this change. Cache TTL is configurable per source (defaults to 6h).
4806
5486
  const { execSync } = require('child_process');
5487
+ const crypto = require('crypto');
4807
5488
  const os = require('os');
4808
5489
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rihal-brain-'));
4809
5490
  const branch = s.branch || cfg.defaults?.branch || 'main';
5491
+ const sparsePaths = Array.isArray(s.paths) ? s.paths : [];
5492
+
5493
+ // Cache key = sha1(repo + branch + sparsePaths joined). Changing any of
5494
+ // those gets a fresh cache slot. Different projects pulling the same
5495
+ // (repo, branch, paths) tuple share one cached download.
5496
+ const cacheKey = crypto
5497
+ .createHash('sha1')
5498
+ .update(`${repo}\n${branch}\n${sparsePaths.sort().join(',')}`)
5499
+ .digest('hex')
5500
+ .slice(0, 16);
5501
+ const cacheRoot = path.join(os.homedir(), '.rihal', 'brain-cache');
5502
+ const cacheDir = path.join(cacheRoot, cacheKey);
5503
+ const cacheManifest = path.join(cacheDir, '.cache-manifest.json');
5504
+
5505
+ // Parse cache_ttl: accept '6h', '15m', '2d', or seconds as bare number.
5506
+ function parseTtlSeconds(raw, fallback) {
5507
+ if (raw == null || raw === '') return fallback;
5508
+ const s = String(raw).trim();
5509
+ const m = s.match(/^(\d+)([smhd]?)$/i);
5510
+ if (!m) return fallback;
5511
+ const n = parseInt(m[1], 10);
5512
+ switch ((m[2] || 's').toLowerCase()) {
5513
+ case 'd': return n * 86400;
5514
+ case 'h': return n * 3600;
5515
+ case 'm': return n * 60;
5516
+ default: return n;
5517
+ }
5518
+ }
5519
+ const ttlSeconds = parseTtlSeconds(s.cache_ttl || cfg.defaults?.cache_ttl, 6 * 3600);
5520
+
5521
+ function readCacheManifest() {
5522
+ if (!fs.existsSync(cacheManifest)) return null;
5523
+ try { return JSON.parse(fs.readFileSync(cacheManifest, 'utf8')); }
5524
+ catch { return null; }
5525
+ }
5526
+ function isCacheFresh(manifest) {
5527
+ if (!manifest || typeof manifest.pulled_at !== 'string') return false;
5528
+ const ageMs = Date.now() - Date.parse(manifest.pulled_at);
5529
+ return Number.isFinite(ageMs) && (ageMs / 1000) < ttlSeconds;
5530
+ }
5531
+ function copyTree(src, dst) {
5532
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
5533
+ if (e.name === '.git' || e.name === '.cache-manifest.json') continue;
5534
+ const sp = path.join(src, e.name);
5535
+ const dp = path.join(dst, e.name);
5536
+ if (e.isDirectory()) { fs.mkdirSync(dp, { recursive: true }); copyTree(sp, dp); }
5537
+ else if (e.isFile()) fs.copyFileSync(sp, dp);
5538
+ }
5539
+ }
5540
+
5541
+ const destPath = resolveDest(s.dest);
4810
5542
  try {
5543
+ // Cache hit path — copy from ~/.rihal/brain-cache/<key>/ directly.
5544
+ const cached = readCacheManifest();
5545
+ if (cached && isCacheFresh(cached)) {
5546
+ fs.mkdirSync(destPath, { recursive: true });
5547
+ copyTree(cacheDir, destPath);
5548
+ report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'hit', cache_key: cacheKey });
5549
+ continue;
5550
+ }
5551
+
5552
+ // Cache miss — clone, then warm the cache for next time.
4811
5553
  execSync(
4812
5554
  `git clone --depth=1 --filter=blob:none --sparse --branch="${branch}" "${repo}" "${tmp}"`,
4813
5555
  { stdio: 'pipe' }
4814
5556
  );
4815
- const paths = Array.isArray(s.paths) ? s.paths : [];
4816
- execSync(`git -C "${tmp}" sparse-checkout set ${paths.map(p => `"${p}"`).join(' ')}`, { stdio: 'pipe' });
5557
+ execSync(`git -C "${tmp}" sparse-checkout set ${sparsePaths.map(p => `"${p}"`).join(' ')}`, { stdio: 'pipe' });
5558
+
5559
+ // Warm cache before destination copy so a copy failure to dest still
5560
+ // saves the next pull. Replace any stale slot atomically.
5561
+ try {
5562
+ fs.rmSync(cacheDir, { recursive: true, force: true });
5563
+ fs.mkdirSync(cacheDir, { recursive: true });
5564
+ copyTree(tmp, cacheDir);
5565
+ const commitSha = (() => {
5566
+ try { return execSync(`git -C "${tmp}" rev-parse HEAD`, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
5567
+ catch { return null; }
5568
+ })();
5569
+ fs.writeFileSync(cacheManifest, JSON.stringify({
5570
+ repo, branch, paths: sparsePaths,
5571
+ pulled_at: new Date().toISOString(),
5572
+ commit_sha: commitSha,
5573
+ ttl_seconds: ttlSeconds,
5574
+ }, null, 2));
5575
+ } catch (_) { /* cache warming is best-effort */ }
4817
5576
 
4818
- const destPath = resolveDest(s.dest);
4819
5577
  fs.mkdirSync(destPath, { recursive: true });
4820
- // Copy everything the sparse checkout materialized.
4821
- function copyTree(src, dst) {
4822
- for (const e of fs.readdirSync(src, { withFileTypes: true })) {
4823
- if (e.name === '.git') continue;
4824
- const sp = path.join(src, e.name);
4825
- const dp = path.join(dst, e.name);
4826
- if (e.isDirectory()) { fs.mkdirSync(dp, { recursive: true }); copyTree(sp, dp); }
4827
- else if (e.isFile()) fs.copyFileSync(sp, dp);
4828
- }
4829
- }
4830
5578
  copyTree(tmp, destPath);
4831
- report.pulled.push({ name: s.name, kind: 'git', repo, branch });
5579
+ report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'miss', cache_key: cacheKey });
4832
5580
  } catch (e) {
4833
5581
  report.errors.push({ name: s.name, error: String(e.message || e).slice(0, 200) });
4834
5582
  } finally {
@@ -5445,6 +6193,61 @@ function cmdValidateRoadmap() {
5445
6193
  };
5446
6194
  }
5447
6195
 
6196
+ /**
6197
+ * cmdRoadmapDetectStructure — detect monolithic vs per-milestone ROADMAP layout.
6198
+ * Closes #734. Reports which convention is in use so workflows can branch correctly
6199
+ * instead of assuming a single ROADMAP.md.
6200
+ *
6201
+ * Conventions detected:
6202
+ * monolithic — .planning/ROADMAP.md (single file, all milestones)
6203
+ * per-milestone — .planning/ROADMAP-M{N}.md or .planning/milestones/{N}-*.md
6204
+ * hybrid — both patterns present
6205
+ * absent — no ROADMAP files found at all
6206
+ */
6207
+ function cmdRoadmapDetectStructure() {
6208
+ const planningDir = path.join(PROJECT_ROOT, '.planning');
6209
+ const monoPath = path.join(planningDir, 'ROADMAP.md');
6210
+ const hasMono = fs.existsSync(monoPath);
6211
+
6212
+ let perMilestoneFiles = [];
6213
+ try {
6214
+ const files = fs.readdirSync(planningDir);
6215
+ // ROADMAP-M1.md, ROADMAP-M2.md, ROADMAP-milestone-name.md
6216
+ perMilestoneFiles = files.filter(f => /^ROADMAP-[A-Za-z0-9].*\.md$/.test(f) && f !== 'ROADMAP.md');
6217
+ } catch {}
6218
+
6219
+ let milestoneDirFiles = [];
6220
+ const msDir = path.join(planningDir, 'milestones');
6221
+ if (fs.existsSync(msDir)) {
6222
+ try { milestoneDirFiles = fs.readdirSync(msDir).filter(f => f.endsWith('.md')); } catch {}
6223
+ }
6224
+
6225
+ const hasPerMilestone = perMilestoneFiles.length > 0 || milestoneDirFiles.length > 0;
6226
+
6227
+ let structure;
6228
+ if (hasMono && hasPerMilestone) structure = 'hybrid';
6229
+ else if (hasMono) structure = 'monolithic';
6230
+ else if (hasPerMilestone) structure = 'per-milestone';
6231
+ else structure = 'absent';
6232
+
6233
+ return {
6234
+ ok: true,
6235
+ structure,
6236
+ monolithic_file: hasMono ? '.planning/ROADMAP.md' : null,
6237
+ per_milestone_files: [
6238
+ ...perMilestoneFiles.map(f => `.planning/${f}`),
6239
+ ...milestoneDirFiles.map(f => `.planning/milestones/${f}`),
6240
+ ],
6241
+ recommendation: structure === 'monolithic'
6242
+ ? 'Standard layout. Use /rihal-plan and /rihal-execute normally.'
6243
+ : structure === 'per-milestone'
6244
+ ? 'Per-milestone layout detected. Pass the specific ROADMAP file to rihal-roadmapper with --roadmap <path>.'
6245
+ : structure === 'hybrid'
6246
+ ? 'Mixed layout. Consolidate to one convention to avoid workflow confusion.'
6247
+ : 'No ROADMAP found. Run /rihal-new-project or /rihal-new-milestone first.',
6248
+ };
6249
+ }
6250
+
5448
6251
  /**
5449
6252
  * cmdMilestoneHealth — gauge for the current milestone (issue #718).
5450
6253
  *
@@ -5735,6 +6538,9 @@ async function main() {
5735
6538
  case 'docs-audit':
5736
6539
  result = cmdDocsAudit(args);
5737
6540
  break;
6541
+ case 'workflow-config-audit':
6542
+ result = cmdWorkflowConfigAudit();
6543
+ break;
5738
6544
  case 'frontmatter':
5739
6545
  if (args[0] === 'get') { cmdFrontmatterGet(args.slice(1)); return; }
5740
6546
  else { console.error('Unknown frontmatter subcommand. Valid: get'); process.exit(1); }
@@ -5835,6 +6641,51 @@ async function main() {
5835
6641
  result = cfg.cmdSet(PROJECT_ROOT, args[0], args.slice(1).join(' '));
5836
6642
  break;
5837
6643
  }
6644
+ case 'config-check-yolo': {
6645
+ // Closes #739. Evaluate whether yolo mode is active for a given scope.
6646
+ // Usage: config-check-yolo [--phase <N>] [--workflow <name>]
6647
+ // Returns JSON: { active: bool, mode, scope, expires_at, reason }
6648
+ const cfg = require(path.join(__dirname, 'lib', 'config.cjs'));
6649
+ const phaseArg = args[args.indexOf('--phase') + 1] || null;
6650
+ const workflowArg = args[args.indexOf('--workflow') + 1] || null;
6651
+ const mode = cfg.cmdGet(PROJECT_ROOT, 'mode') || 'guided';
6652
+ if (mode !== 'yolo') {
6653
+ result = { active: false, mode, scope: null, expires_at: null, reason: 'mode is not yolo' };
6654
+ break;
6655
+ }
6656
+ // Check optional yolo_scope restriction
6657
+ const scopeRaw = cfg.cmdGet(PROJECT_ROOT, 'yolo_scope') || null;
6658
+ if (scopeRaw) {
6659
+ const scope = String(scopeRaw).trim();
6660
+ // scope format: "phase:N" | "workflow:name" | "global"
6661
+ if (scope.startsWith('phase:') && phaseArg) {
6662
+ const allowedPhase = scope.slice('phase:'.length).trim();
6663
+ if (String(phaseArg) !== allowedPhase) {
6664
+ result = { active: false, mode, scope, expires_at: null, reason: `yolo_scope restricts to phase ${allowedPhase}, current is ${phaseArg}` };
6665
+ break;
6666
+ }
6667
+ } else if (scope.startsWith('workflow:') && workflowArg) {
6668
+ const allowedWf = scope.slice('workflow:'.length).trim();
6669
+ if (workflowArg !== allowedWf) {
6670
+ result = { active: false, mode, scope, expires_at: null, reason: `yolo_scope restricts to workflow ${allowedWf}, current is ${workflowArg}` };
6671
+ break;
6672
+ }
6673
+ }
6674
+ }
6675
+ // Check optional TTL
6676
+ const ttlRaw = cfg.cmdGet(PROJECT_ROOT, 'yolo_ttl') || null;
6677
+ let expiresAt = null;
6678
+ if (ttlRaw) {
6679
+ expiresAt = ttlRaw;
6680
+ const expiry = new Date(ttlRaw);
6681
+ if (!Number.isNaN(expiry.getTime()) && Date.now() > expiry.getTime()) {
6682
+ result = { active: false, mode, scope: scopeRaw, expires_at: ttlRaw, reason: `yolo_ttl expired at ${ttlRaw}` };
6683
+ break;
6684
+ }
6685
+ }
6686
+ result = { active: true, mode, scope: scopeRaw || 'global', expires_at: expiresAt, reason: 'yolo active' };
6687
+ break;
6688
+ }
5838
6689
  case 'verify': {
5839
6690
  const verify = require(path.join(__dirname, 'lib', 'verify.cjs'));
5840
6691
  result = verify.dispatch(PROJECT_ROOT, args);
@@ -5844,6 +6695,10 @@ async function main() {
5844
6695
  result = cmdBrain(args);
5845
6696
  break;
5846
6697
  }
6698
+ case 'handoff': {
6699
+ result = cmdHandoff(args);
6700
+ break;
6701
+ }
5847
6702
  case 'progress': {
5848
6703
  result = cmdProgress(args);
5849
6704
  break;
@@ -5872,6 +6727,9 @@ async function main() {
5872
6727
  case 'validate-roadmap':
5873
6728
  result = cmdValidateRoadmap();
5874
6729
  break;
6730
+ case 'roadmap-detect-structure':
6731
+ result = cmdRoadmapDetectStructure();
6732
+ break;
5875
6733
  case 'milestone-health':
5876
6734
  result = cmdMilestoneHealth();
5877
6735
  break;
@@ -5893,6 +6751,9 @@ async function main() {
5893
6751
  console.log(' list-agents → list all available Rihal agents');
5894
6752
  console.log(' state <subcommand> [args] → manage .rihal/state.json');
5895
6753
  console.log(' phase add <name> [--decimal <parent>] → add phase (integer to current milestone, or --decimal slots under parent as parent.M)');
6754
+ console.log(' phase next-range [count] → return next N contiguous free phase numbers (#730)');
6755
+ console.log(' phase scaffold-milestone --names "n1|n2|..." → bulk-create phase folders for a milestone (#731)');
6756
+ console.log(' workflow-config-audit → find workflows still referencing .planning/config.json (#733)');
5896
6757
  console.log(' commit "<msg>" [--files p1 p2 ...] → atomic git commit with conventional-commits validation (no AI attribution, no --no-verify, no auto-push)');
5897
6758
  console.log(' commit-to-subrepo --subrepo <p> "<msg>" → atomic commit inside a git subrepo (same validation as commit)');
5898
6759
  console.log(' generate-claude-md [--force] → bootstrap a project CLAUDE.md scaffold (refuses to overwrite without --force)');
@@ -5918,6 +6779,12 @@ async function main() {
5918
6779
  console.log(' roadmap <get-phase|list-phases|update-plan-progress|clear> → .planning/ROADMAP.md operations');
5919
6780
  console.log(' config-get <dotted.key> → read scalar from .rihal/config.yaml');
5920
6781
  console.log(' config-set <dotted.key> <value> → atomically set a value in .rihal/config.yaml');
6782
+ console.log(' config-check-yolo [--phase N] [--workflow W] → check if yolo mode is active for scope (#739)');
6783
+ console.log(' handoff write --from <skill> --to <skill> --phase N [--context "..."] → write cross-skill handoff token (#741)');
6784
+ console.log(' handoff read [--from <skill>] → read most recent handoff for this skill (#741)');
6785
+ console.log(' handoff clear → consume (clear) the latest handoff token (#741)');
6786
+ console.log(' yolo_scope config keys: "global" | "phase:N" | "workflow:name"');
6787
+ console.log(' yolo_ttl config key: ISO timestamp — yolo auto-expires after this time');
5921
6788
  console.log(' verify schema-drift <phase> [--block] → detect schema vs migration drift across phase commits');
5922
6789
  console.log(' resolve-model <profile> → resolve model name from profile');
5923
6790
  console.log(' version → print rihal-tools version');