@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.
- package/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/server/dashboard.js +26 -7
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- package/server/orchestrator.js +329 -5
package/server/lib/html/icons.js
CHANGED
|
@@ -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.
|
package/server/lib/html/shell.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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. -->
|
package/server/lib/scanner.js
CHANGED
|
@@ -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}
|
|
43
|
-
* @param {Array}
|
|
44
|
-
* @
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
|
|
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 };
|