@hanzlaa/rcode 3.4.33 → 3.6.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 (106) hide show
  1. package/AGENTS.md +6 -6
  2. package/CONTRIBUTING.md +2 -0
  3. package/LICENSE +21 -0
  4. package/README.md +66 -403
  5. package/cli/doctor.js +87 -1
  6. package/cli/install.js +122 -31
  7. package/cli/lib/schemas.cjs +318 -0
  8. package/cli/postinstall.js +19 -3
  9. package/dist/rcode.js +316 -23
  10. package/package.json +14 -4
  11. package/rihal/agents/rihal-cross-platform-auditor.md +1 -1
  12. package/rihal/agents/rihal-dep-auditor.md +1 -1
  13. package/rihal/agents/rihal-docs-auditor.md +3 -145
  14. package/rihal/agents/rihal-i18n-auditor.md +1 -1
  15. package/rihal/agents/rihal-nyquist-auditor.md +4 -156
  16. package/rihal/agents/rihal-observability-auditor.md +1 -1
  17. package/rihal/bin/rihal-hooks.cjs +394 -4
  18. package/rihal/bin/rihal-tools.cjs +891 -24
  19. package/rihal/commands/create-prd.md +18 -0
  20. package/rihal/commands/execute-milestone.md +18 -0
  21. package/rihal/commands/plan-milestone.md +18 -0
  22. package/rihal/commands/scaffold-milestone.md +18 -0
  23. package/rihal/commands/scaffold-skill.md +18 -0
  24. package/rihal/references/REFERENCES_INDEX.md +49 -7
  25. package/rihal/references/agent-contracts.md +10 -0
  26. package/rihal/references/design-tokens.md +98 -0
  27. package/rihal/references/docs-auditor-playbook.md +148 -0
  28. package/rihal/references/git-preflight.md +117 -0
  29. package/rihal/references/iterative-retrieval.md +85 -0
  30. package/rihal/references/nyquist-auditor-playbook.md +157 -0
  31. package/rihal/references/workstream-flag.md +2 -2
  32. package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +9 -0
  33. package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +9 -0
  34. package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +4 -0
  35. package/rihal/skills/actions/4-implementation/rihal-code-review/steps/step-02-review.md +2 -2
  36. package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +4 -0
  37. package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +4 -0
  38. package/rihal/skills/agents/haitham-frontend/SKILL.md +2 -0
  39. package/rihal/templates/settings-hooks.json +39 -0
  40. package/rihal/workflows/check-todos.md +4 -0
  41. package/rihal/workflows/code-review-fix.md +4 -3
  42. package/rihal/workflows/code-review.md +1 -1
  43. package/rihal/workflows/debug.md +1 -1
  44. package/rihal/workflows/dev-story.md +4 -0
  45. package/rihal/workflows/diff.md +2 -2
  46. package/rihal/workflows/do.md +16 -8
  47. package/rihal/workflows/docs-update.md +2 -2
  48. package/rihal/workflows/enable-hooks.md +6 -1
  49. package/rihal/workflows/execute-milestone.md +139 -0
  50. package/rihal/workflows/execute-regression-gates.md +1 -1
  51. package/rihal/workflows/execute-sprint.md +54 -2
  52. package/rihal/workflows/execute-verify-phase-goal.md +31 -4
  53. package/rihal/workflows/execute-waves.md +33 -5
  54. package/rihal/workflows/execute.md +40 -6
  55. package/rihal/workflows/help.md +1 -1
  56. package/rihal/workflows/import.md +1 -1
  57. package/rihal/workflows/lens-audit.md +39 -23
  58. package/rihal/workflows/list-workspaces.md +1 -1
  59. package/rihal/workflows/map-codebase.md +4 -4
  60. package/rihal/workflows/new-milestone.md +18 -1
  61. package/rihal/workflows/new-project-research.md +53 -1
  62. package/rihal/workflows/new-workspace.md +1 -1
  63. package/rihal/workflows/plan-milestone.md +105 -0
  64. package/rihal/workflows/plan-research-validation.md +1 -1
  65. package/rihal/workflows/plan-spawn-planner.md +1 -1
  66. package/rihal/workflows/plan.md +31 -3
  67. package/rihal/workflows/plant-seed.md +6 -0
  68. package/rihal/workflows/quick.md +11 -5
  69. package/rihal/workflows/research-phase.md +24 -0
  70. package/rihal/workflows/scaffold-milestone.md +60 -0
  71. package/rihal/workflows/scaffold-skill.md +137 -0
  72. package/rihal/workflows/scan.md +1 -1
  73. package/rihal/workflows/session-report.md +43 -3
  74. package/rihal/workflows/verify-work.md +3 -3
  75. package/server/dashboard.js +154 -5
  76. package/server/lib/html/client/agents-data.js +27 -0
  77. package/server/lib/html/client/app.js +15 -0
  78. package/server/lib/html/client/components/App.js +211 -0
  79. package/server/lib/html/client/components/OrchPanel.js +293 -0
  80. package/server/lib/html/client/components/Sidebar.js +73 -0
  81. package/server/lib/html/client/components/Topbar.js +53 -0
  82. package/server/lib/html/client/components/XtermPanel.js +220 -0
  83. package/server/lib/html/client/components/shared.js +330 -0
  84. package/server/lib/html/client/icons-client.js +85 -0
  85. package/server/lib/html/client/orchestrator.js +279 -0
  86. package/server/lib/html/client/preact.js +34 -0
  87. package/server/lib/html/client/store.js +91 -0
  88. package/server/lib/html/client/util.js +186 -0
  89. package/server/lib/html/client/views/AgentsView.js +83 -0
  90. package/server/lib/html/client/views/DecisionsView.js +102 -0
  91. package/server/lib/html/client/views/FilesView.js +223 -0
  92. package/server/lib/html/client/views/KanbanView.js +236 -0
  93. package/server/lib/html/client/views/MemoryView.js +157 -0
  94. package/server/lib/html/client/views/MilestonesView.js +136 -0
  95. package/server/lib/html/client/views/OrchestrationView.js +167 -0
  96. package/server/lib/html/client/views/OverviewView.js +221 -0
  97. package/server/lib/html/client/views/PhasesView.js +184 -0
  98. package/server/lib/html/client/views/RoadmapView.js +238 -0
  99. package/server/lib/html/client/views/SprintsView.js +178 -0
  100. package/server/lib/html/client/views/TasksView.js +148 -0
  101. package/server/lib/html/client.js +42 -1064
  102. package/server/lib/html/css.js +2266 -466
  103. package/server/lib/html/icons.js +68 -0
  104. package/server/lib/html/shell.js +16 -210
  105. package/server/lib/scanner.js +109 -0
  106. package/server/orchestrator.js +362 -0
@@ -1,1072 +1,50 @@
1
1
  /**
2
- * Client-side JavaScript for the dashboard.
3
- * Handles routing, rendering, refresh, keyboard shortcuts, etc.
2
+ * Client JS loader.
3
+ *
4
+ * The dashboard's client-side code lives as plain static files under
5
+ * server/lib/html/client/ and is served verbatim at /js/<name>.js by
6
+ * dashboard.js. This module emits:
7
+ * 1. an inline <script> that injects server-scanned state (window.__S__)
8
+ * and the icon map (window.__ICONS__) for the Preact client
9
+ * 2. a module script tag loading /js/app.js (ESM, deferred until DOM ready)
10
+ *
11
+ * Sprint 31.4: legacy modules (client-render.js, client-kanban.js,
12
+ * client-main.js) deleted — the dashboard is 100% Preact.
4
13
  */
