@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.
Files changed (34) hide show
  1. package/package.json +7 -1
  2. package/rihal/bin/rihal-tools.cjs +274 -31
  3. package/server/dashboard.js +105 -3
  4. package/server/lib/html/client/agents-data.js +27 -0
  5. package/server/lib/html/client/app.js +15 -0
  6. package/server/lib/html/client/components/App.js +211 -0
  7. package/server/lib/html/client/components/OrchPanel.js +293 -0
  8. package/server/lib/html/client/components/Sidebar.js +73 -0
  9. package/server/lib/html/client/components/Topbar.js +53 -0
  10. package/server/lib/html/client/components/XtermPanel.js +220 -0
  11. package/server/lib/html/client/components/shared.js +330 -0
  12. package/server/lib/html/client/icons-client.js +85 -0
  13. package/server/lib/html/client/orchestrator.js +280 -0
  14. package/server/lib/html/client/preact.js +34 -0
  15. package/server/lib/html/client/store.js +91 -0
  16. package/server/lib/html/client/util.js +186 -0
  17. package/server/lib/html/client/views/AgentsView.js +83 -0
  18. package/server/lib/html/client/views/DecisionsView.js +102 -0
  19. package/server/lib/html/client/views/FilesView.js +223 -0
  20. package/server/lib/html/client/views/KanbanView.js +236 -0
  21. package/server/lib/html/client/views/MemoryView.js +157 -0
  22. package/server/lib/html/client/views/MilestonesView.js +136 -0
  23. package/server/lib/html/client/views/OrchestrationView.js +167 -0
  24. package/server/lib/html/client/views/OverviewView.js +221 -0
  25. package/server/lib/html/client/views/PhasesView.js +184 -0
  26. package/server/lib/html/client/views/RoadmapView.js +238 -0
  27. package/server/lib/html/client/views/SprintsView.js +178 -0
  28. package/server/lib/html/client/views/TasksView.js +148 -0
  29. package/server/lib/html/client.js +41 -1775
  30. package/server/lib/html/css.js +265 -56
  31. package/server/lib/html/icons.js +68 -0
  32. package/server/lib/html/shell.js +9 -296
  33. package/server/lib/scanner.js +89 -0
  34. package/server/orchestrator.js +252 -310
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "3.5.0",
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) maxNum = Math.max(maxNum, parseInt(m[1], 10));
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
- 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})`);
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 backlogMatch = text.match(/^##\s+Backlog\b/m);
3498
- if (backlogMatch) {
3499
- text = text.slice(0, backlogMatch.index) + entry + '\n' + text.slice(backlogMatch.index);
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
- if (!text.endsWith('\n')) text += '\n';
3502
- text += '\n' + entry;
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 { console.error('Unknown plan subcommand. Valid: list, validate-evidence'); process.exit(1); }
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> → phase/plan operations');
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');
@@ -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 — generated once, passed to orchestrator via env and embedded in HTML
38
- const ORCH_TOKEN = process.env.ORCH_TOKEN || crypto.randomBytes(24).toString('hex');
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
- spawnOrchestrator();
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
+ }