@hanzlaa/rcode 3.6.0 → 3.6.2
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/package.json +1 -1
- package/rihal/bin/rihal-tools.cjs +274 -31
- package/server/lib/html/client/components/App.js +1 -1
- package/server/lib/html/client/components/OrchPanel.js +1 -1
- package/server/lib/html/client/components/XtermPanel.js +1 -0
- package/server/lib/html/client/orchestrator.js +3 -2
- package/server/lib/html/client/util.js +2 -0
- package/server/lib/html/client/views/PhasesView.js +2 -2
- package/server/lib/html/client/views/RoadmapView.js +2 -2
- package/server/lib/html/css.js +1 -12
- package/server/lib/scanner.js +13 -0
- package/server/orchestrator.js +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.2",
|
|
4
4
|
"description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
|
|
5
5
|
"main": "cli/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -3324,6 +3324,137 @@ function cmdPhase(subArgs) {
|
|
|
3324
3324
|
};
|
|
3325
3325
|
}
|
|
3326
3326
|
|
|
3327
|
+
// =====================================================================
|
|
3328
|
+
// phase complete <phase_number> — mark a phase complete and report the
|
|
3329
|
+
// next phase. Closes the workflow/CLI drift (#766): execute.md calls
|
|
3330
|
+
// `phase complete` but only set-status existed.
|
|
3331
|
+
// =====================================================================
|
|
3332
|
+
if (sub === 'complete') {
|
|
3333
|
+
const phaseRef = subArgs[1];
|
|
3334
|
+
if (!phaseRef) throw new Error('phase complete requires <phase_number>');
|
|
3335
|
+
const statePath = path.join(RIHAL_DIR, 'state.json');
|
|
3336
|
+
if (!fs.existsSync(statePath)) {
|
|
3337
|
+
throw new Error(`state.json not found at ${statePath} — run 'rihal-tools state init' first`);
|
|
3338
|
+
}
|
|
3339
|
+
let state;
|
|
3340
|
+
try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
|
|
3341
|
+
catch (e) { throw new Error(`Invalid JSON in state.json: ${e.message}`); }
|
|
3342
|
+
if (!state.phases) state.phases = [];
|
|
3343
|
+
const idx = state.phases.findIndex(p =>
|
|
3344
|
+
String(p.number) === String(phaseRef) ||
|
|
3345
|
+
String(p.id) === String(phaseRef) ||
|
|
3346
|
+
p.name === phaseRef
|
|
3347
|
+
);
|
|
3348
|
+
if (idx === -1) {
|
|
3349
|
+
throw new Error(`Phase "${phaseRef}" not found in state.phases (looked up by number, id, and name)`);
|
|
3350
|
+
}
|
|
3351
|
+
const previous = state.phases[idx].status || null;
|
|
3352
|
+
state.phases[idx].status = 'complete';
|
|
3353
|
+
state.phases[idx].status_updated = new Date().toISOString();
|
|
3354
|
+
state.phases[idx].completed_at = state.phases[idx].completed_at || new Date().toISOString().slice(0, 10);
|
|
3355
|
+
|
|
3356
|
+
const num = parseInt(String(state.phases[idx].number || phaseRef), 10);
|
|
3357
|
+
const next = state.phases
|
|
3358
|
+
.filter(p => parseInt(String(p.number), 10) > num)
|
|
3359
|
+
.sort((a, b) => parseInt(String(a.number), 10) - parseInt(String(b.number), 10))[0] || null;
|
|
3360
|
+
|
|
3361
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
3362
|
+
return {
|
|
3363
|
+
ok: true,
|
|
3364
|
+
phase: phaseRef,
|
|
3365
|
+
previous_status: previous,
|
|
3366
|
+
new_status: 'complete',
|
|
3367
|
+
next_phase: next ? next.number : null,
|
|
3368
|
+
next_phase_name: next ? (next.name || null) : null,
|
|
3369
|
+
is_last_phase: !next,
|
|
3370
|
+
warnings: [],
|
|
3371
|
+
has_warnings: false,
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
// =====================================================================
|
|
3376
|
+
// phase sync-sprints <phase_number> — register sprint records into
|
|
3377
|
+
// state.json by deriving them from the .planning/phases/<dir>/*-SPRINT.md
|
|
3378
|
+
// files (the source of truth). Closes #765: planner agents write SPRINT.md
|
|
3379
|
+
// files but do not always register sprint entries, leaving state.json an
|
|
3380
|
+
// incomplete mirror. This makes registration a deterministic CLI step.
|
|
3381
|
+
// =====================================================================
|
|
3382
|
+
if (sub === 'sync-sprints') {
|
|
3383
|
+
const phaseRef = subArgs[1];
|
|
3384
|
+
if (!phaseRef) throw new Error('phase sync-sprints requires <phase_number>');
|
|
3385
|
+
const statePath = path.join(RIHAL_DIR, 'state.json');
|
|
3386
|
+
if (!fs.existsSync(statePath)) {
|
|
3387
|
+
throw new Error(`state.json not found at ${statePath} — run 'rihal-tools state init' first`);
|
|
3388
|
+
}
|
|
3389
|
+
let state;
|
|
3390
|
+
try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
|
|
3391
|
+
catch (e) { throw new Error(`Invalid JSON in state.json: ${e.message}`); }
|
|
3392
|
+
if (!state.phases) state.phases = [];
|
|
3393
|
+
const idx = state.phases.findIndex(p =>
|
|
3394
|
+
String(p.number) === String(phaseRef) ||
|
|
3395
|
+
String(p.id) === String(phaseRef) ||
|
|
3396
|
+
p.name === phaseRef
|
|
3397
|
+
);
|
|
3398
|
+
if (idx === -1) {
|
|
3399
|
+
throw new Error(`Phase "${phaseRef}" not found in state.phases`);
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
const phasesDir = path.join(PLANNING_DIR, 'phases');
|
|
3403
|
+
const intId = String(phaseRef).split('.')[0];
|
|
3404
|
+
let dirs;
|
|
3405
|
+
try { dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(d => d.isDirectory()); }
|
|
3406
|
+
catch { throw new Error(`No .planning/phases directory found`); }
|
|
3407
|
+
const dir = dirs.find(d => d.name.startsWith(intId + '-') ||
|
|
3408
|
+
d.name.startsWith(intId.padStart(2, '0') + '-'));
|
|
3409
|
+
if (!dir) throw new Error(`No phase directory on disk for phase ${phaseRef}`);
|
|
3410
|
+
|
|
3411
|
+
const files = fs.readdirSync(path.join(phasesDir, dir.name));
|
|
3412
|
+
const sprintFiles = files.filter(f => /-SPRINT\.md$/i.test(f)).sort();
|
|
3413
|
+
const sprints = sprintFiles.map(f => {
|
|
3414
|
+
const m = f.match(/^(\d+)-(\d+)-SPRINT\.md$/i);
|
|
3415
|
+
const num = m ? parseInt(m[2], 10) : 0;
|
|
3416
|
+
const sid = m ? `${parseInt(m[1], 10)}.${num}` : f.replace(/-SPRINT\.md$/i, '');
|
|
3417
|
+
const text = fs.readFileSync(path.join(phasesDir, dir.name, f), 'utf8');
|
|
3418
|
+
const fmGoal = (text.match(/^goal:\s*(.+)$/m) || [])[1];
|
|
3419
|
+
let goal = fmGoal ? fmGoal.trim() : '';
|
|
3420
|
+
if (!goal) {
|
|
3421
|
+
const obj = (text.match(/<objective>\s*([\s\S]*?)<\/objective>/) || [])[1] || '';
|
|
3422
|
+
goal = (obj.trim().split('\n').map(s => s.trim()).filter(Boolean)[0] || '').slice(0, 160);
|
|
3423
|
+
}
|
|
3424
|
+
const stories = [];
|
|
3425
|
+
const taskRe = /<task\b([^>]*)>([\s\S]*?)<\/task>/g;
|
|
3426
|
+
let tm;
|
|
3427
|
+
while ((tm = taskRe.exec(text))) {
|
|
3428
|
+
const idM = tm[1].match(/id="([^"]+)"/);
|
|
3429
|
+
const tM = tm[2].match(/<title>([\s\S]*?)<\/title>/);
|
|
3430
|
+
stories.push({ id: idM ? idM[1] : `${sid}.${stories.length + 1}`,
|
|
3431
|
+
title: tM ? tM[1].trim() : `Task ${stories.length + 1}`,
|
|
3432
|
+
status: 'planned' });
|
|
3433
|
+
}
|
|
3434
|
+
if (!stories.length) {
|
|
3435
|
+
// Legacy SPRINT.md: "### Story|Task <id> — <title>" headings.
|
|
3436
|
+
const headRe = /^#{2,4}\s+(?:Story|Task)\s+([^\s—–-]+)\s*[—–-]\s*(.+?)\s*$/gm;
|
|
3437
|
+
let hm;
|
|
3438
|
+
while ((hm = headRe.exec(text))) {
|
|
3439
|
+
stories.push({ id: hm[1].trim(), title: hm[2].trim(), status: 'planned' });
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
const hasSummary = files.includes(f.replace(/-SPRINT\.md$/i, '-SUMMARY.md'));
|
|
3443
|
+
return { id: sid, number: num, goal: goal || `Sprint ${num}`,
|
|
3444
|
+
status: hasSummary ? 'complete' : 'planned', stories };
|
|
3445
|
+
});
|
|
3446
|
+
|
|
3447
|
+
state.phases[idx].sprints = sprints;
|
|
3448
|
+
state.phases[idx].plan_count = sprints.length;
|
|
3449
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
3450
|
+
return {
|
|
3451
|
+
ok: true,
|
|
3452
|
+
phase: phaseRef,
|
|
3453
|
+
sprints_registered: sprints.length,
|
|
3454
|
+
stories_registered: sprints.reduce((a, s) => a + s.stories.length, 0),
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3327
3458
|
if (sub === 'set-status') {
|
|
3328
3459
|
const phaseRef = subArgs[1];
|
|
3329
3460
|
const newStatus = subArgs[2];
|
|
@@ -3435,33 +3566,35 @@ function cmdPhase(subArgs) {
|
|
|
3435
3566
|
const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
|
|
3436
3567
|
const statePath = path.join(RIHAL_DIR, 'state.json');
|
|
3437
3568
|
|
|
3569
|
+
// #769 — the next free number is derived from phase DIRECTORIES only.
|
|
3570
|
+
// A directory is the physical "slot taken" signal. ROADMAP.md headings and
|
|
3571
|
+
// directory-less state.json entries represent phases that are *planned but
|
|
3572
|
+
// not yet scaffolded* — which is exactly what this command materialises —
|
|
3573
|
+
// so they must NOT push the start number forward. (The old code also read
|
|
3574
|
+
// the roadmap + state into maxNum, which made scaffold-milestone skip past
|
|
3575
|
+
// an already-written roadmap range, e.g. scaffolding 38-41 for a 34-37
|
|
3576
|
+
// milestone.)
|
|
3577
|
+
const dirNumbers = new Set();
|
|
3438
3578
|
let maxNum = 0;
|
|
3439
3579
|
if (fs.existsSync(phasesDir)) {
|
|
3440
3580
|
for (const entry of fs.readdirSync(phasesDir)) {
|
|
3441
3581
|
const m = entry.match(/^(\d+)/);
|
|
3442
|
-
if (m)
|
|
3582
|
+
if (m) {
|
|
3583
|
+
const n = parseInt(m[1], 10);
|
|
3584
|
+
dirNumbers.add(n);
|
|
3585
|
+
maxNum = Math.max(maxNum, n);
|
|
3586
|
+
}
|
|
3443
3587
|
}
|
|
3444
3588
|
}
|
|
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
3589
|
let state = { phases: [] };
|
|
3454
3590
|
if (fs.existsSync(statePath)) {
|
|
3455
3591
|
try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch {}
|
|
3456
3592
|
}
|
|
3457
3593
|
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
3594
|
|
|
3463
3595
|
const firstNum = startOverride !== null ? startOverride : maxNum + 1;
|
|
3464
3596
|
const created = [];
|
|
3597
|
+
const roadmapSkipped = [];
|
|
3465
3598
|
|
|
3466
3599
|
for (let i = 0; i < names.length; i++) {
|
|
3467
3600
|
const phaseName = names[i];
|
|
@@ -3475,8 +3608,17 @@ function cmdPhase(subArgs) {
|
|
|
3475
3608
|
if (!slug) {
|
|
3476
3609
|
throw new Error(`Name at index ${i} ("${phaseName}") produces an empty slug`);
|
|
3477
3610
|
}
|
|
3478
|
-
|
|
3479
|
-
|
|
3611
|
+
// #769 — a real collision is a state entry that ALSO has a directory on
|
|
3612
|
+
// disk (the phase is genuinely already scaffolded). A directory-less
|
|
3613
|
+
// state entry is a phantom — e.g. rihal-roadmapper synced the phase into
|
|
3614
|
+
// state.json but never created the folder — so reconcile it in place
|
|
3615
|
+
// instead of aborting.
|
|
3616
|
+
const existingIdx = state.phases.findIndex(p => String(p.number) === number);
|
|
3617
|
+
if (existingIdx !== -1) {
|
|
3618
|
+
if (dirNumbers.has(firstNum + i)) {
|
|
3619
|
+
throw new Error(`Phase ${number} already scaffolded (directory + state entry exist) — collision at index ${i}`);
|
|
3620
|
+
}
|
|
3621
|
+
state.phases.splice(existingIdx, 1);
|
|
3480
3622
|
}
|
|
3481
3623
|
|
|
3482
3624
|
const dirName = `${number}-${slug}`;
|
|
@@ -3486,22 +3628,29 @@ function cmdPhase(subArgs) {
|
|
|
3486
3628
|
}
|
|
3487
3629
|
fs.mkdirSync(directory, { recursive: true });
|
|
3488
3630
|
|
|
3489
|
-
// Append ROADMAP entry
|
|
3631
|
+
// Append ROADMAP entry — but skip if the roadmap already declares this
|
|
3632
|
+
// phase (#769: rihal-roadmapper writes `## Phase N` sections directly, so
|
|
3633
|
+
// appending a stub here produced a duplicate heading).
|
|
3490
3634
|
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
3635
|
let text = fs.readFileSync(roadmapPath, 'utf8');
|
|
3497
|
-
const
|
|
3498
|
-
if (
|
|
3499
|
-
|
|
3636
|
+
const headingRe = new RegExp(`^#{2,4}\\s*Phase\\s+${number}\\b`, 'm');
|
|
3637
|
+
if (headingRe.test(text)) {
|
|
3638
|
+
roadmapSkipped.push(number);
|
|
3500
3639
|
} else {
|
|
3501
|
-
|
|
3502
|
-
|
|
3640
|
+
const entry = `## Phase ${number} — ${phaseName}\n\n` +
|
|
3641
|
+
`**Goal:** _TBD — fill in via /rihal-discuss-phase ${number} or edit directly._\n\n` +
|
|
3642
|
+
`**Status:** Planned\n\n` +
|
|
3643
|
+
`**Plans:**\n- _TBD_\n\n` +
|
|
3644
|
+
`**Acceptance:** _TBD_\n\n---\n`;
|
|
3645
|
+
const backlogMatch = text.match(/^##\s+Backlog\b/m);
|
|
3646
|
+
if (backlogMatch) {
|
|
3647
|
+
text = text.slice(0, backlogMatch.index) + entry + '\n' + text.slice(backlogMatch.index);
|
|
3648
|
+
} else {
|
|
3649
|
+
if (!text.endsWith('\n')) text += '\n';
|
|
3650
|
+
text += '\n' + entry;
|
|
3651
|
+
}
|
|
3652
|
+
fs.writeFileSync(roadmapPath, text);
|
|
3503
3653
|
}
|
|
3504
|
-
fs.writeFileSync(roadmapPath, text);
|
|
3505
3654
|
}
|
|
3506
3655
|
|
|
3507
3656
|
state.phases.push({
|
|
@@ -3519,10 +3668,10 @@ function cmdPhase(subArgs) {
|
|
|
3519
3668
|
if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
|
|
3520
3669
|
fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
3521
3670
|
|
|
3522
|
-
return { ok: true, count: created.length, phases: created };
|
|
3671
|
+
return { ok: true, count: created.length, phases: created, roadmap_skipped: roadmapSkipped };
|
|
3523
3672
|
}
|
|
3524
3673
|
|
|
3525
|
-
throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add, set-status, next-range, scaffold-milestone`);
|
|
3674
|
+
throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add, complete, sync-sprints, set-status, next-range, scaffold-milestone`);
|
|
3526
3675
|
}
|
|
3527
3676
|
|
|
3528
3677
|
/**
|
|
@@ -4434,6 +4583,96 @@ function cmdPhasePlanIndex(rawArgs) {
|
|
|
4434
4583
|
};
|
|
4435
4584
|
}
|
|
4436
4585
|
|
|
4586
|
+
/**
|
|
4587
|
+
* Extract a frontmatter list field that may be written inline (`key: [a, b]`),
|
|
4588
|
+
* as a single scalar (`key: a`), or as a block list (`key:` then ` - a`).
|
|
4589
|
+
* Returns a string array (empty if the key is absent).
|
|
4590
|
+
*/
|
|
4591
|
+
function fmListField(block, key) {
|
|
4592
|
+
const lines = block.split('\n');
|
|
4593
|
+
const strip = (s) => s.trim().replace(/^["']|["']$/g, '');
|
|
4594
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4595
|
+
const m = lines[i].match(new RegExp(`^${key}\\s*:\\s*(.*)$`));
|
|
4596
|
+
if (!m) continue;
|
|
4597
|
+
const inline = m[1].trim();
|
|
4598
|
+
if (inline.startsWith('[')) {
|
|
4599
|
+
return inline.replace(/^\[|\]$/g, '').split(',').map(strip).filter(Boolean);
|
|
4600
|
+
}
|
|
4601
|
+
if (inline) return [strip(inline)];
|
|
4602
|
+
const out = [];
|
|
4603
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
4604
|
+
const lm = lines[j].match(/^\s+-\s+(.*)$/);
|
|
4605
|
+
if (!lm) break;
|
|
4606
|
+
out.push(strip(lm[1]));
|
|
4607
|
+
}
|
|
4608
|
+
return out;
|
|
4609
|
+
}
|
|
4610
|
+
return [];
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
/**
|
|
4614
|
+
* plan check-wave-overlaps <phase> — enforce the wave-parallelism rule
|
|
4615
|
+
* (plan.md Step 12.5, issue #768): two plans in the SAME wave that both list
|
|
4616
|
+
* the same path in `files_modified` cannot run in parallel — the later plan
|
|
4617
|
+
* (by plan number) must declare `sequential: true`. Returns a JSON report of
|
|
4618
|
+
* unresolved conflicts so /rihal-plan can auto-correct the frontmatter.
|
|
4619
|
+
*/
|
|
4620
|
+
function cmdPlanCheckWaveOverlaps(rawArgs) {
|
|
4621
|
+
const phaseArg = String(rawArgs || '').trim().split(/\s+/)[0] || '';
|
|
4622
|
+
if (!phaseArg) {
|
|
4623
|
+
console.error('Usage: plan check-wave-overlaps <phase-number>');
|
|
4624
|
+
process.exit(1);
|
|
4625
|
+
}
|
|
4626
|
+
const phasesDir = path.join(PLANNING_DIR, 'phases');
|
|
4627
|
+
const norm = phaseArg.replace(/^0+/, '') || '0';
|
|
4628
|
+
let phaseDir = null;
|
|
4629
|
+
if (fs.existsSync(phasesDir)) {
|
|
4630
|
+
for (const d of fs.readdirSync(phasesDir)) {
|
|
4631
|
+
const m = d.match(/^(\d+)(?:[-.])/);
|
|
4632
|
+
if (m && (m[1].replace(/^0+/, '') || '0') === norm) { phaseDir = path.join(phasesDir, d); break; }
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
if (!phaseDir) return { phase: phaseArg, conflicts: [], plans_checked: 0, phase_dir: null };
|
|
4636
|
+
|
|
4637
|
+
const plans = [];
|
|
4638
|
+
for (const file of fs.readdirSync(phaseDir).filter((f) => /-SPRINT\.md$/i.test(f)).sort()) {
|
|
4639
|
+
const text = fs.readFileSync(path.join(phaseDir, file), 'utf8');
|
|
4640
|
+
let block = '';
|
|
4641
|
+
if (text.startsWith('---\n')) {
|
|
4642
|
+
const end = text.indexOf('\n---\n', 4);
|
|
4643
|
+
if (end !== -1) block = text.slice(4, end);
|
|
4644
|
+
}
|
|
4645
|
+
const stem = file.replace(/-SPRINT\.md$/i, '');
|
|
4646
|
+
const numMatch = (block.match(/^plan_number\s*:\s*(\d+)/m) || stem.match(/-(\d+)$/));
|
|
4647
|
+
plans.push({
|
|
4648
|
+
id: stem,
|
|
4649
|
+
order: numMatch ? parseInt(numMatch[1], 10) : 0,
|
|
4650
|
+
wave: parseInt((block.match(/^wave\s*:\s*(\d+)/m) || [])[1] || '1', 10) || 1,
|
|
4651
|
+
sequential: /^sequential\s*:\s*true\s*$/m.test(block),
|
|
4652
|
+
files: fmListField(block, 'files_modified'),
|
|
4653
|
+
});
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
const conflicts = [];
|
|
4657
|
+
for (let a = 0; a < plans.length; a++) {
|
|
4658
|
+
for (let b = a + 1; b < plans.length; b++) {
|
|
4659
|
+
const [earlier, later] = plans[a].order <= plans[b].order ? [plans[a], plans[b]] : [plans[b], plans[a]];
|
|
4660
|
+
if (earlier.wave !== later.wave) continue;
|
|
4661
|
+
const shared = earlier.files.filter((f) => later.files.includes(f));
|
|
4662
|
+
if (shared.length === 0) continue;
|
|
4663
|
+
if (later.sequential) continue;
|
|
4664
|
+
conflicts.push({
|
|
4665
|
+
wave: earlier.wave,
|
|
4666
|
+
plan_a: earlier.id,
|
|
4667
|
+
plan_b: later.id,
|
|
4668
|
+
shared_files: shared,
|
|
4669
|
+
plan_b_sequential: false,
|
|
4670
|
+
});
|
|
4671
|
+
}
|
|
4672
|
+
}
|
|
4673
|
+
return { phase: phaseArg, phase_dir: path.relative(PROJECT_ROOT, phaseDir), plans_checked: plans.length, conflicts };
|
|
4674
|
+
}
|
|
4675
|
+
|
|
4437
4676
|
/** phases list — directory inventory under .planning/phases with optional --type filter and --pick path. */
|
|
4438
4677
|
function cmdPhasesList(args) {
|
|
4439
4678
|
const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
|
|
@@ -6501,7 +6740,10 @@ async function main() {
|
|
|
6501
6740
|
console.log(JSON.stringify(result, null, 2));
|
|
6502
6741
|
process.exit(result.ok ? 0 : 1);
|
|
6503
6742
|
}
|
|
6504
|
-
else
|
|
6743
|
+
else if (args[0] === 'check-wave-overlaps') {
|
|
6744
|
+
result = cmdPlanCheckWaveOverlaps(args.slice(1).join(' '));
|
|
6745
|
+
}
|
|
6746
|
+
else { console.error('Unknown plan subcommand. Valid: list, validate-evidence, check-wave-overlaps'); process.exit(1); }
|
|
6505
6747
|
break;
|
|
6506
6748
|
case 'phase-plan-index':
|
|
6507
6749
|
result = cmdPhasePlanIndex(args.join(' '));
|
|
@@ -6761,8 +7003,9 @@ async function main() {
|
|
|
6761
7003
|
console.log(' classify-tech --keywords "<keywords>" → classify tech stack from keywords (frontend/backend/mobile/styling)');
|
|
6762
7004
|
console.log(' context refresh → refresh .rihal/context/ cache from .rihal/sources.yaml');
|
|
6763
7005
|
console.log(' module <subcommand> [args] → module system helpers');
|
|
6764
|
-
console.log(' plan <list|validate-evidence>
|
|
7006
|
+
console.log(' plan <list|validate-evidence|check-wave-overlaps> → phase/plan operations');
|
|
6765
7007
|
console.log(' plan validate-evidence <N> [--spot-check] → enforce <evidence> blocks in SPRINT.md (#649); exit 1 on violation');
|
|
7008
|
+
console.log(' plan check-wave-overlaps <N> → detect same-wave plans sharing files_modified (#768)');
|
|
6766
7009
|
console.log(' phase-plan-index <N> → JSON inventory of plans under phase N (waves, summary status, task counts)');
|
|
6767
7010
|
console.log(' phases list [--type X] [--pick path] → directory inventory of .planning/phases (--type: summaries|sprints|directories|all; --pick: e.g. directories[-1])');
|
|
6768
7011
|
console.log(' find-phase <N> [--raw] → resolve phase number to dir/slug + decimal children');
|
|
@@ -157,7 +157,7 @@ export function App() {
|
|
|
157
157
|
const r = await fetch('/api/state');
|
|
158
158
|
if (!r.ok) return;
|
|
159
159
|
const s = await r.json();
|
|
160
|
-
if (s.lastScanned !== lastScannedRef.current) await fetchAndRerender();
|
|
160
|
+
if (s && s.lastScanned !== lastScannedRef.current) await fetchAndRerender();
|
|
161
161
|
} catch { /* ignore */ }
|
|
162
162
|
}, 30000);
|
|
163
163
|
return () => clearInterval(id);
|
|
@@ -78,9 +78,10 @@ export function fetchSessions() {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* POST /api/clean-sessions — remove sessions
|
|
81
|
+
* POST /api/clean-sessions — remove ended sessions.
|
|
82
|
+
* olderThanDays = 0 removes all ended sessions; > 0 keeps recent ones.
|
|
82
83
|
*/
|
|
83
|
-
export function cleanSessions(olderThanDays =
|
|
84
|
+
export function cleanSessions(olderThanDays = 0) {
|
|
84
85
|
const tok = orchToken();
|
|
85
86
|
return fetch(ORCH_HTTP + '/api/clean-sessions', {
|
|
86
87
|
method: 'POST',
|
|
@@ -110,6 +110,7 @@ export function orchElapsed(iso) {
|
|
|
110
110
|
* @returns {Array<[string, string]>}
|
|
111
111
|
*/
|
|
112
112
|
export function sprintHints(s) {
|
|
113
|
+
if (!s) return [];
|
|
113
114
|
const stories = Array.isArray(s.stories) ? s.stories : [];
|
|
114
115
|
const st = s.status || 'planned';
|
|
115
116
|
const sid = s.id || '';
|
|
@@ -155,6 +156,7 @@ export function sprintHints(s) {
|
|
|
155
156
|
* @returns {Array<[string, string]>}
|
|
156
157
|
*/
|
|
157
158
|
export function phaseHints(p) {
|
|
159
|
+
if (!p) return [];
|
|
158
160
|
const sps = Array.isArray(p.sprints) ? p.sprints : [];
|
|
159
161
|
const st = p.status || 'planned';
|
|
160
162
|
const pid = p.id || '';
|
|
@@ -53,7 +53,7 @@ function VelocityBars({ sprints }) {
|
|
|
53
53
|
|
|
54
54
|
function PhaseDetail({ phase: p, S }) {
|
|
55
55
|
const sps = Array.isArray(p.sprints) ? p.sprints : [];
|
|
56
|
-
const stories = sps.flatMap(s => (Array.isArray(s.stories) ? s.stories : []));
|
|
56
|
+
const stories = sps.flatMap(s => (s && Array.isArray(s.stories) ? s.stories : []));
|
|
57
57
|
const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
|
|
58
58
|
const running = runningInPhase(p);
|
|
59
59
|
const hints = phaseHints(p);
|
|
@@ -158,7 +158,7 @@ export function PhasesView({ subId }) {
|
|
|
158
158
|
|
|
159
159
|
const q = filter.toLowerCase();
|
|
160
160
|
const filtered = q
|
|
161
|
-
? phases.filter(p => p.name.toLowerCase().includes(q) || String(p.id).includes(q))
|
|
161
|
+
? phases.filter(p => (p.name || '').toLowerCase().includes(q) || String(p.id).includes(q))
|
|
162
162
|
: phases;
|
|
163
163
|
|
|
164
164
|
return html`
|
|
@@ -70,13 +70,13 @@ function PhaseNode({ phase: p, filterQuery, expandSignal }) {
|
|
|
70
70
|
if (expandSignal && expandSignal.key > 0) setOpen(expandSignal.open);
|
|
71
71
|
}, [expandSignal && expandSignal.key]);
|
|
72
72
|
const sps = p.sprints || [];
|
|
73
|
-
const pStories = sps.flatMap(s => s.stories || []);
|
|
73
|
+
const pStories = sps.flatMap(s => (s ? s.stories || [] : []));
|
|
74
74
|
const pDone = pStories.filter(t => t.status === 'done' || t.status === 'completed').length;
|
|
75
75
|
const pp = pctNum(pDone, pStories.length);
|
|
76
76
|
const running = runningInPhase(p);
|
|
77
77
|
|
|
78
78
|
// Filter: hide this node if query doesn't match phase name
|
|
79
|
-
if (filterQuery && !p.name.toLowerCase().includes(filterQuery)) return null;
|
|
79
|
+
if (filterQuery && !(p.name || '').toLowerCase().includes(filterQuery)) return null;
|
|
80
80
|
|
|
81
81
|
function handleDblClick(e) {
|
|
82
82
|
e.stopPropagation();
|
package/server/lib/html/css.js
CHANGED
|
@@ -28,7 +28,6 @@ function renderCss() {
|
|
|
28
28
|
--text-secondary: #b4bcd0;
|
|
29
29
|
--text-tertiary: #8a8f98;
|
|
30
30
|
--text-muted: #62666d;
|
|
31
|
-
--text-on-accent: #ffffff;
|
|
32
31
|
|
|
33
32
|
/* Brand — Rihal keeps Aether Blue */
|
|
34
33
|
--accent-primary: #5e6ad2;
|
|
@@ -43,7 +42,6 @@ function renderCss() {
|
|
|
43
42
|
--red: #eb5757;
|
|
44
43
|
--blue: #26b5ce;
|
|
45
44
|
--violet: #bf7af0;
|
|
46
|
-
--orange: #f2994a;
|
|
47
45
|
|
|
48
46
|
/* Status */
|
|
49
47
|
--status-todo: #e2e2e2;
|
|
@@ -56,7 +54,6 @@ function renderCss() {
|
|
|
56
54
|
--font-mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
|
57
55
|
|
|
58
56
|
/* Size scale */
|
|
59
|
-
--text-2xl: 24px;
|
|
60
57
|
--text-xl: 20px;
|
|
61
58
|
--text-lg: 17px;
|
|
62
59
|
--text-md: 15px;
|
|
@@ -78,7 +75,6 @@ function renderCss() {
|
|
|
78
75
|
--space-6: 20px;
|
|
79
76
|
--space-7: 24px;
|
|
80
77
|
--space-8: 32px;
|
|
81
|
-
--space-9: 48px;
|
|
82
78
|
--space-10: 64px;
|
|
83
79
|
|
|
84
80
|
/* Radius */
|
|
@@ -90,19 +86,13 @@ function renderCss() {
|
|
|
90
86
|
--radius-full: 9999px;
|
|
91
87
|
|
|
92
88
|
/* Shadows */
|
|
93
|
-
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
|
|
94
|
-
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
|
|
95
89
|
--shadow-lg: 0 16px 32px rgba(0,0,0,0.6);
|
|
96
|
-
--shadow-modal: 0 32px 64px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06);
|
|
97
|
-
--shadow-focus: 0 0 0 2px var(--bg-page), 0 0 0 4px var(--accent-primary);
|
|
98
90
|
|
|
99
91
|
/* Motion */
|
|
100
92
|
--ease: cubic-bezier(0.4,0,0.2,1);
|
|
101
|
-
--ease-in: cubic-bezier(0.4,0,1,1);
|
|
102
93
|
--t-fast: 120ms;
|
|
103
94
|
--t-base: 200ms;
|
|
104
95
|
--t-menu: 240ms;
|
|
105
|
-
--t-view: 320ms;
|
|
106
96
|
|
|
107
97
|
/* Legacy compat aliases */
|
|
108
98
|
--bg: var(--bg-page);
|
|
@@ -110,7 +100,6 @@ function renderCss() {
|
|
|
110
100
|
--border: var(--border-default);
|
|
111
101
|
--radius-sm: var(--radius-2);
|
|
112
102
|
--radius-md: var(--radius-4);
|
|
113
|
-
--radius-lg: var(--radius-5);
|
|
114
103
|
--accent-green: var(--green);
|
|
115
104
|
--accent-amber: var(--amber);
|
|
116
105
|
--accent-red: var(--red);
|
|
@@ -1005,7 +994,7 @@ section .body {
|
|
|
1005
994
|
.agent-card .role {
|
|
1006
995
|
font-size: var(--text-2xs);
|
|
1007
996
|
color: var(--text-tertiary);
|
|
1008
|
-
letter
|
|
997
|
+
letter-spacing: -0.006em;
|
|
1009
998
|
}
|
|
1010
999
|
.real-badge {
|
|
1011
1000
|
font-size: var(--text-2xs);
|
package/server/lib/scanner.js
CHANGED
|
@@ -91,6 +91,19 @@ function buildPhaseTree(projectDir, rawPhases) {
|
|
|
91
91
|
status: phaseComplete ? 'done' : 'todo',
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
|
+
// Fallback for pre-<task> SPRINT.md format (phases 20-30 era):
|
|
95
|
+
// "### Story 20.01.01 — title" / "### Task X — title" headings.
|
|
96
|
+
if (!stories.length) {
|
|
97
|
+
const headRe = /^#{2,4}\s+(?:Story|Task)\s+([^\s—–-]+)\s*[—–-]\s*(.+?)\s*$/gm;
|
|
98
|
+
let hm;
|
|
99
|
+
while ((hm = headRe.exec(text))) {
|
|
100
|
+
stories.push({
|
|
101
|
+
id: hm[1].trim(),
|
|
102
|
+
title: hm[2].trim(),
|
|
103
|
+
status: phaseComplete ? 'done' : 'todo',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
94
107
|
|
|
95
108
|
// Status: a *-SUMMARY.md sibling means the sprint shipped.
|
|
96
109
|
const hasSummary = files.includes(f.replace(/-SPRINT\.md$/i, '-SUMMARY.md'));
|
package/server/orchestrator.js
CHANGED
|
@@ -279,6 +279,23 @@ async function handleStop(req, res) {
|
|
|
279
279
|
json(res, 200, { storyId, status: 'stopped' });
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// Remove ended sessions (done/exited/stopped/error). Running sessions are never
|
|
283
|
+
// touched. Optional body.olderThanDays gates removal by session start age.
|
|
284
|
+
async function handleCleanSessions(req, res) {
|
|
285
|
+
const body = await parseBody(req);
|
|
286
|
+
const olderThanDays = Number(body.olderThanDays) || 0;
|
|
287
|
+
const cutoff = olderThanDays > 0 ? Date.now() - olderThanDays * 86400000 : null;
|
|
288
|
+
let removed = 0;
|
|
289
|
+
for (const [id, s] of sessions) {
|
|
290
|
+
if (s.status === 'running') continue;
|
|
291
|
+
if (cutoff !== null && (Date.parse(s.startTime || '') || 0) > cutoff) continue;
|
|
292
|
+
s.wsClients.forEach(ws => { try { ws.close(); } catch {} });
|
|
293
|
+
sessions.delete(id);
|
|
294
|
+
removed++;
|
|
295
|
+
}
|
|
296
|
+
json(res, 200, { removed });
|
|
297
|
+
}
|
|
298
|
+
|
|
282
299
|
// ── WebSocket data plane ───────────────────────────────────────────────────────
|
|
283
300
|
|
|
284
301
|
function attachWebSocket(ws, storyId) {
|
|
@@ -333,6 +350,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
333
350
|
if (method === 'GET' && pathOnly === '/api/sessions') { await handleSessions(res); return; }
|
|
334
351
|
if (method === 'POST' && pathOnly === '/api/run') { await handleRun(req, res); return; }
|
|
335
352
|
if (method === 'POST' && pathOnly === '/api/stop') { await handleStop(req, res); return; }
|
|
353
|
+
if (method === 'POST' && pathOnly === '/api/clean-sessions') { await handleCleanSessions(req, res); return; }
|
|
336
354
|
|
|
337
355
|
res.writeHead(404); res.end('Not found');
|
|
338
356
|
});
|