5
- function renderClientJs(state) {
6
- const clientData = JSON.stringify({
7
- phases: state.raw?.phases || [],
8
- milestone: state.raw?.milestone || '',
9
- currentPhase: state.raw?.current_phase || null,
10
- currentSprint: state.raw?.current_sprint|| null,
11
- decisions: state.raw?.decisions || [],
12
- blockers: state.raw?.blockers || [],
13
- council_sessions: state.raw?.council_sessions || [],
14
- last_session: state.raw?.last_session || null,
15
- chains: state.raw?.chains || [],
16
- workstreams: state.raw?.workstreams || [],
17
- });
18
-
19
- return `<script>
20
- // ---- Embedded state ----
21
- window.__S__ = ${clientData};
22
- const S = window.__S__;
23
- const _phases = S.phases || [];
24
-
25
- // ---- Helpers ----
26
- function chip(s) {
27
- const c = (s === 'complete' || s === 'completed' || s === 'done') ? 'complete'
28
- : (s === 'active' || s === 'in_progress') ? 'active'
29
- : s === 'blocked' ? 'blocked'
30
- : s === 'planned' ? 'planned'
31
- : s === 'todo' ? 'todo' : 'other';
32
- return '<span class="status-chip ' + c + '">● ' + esc(s) + '</span>';
33
- }
34
- function tag(t) { return '<span class="tag">' + esc(t) + '</span>'; }
35
- function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
36
- function pct(d, t) { return t > 0 ? Math.round(d/t*100) + '%' : '—'; }
37
- function pctNum(d, t) { return t > 0 ? Math.round(d/t*100) : 0; }
38
- function dateStr(s) { return s ? String(s).slice(0,10) : null; }
39
- function humanDate(s) {
40
- if (!s) return null;
41
- try { const d = new Date(s); return d.toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'}); }
42
- catch { return dateStr(s); }
43
- }
44
- function allSprints() {
45
- return _phases.flatMap(p => (p.sprints||[]).map(s => Object.assign({}, s, {phaseId:p.id, phaseName:p.name})));
46
- }
47
- function allTasks() {
48
- return _phases.flatMap(p => (p.sprints||[]).flatMap(s =>
49
- (s.stories||[]).map(t => Object.assign({}, t, {sprintId:s.id, phaseId:p.id, phaseName:p.name}))
50
- ));
51
- }
52
- function attr(label, val) {
53
- return '<div class="attr-item"><span class="attr-label">' + label + '</span><span class="attr-value">' + (val||'—') + '</span></div>';
54
- }
55
- function breadcrumb(label, hash) {
56
- return '<div class="breadcrumb"><button class="back-btn" onclick="navTo(\\'' + hash + '\\')">← ' + label + '</button></div>';
57
- }
58
- function filterInput(listId) {
59
- return '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Filter…" oninput="filterItems(this,\\'' + listId + '\\')"></div>';
60
- }
61
- function progressBar(done, total) {
62
- const p = pctNum(done, total);
63
- return '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + p + '%;' +
64
- (p >= 100 ? 'background:var(--accent-green)' : p > 50 ? 'background:var(--accent-blue)' : 'background:var(--accent-amber)') +
65
- '"></div></div>';
66
- }
67
- function completionRing(done, total) {
68
- const p = pctNum(done, total);
69
- const r = 28, c = 2 * Math.PI * r, offset = c - (p / 100) * c;
70
- return '<div class="completion-ring"><svg width="64" height="64" viewBox="0 0 64 64">' +
71
- '<circle cx="32" cy="32" r="' + r + '" fill="none" stroke="var(--border)" stroke-width="4"/>' +
72
- '<circle cx="32" cy="32" r="' + r + '" fill="none" stroke="var(--accent-green)" stroke-width="4" ' +
73
- 'stroke-dasharray="' + c + '" stroke-dashoffset="' + offset + '" stroke-linecap="round"/>' +
74
- '</svg><span class="ring-text">' + p + '%</span></div>';
75
- }
76
- function showToast(msg) {
77
- const el = document.getElementById('toast');
78
- if (!el) return;
79
- el.textContent = msg; el.classList.add('show');
80
- setTimeout(() => el.classList.remove('show'), 2000);
81
- }
82
- function copyCmd(el) {
83
- const cmd = el.getAttribute('data-cmd');
84
- if (!cmd) return;
85
- navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd)).catch(() => {
86
- const ta = document.createElement('textarea'); ta.value = cmd;
87
- document.body.appendChild(ta); ta.select(); document.execCommand('copy');
88
- document.body.removeChild(ta); showToast('Copied: ' + cmd);
89
- });
90
- }
91
- function cmdHint(cmd, desc) {
92
- return '<div class="cmd-hint-item" data-cmd="' + esc(cmd) + '" onclick="copyCmd(this)">' +
93
- '<span class="cmd-text">' + esc(cmd) + '</span>' +
94
- '<span class="cmd-desc">' + esc(desc) + '</span>' +
95
- '<span class="cmd-copy">📋</span></div>';
96
- }
97
- function cmdAccordion(hints) {
98
- if (!hints.length) return '';
99
- return '<details class="cmd-hints"><summary>💡 Commands</summary>' +
100
- '<div class="cmd-hints-list">' + hints.join('') + '</div></details>';
101
- }
102
- function sprintHints(s) {
103
- const stories = Array.isArray(s.stories) ? s.stories : [];
104
- const st = s.status || 'planned';
105
- const sid = s.id || '';
106
- const pid = s.phaseId || '';
107
- const h = [];
108
- if (st === 'completed' || st === 'complete' || st === 'done') {
109
- h.push(cmdHint('/rihal-verify-work', 'Verify UAT for Sprint ' + sid));
110
- h.push(cmdHint('/rihal-audit', 'Audit completed Sprint ' + sid));
111
- h.push(cmdHint('/rihal-session-report', 'Generate session report'));
112
- h.push(cmdHint('/rihal-code-review', 'Review code from Sprint ' + sid));
113
- } else if (st === 'active' || st === 'in_progress') {
114
- h.push(cmdHint('/rihal-progress', 'Check Sprint ' + sid + ' progress'));
115
- h.push(cmdHint('/rihal-sprint-status', 'Status report for Sprint ' + sid));
116
- h.push(cmdHint('/rihal-pause-work', 'Pause and save context'));
117
- } else if (st === 'blocked') {
118
- h.push(cmdHint('/rihal-debug', 'Debug blocker in Sprint ' + sid));
119
- h.push(cmdHint('/rihal-correct-course', 'Course-correct Sprint ' + sid));
120
- } else {
121
- if (!stories.length) {
122
- h.push(cmdHint('/rihal-sprint-planning', 'Groom Sprint ' + sid + ' — add stories'));
123
- h.push(cmdHint('/rihal-create-story', 'Create a story for Sprint ' + sid));
124
- h.push(cmdHint('/rihal-discuss-phase', 'Discuss approach before planning'));
125
- } else {
126
- h.push(cmdHint('/rihal-execute', 'Execute Sprint ' + sid));
127
- h.push(cmdHint('/rihal-discuss-phase', 'Discuss before executing'));
128
- h.push(cmdHint('/rihal-sprint-planning', 'Refine Sprint ' + sid + ' plan'));
129
- }
130
- }
131
- return h;
132
- }
133
- function phaseHints(p) {
134
- const sps = Array.isArray(p.sprints) ? p.sprints : [];
135
- const st = p.status || 'planned';
136
- const pid = p.id || '';
137
- const h = [];
138
- if (st === 'completed' || st === 'complete' || st === 'done') {
139
- h.push(cmdHint('/rihal-validate-phase', 'Validate Phase ' + pid + ' deliverables'));
140
- h.push(cmdHint('/rihal-audit', 'Audit Phase ' + pid + ' completion'));
141
- h.push(cmdHint('/rihal-code-review', 'Review Phase ' + pid + ' code'));
142
- } else if (st === 'active' || st === 'in_progress') {
143
- h.push(cmdHint('/rihal-progress', 'Check Phase ' + pid + ' progress'));
144
- h.push(cmdHint('/rihal-sprint-status', 'Current sprint status'));
145
- h.push(cmdHint('/rihal-code-review', 'Review code in Phase ' + pid));
146
- } else {
147
- if (!sps.length) {
148
- h.push(cmdHint('/rihal-plan', 'Create sprint plan for Phase ' + pid));
149
- h.push(cmdHint('/rihal-discuss-phase', 'Discuss Phase ' + pid + ' approach'));
150
- h.push(cmdHint('/rihal-research-phase', 'Research Phase ' + pid + ' before planning'));
151
- } else {
152
- h.push(cmdHint('/rihal-execute', 'Start executing Phase ' + pid));
153
- h.push(cmdHint('/rihal-sprint-planning', 'Plan next sprint in Phase ' + pid));
154
- }
155
- }
156
- return h;
157
- }
158
-
159
- // ---- Entity cards ----
160
- function phaseCard(p) {
161
- const sps = p.sprints || [];
162
- const stories = sps.flatMap(s => s.stories || []);
163
- const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
164
- const isCur = String(p.id) === String(S.currentPhase);
165
- return '<div class="item item-clickable" onclick="navTo(\\'phases/' + p.id + '\\')"' +
166
- (isCur ? ' style="border-left-color:var(--accent-amber)"' : '') + '>' +
167
- '<div class="item-title">Phase ' + esc(p.id) + ' — ' + esc(p.name) +
168
- (isCur ? tag('current') : '') + chip(p.status) + '</div>' +
169
- '<div class="item-meta">' + tag(sps.length + ' sprint' + (sps.length!==1?'s':'')) +
170
- tag(done + '/' + stories.length + ' tasks') +
171
- (stories.length > 0 ? tag(pct(done,stories.length) + ' done') : '') +
172
- (p.completed_at ? ' <span style="color:var(--text-muted);font-size:var(--text-xs);">Done ' + humanDate(p.completed_at) + '</span>' : '') +
173
- '</div>' +
174
- (stories.length > 0 ? '<div style="margin-top:6px;">' + progressBar(done, stories.length) + '</div>' : '') +
175
- (sps[0]?.goal ? '<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">' + esc(sps[0].goal) + '</div>' : '') +
176
- '</div>';
177
- }
178
-
179
- function sprintCard(s) {
180
- const stories = s.stories || [];
181
- const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
182
- const isCur = s.id === S.currentSprint;
183
- const phaseId = s.phaseId || s.id || '';
184
- return '<div class="item item-clickable' + (isCur ? ' sprint-current' : '') + '" onclick="navTo(\\'sprints/' + s.id + '\\')"' +
185
- (isCur ? ' style="border-left-color:var(--accent-amber);background:rgba(245,158,11,0.04)"' : '') + '>' +
186
- '<div class="item-title">Sprint ' + esc(s.id) + ' — ' + esc(s.goal || 'No goal') +
187
- (isCur ? tag('current') : '') + chip(s.status) + '</div>' +
188
- '<div class="item-meta">' +
189
- (s.phaseId ? tag('Phase ' + s.phaseId) : '') +
190
- tag(done + '/' + stories.length + ' tasks') +
191
- (s.velocity_target != null ? tag('Target: ' + s.velocity_target + 'pts') : '') +
192
- (s.velocity_actual != null ? tag('Actual: ' + s.velocity_actual + 'pts') : '') + '</div>' +
193
- '<div style="margin-top:6px;">' + progressBar(done, stories.length) + '</div>' +
194
- (stories.length === 0 ? '<div class="empty-action" style="margin-top:var(--space-2);font-size:var(--text-xs);">No tasks — run <code>/rihal-plan ' + esc(phaseId) + '</code> to populate</div>' : '') +
195
- (s.started_at ? '<div style="color:var(--text-muted);font-size:var(--text-xs);margin-top:4px;">' +
196
- humanDate(s.started_at) + (s.completed_at ? ' → ' + humanDate(s.completed_at) : ' → ongoing') + '</div>' : '') +
197
- '</div>';
198
- }
199
-
200
- function taskCard(t) {
201
- const done = t.status === 'done' || t.status === 'completed';
202
- const tid = 'task-' + (t.id || t.title || '').replace(/[^a-zA-Z0-9]/g, '-').slice(0, 40) + '-' + Math.random().toString(36).slice(2, 6);
203
- // Build detail rows from all available context
204
- var rows = '';
205
- if (t.id) rows += '<div class="task-detail-row"><strong>ID:</strong> <code>' + esc(t.id) + '</code></div>';
206
- if (t.points) rows += '<div class="task-detail-row"><strong>Points:</strong> ' + t.points + '</div>';
207
- rows += '<div class="task-detail-row"><strong>Status:</strong> ' + chip(t.status || 'unknown') + '</div>';
208
- if (t.sprintId) rows += '<div class="task-detail-row"><strong>Sprint:</strong> ' + esc(t.sprintId) + '</div>';
209
- if (t.sprintGoal) rows += '<div class="task-detail-row"><strong>Sprint Goal:</strong> ' + esc(t.sprintGoal) + '</div>';
210
- if (t.phaseId) rows += '<div class="task-detail-row"><strong>Phase:</strong> P' + esc(t.phaseId) + (t.phaseName ? ' — ' + esc(t.phaseName) : '') + '</div>';
211
- if (t.acceptance) rows += '<div class="task-detail-row"><strong>Acceptance:</strong> ' + esc(t.acceptance) + '</div>';
212
- if (t.assignee) rows += '<div class="task-detail-row"><strong>Assignee:</strong> ' + esc(t.assignee) + '</div>';
213
- // Context-aware commands for this specific task
214
- var cmds = '';
215
- if (t.id) {
216
- var taskCmds = [];
217
- if (!done) {
218
- taskCmds.push(cmdHint('/rihal-dev-story ' + t.id, 'Implement this story'));
219
- taskCmds.push(cmdHint('/rihal-create-story ' + (t.sprintId || ''), 'Add related story'));
220
- } else {
221
- taskCmds.push(cmdHint('/rihal-verify-work ' + t.id, 'Verify this story'));
222
- taskCmds.push(cmdHint('/rihal-code-review ' + t.id, 'Review code for this story'));
223
- }
224
- if (t.sprintId) {
225
- taskCmds.push(cmdHint('/rihal-sprint-status ' + t.sprintId, 'Sprint ' + t.sprintId + ' status'));
226
- }
227
- cmds = '<div class="task-detail-cmds">' + taskCmds.join('') + '</div>';
228
- }
229
- return '<div class="item item-clickable" data-status="' + (t.status||'') + '" style="' + (done ? 'opacity:.65' : '') + '"' +
230
- ' onclick="toggleTaskDetail(\\'' + tid + '\\')">' +
231
- '<div class="item-title" style="' + (done ? 'text-decoration:line-through' : '') + '">' +
232
- (done ? '✓ ' : '') + esc(t.title) + chip(t.status) +
233
- '<span class="task-expand-icon" id="icon-' + tid + '">▶</span></div>' +
234
- '<div class="item-meta">' +
235
- (t.points ? tag(t.points + 'pts') : '') +
236
- (t.id ? tag(t.id) : '') +
237
- (t.sprintId ? tag('Sprint ' + t.sprintId) : '') +
238
- (t.phaseId ? tag('Phase ' + t.phaseId) : '') + '</div>' +
239
- '<div class="task-detail" id="' + tid + '" style="display:none;">' +
240
- rows + cmds + '</div>' +
241
- '</div>';
242
- }
243
- function toggleTaskDetail(id) {
244
- const el = document.getElementById(id);
245
- const icon = document.getElementById('icon-' + id);
246
- if (!el) return;
247
- const open = el.style.display !== 'none';
248
- el.style.display = open ? 'none' : 'block';
249
- if (icon) icon.textContent = open ? '▶' : '▼';
250
- }
251
-
252
- // ---- View renderers ----
253
- function renderOverview() {
254
- // #268: current sprint progress bar on overview
255
- const sprints = allSprints();
256
- const curSprint = sprints.find(s => s.id === S.currentSprint);
257
- let sprintProgressHtml = '';
258
- if (curSprint) {
259
- const sts = curSprint.stories || [];
260
- const d = sts.filter(t => t.status === 'done' || t.status === 'completed').length;
261
- sprintProgressHtml = '<section><h2>⚡ Current Sprint — ' + esc(curSprint.id) + '</h2><div class="body">' +
262
- '<div style="margin-bottom:8px;font-size:var(--text-sm);color:var(--text-secondary);">' + esc(curSprint.goal || '') + '</div>' +
263
- '<div style="display:flex;align-items:center;gap:var(--space-3);">' +
264
- '<div style="flex:1;">' + progressBar(d, sts.length) + '</div>' +
265
- '<span style="font-size:var(--text-sm);font-weight:600;">' + d + '/' + sts.length + ' (' + pct(d,sts.length) + ')</span>' +
266
- '</div></div></section>';
267
- }
268
-
269
- // #267: velocity sparkline
270
- let velocityHtml = '';
271
- const completedSprints = sprints.filter(s => s.velocity_actual != null);
272
- if (completedSprints.length > 1) {
273
- const vals = completedSprints.map(s => s.velocity_actual);
274
- const max = Math.max(...vals, 1);
275
- const w = 200, h = 40, step = w / (vals.length - 1);
276
- const points = vals.map((v, i) => (i * step) + ',' + (h - (v / max) * h));
277
- velocityHtml = '<div class="stat"><div class="label">Sprint Velocity</div>' +
278
- '<svg width="' + w + '" height="' + (h+4) + '" style="margin-top:8px;">' +
279
- '<polyline points="' + points.join(' ') + '" fill="none" stroke="var(--accent-blue)" stroke-width="2"/>' +
280
- '</svg><div class="sub">Last ' + vals.length + ' sprints</div></div>';
281
- }
282
-
283
- // #269: council sessions
284
- let councilHtml = '';
285
- if (Array.isArray(S.council_sessions) && S.council_sessions.length) {
286
- councilHtml = '<section><h2>🏛 Council Sessions</h2><div class="body"><div class="phase-list">' +
287
- S.council_sessions.slice(-5).reverse().map(cs =>
288
- '<div class="item"><div class="item-title">' + esc(cs.topic || cs.title || 'Session') + '</div>' +
289
- '<div class="item-meta">' + (cs.date ? humanDate(cs.date) : '') +
290
- (cs.participants ? ' · ' + esc(cs.participants.join(', ')) : '') + '</div></div>'
291
- ).join('') + '</div></div></section>';
292
- }
293
-
294
- // #271: last session
295
- let lastSessionHtml = '';
296
- if (S.last_session) {
297
- const ls = S.last_session;
298
- lastSessionHtml = '<span style="color:var(--text-muted);font-size:var(--text-xs);margin-left:var(--space-3);">' +
299
- 'Last session: ' + (humanDate(ls.date || ls.timestamp) || '—') + '</span>';
300
- }
301
-
302
- // #270: chains/workstreams
303
- let chainsHtml = '';
304
- const chains = S.chains || [];
305
- const workstreams = S.workstreams || [];
306
- if (chains.length || workstreams.length) {
307
- chainsHtml = '<section><h2>🔗 Chains & Workstreams</h2><div class="body">';
308
- if (chains.length) {
309
- chainsHtml += '<div style="margin-bottom:var(--space-4);"><strong>Chains</strong><div class="phase-list" style="margin-top:var(--space-2);">' +
310
- chains.map(c => '<div class="item"><div class="item-title">' + esc(c.name || c.id || 'Chain') + '</div></div>').join('') + '</div></div>';
311
- }
312
- if (workstreams.length) {
313
- chainsHtml += '<div><strong>Workstreams</strong><div class="phase-list" style="margin-top:var(--space-2);">' +
314
- workstreams.map(w => '<div class="item"><div class="item-title">' + esc(w.name || w.id || 'Workstream') + ' ' + chip(w.status || 'active') + '</div></div>').join('') + '</div></div>';
315
- }
316
- chainsHtml += '</div></section>';
317
- }
318
-
319
- const el = document.getElementById('view-overview-dynamic');
320
- // Overview hints
321
- var oHints = [cmdHint('/rihal-next', 'What should I do next?'), cmdHint('/rihal-status', 'Quick project status'), cmdHint('/rihal-council', 'Ask the team a question')];
322
- if (curSprint) { oHints = sprintHints(curSprint).concat(oHints); }
323
- if (el) el.innerHTML = sprintProgressHtml + velocityHtml + councilHtml + chainsHtml + lastSessionHtml + cmdAccordion(oHints);
324
- }
325
-
326
- function renderRoadmap() {
327
- const ms = S.milestone || 'M1';
328
- const totalStories = allTasks();
329
- const doneStories = totalStories.filter(t => t.status === 'done' || t.status === 'completed');
330
- let h = '<div class="view-title">Roadmap</div>';
331
- // #273: filter
332
- h += '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Filter roadmap…" id="roadmap-filter" oninput="filterRoadmap(this.value)"></div>';
333
- h += '<div class="tree-container" id="roadmap-tree">';
334
- h += '<div class="tree-node tree-ms"><div class="tree-row tree-header" onclick="toggleNode(this)">';
335
- h += '<span class="tree-chevron">▼</span><span class="tree-icon">🎯</span>';
336
- h += '<span class="tree-label">' + esc(ms) + '</span>';
337
- h += '<span class="tree-badge">' + _phases.length + ' phases · ' + doneStories.length + '/' + totalStories.length + ' tasks</span></div>';
338
- h += '<div class="tree-children">';
339
- for (const p of _phases) {
340
- const sps = p.sprints || [];
341
- const pStories = sps.flatMap(s => s.stories||[]);
342
- const pDone = pStories.filter(t => t.status==='done'||t.status==='completed').length;
343
- // #274: phase nodes navigate to phase detail
344
- h += '<div class="tree-node" data-filter-text="' + esc(p.name).toLowerCase() + '"><div class="tree-row" onclick="toggleNode(this)">';
345
- h += '<span class="tree-chevron">▶</span><span class="tree-icon">📋</span>';
346
- h += '<span class="tree-label" ondblclick="navTo(\\'phases/' + p.id + '\\');event.stopPropagation();">P' + esc(p.id) + ' — ' + esc(p.name) + '</span>' + chip(p.status);
347
- // #276: inline mini progress bar
348
- const pp = pctNum(pDone, pStories.length);
349
- h += '<span style="width:60px;display:inline-block;margin:0 8px;"><div class="progress-bar" style="height:4px;"><div class="progress-bar-fill" style="width:' + pp + '%;height:100%;"></div></div></span>';
350
- h += '<span class="tree-badge">' + sps.length + ' sprints · ' + pDone + '/' + pStories.length + '</span></div>';
351
- // #272: start collapsed
352
- h += '<div class="tree-children" style="display:none">';
353
- for (const s of sps) {
354
- const sts = s.stories || [];
355
- const sDone = sts.filter(t => t.status==='done'||t.status==='completed').length;
356
- // #275: sprint nodes link to file
357
- h += '<div class="tree-node"><div class="tree-row" onclick="toggleNode(this)">';
358
- h += '<span class="tree-chevron">▶</span><span class="tree-icon">⚡</span>';
359
- h += '<span class="tree-label">Sprint ' + esc(s.id) + ' — ' + esc(s.goal||'No goal') + '</span>' + chip(s.status);
360
- h += '<span class="tree-badge">' + sDone + '/' + sts.length + '</span></div>';
361
- h += '<div class="tree-children" style="display:none">';
362
- for (const t of sts) {
363
- const td = t.status==='done'||t.status==='completed';
364
- h += '<div class="tree-node task-leaf"><div class="tree-row">';
365
- h += '<span class="tree-icon">' + (td?'✓':'○') + '</span>';
366
- h += '<span class="tree-label" style="' + (td?'opacity:.6;text-decoration:line-through':'') + '">' + esc(t.title) + '</span>';
367
- h += chip(t.status) + (t.points ? '<span class="tree-badge">' + t.points + 'pts</span>' : '');
368
- h += '</div></div>';
369
- }
370
- if (!sts.length) h += '<div style="color:var(--text-muted);font-size:var(--text-xs);padding:var(--space-2) var(--space-6);">No tasks</div>';
371
- h += '</div></div>';
372
- }
373
- if (!sps.length) h += '<div style="color:var(--text-muted);font-size:var(--text-xs);padding:var(--space-2) var(--space-6);">No sprints</div>';
374
- h += '</div></div>';
375
- }
376
- h += '</div></div></div>';
377
- // Roadmap hints
378
- var rmHints = [cmdHint('/rihal-add-phase', 'Add a new phase'), cmdHint('/rihal-milestone-summary', 'View milestone summary'), cmdHint('/rihal-new-milestone', 'Start a new milestone')];
379
- var allPDone = _phases.length > 0 && _phases.every(ph => ph.status === 'complete' || ph.status === 'completed' || ph.status === 'done');
380
- if (allPDone) { rmHints.push(cmdHint('/rihal-audit-milestone', 'Audit milestone completion')); rmHints.push(cmdHint('/rihal-complete-milestone', 'Complete and archive milestone')); }
381
- document.getElementById('view-roadmap').innerHTML = h + cmdAccordion(rmHints);
382
- }
383
-
384
- function filterRoadmap(q) {
385
- q = q.toLowerCase().trim();
386
- document.querySelectorAll('#roadmap-tree .tree-node[data-filter-text]').forEach(n => {
387
- n.style.display = !q || n.dataset.filterText.includes(q) ? '' : 'none';
388
- });
389
- }
390
-
391
- function renderMilestones(subId) {
392
- const el = document.getElementById('view-milestones');
393
- const ms = S.milestone || 'M1';
394
- if (subId) {
395
- const doneP = _phases.filter(p => p.status==='complete'||p.status==='completed').length;
396
- const total = allTasks(), done = total.filter(t => t.status==='done'||t.status==='completed');
397
- // #278: velocity history
398
- const sprints = allSprints().filter(s => s.velocity_actual != null);
399
- let velocityHtml = '';
400
- if (sprints.length) {
401
- velocityHtml = '<div class="view-title" style="margin-top:var(--space-6)">Velocity History</div>';
402
- const maxV = Math.max(...sprints.map(s => Math.max(s.velocity_actual||0, s.velocity_target||0)), 1);
403
- velocityHtml += '<div style="max-width:600px;">' + sprints.map(s =>
404
- '<div class="velocity-bar">' +
405
- '<div class="velocity-bar-label">S' + esc(s.id) + '</div>' +
406
- '<div class="velocity-bar-track">' +
407
- '<div class="velocity-bar-fill" style="width:' + ((s.velocity_actual||0)/maxV*100) + '%;background:var(--accent-blue);"></div>' +
408
- '</div>' +
409
- '<div class="velocity-bar-val">' + (s.velocity_actual||0) + '/' + (s.velocity_target||'—') + '</div>' +
410
- '</div>'
411
- ).join('') + '</div>';
412
- }
413
- // #279: phase timeline
414
- let timelineHtml = '';
415
- const phasesWithDates = _phases.filter(p => (p.sprints||[]).some(s => s.started_at));
416
- if (phasesWithDates.length) {
417
- timelineHtml = '<div class="view-title" style="margin-top:var(--space-6)">Phase Timeline</div>' +
418
- '<div class="phase-list">' + phasesWithDates.map(p => {
419
- const sps = p.sprints || [];
420
- const startDates = sps.map(s => s.started_at).filter(Boolean).sort();
421
- const endDates = sps.map(s => s.completed_at).filter(Boolean).sort().reverse();
422
- return '<div class="item"><div class="item-title">P' + esc(p.id) + ' — ' + esc(p.name) + ' ' + chip(p.status) + '</div>' +
423
- '<div class="item-meta">' + (startDates[0] ? humanDate(startDates[0]) : '?') + ' → ' +
424
- (endDates[0] ? humanDate(endDates[0]) : 'ongoing') + '</div></div>';
425
- }).join('') + '</div>';
426
- }
427
- // #280: completion ring
428
- el.innerHTML = breadcrumb('Milestones','milestones') +
429
- '<div class="entity-header"><div style="display:flex;align-items:center;gap:var(--space-6);"><div>' +
430
- '<div class="entity-title">🎯 ' + esc(ms) + '</div></div>' +
431
- completionRing(done.length, total.length) + '</div>' +
432
- '<div class="attr-grid">' +
433
- attr('Total Phases', _phases.length) + attr('Completed Phases', doneP) +
434
- attr('Current Phase', S.currentPhase||'—') + attr('Current Sprint', S.currentSprint||'—') +
435
- attr('Tasks Done', done.length + '/' + total.length) +
436
- attr('Progress', pct(done.length, total.length)) + '</div></div>' +
437
- velocityHtml + timelineHtml +
438
- '<div class="view-title" style="margin-top:var(--space-6)">Phases under this milestone</div>' +
439
- '<div class="phase-list">' + _phases.map(phaseCard).join('') + '</div>';
440
- } else {
441
- const total = allTasks(), done = total.filter(t => t.status==='done'||t.status==='completed');
442
- el.innerHTML = '<div class="view-title">Milestones</div>' +
443
- '<div class="phase-list"><div class="item item-clickable" onclick="navTo(\\'milestones/M1\\')">' +
444
- '<div style="display:flex;align-items:center;gap:var(--space-4);">' +
445
- completionRing(done.length, total.length) +
446
- '<div><div class="item-title">🎯 ' + esc(ms) + '</div>' +
447
- '<div class="item-meta">' + tag(_phases.length + ' phases') + tag(allSprints().length + ' sprints') +
448
- tag(done.length + '/' + total.length + ' tasks done') + tag(pct(done.length,total.length) + ' complete') + '</div></div>' +
449
- '</div></div></div>';
450
- }
451
- }
452
-
453
- function renderPhases(subId) {
454
- const el = document.getElementById('view-phases');
455
- if (subId) {
456
- const p = _phases.find(ph => String(ph.id) === String(subId) || String(ph.number) === String(subId));
457
- // Fix #319: guard against missing sprints key
458
- if (!p) { el.innerHTML = breadcrumb('Phases','phases') + '<div class="empty">Phase not found.</div>'; return; }
459
- const sps = Array.isArray(p.sprints) ? p.sprints : [];
460
- const stories = sps.flatMap(s => Array.isArray(s.stories) ? s.stories : []);
461
- const done = stories.filter(t => t.status==='done'||t.status==='completed').length;
462
- // #284: velocity bars
463
- let velocityHtml = '';
464
- const sprintsWithVel = sps.filter(s => s.velocity_actual != null || s.velocity_target != null);
465
- if (sprintsWithVel.length) {
466
- const maxV = Math.max(...sprintsWithVel.map(s => Math.max(s.velocity_actual||0, s.velocity_target||0)), 1);
467
- velocityHtml = '<div class="view-title" style="margin-top:var(--space-6)">Sprint Velocity</div>' +
468
- '<div style="max-width:600px;">' + sprintsWithVel.map(s =>
469
- '<div class="velocity-bar">' +
470
- '<div class="velocity-bar-label">S' + esc(s.id) + '</div>' +
471
- '<div class="velocity-bar-track">' +
472
- '<div class="velocity-bar-fill" style="width:' + ((s.velocity_actual||0)/maxV*100) + '%;"></div>' +
473
- '</div>' +
474
- '<div class="velocity-bar-val">' + (s.velocity_actual||0) + '/' + (s.velocity_target||'—') + '</div></div>'
475
- ).join('') + '</div>';
476
- }
477
- el.innerHTML = breadcrumb('All Phases','phases') +
478
- '<div class="entity-header"><div class="entity-title">📋 Phase ' + esc(p.id) + ' — ' + esc(p.name) + '</div>' +
479
- '<div class="attr-grid">' +
480
- attr('Status', chip(p.status)) + attr('Sprints', sps.length) +
481
- attr('Tasks Done', done + '/' + stories.length) + attr('Progress', pct(done,stories.length)) +
482
- // #282: completed_at date
483
- (p.completed_at ? attr('Completed', humanDate(p.completed_at)) : '') + '</div></div>' +
484
- '<div style="margin-bottom:var(--space-4);">' + progressBar(done, stories.length) + '</div>' +
485
- // #283: View plan file button
486
- '<div style="margin-bottom:var(--space-6);"><button class="back-btn" onclick="viewPlanFile(\\'' + esc(p.id) + '\\')">📄 View plan file →</button></div>' +
487
- velocityHtml +
488
- '<div class="view-title" style="margin-top:var(--space-6)">Sprints</div>' +
489
- '<div class="phase-list">' + (sps.length ? sps.map(s => sprintCard(Object.assign({},s,{phaseId:p.id,phaseName:p.name}))).join('') :
490
- '<div class="empty">No sprints in this phase yet.<div class="empty-action">Run /rihal-plan to create sprints</div></div>') + '</div>' +
491
- cmdAccordion(phaseHints(p));
492
- } else {
493
- var plHints = [cmdHint('/rihal-add-phase', 'Add a new phase'), cmdHint('/rihal-stats', 'Project statistics'), cmdHint('/rihal-progress', 'Overall progress')];
494
- var allComplete = _phases.length > 0 && _phases.every(ph => ph.status === 'complete' || ph.status === 'completed' || ph.status === 'done');
495
- if (allComplete) { plHints.push(cmdHint('/rihal-audit-milestone', 'Audit milestone completion')); plHints.push(cmdHint('/rihal-complete-milestone', 'Complete and archive milestone')); plHints.push(cmdHint('/rihal-ship', 'Create PR and ship')); }
496
- el.innerHTML = '<div class="view-title">Phases</div>' + filterInput('phases-inner') +
497
- '<div id="phases-inner" class="phase-list">' +
498
- (_phases.length ? _phases.map(phaseCard).join('') : '<div class="empty">No phases yet.<div class="empty-action">Run /rihal-new-project to start</div></div>') + '</div>' + cmdAccordion(plHints);
499
- }
500
- }
501
-
502
- function renderSprints(subId) {
503
- const el = document.getElementById('view-sprints');
504
- const sprints = allSprints();
505
- if (subId) {
506
- const s = sprints.find(sp => String(sp.id) === String(subId));
507
- if (!s) { el.innerHTML = breadcrumb('All Sprints','sprints') + '<div class="empty">Sprint not found.</div>'; return; }
508
- const rawStories = Array.isArray(s.stories) ? s.stories : [];
509
- const stories = rawStories.map(function(t) { return Object.assign({}, t, {sprintId: s.id, sprintGoal: s.goal || '', phaseId: s.phaseId, phaseName: s.phaseName}); });
510
- const done = stories.filter(t => t.status==='done'||t.status==='completed').length;
511
- // #290: acceptance criteria
512
- let acHtml = '';
513
- const storiesWithAc = stories.filter(t => t.acceptance);
514
- if (storiesWithAc.length) {
515
- acHtml = '<div class="view-title" style="margin-top:var(--space-6)">Acceptance Criteria</div>' +
516
- '<div class="phase-list">' + storiesWithAc.map(t =>
517
- '<div class="item"><div class="item-title">' + esc(t.title) + '</div>' +
518
- '<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">✓ ' + esc(t.acceptance) + '</div></div>'
519
- ).join('') + '</div>';
520
- }
521
- // #292: full breadcrumb path
522
- el.innerHTML = '<div class="breadcrumb"><button class="back-btn" onclick="navTo(\\'sprints\\')">← All Sprints</button> ' +
523
- (s.phaseId ? '<button class="back-btn" onclick="navTo(\\'phases/' + s.phaseId + '\\')">← Phase ' + esc(s.phaseId) + '</button>' : '') + '</div>' +
524
- '<div class="entity-header"><div class="entity-title">⚡ Sprint ' + esc(s.id) + '</div>' +
525
- '<div class="attr-grid">' +
526
- attr('Goal', esc(s.goal||'—')) + attr('Status', chip(s.status)) +
527
- attr('Phase', 'P' + s.phaseId + ' — ' + esc(s.phaseName)) +
528
- attr('Velocity', (s.velocity_actual!=null?s.velocity_actual:'—') + ' / ' + (s.velocity_target!=null?s.velocity_target:'—') + ' pts') +
529
- attr('Tasks Done', done + '/' + stories.length) + attr('Progress', pct(done,stories.length)) +
530
- // #293: human-readable dates
531
- (s.started_at ? attr('Started', humanDate(s.started_at)) : '') +
532
- (s.completed_at ? attr('Completed', humanDate(s.completed_at)) : '') + '</div></div>' +
533
- // #289: progress bar
534
- '<div style="margin-bottom:var(--space-6);">' + progressBar(done, stories.length) + '</div>' +
535
- '<div class="view-title" style="margin-top:var(--space-6)">Tasks</div>' +
536
- '<div class="phase-list">' + (stories.length ? stories.map(taskCard).join('') :
537
- '<div class="empty">No tasks in this sprint yet.<div class="empty-action">Run /rihal-create-story to add tasks</div></div>') + '</div>' +
538
- acHtml + cmdAccordion(sprintHints(s));
539
- } else {
540
- var slHints = [cmdHint('/rihal-sprint-planning', 'Plan a new sprint'), cmdHint('/rihal-stats', 'Project statistics')];
541
- var curSp = sprints.find(sp => sp.id === S.currentSprint);
542
- if (curSp) { slHints.push(cmdHint('/rihal-execute', 'Execute current sprint ' + curSp.id)); slHints.push(cmdHint('/rihal-sprint-status', 'Status of Sprint ' + curSp.id)); }
543
- el.innerHTML = '<div class="view-title">Sprints</div>' + filterInput('sprints-inner') +
544
- '<div id="sprints-inner" class="phase-list">' +
545
- (sprints.length ? sprints.map(sprintCard).join('') :
546
- '<div class="empty">No sprints yet.<div class="empty-action">Run /rihal-plan to create sprints</div></div>') + '</div>' + cmdAccordion(slHints);
547
- }
548
- }
549
-
550
- function renderTasks() {
551
- const el = document.getElementById('view-tasks');
552
- const tasks = allTasks();
553
- // #295: aggregate points
554
- const totalPts = tasks.reduce((sum, t) => sum + (t.points || 0), 0);
555
- const donePts = tasks.filter(t => t.status === 'done' || t.status === 'completed').reduce((sum, t) => sum + (t.points || 0), 0);
556
- // #296: filter by status + #297: sort options
557
- el.innerHTML = '<div class="view-title">Tasks</div>' +
558
- '<div class="filter-bar">' +
559
- '<input class="filter-input" type="text" placeholder="Filter…" oninput="filterItems(this,\\'tasks-inner\\')">' +
560
- '<select class="filter-select" id="task-status-filter" onchange="filterTasksByStatus()">' +
561
- '<option value="">All statuses</option><option value="todo">Todo</option>' +
562
- '<option value="in_progress">In Progress</option><option value="done">Done</option>' +
563
- '<option value="blocked">Blocked</option></select>' +
564
- '<select class="filter-select" id="task-sort" onchange="sortTasks()">' +
565
- '<option value="default">Default order</option><option value="status">By status</option>' +
566
- '<option value="points-desc">Points ↓</option><option value="points-asc">Points ↑</option></select>' +
567
- '</div>' +
568
- (totalPts > 0 ? '<div style="color:var(--text-muted);font-size:var(--text-sm);margin-bottom:var(--space-4);">' +
569
- donePts + '/' + totalPts + ' points completed</div>' : '') +
570
- // #294: group by sprint
571
- '<div id="tasks-inner" class="phase-list">' +
572
- renderTasksGrouped(tasks) + '</div>';
573
- // Task hints accordion
574
- var tHints = [cmdHint('/rihal-create-story', 'Add a new story/task'), cmdHint('/rihal-sprint-planning', 'Plan the next sprint')];
575
- var allDone = tasks.length > 0 && tasks.every(t => t.status === 'done' || t.status === 'completed');
576
- var hasBlocked = tasks.some(t => t.status === 'blocked');
577
- if (allDone) { tHints.push(cmdHint('/rihal-verify-work', 'Verify all tasks pass UAT')); tHints.push(cmdHint('/rihal-audit-uat', 'Audit UAT coverage')); }
578
- if (hasBlocked) { tHints.push(cmdHint('/rihal-debug', 'Debug blocked tasks')); tHints.push(cmdHint('/rihal-correct-course', 'Course-correct blockers')); }
579
- el.innerHTML += cmdAccordion(tHints);
580
- }
581
-
582
- function renderTasksGrouped(tasks) {
583
- if (!tasks.length) {
584
- var phaseHint = S.currentPhase ? ' ' + S.currentPhase : '';
585
- return '<div class="empty">No tasks yet.' +
586
- '<div class="empty-action">Run <code>/rihal-plan' + phaseHint + '</code> to generate tasks for this project.</div></div>';
587
- }
588
- const groups = {};
589
- for (const t of tasks) {
590
- const key = t.sprintId || 'unassigned';
591
- if (!groups[key]) groups[key] = [];
592
- groups[key].push(t);
593
- }
594
- let h = '';
595
- for (const [sprintId, items] of Object.entries(groups)) {
596
- h += '<div style="margin-bottom:var(--space-4);"><div style="font-size:var(--text-sm);font-weight:600;color:var(--text-muted);margin-bottom:var(--space-2);">Sprint ' + esc(sprintId) + '</div>';
597
- h += items.map(taskCard).join('');
598
- h += '</div>';
599
- }
600
- return h;
601
- }
602
-
603
- function filterTasksByStatus() {
604
- const status = document.getElementById('task-status-filter')?.value || '';
605
- const el = document.getElementById('tasks-inner');
606
- if (!el) return;
607
- el.querySelectorAll('.item').forEach(item => {
608
- if (!status) { item.style.display = ''; return; }
609
- const s = item.dataset.status || '';
610
- const match = status === 'done' ? (s === 'done' || s === 'completed') : s === status;
611
- item.style.display = match ? '' : 'none';
612
- });
613
- }
614
-
615
- function sortTasks() {
616
- const sort = document.getElementById('task-sort')?.value || 'default';
617
- const tasks = allTasks();
618
- if (sort === 'status') tasks.sort((a,b) => (a.status||'').localeCompare(b.status||''));
619
- else if (sort === 'points-desc') tasks.sort((a,b) => (b.points||0) - (a.points||0));
620
- else if (sort === 'points-asc') tasks.sort((a,b) => (a.points||0) - (b.points||0));
621
- const el = document.getElementById('tasks-inner');
622
- if (el) el.innerHTML = sort === 'default' ? renderTasksGrouped(tasks) : tasks.map(taskCard).join('');
623
- }
624
-
625
- // #283: view plan file
626
- async function viewPlanFile(phaseId) {
627
- // Try to find the plan file via the file tree
628
- const padded = String(phaseId).padStart(2, '0');
629
- const items = document.querySelectorAll('.file-tree-item');
630
- for (const item of items) {
631
- const p = item.dataset.path || '';
632
- if (p.includes('phases') && p.includes(padded) && (p.includes('PLAN') || p.includes('SPRINT'))) {
633
- item.click();
634
- return;
635
- }
636
- }
637
- // Fallback: navigate to files view
638
- navTo('files');
639
- }
640
-
641
- // ---- Tree toggle (with #311 animation) ----
642
- function toggleNode(row) {
643
- const children = row.nextElementSibling;
644
- const chevron = row.querySelector('.tree-chevron');
645
- if (!children) return;
646
- const open = children.style.display !== 'none';
647
- children.style.display = open ? 'none' : 'block';
648
- if (chevron) chevron.textContent = open ? '▶' : '▼';
649
- }
650
-
651
- // #277: collapse/expand all roadmap nodes
652
- function toggleAllRoadmap(expand) {
653
- document.querySelectorAll('#roadmap-tree .tree-children').forEach(c => {
654
- c.style.display = expand ? 'block' : 'none';
655
- });
656
- document.querySelectorAll('#roadmap-tree .tree-chevron').forEach(ch => {
657
- ch.textContent = expand ? '▼' : '▶';
658
- });
659
- // Keep root open
660
- const root = document.querySelector('#roadmap-tree .tree-ms > .tree-row + .tree-children');
661
- if (root) root.style.display = 'block';
662
- const rootChev = document.querySelector('#roadmap-tree .tree-ms > .tree-row .tree-chevron');
663
- if (rootChev) rootChev.textContent = '▼';
664
- }
665
-
666
- // ---- Hash router ----
667
- function navTo(hash) { location.hash = hash; }
668
-
669
- function route() {
670
- const raw = location.hash.slice(1) || 'overview';
671
- const slash = raw.indexOf('/');
672
- const view = slash === -1 ? raw : raw.slice(0, slash);
673
- const subId = slash === -1 ? null : raw.slice(slash + 1);
674
-
675
- // Fix #264: highlight active nav on reload
676
- document.querySelectorAll('.nav-link[data-view]').forEach(l =>
677
- l.classList.toggle('active', l.dataset.view === view));
678
- document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
679
- const el = document.getElementById('view-' + view);
680
- if (el) {
681
- el.classList.add('active');
682
- } else {
683
- // Fix #263: unknown hash routes show overview instead of blank
684
- document.getElementById('view-overview')?.classList.add('active');
685
- document.querySelector('.nav-link[data-view="overview"]')?.classList.add('active');
686
- }
687
-
688
- // #310: scroll to top on view switch
689
- document.querySelector('.content-area')?.scrollTo(0, 0);
690
-
691
- if (view === 'overview') renderOverview();
692
- else if (view === 'roadmap') renderRoadmap();
693
- else if (view === 'milestones') renderMilestones(subId);
694
- else if (view === 'phases') renderPhases(subId);
695
- else if (view === 'sprints') renderSprints(subId);
696
- else if (view === 'tasks') renderTasks();
697
- else if (view === 'decisions') renderDecisions();
698
- else if (view === 'memory') renderMemory();
699
- }
700
-
701
- function renderMemory() {
702
- const el = document.getElementById('view-memory-content');
703
- if (!el) return;
704
- el.innerHTML = '<div class="view-title">🧠 Memory Bank</div><div class="empty">Loading…</div>';
705
- fetch('/api/memory').then(r => r.json()).then(m => {
706
- if (!m.exists) {
707
- el.innerHTML = '<div class="view-title">🧠 Memory Bank</div>' +
708
- '<div class="empty"><h3 style="color:var(--rihal-gold);">Not initialised</h3>' +
709
- '<p>The Memory Bank is rcode\\'s structured project context.</p>' +
710
- '<div class="empty-action">Run <code>/rcode:memory-init</code> to bootstrap</div></div>';
711
- return;
712
- }
713
- let h = '<div class="view-title">🧠 Memory Bank</div>';
714
- if (!m.initialised) {
715
- h += '<div class="empty"><p>Directory exists but INDEX.md is missing — re-run <code>/rcode:memory-init</code></p></div>';
716
- el.innerHTML = h;
717
- return;
718
- }
719
- const sections = m.sections || {};
720
- h += '<div class="filter-bar"><span style="color:var(--text-muted);font-size:var(--text-sm);">Last scanned: ' + esc(m.lastScanned) + '</span></div>';
721
- h += '<div id="memory-sections">';
722
- for (const [section, files] of Object.entries(sections)) {
723
- h += '<div style="font-size:var(--text-sm);font-weight:600;color:var(--text-muted);margin:var(--space-4) 0 var(--space-2);">' + esc(section) + '</div>';
724
- h += '<div class="decision-list">';
725
- for (const f of files) {
726
- const status = f.exists ? (f.populated ? '✓' : '○') : '✗';
727
- const meta = f.exists ? (f.populated ? 'populated' : 'template only') : 'missing';
728
- h += '<div class="item">' +
729
- '<div class="item-title">' + status + ' ' + esc(f.name) + '</div>' +
730
- '<div class="item-meta">' + esc(meta) + ' · ' + (f.bytes || 0) + ' bytes</div>' +
731
- '</div>';
732
- }
733
- h += '</div>';
734
- }
735
- function listGroup(label, items) {
736
- if (!items || !items.length) return '';
737
- let g = '<div style="font-size:var(--text-sm);font-weight:600;color:var(--text-muted);margin:var(--space-4) 0 var(--space-2);">' + esc(label) + ' (' + items.length + ')</div>';
738
- g += '<div class="decision-list">';
739
- for (const f of items) {
740
- g += '<div class="item">' +
741
- '<div class="item-title">' + esc(f.name) + '</div></div>';
742
- }
743
- g += '</div>';
744
- return g;
745
- }
746
- h += listGroup('Distillates', m.distillates);
747
- h += listGroup('Change Records', m.changeRecords);
748
- h += listGroup('Milestone Archive', m.archive);
749
- h += listGroup('Post-mortems', m.postMortems);
750
- h += '</div>';
751
- h += cmdAccordion([
752
- cmdHint('/rcode:memory-init', 'Bootstrap the Memory Bank'),
753
- cmdHint('/rcode:memory-update', 'Append a decision, issue, or stakeholder entry'),
754
- cmdHint('/rcode:memory-distill', 'Regenerate fast-load distillates'),
755
- cmdHint('/rcode:memory-audit', 'Find stale entries and gaps')
756
- ]);
757
- el.innerHTML = h;
758
- }).catch(err => {
759
- el.innerHTML = '<div class="view-title">🧠 Memory Bank</div><div class="empty">Failed to load /api/memory: ' + esc(String(err)) + '</div>';
760
- });
761
- }
762
-
763
- function renderDecisions() {
764
- const el = document.getElementById('view-decisions');
765
- if (!el) return;
766
- const decisions = S.decisions || [];
767
- if (!decisions.length) {
768
- el.innerHTML = '<div class="view-title">Decisions (ADRs)</div>' +
769
- '<div class="empty">No decisions recorded yet.<div class="empty-action">Decisions made during /rihal-council appear here</div></div>';
770
- return;
771
- }
772
- // #307: group by phase
773
- const grouped = {};
774
- for (const d of decisions) {
775
- const phase = (typeof d === 'object' ? d.phase : null) || 'General';
776
- if (!grouped[phase]) grouped[phase] = [];
777
- grouped[phase].push(d);
778
- }
779
- let h = '<div class="view-title">Decisions (ADRs)</div>' +
780
- '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Filter…" oninput="filterItems(this,\\'decisions-inner\\')"></div>' +
781
- '<div id="decisions-inner">';
782
- for (const [phase, decs] of Object.entries(grouped)) {
783
- h += '<div style="font-size:var(--text-sm);font-weight:600;color:var(--text-muted);margin:var(--space-4) 0 var(--space-2);">' + esc(phase) + '</div>';
784
- h += '<div class="decision-list">';
785
- for (const d of decs) {
786
- const title = typeof d === 'string' ? d : (d.title || d.summary || d.decision || JSON.stringify(d).slice(0, 80));
787
- const filterText = String(title).toLowerCase();
788
- // #306: date and phase context
789
- const dateInfo = (typeof d === 'object' && d.date) ? '<span style="color:var(--text-muted);font-size:var(--text-xs);margin-left:8px;">' + humanDate(d.date) + '</span>' : '';
790
- const phaseInfo = (typeof d === 'object' && d.phase) ? tag('Phase ' + d.phase) : '';
791
- h += '<div class="item" data-filter-text="' + esc(filterText) + '">' +
792
- '<div class="item-title">' + esc(title) + dateInfo + '</div>' +
793
- '<div class="item-meta">' + phaseInfo + '</div>' +
794
- // #308: rationale
795
- (typeof d === 'object' && d.rationale ? '<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">' + esc(d.rationale) + '</div>' : '') +
796
- '</div>';
797
- }
798
- h += '</div>';
799
- }
800
- h += '</div>';
801
- el.innerHTML = h + cmdAccordion([
802
- cmdHint('/rihal-council', 'Convene the council for a new decision'),
803
- cmdHint('/rihal-discuss [agent] \"topic\"', 'Discuss with a specific expert'),
804
- cmdHint('/rihal-decisions', 'View decision log')
805
- ]);
806
- }
807
-
808
- window.addEventListener('hashchange', route);
809
- document.querySelectorAll('.nav-link[data-view]').forEach(l =>
810
- l.addEventListener('click', () => navTo(l.dataset.view)));
811
-
812
- // ---- Inline filter ----
813
- function filterItems(input, listId) {
814
- const q = input.value.toLowerCase().trim();
815
- const el = document.getElementById(listId);
816
- if (!el) return;
817
- el.querySelectorAll('.item').forEach(item => {
818
- item.style.display = !q || item.textContent.toLowerCase().includes(q) ? '' : 'none';
819
- });
820
- }
821
-
822
- // ---- Shared file-list fetch (single request for all consumers) ----
823
- const _filesPromise = fetch('/api/files').then(function(r) { return r.json(); }).catch(function() { return []; });
824
-
825
- // Inline file list inside Files view
826
- (async function() {
827
- let groups = [];
828
- try { groups = await _filesPromise; } catch { return; }
829
- const el = document.getElementById('file-list-inline');
830
- if (!el) return;
831
- let h = '<div class="filter-bar"><input class="filter-input" type="text" placeholder="Search files…" oninput="filterInlineFiles(this.value)"></div>';
832
- h += '<div id="inline-file-items" class="phase-list">';
833
-
834
- function renderFileItem(f, extraFilterText) {
835
- var filterText = esc(f.label + ' ' + f.path + (extraFilterText ? ' ' + extraFilterText : '')).toLowerCase();
836
- return '<div class="item item-clickable inline-file-entry" data-path="' + esc(f.path) + '" data-filter-text="' + filterText + '" onclick="loadInlineFile(this)" style="padding:var(--space-2) var(--space-3);font-family:\\'SF Mono\\',Monaco,Consolas,monospace;font-size:var(--text-xs);">' + esc(f.label) + '</div>';
837
- }
838
-
839
- groups.forEach(function(g) {
840
- h += '<div class="inline-file-group" style="margin-bottom:var(--space-3);">';
841
- h += '<div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) 0;">' + esc(g.group) + '</div>';
842
- if (g.subGroups) {
843
- // Render expandable sub-groups (e.g. per-phase)
844
- g.subGroups.forEach(function(sg) {
845
- h += '<details class="inline-subgroup" open style="margin-left:var(--space-2);margin-bottom:var(--space-1);">';
846
- h += '<summary style="font-size:var(--text-xs);font-weight:500;color:var(--text-secondary);cursor:pointer;padding:var(--space-1) 0;user-select:none;">' + esc(sg.subGroup) + ' <span style="color:var(--text-muted);font-weight:400;">(' + sg.files.length + ')</span></summary>';
847
- sg.files.forEach(function(f) {
848
- h += renderFileItem(f, sg.subGroup);
849
- });
850
- h += '</details>';
851
- });
852
- } else if (g.files) {
853
- g.files.forEach(function(f) {
854
- h += renderFileItem(f, '');
855
- });
856
- }
857
- h += '</div>';
858
- });
859
- h += '</div>';
860
- el.innerHTML = h;
861
- })();
862
- function filterInlineFiles(q) {
863
- q = q.toLowerCase().trim();
864
- document.querySelectorAll('#inline-file-items .inline-file-entry').forEach(function(item) {
865
- item.style.display = !q || (item.dataset.filterText || '').includes(q) ? '' : 'none';
866
- });
867
- }
868
- async function loadInlineFile(el) {
869
- var fv = document.getElementById('file-view');
870
- if (!fv) return;
871
- fv.innerHTML = '<div class="skeleton"></div><div class="skeleton" style="height:200px;"></div>';
872
- // Scroll file content into view immediately
873
- fv.scrollIntoView({ behavior: 'smooth', block: 'start' });
874
- document.querySelectorAll('.inline-file-entry').forEach(function(e) { e.style.borderLeftColor = ''; });
875
- el.style.borderLeftColor = 'var(--accent-blue)';
876
- // Also sync sidebar selection
877
- document.querySelectorAll('.file-tree-item').forEach(function(e) {
878
- e.classList.toggle('selected', e.dataset.path === el.dataset.path);
879
- });
880
- try {
881
- var resp = await fetch('/api/file?path=' + encodeURIComponent(el.dataset.path));
882
- if (!resp.ok) { fv.innerHTML = '<div style="color:var(--accent-red);padding:16px;">Failed to load file.</div>'; return; }
883
- var text = await resp.text();
884
- fv.innerHTML = '<div class="file-path-header"><span>' + esc(el.dataset.path) + '</span>' +
885
- '<button class="copy-btn" onclick="navigator.clipboard.writeText(\\'' + el.dataset.path.replace(/'/g, "\\\\'") + '\\');showToast(\\'Path copied!\\')">📋 Copy</button></div>' +
886
- '<div class="md-render">' + renderMd(text) + '</div>';
887
- } catch { fv.innerHTML = '<div style="color:var(--accent-red);padding:16px;">Network error.</div>'; }
888
- }
889
-
890
- // ---- Markdown + frontmatter ----
891
- function stripFrontmatter(md) {
892
- if (!md.startsWith('---')) return md;
893
- var end = md.indexOf('\\n---', 3);
894
- return end === -1 ? md : md.slice(end + 4).trimStart();
895
- }
896
- function renderMd(md) {
897
- var clean = stripFrontmatter(md);
898
- return (typeof marked !== 'undefined') ? marked.parse(clean) : '<pre>' + clean.replace(/</g,'&lt;') + '</pre>';
899
- }
900
-
901
- // ---- Open file from phase card ----
902
- async function openFile(filePath) {
903
- navTo('files');
904
- document.querySelectorAll('.file-tree-item').forEach(function(el) {
905
- el.classList.toggle('selected', el.dataset.path === filePath);
906
- });
907
- var fv = document.getElementById('file-view');
908
- if (!fv) return;
909
- fv.innerHTML = '<div class="skeleton"></div><div class="skeleton" style="height:200px;"></div>';
910
- try {
911
- var resp = await fetch('/api/file?path=' + encodeURIComponent(filePath));
912
- if (!resp.ok) { fv.innerHTML = '<div style="color:var(--accent-red);padding:var(--space-8);">Failed.</div>'; return; }
913
- var text = await resp.text();
914
- fv.innerHTML = '<div class="file-path-header"><span>' + esc(filePath) + '</span></div>' +
915
- '<div class="md-render">' + renderMd(text) + '</div>';
916
- } catch { fv.innerHTML = '<div style="color:var(--accent-red);padding:var(--space-8);">Network error.</div>'; }
917
- }
918
-
919
- // ---- Refresh ----
920
- var _lastScanned = ${JSON.stringify(state.lastScanned)};
921
- var _scanTime = Date.now();
922
- function renderUpdatedAgo() {
923
- var s = Math.floor((Date.now() - _scanTime) / 1000);
924
- var el = document.getElementById('updated-ago');
925
- if (el) el.textContent = s < 5 ? 'just now' : s < 60 ? s + 's ago' : Math.floor(s/60) + 'm ago';
926
- }
927
- setInterval(renderUpdatedAgo, 1000);
928
-
929
- // #262: hot-swap without full page reload
930
- async function fetchAndRerender() {
931
- var btn = document.getElementById('refresh-btn');
932
- if (btn) btn.textContent = '↺ …';
933
- try {
934
- var r = await fetch('/api/state');
935
- var newState = await r.json();
936
- _lastScanned = newState.lastScanned;
937
- _scanTime = Date.now();
938
- renderUpdatedAgo();
939
- // Update embedded data
940
- if (newState.raw) {
941
- S.phases = newState.raw.phases || [];
942
- S.milestone = newState.raw.milestone || '';
943
- S.currentPhase = newState.raw.current_phase || null;
944
- S.currentSprint = newState.raw.current_sprint || null;
945
- S.decisions = newState.raw.decisions || [];
946
- S.blockers = newState.raw.blockers || [];
947
- S.council_sessions = newState.raw.council_sessions || [];
948
- S.last_session = newState.raw.last_session || null;
949
- _phases.length = 0;
950
- _phases.push(...S.phases);
951
- }
952
- // #261: re-render active view
953
- route();
954
- } catch {}
955
- if (btn) btn.textContent = '↺ Refresh';
956
- }
957
- setInterval(async function() {
958
- try {
959
- var r = await fetch('/api/state');
960
- var s = await r.json();
961
- if (s.lastScanned !== _lastScanned) fetchAndRerender();
962
- } catch {}
963
- }, 30000);
964
- function manualRefresh() { fetchAndRerender(); }
965
-
966
- // ---- Blocker banner ----
967
- (function() {
968
- // #317: allow re-show via custom event
969
- if (sessionStorage.getItem('blockers-dismissed') === '1') {
970
- var b = document.getElementById('blocker-banner');
971
- if (b) b.style.display = 'none';
972
- }
973
- })();
974
- function dismissBlockers() {
975
- sessionStorage.setItem('blockers-dismissed','1');
976
- var b = document.getElementById('blocker-banner');
977
- if (b) b.style.display = 'none';
978
- }
979
- function showBlockers() {
980
- sessionStorage.removeItem('blockers-dismissed');
981
- var b = document.getElementById('blocker-banner');
982
- if (b) b.style.display = '';
983
- }
984
14
 
985
- // #309: keyboard shortcuts
986
- document.addEventListener('keydown', function(e) {
987
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
988
- var key = e.key.toLowerCase();
989
- if (key === 'r' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); manualRefresh(); }
990
- else if (key === 'f') { e.preventDefault(); var fi = document.querySelector('.view.active .filter-input'); if (fi) fi.focus(); }
991
- else if (key === '1') navTo('overview');
992
- else if (key === '2') navTo('roadmap');
993
- else if (key === '3') navTo('milestones');
994
- else if (key === '4') navTo('phases');
995
- else if (key === '5') navTo('sprints');
996
- else if (key === '6') navTo('tasks');
997
- else if (key === '7') navTo('files');
998
- else if (key === '8') navTo('agents');
999
- else if (key === '9') navTo('decisions');
1000
- // #277: E/C for expand/collapse all in roadmap
1001
- else if (key === 'e' && location.hash.includes('roadmap')) toggleAllRoadmap(true);
1002
- else if (key === 'c' && location.hash.includes('roadmap')) toggleAllRoadmap(false);
1003
- });
1004
-
1005
- // #318: export snapshot
1006
- function exportSnapshot() {
1007
- var data = JSON.stringify(S, null, 2);
1008
- var blob = new Blob([data], {type: 'application/json'});
1009
- var url = URL.createObjectURL(blob);
1010
- var a = document.createElement('a');
1011
- a.href = url; a.download = 'majlis-snapshot-' + new Date().toISOString().slice(0,10) + '.json';
1012
- a.click(); URL.revokeObjectURL(url);
1013
- showToast('Snapshot exported!');
1014
- }
1015
-
1016
- // #312: copy URL
1017
- function copyUrl() {
1018
- navigator.clipboard.writeText(location.href);
1019
- showToast('URL copied!');
1020
- }
1021
-
1022
- // #313: dark/light mode
1023
- function toggleTheme() {
1024
- var current = document.documentElement.getAttribute('data-theme');
1025
- var next = current === 'light' ? 'dark' : 'light';
1026
- document.documentElement.setAttribute('data-theme', next === 'dark' ? '' : next);
1027
- localStorage.setItem('majlis-theme', next);
1028
- var btn = document.getElementById('theme-btn');
1029
- if (btn) btn.textContent = next === 'light' ? '🌙' : '☀️';
1030
- }
1031
- (function() {
1032
- var saved = localStorage.getItem('majlis-theme');
1033
- if (saved === 'light') {
1034
- document.documentElement.setAttribute('data-theme', 'light');
1035
- var btn = document.getElementById('theme-btn');
1036
- if (btn) btn.textContent = '🌙';
1037
- }
1038
- })();
1039
-
1040
- // #324: dynamic title
1041
- var _origTitle = document.title;
1042
- function updateTitle() {
1043
- var view = (location.hash.slice(1) || 'overview').split('/')[0];
1044
- var viewNames = {overview:'Overview',roadmap:'Roadmap',milestones:'Milestones',phases:'Phases',sprints:'Sprints',tasks:'Tasks',files:'Files',agents:'Agents',decisions:'Decisions'};
1045
- document.title = (viewNames[view] || 'Overview') + ' — Majlis';
1046
- }
1047
- window.addEventListener('hashchange', updateTitle);
1048
-
1049
- // Sidebar toggle (hamburger menu)
1050
- function toggleSidebar() {
1051
- var sidebar = document.querySelector('.sidebar');
1052
- var backdrop = document.getElementById('sidebar-backdrop');
1053
- if (!sidebar) return;
1054
- var open = sidebar.classList.toggle('sidebar-open');
1055
- if (backdrop) backdrop.classList.toggle('active', open);
1056
- document.body.classList.toggle('sidebar-visible', open);
1057
- }
1058
- function closeSidebar() {
1059
- var sidebar = document.querySelector('.sidebar');
1060
- var backdrop = document.getElementById('sidebar-backdrop');
1061
- if (sidebar) sidebar.classList.remove('sidebar-open');
1062
- if (backdrop) backdrop.classList.remove('active');
1063
- document.body.classList.remove('sidebar-visible');
15
+ const { ICONS } = require('./icons');
16
+
17
+ // Fields the client needs from the scanned state. Kept in sync with
18
+ // store.js initial state and the view components that read it.
19
+ function clientState(state) {
20
+ return JSON.stringify({
21
+ phases: state.phaseTree || state.raw?.phases || [],
22
+ milestone: state.raw?.milestone || '',
23
+ currentPhase: state.raw?.current_phase || null,
24
+ currentSprint: state.raw?.current_sprint || null,
25
+ decisions: state.raw?.decisions || [],
26
+ blockers: state.raw?.blockers || [],
27
+ council_sessions: state.raw?.council_sessions || [],
28
+ last_session: state.raw?.last_session || null,
29
+ chains: state.raw?.chains || [],
30
+ workstreams: state.raw?.workstreams || [],
31
+ pendingHandoff: state.pendingHandoff || null,
32
+ memoryBank: state.memoryBank || null,
33
+ })
34
+ // Prevent a stray "</script>" inside any string from closing the inline
35
+ // <script> early. Escaping "<" keeps the JSON valid and inert.
36
+ .replace(/</g, '\\u003c');
1064
37
  }
1065
38
 
1066
- // ---- Boot ----
1067
- route();
1068
- updateTitle();
1069
- </script>`;
39
+ function renderClientJs(state) {
40
+ // Emit __ICONS__ so the Preact client can use the same icon set as the
41
+ // server without duplicating the map in a way that would require a build step.
42
+ const iconsJson = JSON.stringify(ICONS).replace(/</g, '\\u003c');
43
+ return [
44
+ `<script>window.__S__ = ${clientState(state)}; window.__ICONS__ = ${iconsJson};</script>`,
45
+ // Preact entry — type=module defers until HTML is parsed.
46
+ `<script type="module" src="/js/app.js"></script>`,
47
+ ].join('\n');
1070
48
  }
1071
49
 
1072
50
  module.exports = { renderClientJs };