@hanzlaa/rcode 3.5.0 → 3.6.1
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 +7 -1
- package/rihal/bin/rihal-tools.cjs +274 -31
- package/server/dashboard.js +105 -3
- package/server/lib/html/client/agents-data.js +27 -0
- package/server/lib/html/client/app.js +15 -0
- package/server/lib/html/client/components/App.js +211 -0
- package/server/lib/html/client/components/OrchPanel.js +293 -0
- package/server/lib/html/client/components/Sidebar.js +73 -0
- package/server/lib/html/client/components/Topbar.js +53 -0
- package/server/lib/html/client/components/XtermPanel.js +220 -0
- package/server/lib/html/client/components/shared.js +330 -0
- package/server/lib/html/client/icons-client.js +85 -0
- package/server/lib/html/client/orchestrator.js +280 -0
- package/server/lib/html/client/preact.js +34 -0
- package/server/lib/html/client/store.js +91 -0
- package/server/lib/html/client/util.js +186 -0
- package/server/lib/html/client/views/AgentsView.js +83 -0
- package/server/lib/html/client/views/DecisionsView.js +102 -0
- package/server/lib/html/client/views/FilesView.js +223 -0
- package/server/lib/html/client/views/KanbanView.js +236 -0
- package/server/lib/html/client/views/MemoryView.js +157 -0
- package/server/lib/html/client/views/MilestonesView.js +136 -0
- package/server/lib/html/client/views/OrchestrationView.js +167 -0
- package/server/lib/html/client/views/OverviewView.js +221 -0
- package/server/lib/html/client/views/PhasesView.js +184 -0
- package/server/lib/html/client/views/RoadmapView.js +238 -0
- package/server/lib/html/client/views/SprintsView.js +178 -0
- package/server/lib/html/client/views/TasksView.js +148 -0
- package/server/lib/html/client.js +41 -1775
- package/server/lib/html/css.js +265 -56
- package/server/lib/html/icons.js +68 -0
- package/server/lib/html/shell.js +9 -296
- package/server/lib/scanner.js +89 -0
- package/server/orchestrator.js +252 -310
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1",
|
|
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": {
|
|
@@ -74,5 +74,11 @@
|
|
|
74
74
|
},
|
|
75
75
|
"publishConfig": {
|
|
76
76
|
"access": "public"
|
|
77
|
+
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"ws": "^8.20.1"
|
|
80
|
+
},
|
|
81
|
+
"optionalDependencies": {
|
|
82
|
+
"@lydell/node-pty": "1.2.0-beta.12"
|
|
77
83
|
}
|
|
78
84
|
}
|
|
@@ -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');
|
package/server/dashboard.js
CHANGED
|
@@ -22,9 +22,14 @@
|
|
|
22
22
|
|
|
23
23
|
const http = require('http');
|
|
24
24
|
const path = require('path');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
25
27
|
const crypto = require('crypto');
|
|
26
28
|
const { spawn } = require('child_process');
|
|
27
29
|
|
|
30
|
+
// Client JS modules live here and are served verbatim at /js/<name>.js
|
|
31
|
+
const CLIENT_DIR = path.join(__dirname, 'lib', 'html', 'client');
|
|
32
|
+
|
|
28
33
|
const { scanState } = require('./lib/scanner');
|
|
29
34
|
const { handleApiState, handleApiFiles, handleApiFile, handleApiHierarchy, handleApiMemory } = require('./lib/api');
|
|
30
35
|
const { renderHtml } = require('./lib/html/shell');
|
|
@@ -34,8 +39,25 @@ const PORT = parseInt(process.env.PORT || '7717', 10);
|
|
|
34
39
|
const RIHAL_DIR = process.env.RIHAL_DIR || path.join(process.cwd(), '.rihal');
|
|
35
40
|
const PROJECT_ROOT = path.dirname(RIHAL_DIR);
|
|
36
41
|
|
|
37
|
-
// Shared orchestrator token —
|
|
38
|
-
|
|
42
|
+
// Shared orchestrator token — passed to the orchestrator via env and embedded
|
|
43
|
+
// in the HTML. Persisted to ~/.rihal/orch-token so it stays STABLE across
|
|
44
|
+
// dashboard restarts; otherwise every restart invalidates the token baked
|
|
45
|
+
// into already-open browser tabs and their API calls 401.
|
|
46
|
+
function loadOrchToken() {
|
|
47
|
+
if (process.env.ORCH_TOKEN) return process.env.ORCH_TOKEN;
|
|
48
|
+
const tokenFile = path.join(os.homedir(), '.rihal', 'orch-token');
|
|
49
|
+
try {
|
|
50
|
+
const existing = fs.readFileSync(tokenFile, 'utf8').trim();
|
|
51
|
+
if (existing) return existing;
|
|
52
|
+
} catch { /* not yet created */ }
|
|
53
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(tokenFile), { recursive: true });
|
|
56
|
+
fs.writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
57
|
+
} catch { /* non-fatal — fall back to an in-memory token */ }
|
|
58
|
+
return token;
|
|
59
|
+
}
|
|
60
|
+
const ORCH_TOKEN = loadOrchToken();
|
|
39
61
|
|
|
40
62
|
// ---------- HTTP Server ----------
|
|
41
63
|
const server = http.createServer((req, res) => {
|
|
@@ -72,6 +94,37 @@ const server = http.createServer((req, res) => {
|
|
|
72
94
|
return;
|
|
73
95
|
}
|
|
74
96
|
|
|
97
|
+
// Lets the client fetch the current orchestrator token at runtime, so a
|
|
98
|
+
// long-open tab can self-heal instead of 401'ing if the token ever drifts.
|
|
99
|
+
if (url === '/api/orch-token') {
|
|
100
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
101
|
+
res.end(JSON.stringify({ token: ORCH_TOKEN }));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (url.startsWith('/js/')) {
|
|
106
|
+
const name = url.slice(4).split('?')[0];
|
|
107
|
+
// Allow exactly one optional subdirectory (e.g. components/App.js, views/Foo.js)
|
|
108
|
+
// while still rejecting traversal attempts. The regex blocks `..`, encoded
|
|
109
|
+
// separators, and anything other than word chars, dots, hyphens, and one `/`.
|
|
110
|
+
if (!/^(?:[\w.-]+\/)?[\w.-]+\.js$/.test(name)) { res.writeHead(404); res.end('Not found'); return; }
|
|
111
|
+
// Defense-in-depth: resolved path must stay inside CLIENT_DIR even after
|
|
112
|
+
// any OS-level resolution (handles encoded traversal the regex might miss).
|
|
113
|
+
const resolved = path.resolve(CLIENT_DIR, name);
|
|
114
|
+
if (!resolved.startsWith(CLIENT_DIR + path.sep) && resolved !== CLIENT_DIR) {
|
|
115
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
116
|
+
}
|
|
117
|
+
fs.readFile(resolved, (err, data) => {
|
|
118
|
+
if (err) { res.writeHead(404); res.end('Not found'); return; }
|
|
119
|
+
res.writeHead(200, {
|
|
120
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
121
|
+
'Cache-Control': 'no-cache',
|
|
122
|
+
});
|
|
123
|
+
res.end(data);
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
75
128
|
if (url === '/' || url === '/index.html') {
|
|
76
129
|
const state = scanState(RIHAL_DIR);
|
|
77
130
|
const html = renderHtml(state, ORCH_TOKEN);
|
|
@@ -96,6 +149,54 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
96
149
|
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
97
150
|
});
|
|
98
151
|
|
|
152
|
+
// ── Ensure interactive-terminal native module is present ─────────
|
|
153
|
+
// @lydell/node-pty is an optionalDependency, so it can be absent if the
|
|
154
|
+
// package was installed with --omit=optional or a partial CI install.
|
|
155
|
+
// It ships prebuilt binaries (no node-gyp), so fetching it is a fast,
|
|
156
|
+
// no-compile, one-time step. Runs async — the dashboard never blocks; the
|
|
157
|
+
// orchestrator is spawned via the callback once the install settles.
|
|
158
|
+
// Failure is non-fatal: the terminal just degrades with a clear message.
|
|
159
|
+
function ensurePty(done) {
|
|
160
|
+
try { require.resolve('@lydell/node-pty'); done(); return; } catch {}
|
|
161
|
+
|
|
162
|
+
const pkgRoot = path.join(__dirname, '..');
|
|
163
|
+
|
|
164
|
+
// @lydell/node-pty is already declared in optionalDependencies, so a plain
|
|
165
|
+
// lockfile-respecting `install` pulls it in without mutating package.json.
|
|
166
|
+
// Use pnpm when the repo is pnpm-managed — `npm install` fights pnpm's
|
|
167
|
+
// symlinked node_modules and stalls. End-user installs use npm.
|
|
168
|
+
const usePnpm = fs.existsSync(path.join(pkgRoot, 'pnpm-lock.yaml'));
|
|
169
|
+
const cmd = usePnpm ? 'pnpm' : 'npm';
|
|
170
|
+
const args = usePnpm
|
|
171
|
+
? ['install', '--ignore-scripts']
|
|
172
|
+
: ['install', '--ignore-scripts', '--no-audit', '--no-fund'];
|
|
173
|
+
|
|
174
|
+
console.log('[setup] Installing interactive-terminal support (@lydell/node-pty)…');
|
|
175
|
+
let settled = false;
|
|
176
|
+
const finish = (ok) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
console.log(ok ? '[setup] Interactive terminal ready.'
|
|
180
|
+
: '[setup] node-pty install incomplete — terminal stays unavailable.');
|
|
181
|
+
done();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
let child;
|
|
185
|
+
try {
|
|
186
|
+
child = spawn(cmd, args, {
|
|
187
|
+
cwd: pkgRoot, stdio: 'inherit', shell: process.platform === 'win32',
|
|
188
|
+
});
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.log('[setup] node-pty install could not start:', err.message);
|
|
191
|
+
finish(false);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const timer = setTimeout(() => { try { child.kill(); } catch {} }, 180000);
|
|
195
|
+
child.on('exit', code => { clearTimeout(timer); finish(code === 0); });
|
|
196
|
+
child.on('error', err => { clearTimeout(timer);
|
|
197
|
+
console.log('[setup] node-pty install error:', err.message); finish(false); });
|
|
198
|
+
}
|
|
199
|
+
|
|
99
200
|
// ── Auto-spawn orchestrator (port 7718) ──────────────────────────
|
|
100
201
|
const ORCH_BIN = path.join(__dirname, 'orchestrator.js');
|
|
101
202
|
let _orchProc = null;
|
|
@@ -132,7 +233,8 @@ function spawnOrchestrator() {
|
|
|
132
233
|
}
|
|
133
234
|
}
|
|
134
235
|
|
|
135
|
-
|
|
236
|
+
// Orchestrator spawns only once node-pty is settled (present or installed).
|
|
237
|
+
ensurePty(spawnOrchestrator);
|
|
136
238
|
|
|
137
239
|
// Graceful shutdown
|
|
138
240
|
function shutdown() {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-data.js — the 18-agent roster, moved client-side from shell.js.
|
|
3
|
+
*
|
|
4
|
+
* Previously lived in shell.js:17-36 as a server-rendered array.
|
|
5
|
+
* Now exported as a pure ESM constant so AgentsView can render it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const AGENTS = [
|
|
9
|
+
{ name: 'Sadiq Damani', arabic: 'صادق', role: 'Director of Strategy', real: true, type: 'leadership' },
|
|
10
|
+
{ name: 'Waleed Al Harthi', arabic: 'وليد', role: 'CTO', real: true, type: 'leadership' },
|
|
11
|
+
{ name: 'Ahmed Al Hassani', arabic: 'أحمد الحسني', role: 'Technology & Development Director', real: true, type: 'leadership' },
|
|
12
|
+
{ name: 'Nasser', arabic: 'ناصر', role: 'Engineering Manager', real: true, type: 'leadership' },
|
|
13
|
+
{ name: 'Hussain', arabic: 'حسين', role: 'PM + Scrum Master', type: 'product' },
|
|
14
|
+
{ name: 'Layla', arabic: 'ليلى', role: 'Lead UX Designer', type: 'design' },
|
|
15
|
+
{ name: 'Zahra', arabic: 'زهرة', role: 'Branding & Creative Director', type: 'design' },
|
|
16
|
+
{ name: 'Omar', arabic: 'عمر', role: 'Full-Stack Engineer', type: 'engineering' },
|
|
17
|
+
{ name: 'Haitham Al Khamiyasi', arabic: 'هيثم', role: 'Senior Frontend', real: true, type: 'engineering' },
|
|
18
|
+
{ name: 'Yousef', arabic: 'يوسف', role: 'Senior Backend', type: 'engineering' },
|
|
19
|
+
{ name: 'Zayd', arabic: 'زيد', role: 'ML Engineer', type: 'engineering' },
|
|
20
|
+
{ name: 'Fatima', arabic: 'فاطمة', role: 'QA Lead', type: 'quality' },
|
|
21
|
+
{ name: 'Khalid', arabic: 'خالد', role: 'DevOps', type: 'engineering' },
|
|
22
|
+
{ name: 'Noor', arabic: 'نور', role: 'Scribe', type: 'support' },
|
|
23
|
+
{ name: 'Mariam', arabic: 'مريم', role: 'Marketing Lead', type: 'product' },
|
|
24
|
+
{ name: 'Raees', arabic: 'رئيس', role: 'Orchestration Director', type: 'system' },
|
|
25
|
+
{ name: 'Majlis', arabic: 'مجلس', role: 'Consulting Council', type: 'system' },
|
|
26
|
+
{ name: 'Diwan', arabic: 'ديوان', role: 'Dashboard Registry', type: 'system' },
|
|
27
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESM entry point — mounts the Preact App into #app-root.
|
|
3
|
+
*
|
|
4
|
+
* Loaded via <script type="module" src="/js/app.js"> from client.js,
|
|
5
|
+
* AFTER the legacy <script src> modules (which fill the 10 un-migrated
|
|
6
|
+
* view host divs). Legacy modules remain active during coexistence phase.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { render, html } from './preact.js';
|
|
10
|
+
import { App } from './components/App.js';
|
|
11
|
+
|
|
12
|
+
const root = document.getElementById('app-root');
|
|
13
|
+
if (root) {
|
|
14
|
+
render(html`<${App}/>`, root);
|
|
15
|
+
}
|