@hanzlaa/rcode 3.4.33 → 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.
- package/AGENTS.md +6 -6
- package/CONTRIBUTING.md +2 -0
- package/LICENSE +21 -0
- package/README.md +66 -403
- package/cli/doctor.js +87 -1
- package/cli/install.js +122 -31
- package/cli/lib/schemas.cjs +318 -0
- package/cli/postinstall.js +19 -3
- package/dist/rcode.js +316 -23
- package/package.json +8 -4
- package/rihal/agents/rihal-cross-platform-auditor.md +1 -1
- package/rihal/agents/rihal-dep-auditor.md +1 -1
- package/rihal/agents/rihal-docs-auditor.md +3 -145
- package/rihal/agents/rihal-i18n-auditor.md +1 -1
- package/rihal/agents/rihal-nyquist-auditor.md +4 -156
- package/rihal/agents/rihal-observability-auditor.md +1 -1
- package/rihal/bin/rihal-hooks.cjs +394 -4
- package/rihal/bin/rihal-tools.cjs +891 -24
- package/rihal/commands/create-prd.md +18 -0
- package/rihal/commands/execute-milestone.md +18 -0
- package/rihal/commands/plan-milestone.md +18 -0
- package/rihal/commands/scaffold-milestone.md +18 -0
- package/rihal/commands/scaffold-skill.md +18 -0
- package/rihal/references/REFERENCES_INDEX.md +49 -7
- package/rihal/references/agent-contracts.md +10 -0
- package/rihal/references/design-tokens.md +98 -0
- package/rihal/references/docs-auditor-playbook.md +148 -0
- package/rihal/references/git-preflight.md +117 -0
- package/rihal/references/iterative-retrieval.md +85 -0
- package/rihal/references/nyquist-auditor-playbook.md +157 -0
- package/rihal/references/workstream-flag.md +2 -2
- package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +9 -0
- package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +9 -0
- package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +4 -0
- package/rihal/skills/actions/4-implementation/rihal-code-review/steps/step-02-review.md +2 -2
- package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +4 -0
- package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +4 -0
- package/rihal/skills/agents/haitham-frontend/SKILL.md +2 -0
- package/rihal/templates/settings-hooks.json +39 -0
- package/rihal/workflows/check-todos.md +4 -0
- package/rihal/workflows/code-review-fix.md +4 -3
- package/rihal/workflows/code-review.md +1 -1
- package/rihal/workflows/debug.md +1 -1
- package/rihal/workflows/dev-story.md +4 -0
- package/rihal/workflows/diff.md +2 -2
- package/rihal/workflows/do.md +16 -8
- package/rihal/workflows/docs-update.md +2 -2
- package/rihal/workflows/enable-hooks.md +6 -1
- package/rihal/workflows/execute-milestone.md +139 -0
- package/rihal/workflows/execute-regression-gates.md +1 -1
- package/rihal/workflows/execute-sprint.md +54 -2
- package/rihal/workflows/execute-verify-phase-goal.md +31 -4
- package/rihal/workflows/execute-waves.md +33 -5
- package/rihal/workflows/execute.md +40 -6
- package/rihal/workflows/help.md +1 -1
- package/rihal/workflows/import.md +1 -1
- package/rihal/workflows/lens-audit.md +39 -23
- package/rihal/workflows/list-workspaces.md +1 -1
- package/rihal/workflows/map-codebase.md +4 -4
- package/rihal/workflows/new-milestone.md +18 -1
- package/rihal/workflows/new-project-research.md +53 -1
- package/rihal/workflows/new-workspace.md +1 -1
- package/rihal/workflows/plan-milestone.md +105 -0
- package/rihal/workflows/plan-research-validation.md +1 -1
- package/rihal/workflows/plan-spawn-planner.md +1 -1
- package/rihal/workflows/plan.md +31 -3
- package/rihal/workflows/plant-seed.md +6 -0
- package/rihal/workflows/quick.md +11 -5
- package/rihal/workflows/research-phase.md +24 -0
- package/rihal/workflows/scaffold-milestone.md +60 -0
- package/rihal/workflows/scaffold-skill.md +137 -0
- package/rihal/workflows/scan.md +1 -1
- package/rihal/workflows/session-report.md +43 -3
- package/rihal/workflows/verify-work.md +3 -3
- package/server/dashboard.js +52 -5
- package/server/lib/html/client.js +723 -11
- package/server/lib/html/css.js +2046 -466
- package/server/lib/html/shell.js +227 -134
- package/server/lib/scanner.js +33 -0
- 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
|
-
|
|
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))
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4816
|
-
|
|
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');
|