@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "3.6.0",
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) 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');
@@ -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);
@@ -191,7 +191,7 @@ export function OrchPanel() {
191
191
  }
192
192
 
193
193
  function handleClean() {
194
- cleanSessions(7).then(d => {
194
+ cleanSessions().then(d => {
195
195
  showToast('Cleaned ' + (d.removed || 0) + ' sessions');
196
196
  });
197
197
  }
@@ -82,6 +82,7 @@ function connectWs(storyId) {
82
82
  ws.onmessage = e => {
83
83
  let m;
84
84
  try { m = JSON.parse(e.data); } catch { return; }
85
+ if (!m) return;
85
86
  if (m.t === 'o' || m.t === 'hist') {
86
87
  if (_term) _term.write(m.d);
87
88
  } else if (m.t === 's') {
@@ -78,9 +78,10 @@ export function fetchSessions() {
78
78
  }
79
79
 
80
80
  /**
81
- * POST /api/clean-sessions — remove sessions older than N days.
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 = 7) {
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();
@@ -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---spacing: -0.006em;
997
+ letter-spacing: -0.006em;
1009
998
  }
1010
999
  .real-badge {
1011
1000
  font-size: var(--text-2xs);
@@ -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'));
@@ -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
  });