@hanzlaa/rcode 4.1.2 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. package/server/orchestrator.js +329 -5
@@ -33,6 +33,7 @@ const ICONS = {
33
33
  minimize: '<line x1="5" y1="14" x2="19" y2="14"/>',
34
34
  maximize: '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
35
35
  clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
36
+ history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
36
37
  eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>',
37
38
  filePen: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7"/><polyline points="14 2 14 8 20 8"/><path d="M18.4 12.6a2 2 0 0 1 3 3L17 20l-4 1 1-4z"/>',
38
39
  hourglass: '<path d="M5 22h14M5 2h14M17 22v-4.17a2 2 0 0 0-.59-1.42L12 12l-4.41 4.41A2 2 0 0 0 7 17.83V22M7 2v4.17a2 2 0 0 0 .59 1.42L12 12l4.41-4.41A2 2 0 0 0 17 6.17V2"/>',
@@ -53,6 +54,12 @@ const ICONS = {
53
54
  // Added in sprint 32.3 — App/Topbar theme toggle icons
54
55
  moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
55
56
  sun: '<circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="7.05" y2="7.05"/><line x1="16.95" y1="16.95" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="7.05" y2="16.95"/><line x1="16.95" y1="7.05" x2="19.78" y2="4.22"/>',
57
+
58
+ // Added in sprint 36.1 — command palette search icon
59
+ search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
60
+
61
+ // Blocked-session notifications — topbar bell
62
+ bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
56
63
  };
57
64
 
58
65
  // Render an icon as an inline <svg>. size in px; cls adds extra classes.
@@ -17,7 +17,7 @@ function renderHtml(state, orchToken) {
17
17
  <head>
18
18
  <meta charset="UTF-8">
19
19
  <meta name="viewport" content="width=device-width, initial-scale=1">
20
- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
20
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' http://localhost:7718 http://127.0.0.1:7718 ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
21
21
  <meta http-equiv="X-Content-Type-Options" content="nosniff">
22
22
  <meta name="referrer" content="strict-origin-when-cross-origin">
23
23
  <title>Majlis — ${esc(projectName)}</title>
@@ -50,10 +50,17 @@ ${renderCss()}
50
50
 
51
51
  <!-- ── Preact app mount ────────────────────────────────────────────────── -->
52
52
  <!-- App renders: sidebar, topbar, and all 12 Preact views (sprint 31.4). -->
53
- <div id="app-root"></div>
53
+ <!-- The loading shell below is visible until app.js boots, then cleared. -->
54
+ <div id="app-root">
55
+ <div class="app-loading" role="status">
56
+ <div class="app-loading-spinner"></div>
57
+ <p class="app-loading-text">Loading Majlis…</p>
58
+ <noscript><p class="app-loading-text">This dashboard requires JavaScript.</p></noscript>
59
+ </div>
60
+ </div>
54
61
 
55
62
  <!-- ── Toast ──────────────────────────────────────────────── -->
56
- <div class="toast" id="toast"></div>
63
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
57
64
 
58
65
  <!-- Xterm and orchestrator panels are now rendered by Preact (Sprint 31.4).
59
66
  Static panel markup removed — XtermPanel.js + OrchPanel.js own the DOM. -->
@@ -21,6 +21,26 @@ function listDir(dir) {
21
21
  try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
22
22
  }
23
23
 
24
+ /**
25
+ * Per-scan directory-listing cache. buildPhaseTree and the state.phases
26
+ * loop walk the same .planning/phases/ directories; one scan previously
27
+ * issued up to 4 readdirs per phase dir. The returned function memoizes
28
+ * dirent listings for the lifetime of a single scan.
29
+ * Returns null (not []) for unreadable dirs so callers can distinguish
30
+ * "missing dir" from "empty dir" like the raw readdirSync try/catch did.
31
+ */
32
+ function makeDirLister() {
33
+ const cache = new Map();
34
+ return function listCached(dir) {
35
+ let entries = cache.get(dir);
36
+ if (entries === undefined) {
37
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { entries = null; }
38
+ cache.set(dir, entries);
39
+ }
40
+ return entries;
41
+ };
42
+ }
43
+
24
44
  function parseSimpleYaml(text) {
25
45
  if (!text) return {};
26
46
  const out = {};
@@ -31,6 +51,35 @@ function parseSimpleYaml(text) {
31
51
  return out;
32
52
  }
33
53
 
54
+ /**
55
+ * Extract an array value from raw YAML frontmatter text by key.
56
+ * Handles inline arrays (`key: [a, b]`) and block lists (`key:\n - a\n - b`).
57
+ * Returns string[] — empty array when the key is absent or the value is empty.
58
+ */
59
+ function parseYamlList(text, key) {
60
+ if (!text || !key) return [];
61
+ const lines = text.split('\n');
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const inlineM = lines[i].match(new RegExp('^' + key + '\\s*:\\s*\\[(.*)\\]\\s*$'));
64
+ if (inlineM) {
65
+ return inlineM[1].split(',')
66
+ .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
67
+ .filter(Boolean);
68
+ }
69
+ const headerM = lines[i].match(new RegExp('^' + key + '\\s*:\\s*$'));
70
+ if (headerM) {
71
+ const items = [];
72
+ for (let j = i + 1; j < lines.length; j++) {
73
+ const itemM = lines[j].match(/^\s+-\s+(.+)/);
74
+ if (itemM) items.push(itemM[1].trim().replace(/^['"]|['"]$/g, ''));
75
+ else if (lines[j].match(/^\s*[a-zA-Z_]/) || lines[j].trim() === '') break;
76
+ }
77
+ return items;
78
+ }
79
+ }
80
+ return [];
81
+ }
82
+
34
83
  /**
35
84
  * Derive the phase → sprint → story tree from the .planning/phases/ filesystem,
36
85
  * which is the committed source of truth. state.json sprint/story records are
@@ -39,17 +88,18 @@ function parseSimpleYaml(text) {
39
88
  * state.json. When a phase has a directory with *-SPRINT.md files, those win;
40
89
  * otherwise the raw state.json sprints array is kept as-is.
41
90
  *
42
- * @param {string} projectDir repo root
43
- * @param {Array} rawPhases state.raw.phases
44
- * @returns {Array|null} phases with a populated `sprints` array each
91
+ * @param {string} projectDir repo root
92
+ * @param {Array} rawPhases state.raw.phases
93
+ * @param {function} [listCached] per-scan dir lister from makeDirLister()
94
+ * @returns {Array|null} phases with a populated `sprints` array each
45
95
  */
46
- function buildPhaseTree(projectDir, rawPhases) {
96
+ function buildPhaseTree(projectDir, rawPhases, listCached) {
47
97
  if (!Array.isArray(rawPhases)) return null;
98
+ const list = listCached || makeDirLister();
48
99
  const phasesDir = path.join(projectDir, '.planning', 'phases');
49
- let dirs;
50
- try {
51
- dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(d => d.isDirectory());
52
- } catch { return rawPhases; }
100
+ const allEntries = list(phasesDir);
101
+ if (allEntries === null) return rawPhases;
102
+ const dirs = allEntries.filter(d => d.isDirectory());
53
103
 
54
104
  return rawPhases.map(p => {
55
105
  const intId = String(p.id || p.number || '').split('.')[0];
@@ -58,8 +108,9 @@ function buildPhaseTree(projectDir, rawPhases) {
58
108
  d.name.startsWith(intId.padStart(2, '0') + '-'));
59
109
  if (!dir) return p;
60
110
 
61
- let files;
62
- try { files = fs.readdirSync(path.join(phasesDir, dir.name)); } catch { return p; }
111
+ const fileEntries = list(path.join(phasesDir, dir.name));
112
+ if (fileEntries === null) return p;
113
+ const files = fileEntries.map(e => e.name);
63
114
  const sprintFiles = files.filter(f => /-SPRINT\.md$/i.test(f)).sort();
64
115
  if (!sprintFiles.length) return p;
65
116
 
@@ -71,7 +122,9 @@ function buildPhaseTree(projectDir, rawPhases) {
71
122
  const text = safeReadText(path.join(phasesDir, dir.name, f)) || '';
72
123
 
73
124
  // Sprint goal: frontmatter `goal:`, else first line of <objective>.
74
- const fm = parseSimpleYaml((text.match(/^---\n([\s\S]*?)\n---/) || [])[1] || '');
125
+ const fmRaw = (text.match(/^---\n([\s\S]*?)\n---/) || [])[1] || '';
126
+ const fm = parseSimpleYaml(fmRaw);
127
+ const dependsOn = parseYamlList(fmRaw, 'depends_on');
75
128
  let goal = fm.goal || '';
76
129
  if (!goal) {
77
130
  const obj = (text.match(/<objective>\s*([\s\S]*?)<\/objective>/) || [])[1] || '';
@@ -114,15 +167,247 @@ function buildPhaseTree(projectDir, rawPhases) {
114
167
  : (p.status === 'active' || p.status === 'in_progress') ? 'in_progress'
115
168
  : 'planned';
116
169
 
117
- return { id: sid, number: num, goal: goal || `Sprint ${num}`, status, stories };
170
+ return { id: sid, number: num, goal: goal || `Sprint ${num}`, status, stories, dependsOn };
118
171
  });
119
172
 
120
- return { ...p, sprints };
173
+ // Derive phase-level depends_on by aggregating sprint-level depends_on entries.
174
+ // Sprint IDs appear as "NN.S" (dot) or "NN-S" (dash); extract the leading integer
175
+ // to get the phase ID. Drop self-references (sibling sprints within this phase).
176
+ const phaseDependsOn = [...new Set(
177
+ sprints.flatMap(s => s.dependsOn || []).map(dep => {
178
+ const m = String(dep).match(/^(\d+)/);
179
+ return m ? m[1] : null;
180
+ }).filter(depId => depId !== null && depId !== intId)
181
+ )];
182
+
183
+ return { ...p, sprints, dependsOn: phaseDependsOn };
121
184
  });
122
185
  }
123
186
 
124
- function scanState(rcodeDir) {
187
+ /** Format an ISO timestamp as a short "Mon D" display string; '' when absent. */
188
+ function fmtShort(iso) {
189
+ if (!iso) return '';
190
+ const d = new Date(iso);
191
+ if (isNaN(d.getTime())) return '';
192
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
193
+ }
194
+
195
+ /** Format an ISO timestamp (or Date) as YYYY-MM-DD; '' when unparseable. */
196
+ function fmtISODate(iso) {
197
+ const d = iso ? new Date(iso) : new Date();
198
+ if (isNaN(d.getTime())) return '';
199
+ return d.toISOString().slice(0, 10);
200
+ }
201
+
202
+ /** Map an rcode phase/sprint status string to the contract enum done|active|todo.
203
+ * "executing" is what /rcode-execute writes for an in-flight phase — it must
204
+ * map to active or the Overview falls back to a stale planned phase. */
205
+ function toState(status) {
206
+ if (/complete|done/i.test(status || '')) return 'done';
207
+ if (/active|in_progress|progress|executing/i.test(status || '')) return 'active';
208
+ return 'todo';
209
+ }
210
+
211
+ /** Slugify a phase name/slug the way state.json records current_phase. */
212
+ function slugify(s) {
213
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
214
+ }
215
+
216
+ /**
217
+ * Derive the redesign dashboard contract (see .planning/campaign/DATA-CONTRACT.md)
218
+ * from a scanned state object. Pure — reads only what scanState already gathered
219
+ * (raw, phaseTree, projectName). Where the real .rcode/ scan has no data, falls
220
+ * back to sensible computed values so every contract key is always present and
221
+ * correctly typed. Never throws.
222
+ *
223
+ * Returns: { project, progress, currentPhase, timeline, tasks, blockers,
224
+ * health, decisions, phases } matching the contract exactly.
225
+ * The `phases` field is the existing rich phaseTree enriched with `range`/`state`
226
+ * (a superset) so legacy views and the redesign ProgressTimeline both read it.
227
+ */
228
+ function buildDashboard(state) {
229
+ const raw = state.raw || {};
230
+ const cfg = state.config || {};
231
+ const tree = Array.isArray(state.phaseTree) ? state.phaseTree
232
+ : (Array.isArray(raw.phases) ? raw.phases : []);
233
+
234
+ // ---- phases (superset: rich phaseTree + contract range/state) ----
235
+ const phases = tree.map(p => {
236
+ const started = p.started || p.created || null;
237
+ const completed = p.completed || p.completed_at || null;
238
+ const range = started || completed
239
+ ? [fmtShort(started), fmtShort(completed)].filter(Boolean).join(' – ')
240
+ : '';
241
+ return { ...p, name: p.name || p.slug || String(p.id || ''), range, state: toState(p.status) };
242
+ });
243
+
244
+ // ---- progress (prefer story counts; fall back to phase-level counts) ----
245
+ let completed = 0, total = 0, inProg = 0;
246
+ for (const p of phases) {
247
+ const sprints = Array.isArray(p.sprints) ? p.sprints : [];
248
+ const stories = sprints.flatMap(s => Array.isArray(s.stories) ? s.stories : []);
249
+ for (const st of stories) {
250
+ total += 1;
251
+ if (/done|complete/i.test(st.status || '')) completed += 1;
252
+ else if (p.state === 'active') inProg += 1;
253
+ }
254
+ }
255
+ if (total === 0 && phases.length) {
256
+ completed = phases.filter(p => p.state === 'done').length;
257
+ inProg = phases.filter(p => p.state === 'active').length;
258
+ total = phases.length;
259
+ }
260
+ const notStarted = Math.max(0, total - completed - inProg);
261
+ const pct = total ? Math.round((completed / total) * 100) : 0;
262
+ const progress = { completed, inProgress: inProg, notStarted, total, pct };
263
+
264
+ // ---- currentPhase (object: id, name, status, milestones[]; null when no phases) ----
265
+ // state.json's current_phase is a slug ("phase-foo-bar" or "foo-bar") naming
266
+ // the phase the team is actually on — prefer it over positional guessing,
267
+ // since several phases can be `executing` at once (parallel worktrees).
268
+ const cpSlug = slugify(raw.current_phase);
269
+ const matchesCp = (p) => {
270
+ if (!cpSlug) return false;
271
+ return [slugify(p.slug), slugify(p.name)].some(c =>
272
+ c && (c === cpSlug || 'phase-' + c === cpSlug || c === 'phase-' + cpSlug));
273
+ };
274
+ const cpMatch = phases.find(matchesCp) || null;
275
+ const activePhase = (cpMatch && cpMatch.state === 'active' ? cpMatch : null)
276
+ || phases.find(p => p.state === 'active')
277
+ || (cpMatch && cpMatch.state !== 'done' ? cpMatch : null)
278
+ || phases.find(p => p.state === 'todo')
279
+ || phases[phases.length - 1] || null;
280
+ const cpSprints = activePhase && Array.isArray(activePhase.sprints) ? activePhase.sprints : [];
281
+ // Milestones are this phase's real sprints only — an empty array means the
282
+ // phase genuinely has no sprints planned; consumers show that, not neighbours.
283
+ const milestones = cpSprints.map(s => ({
284
+ name: (s.goal || ('Sprint ' + (s.number || s.id || ''))).slice(0, 60),
285
+ state: toState(s.status),
286
+ }));
287
+ // The in-flight sprint (first not-done) — its goal is the card's "what's
288
+ // happening right now" subtitle; null when all sprints shipped or none exist.
289
+ const liveSprint = cpSprints.find(s => toState(s.status) !== 'done') || null;
290
+ let startedDaysAgo = null;
291
+ if (activePhase && activePhase.started) {
292
+ const t = new Date(activePhase.started).getTime();
293
+ if (!isNaN(t)) startedDaysAgo = Math.max(0, Math.floor((Date.now() - t) / 86400000));
294
+ }
295
+ const currentPhase = activePhase ? {
296
+ id: activePhase.id != null ? activePhase.id : null,
297
+ name: activePhase.name || raw.current_phase || '',
298
+ status: activePhase.status || 'planned',
299
+ // True when nothing is actually active — the card shows "Up next" instead
300
+ // of implying work is happening.
301
+ next: activePhase.state !== 'active',
302
+ startedDaysAgo,
303
+ currentTask: liveSprint
304
+ ? (liveSprint.goal || 'Sprint ' + (liveSprint.number || liveSprint.id || ''))
305
+ : null,
306
+ milestones,
307
+ } : null;
308
+
309
+ // ---- timeline (real values only — no projections, no synthesized series) ----
310
+ // Points come from recorded velocity_history; [] when the project has none.
311
+ const velocity = Array.isArray(raw.velocity_history) ? raw.velocity_history : [];
312
+ const points = velocity.map((v, i) => ({
313
+ label: 'S' + (v.sprint || (i + 1)),
314
+ value: Number(v.points) || 0,
315
+ }));
316
+ // Launch date only when the project declares one (state.json or config.yaml);
317
+ // null otherwise — the UI shows "not set" instead of an invented date.
318
+ const launchDate = raw.launch_date || raw.target_date || raw.target_launch
319
+ || cfg.launch_date || cfg.target_date || null;
320
+
321
+ // ---- blockers ([] when none; shaped to title/desc/severity) ----
322
+ const rawBlockers = Array.isArray(raw.blockers) ? raw.blockers : [];
323
+ const blockers = rawBlockers.map(b => {
324
+ if (typeof b === 'string') return { title: b, desc: '', severity: 'medium' };
325
+ return {
326
+ title: b.title || b.summary || b.name || 'Blocker',
327
+ desc: b.desc || b.description || b.detail || '',
328
+ severity: /high|medium|low/i.test(b.severity || '') ? b.severity.toLowerCase() : 'medium',
329
+ };
330
+ });
331
+
332
+ // ---- tasks (completed + inProgress) ----
333
+ const completedTasks = [];
334
+ const inProgressTasks = [];
335
+ for (const p of phases) {
336
+ const sprints = Array.isArray(p.sprints) ? p.sprints : [];
337
+ for (const s of sprints) {
338
+ const stories = Array.isArray(s.stories) ? s.stories : [];
339
+ for (const st of stories) {
340
+ if (/done|complete/i.test(st.status || '')) {
341
+ completedTasks.push({ title: st.title || st.id, date: fmtISODate(p.completed || s.completed_at || p.created) });
342
+ } else if (p.state === 'active') {
343
+ // No per-task progress tracking exists — pct stays null and the UI
344
+ // omits the percent pill rather than inventing a number.
345
+ inProgressTasks.push({ title: st.title || st.id, pct: null });
346
+ }
347
+ }
348
+ }
349
+ }
350
+ // Fallbacks so the cards are never empty when stories are unregistered in state.json.
351
+ if (!completedTasks.length) {
352
+ phases.filter(p => p.state === 'done').slice(-6).forEach(p =>
353
+ completedTasks.push({ title: p.name, date: fmtISODate(p.completed || p.created) }));
354
+ }
355
+ if (!inProgressTasks.length && activePhase && activePhase.state === 'active') {
356
+ inProgressTasks.push({ title: activePhase.name, pct: null });
357
+ }
358
+ const tasks = {
359
+ completed: completedTasks.slice(-8).reverse(),
360
+ inProgress: inProgressTasks.slice(0, 8),
361
+ };
362
+
363
+ // ---- health (real facts only: story-completion % + blocker count) ----
364
+ // There is no measured "health" metric in the data model, so the card shows
365
+ // real completion + blockers instead of an invented composite score. pct is
366
+ // null (UI shows "—") when nothing is tracked yet. Sparkline only from real
367
+ // velocity_history — never a synthesized series.
368
+ const health = total
369
+ ? {
370
+ pct,
371
+ label: blockers.length
372
+ ? blockers.length + ' blocker' + (blockers.length === 1 ? '' : 's')
373
+ : 'No blockers',
374
+ points: points.map(p => ({ label: p.label, value: p.value })),
375
+ }
376
+ : { pct: null, label: 'Not started', points: [] };
377
+
378
+ // ---- decisions (superset: keep raw fields + contract title/status/date) ----
379
+ // Status stays '' when unrecorded — no default "Approved" badge.
380
+ const rawDecisions = Array.isArray(raw.decisions) ? raw.decisions : [];
381
+ const decisions = rawDecisions
382
+ .map(d => {
383
+ if (typeof d === 'string') return { title: d, status: '', date: '' };
384
+ return {
385
+ ...d,
386
+ title: d.title || d.summary || d.decision || 'Decision',
387
+ status: d.status || '',
388
+ date: d.date || d.created || '',
389
+ };
390
+ })
391
+ .sort((a, b) => String(b.date).localeCompare(String(a.date)))
392
+ .slice(0, 8);
393
+
394
+ // ---- project (identity + current user) ----
395
+ // User comes from config, else the real OS account running the server;
396
+ // null when neither exists (UI greets generically, hides the profile row).
397
+ const envUser = (typeof process !== 'undefined' && process.env && process.env.USER) || '';
398
+ const userName = cfg.user_name
399
+ || (envUser ? envUser.charAt(0).toUpperCase() + envUser.slice(1) : null);
400
+ const project = {
401
+ name: state.projectName || raw.project_name || raw.project || '',
402
+ user: { name: userName, email: cfg.user_email || '' },
403
+ };
404
+
405
+ return { project, progress, currentPhase, timeline: { launchDate, onTrack: blockers.length === 0, points }, tasks, blockers, health, decisions, phases };
406
+ }
407
+
408
+ function scanStateUncached(rcodeDir) {
125
409
  const projectDir = path.dirname(rcodeDir);
410
+ const listCached = makeDirLister();
126
411
  const state = {
127
412
  exists: fs.existsSync(rcodeDir),
128
413
  projectName: null,
@@ -135,8 +420,6 @@ function scanState(rcodeDir) {
135
420
  milestone: null,
136
421
  currentPhase: null,
137
422
  currentSprint: null,
138
- planningFiles: [],
139
- context: null,
140
423
  lastScanned: new Date().toISOString(),
141
424
  };
142
425
 
@@ -151,6 +434,7 @@ function scanState(rcodeDir) {
151
434
  }
152
435
 
153
436
  const cfg = parseSimpleYaml(safeReadText(path.join(rcodeDir, 'config.yaml')));
437
+ state.config = cfg;
154
438
 
155
439
  // Fix #260: project name shows '.' — derive from directory name as fallback
156
440
  const dirName = path.basename(projectDir);
@@ -179,10 +463,11 @@ function scanState(rcodeDir) {
179
463
  try {
180
464
  const intIdFb = String(p.id || p.number || '').split('.')[0];
181
465
  const paddedFb = intIdFb.padStart(2, '0');
182
- const dirsFb = fs.readdirSync(phasesDir2, { withFileTypes: true });
466
+ const dirsFb = listCached(phasesDir2) || [];
183
467
  const matchFb = dirsFb.find(d => d.isDirectory() && d.name.startsWith(paddedFb + '-'));
184
468
  if (matchFb) {
185
- const allMdFb = fs.readdirSync(path.join(phasesDir2, matchFb.name)).filter(f => f.endsWith('.md'));
469
+ const allMdFb = (listCached(path.join(phasesDir2, matchFb.name)) || [])
470
+ .map(e => e.name).filter(f => f.endsWith('.md'));
186
471
  const numberedFb = allMdFb.filter(f => /^\d{2}-\d{2}-/.test(f)).sort().reverse();
187
472
  const chosenFb = numberedFb.length ? numberedFb[0] : allMdFb.sort().reverse()[0];
188
473
  if (chosenFb) {
@@ -208,11 +493,12 @@ function scanState(rcodeDir) {
208
493
  const padded = intId.padStart(2, '0');
209
494
  let phaseDir = null, sprintFile = null;
210
495
  try {
211
- const dirs = fs.readdirSync(phasesDir, { withFileTypes: true });
496
+ const dirs = listCached(phasesDir) || [];
212
497
  const match = dirs.find(d => d.isDirectory() && d.name.startsWith(padded + '-'));
213
498
  if (match) {
214
499
  phaseDir = match.name;
215
- const allMd = fs.readdirSync(path.join(phasesDir, match.name)).filter(f => f.endsWith('.md'));
500
+ const allMd = (listCached(path.join(phasesDir, match.name)) || [])
501
+ .map(e => e.name).filter(f => f.endsWith('.md'));
216
502
  const numbered = allMd.filter(f => /^\d{2}-\d{2}-/.test(f)).sort().reverse();
217
503
  const chosen = numbered.length ? numbered[0] : allMd.sort().reverse()[0];
218
504
  if (chosen) sprintFile = `.planning/phases/${match.name}/${chosen}`;
@@ -241,23 +527,11 @@ function scanState(rcodeDir) {
241
527
  state.blockers = state.raw.blockers.filter(b => b && (typeof b === 'string' || b.title));
242
528
  }
243
529
 
244
- state.context = safeReadText(path.join(rcodeDir, 'context', 'active.md'))
245
- || safeReadText(path.join(projectDir, '.planning', 'CONTEXT.md'));
246
-
247
- // Walk .planning/ for file tree
248
- const planningDir = path.join(projectDir, '.planning');
249
- function walkPlanning(dir, prefix) {
250
- for (const entry of listDir(dir)) {
251
- const full = path.join(dir, entry.name);
252
- const rel = path.join(prefix, entry.name);
253
- if (entry.isDirectory()) {
254
- walkPlanning(full, rel);
255
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
256
- state.planningFiles.push({ path: rel, name: entry.name });
257
- }
258
- }
259
- }
260
- if (fs.existsSync(planningDir)) walkPlanning(planningDir, '');
530
+ // `context` (full active.md text) and `planningFiles` (the .planning/ walk)
531
+ // were shipped on every /api/state poll with zero client consumers — the
532
+ // Files view uses /api/files and the memory summary uses memoryBank.active.
533
+ // Dropped from the payload; restore behind an explicit ?full param if a
534
+ // view ever needs them.
261
535
 
262
536
  // #12 — surface pending handoff (.rcode/HANDOFF.json) and active context
263
537
  // (.rcode/context/active.md) for the dashboard banner + memory-bank summary.
@@ -292,8 +566,71 @@ function scanState(rcodeDir) {
292
566
  } catch { /* ignore */ }
293
567
  }
294
568
 
295
- state.phaseTree = buildPhaseTree(projectDir, state.raw && state.raw.phases);
569
+ state.phaseTree = buildPhaseTree(projectDir, state.raw && state.raw.phases, listCached);
570
+
571
+ // Derive the redesign dashboard contract (DATA-CONTRACT.md). Attached to the
572
+ // scan so GET /api/state returns the exact shape and client.js seeds it into
573
+ // window.__S__. The enriched phaseTree (with range/state) is folded back so
574
+ // legacy phase consumers also get the superset.
575
+ state.dashboard = buildDashboard(state);
576
+ state.phaseTree = state.dashboard.phases;
577
+
578
+ return state;
579
+ }
580
+
581
+ // ── Scan cache ────────────────────────────────────────────────────────────────
582
+ // Every /api/state poll (per tab, every 30s) and every / load used to pay a
583
+ // full synchronous read+parse of state.json and all SPRINT.md files. Two-layer
584
+ // cache:
585
+ // 1. TTL fast-path — requests within SCAN_TTL_MS share one scan (dedupes the
586
+ // page-load burst of / + /api/state and concurrent tabs).
587
+ // 2. mtime signature — stat'ing the watched files is ~100× cheaper than
588
+ // reading + regex-parsing them; when no mtime/size changed, the cached
589
+ // state (with its ORIGINAL lastScanned stamp) is returned, which also
590
+ // lets the client skip its store patch on identical data.
591
+ let _scanCache = null; // { rcodeDir, sig, state, ts }
592
+ const SCAN_TTL_MS = 2000;
593
+
594
+ /** Max directory depth for the signature walk — guards against pathological
595
+ * nesting; readdirSync withFileTypes does not follow symlinks, so cycles
596
+ * via symlinked dirs are not walked. */
597
+ const SIG_WALK_MAX_DEPTH = 12;
598
+
599
+ /** Cheap change signature: mtime+size of every file scanState reads. */
600
+ function scanSignature(rcodeDir, projectDir) {
601
+ const parts = [];
602
+ const statOne = (f) => {
603
+ try { const s = fs.statSync(f); parts.push(f + ':' + s.mtimeMs + ':' + s.size); }
604
+ catch { parts.push(f + ':absent'); }
605
+ };
606
+ statOne(path.join(rcodeDir, 'state.json'));
607
+ statOne(path.join(rcodeDir, 'config.yaml'));
608
+ statOne(path.join(rcodeDir, 'HANDOFF.json'));
609
+ statOne(path.join(rcodeDir, 'context', 'active.md'));
610
+ (function walk(dir, depth) {
611
+ if (depth > SIG_WALK_MAX_DEPTH) return;
612
+ for (const e of listDir(dir)) {
613
+ const full = path.join(dir, e.name);
614
+ if (e.isDirectory()) walk(full, depth + 1);
615
+ else if (e.isFile() && e.name.endsWith('.md')) statOne(full);
616
+ }
617
+ })(path.join(projectDir, '.planning'), 0);
618
+ return parts.join('|');
619
+ }
296
620
 
621
+ function scanState(rcodeDir) {
622
+ const now = Date.now();
623
+ if (_scanCache && _scanCache.rcodeDir === rcodeDir && now - _scanCache.ts < SCAN_TTL_MS) {
624
+ return _scanCache.state;
625
+ }
626
+ const projectDir = path.dirname(rcodeDir);
627
+ const sig = scanSignature(rcodeDir, projectDir);
628
+ if (_scanCache && _scanCache.rcodeDir === rcodeDir && _scanCache.sig === sig) {
629
+ _scanCache.ts = now;
630
+ return _scanCache.state;
631
+ }
632
+ const state = scanStateUncached(rcodeDir);
633
+ _scanCache = { rcodeDir, sig, state, ts: now };
297
634
  return state;
298
635
  }
299
636
 
@@ -372,4 +709,4 @@ function scanMemoryBank(rcodeDir) {
372
709
  return result;
373
710
  }
374
711
 
375
- module.exports = { scanState, scanMemoryBank, safeReadText, safeReadJson, listDir, parseSimpleYaml };
712
+ module.exports = { scanState, scanMemoryBank, buildDashboard, safeReadText, safeReadJson, listDir, parseSimpleYaml };