@hanzlaa/rcode 4.12.1 → 4.13.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/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/CONTRIBUTING.md +1 -0
- package/cli/doctor.js +40 -5
- package/cli/install.js +6 -1
- package/dist/rcode.js +87 -87
- package/package.json +1 -1
- package/rcode/agents/rcode-hussain-pm.md +37 -3
- package/rcode/agents/rcode-orchestrator.md +91 -0
- package/rcode/agents/rules/executor/correctness-hazard-scan.md +98 -0
- package/rcode/agents/rules/executor/execution-flow.md +8 -0
- package/rcode/agents/rules/executor/self-check.md +8 -0
- package/rcode/agents/rules/orchestrator/contract.md +76 -0
- package/rcode/agents/rules/sprint-checker/dimensions.md +38 -0
- package/rcode/agents/rules/verifier/reachability-check.md +45 -2
- package/rcode/bin/lib/progress.cjs +41 -13
- package/rcode/bin/lib/state-digest.cjs +88 -0
- package/rcode/bin/rcode-hooks.cjs +192 -23
- package/rcode/bin/rcode-tools.cjs +94 -4
- package/rcode/references/REFERENCES_INDEX.md +3 -1
- package/rcode/references/agent-shared-rules.md +87 -0
- package/rcode/references/code-reviewer-playbook.md +5 -0
- package/rcode/references/executor-playbook.md +2 -0
- package/rcode/references/github-comment-style.md +57 -0
- package/rcode/references/persona-executor-mode.md +61 -0
- package/rcode/references/response-style.md +21 -4
- package/rcode/references/verifier-playbook.md +14 -0
- package/rcode/skills/SKILLS_INDEX.md +1 -1
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/references.md +7 -0
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/merge-strategy.md +19 -3
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/wave-prompt.md +3 -1
- package/rcode/skills/agents/{raees-orchestrator → orchestrator}/SKILL.md +1 -1
- package/rcode/team.yaml +20 -1
- package/rcode/workflows/audit-worktrees.md +15 -1
- package/rcode/workflows/execute-verify-phase-goal.md +58 -2
- package/rcode/workflows/execute.md +37 -8
- package/rcode/workflows/plan-research-validation.md +8 -2
- package/rcode/workflows/plan-spawn-planner.md +32 -4
- package/rcode/workflows/plan.md +138 -10
- package/rcode/workflows/pr-branch.md +2 -0
- package/rcode/workflows/research-phase.md +12 -4
- package/rcode/workflows/ship.md +4 -0
- package/rcode/workflows/verify-phase.md +40 -0
- package/server/dashboard.js +57 -17
- package/server/lib/html/client/components/OrchPanel.js +6 -2
- package/server/lib/html/client/components/XtermPanel.js +7 -2
- package/server/lib/html/client/orchestrator.js +58 -21
- package/server/lib/html/client/views/MemoryView.js +59 -3
- package/server/lib/html/css.js +40 -0
- package/server/lib/html/shell.js +10 -4
- package/server/lib/scanner.js +150 -3
- package/server/lib/view-only.js +32 -0
- package/server/orchestrator.js +63 -4
- /package/rcode/skills/agents/{raees-orchestrator → orchestrator}/references.md +0 -0
|
@@ -14,18 +14,40 @@ import { getState, setState } from './store.js';
|
|
|
14
14
|
import { showToast } from './components/shared.js';
|
|
15
15
|
import { trackBlocked } from './notify.js';
|
|
16
16
|
|
|
17
|
-
// #969 — the orchestrator port is injected by the server (see
|
|
18
|
-
// window.__ORCH_PORT__, since a dashboard started with
|
|
19
|
-
// second instance under test) spawns its orchestrator
|
|
20
|
-
//
|
|
17
|
+
// #969 / #1037 — the orchestrator port is injected by the server (see
|
|
18
|
+
// shell.js) as window.__ORCH_PORT__, since a dashboard started with
|
|
19
|
+
// ORCH_PORT set (e.g. a second instance under test) spawns its orchestrator
|
|
20
|
+
// on a non-default port. window.__ORCH_PORT__ is null when THIS dashboard's
|
|
21
|
+
// orchestrator never bound — a hardcoded fallback (e.g. 7718) here would
|
|
22
|
+
// silently drive some OTHER project's orchestrator, which is the exact bug
|
|
23
|
+
// #1037 fixes server-side. So there is no fallback: null stays null, and
|
|
24
|
+
// callers must check isOrchAvailable() before using orchHttp()/orchWs().
|
|
21
25
|
// Resolved per-call (not cached at module load) so it works even if a caller
|
|
22
26
|
// loads this module before the inline bootstrap script has run.
|
|
23
27
|
function orchPort() {
|
|
24
|
-
return (typeof window !== 'undefined' && window.__ORCH_PORT__) ||
|
|
28
|
+
return (typeof window !== 'undefined' && window.__ORCH_PORT__) || null;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
export function
|
|
31
|
+
/** True when this dashboard has a live orchestrator to talk to. */
|
|
32
|
+
export function isOrchAvailable() { return orchPort() != null; }
|
|
33
|
+
|
|
34
|
+
/** The project root this dashboard is scanning — sent on every orchestrator
|
|
35
|
+
* request so the orchestrator can reject a mismatched-project caller (#1037). */
|
|
36
|
+
export function projectRoot() {
|
|
37
|
+
return (typeof window !== 'undefined' && window.__PROJECT_ROOT__) || '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function orchHttp() { return isOrchAvailable() ? 'http://localhost:' + orchPort() : null; }
|
|
41
|
+
export function orchWs() { return isOrchAvailable() ? 'ws://localhost:' + orchPort() : null; }
|
|
42
|
+
|
|
43
|
+
// #967 — view-only mode, injected by shell.js from the server-side
|
|
44
|
+
// dashboard.view_only config check (see server/lib/view-only.js). This gate
|
|
45
|
+
// is UI convenience only: the orchestrator refuses POST /api/run itself
|
|
46
|
+
// regardless of what the client sends, so a stale/bypassed client can't
|
|
47
|
+
// actually spawn an agent — it just gets a clearer message here first.
|
|
48
|
+
export function isViewOnly() {
|
|
49
|
+
return typeof window !== 'undefined' && !!window.__VIEW_ONLY__;
|
|
50
|
+
}
|
|
29
51
|
|
|
30
52
|
// ── Token helpers ─────────────────────────────────────────────────────────────
|
|
31
53
|
|
|
@@ -42,8 +64,13 @@ export function refreshOrchToken() {
|
|
|
42
64
|
return fetch('/api/orch-token')
|
|
43
65
|
.then(r => r.json())
|
|
44
66
|
.then(d => {
|
|
45
|
-
if (d
|
|
46
|
-
if (d
|
|
67
|
+
if (!d) return;
|
|
68
|
+
if (d.token) window.__ORCH_TOKEN__ = d.token;
|
|
69
|
+
// #1037 — orchPort may legitimately have GONE null (orchestrator died
|
|
70
|
+
// or never started) since the page loaded, so this must not only ever
|
|
71
|
+
// set a truthy value — a stale truthy value must be cleared too.
|
|
72
|
+
window.__ORCH_PORT__ = d.orchPort || null;
|
|
73
|
+
if (d.projectRoot) window.__PROJECT_ROOT__ = d.projectRoot;
|
|
47
74
|
})
|
|
48
75
|
.catch(() => {});
|
|
49
76
|
}
|
|
@@ -57,6 +84,7 @@ export function refreshOrchToken() {
|
|
|
57
84
|
* Returns the parsed JSON response (or throws on network error).
|
|
58
85
|
*/
|
|
59
86
|
export function runSession(storyId, cmd, opts) {
|
|
87
|
+
if (!isOrchAvailable()) return Promise.resolve({ error: 'orchestrator unavailable' });
|
|
60
88
|
const tok = orchToken();
|
|
61
89
|
const body = { storyId, cmd };
|
|
62
90
|
if (opts && opts.runner) {
|
|
@@ -65,7 +93,7 @@ export function runSession(storyId, cmd, opts) {
|
|
|
65
93
|
}
|
|
66
94
|
return fetch(orchHttp() + '/api/run', {
|
|
67
95
|
method: 'POST',
|
|
68
|
-
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
96
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json', 'X-Project-Root': projectRoot() },
|
|
69
97
|
body: JSON.stringify(body),
|
|
70
98
|
}).then(r => r.json());
|
|
71
99
|
}
|
|
@@ -79,9 +107,10 @@ export function runSession(storyId, cmd, opts) {
|
|
|
79
107
|
let _runnersPromise = null;
|
|
80
108
|
export function fetchRunners() {
|
|
81
109
|
if (_runnersPromise) return _runnersPromise;
|
|
110
|
+
if (!isOrchAvailable()) return Promise.resolve([]);
|
|
82
111
|
const tok = orchToken();
|
|
83
112
|
_runnersPromise = fetch(orchHttp() + '/api/runners', {
|
|
84
|
-
headers: { 'Authorization': 'Bearer ' + tok },
|
|
113
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'X-Project-Root': projectRoot() },
|
|
85
114
|
})
|
|
86
115
|
.then(r => r.json())
|
|
87
116
|
.then(d => (d && d.runners) || [])
|
|
@@ -93,10 +122,11 @@ export function fetchRunners() {
|
|
|
93
122
|
* POST /api/stop — stop a running session.
|
|
94
123
|
*/
|
|
95
124
|
export function stopSession(storyId) {
|
|
125
|
+
if (!isOrchAvailable()) return Promise.resolve();
|
|
96
126
|
const tok = orchToken();
|
|
97
127
|
return fetch(orchHttp() + '/api/stop', {
|
|
98
128
|
method: 'POST',
|
|
99
|
-
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
129
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json', 'X-Project-Root': projectRoot() },
|
|
100
130
|
body: JSON.stringify({ storyId }),
|
|
101
131
|
}).catch(() => {});
|
|
102
132
|
}
|
|
@@ -108,9 +138,9 @@ export function stopSession(storyId) {
|
|
|
108
138
|
*/
|
|
109
139
|
function fetchSessionsWithStatus() {
|
|
110
140
|
const tok = orchToken();
|
|
111
|
-
if (!tok) return Promise.resolve({ ok: false, sessions: [] });
|
|
141
|
+
if (!tok || !isOrchAvailable()) return Promise.resolve({ ok: false, sessions: [] });
|
|
112
142
|
return fetch(orchHttp() + '/api/sessions', {
|
|
113
|
-
headers: { 'Authorization': 'Bearer ' + tok },
|
|
143
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'X-Project-Root': projectRoot() },
|
|
114
144
|
})
|
|
115
145
|
.then(r => {
|
|
116
146
|
if (r.status === 401) { refreshOrchToken(); return { ok: true, sessions: [] }; }
|
|
@@ -131,8 +161,8 @@ export function fetchSessions() {
|
|
|
131
161
|
*/
|
|
132
162
|
export function fetchHistory() {
|
|
133
163
|
const tok = orchToken();
|
|
134
|
-
if (!tok) return Promise.resolve([]);
|
|
135
|
-
return fetch(orchHttp() + '/api/history', { headers: { 'Authorization': 'Bearer ' + tok } })
|
|
164
|
+
if (!tok || !isOrchAvailable()) return Promise.resolve([]);
|
|
165
|
+
return fetch(orchHttp() + '/api/history', { headers: { 'Authorization': 'Bearer ' + tok, 'X-Project-Root': projectRoot() } })
|
|
136
166
|
.then(r => {
|
|
137
167
|
if (r.status === 401) { refreshOrchToken(); return []; }
|
|
138
168
|
return r.json().then(d => (d && d.history) || []);
|
|
@@ -172,10 +202,11 @@ export function isOrchOnline() {
|
|
|
172
202
|
* phase is optional. Returns the parsed JSON response.
|
|
173
203
|
*/
|
|
174
204
|
export function submitRejection(storyId, reason, phase) {
|
|
205
|
+
if (!isOrchAvailable()) return Promise.resolve({ error: 'orchestrator unavailable' });
|
|
175
206
|
const tok = orchToken();
|
|
176
207
|
return fetch(orchHttp() + '/api/reject', {
|
|
177
208
|
method: 'POST',
|
|
178
|
-
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
209
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json', 'X-Project-Root': projectRoot() },
|
|
179
210
|
body: JSON.stringify({ storyId, reason, phase: phase || null }),
|
|
180
211
|
}).then(r => r.json());
|
|
181
212
|
}
|
|
@@ -185,8 +216,8 @@ export function submitRejection(storyId, reason, phase) {
|
|
|
185
216
|
*/
|
|
186
217
|
export function fetchRejections() {
|
|
187
218
|
const tok = orchToken();
|
|
188
|
-
if (!tok) return Promise.resolve([]);
|
|
189
|
-
return fetch(orchHttp() + '/api/rejections', { headers: { 'Authorization': 'Bearer ' + tok } })
|
|
219
|
+
if (!tok || !isOrchAvailable()) return Promise.resolve([]);
|
|
220
|
+
return fetch(orchHttp() + '/api/rejections', { headers: { 'Authorization': 'Bearer ' + tok, 'X-Project-Root': projectRoot() } })
|
|
190
221
|
.then(r => r.ok ? r.json().then(d => (d && d.rejections) || []) : [])
|
|
191
222
|
.catch(() => []);
|
|
192
223
|
}
|
|
@@ -196,10 +227,11 @@ export function fetchRejections() {
|
|
|
196
227
|
* status is the target column id (todo | in_progress | blocked | done).
|
|
197
228
|
*/
|
|
198
229
|
export function setTaskStatus(storyId, status) {
|
|
230
|
+
if (!isOrchAvailable()) return Promise.resolve({});
|
|
199
231
|
const tok = orchToken();
|
|
200
232
|
return fetch(orchHttp() + '/api/task-status', {
|
|
201
233
|
method: 'POST',
|
|
202
|
-
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
234
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json', 'X-Project-Root': projectRoot() },
|
|
203
235
|
body: JSON.stringify({ storyId, status }),
|
|
204
236
|
}).then(r => r.json()).catch(() => ({}));
|
|
205
237
|
}
|
|
@@ -209,10 +241,12 @@ export function setTaskStatus(storyId, status) {
|
|
|
209
241
|
* olderThanDays = 0 removes all ended sessions; > 0 keeps recent ones.
|
|
210
242
|
*/
|
|
211
243
|
export function cleanSessions(olderThanDays = 0) {
|
|
244
|
+
if (!isOrchAvailable()) return Promise.resolve({ removed: 0 });
|
|
245
|
+
if (isViewOnly()) { showToast('View-only mode — clean is disabled'); return Promise.resolve({ removed: 0 }); }
|
|
212
246
|
const tok = orchToken();
|
|
213
247
|
return fetch(orchHttp() + '/api/clean-sessions', {
|
|
214
248
|
method: 'POST',
|
|
215
|
-
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
249
|
+
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json', 'X-Project-Root': projectRoot() },
|
|
216
250
|
body: JSON.stringify({ olderThanDays }),
|
|
217
251
|
})
|
|
218
252
|
.then(r => r.json())
|
|
@@ -316,6 +350,7 @@ function _poll() {
|
|
|
316
350
|
* @param {{ runner?: string, model?: string }} [opts] — agent CLI selection
|
|
317
351
|
*/
|
|
318
352
|
export function runAndOpenTerm(storyId, cmd, title, opts) {
|
|
353
|
+
if (isViewOnly()) { showToast('View-only mode — runs are disabled'); return; }
|
|
319
354
|
// #916 — spawning an orchestrator session launches a real agent with
|
|
320
355
|
// permissions skipped. Gate it behind an explicit confirmation dialog
|
|
321
356
|
// instead of running on the first click. The dialog calls execRunAndOpenTerm
|
|
@@ -381,6 +416,7 @@ export function openOrchPanel(storyId) {
|
|
|
381
416
|
* stopStory — Kanban "Stop" action.
|
|
382
417
|
*/
|
|
383
418
|
export function stopStory(storyId) {
|
|
419
|
+
if (isViewOnly()) { showToast('View-only mode — stop is disabled'); return; }
|
|
384
420
|
stopSession(storyId).catch(err => console.error('[orchestrator] session op failed:', err.message));
|
|
385
421
|
}
|
|
386
422
|
|
|
@@ -432,6 +468,7 @@ export const ALLOWED_COMMANDS = [
|
|
|
432
468
|
*/
|
|
433
469
|
export function runCommandFromUI(cmd, opts) {
|
|
434
470
|
if (!cmd) return;
|
|
471
|
+
if (isViewOnly()) { showToast('View-only mode — runs are disabled'); return; }
|
|
435
472
|
// #916 — gate command-runner spawns behind the same confirmation dialog.
|
|
436
473
|
const title = cmd + ' (command runner)';
|
|
437
474
|
setState({
|
|
@@ -43,6 +43,13 @@ function CmdAccordion({ hints }) {
|
|
|
43
43
|
`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// ---- Age pill (#968) — fresh / aging / stale by mtime ----
|
|
47
|
+
function AgePill({ ageDays, ageBand }) {
|
|
48
|
+
if (ageBand == null || ageDays == null) return null;
|
|
49
|
+
const label = ageDays === 0 ? 'updated today' : `updated ${ageDays}d ago`;
|
|
50
|
+
return html`<span class=${'memory-age-pill ' + ageBand}>${label}</span>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
// ---- Section file list ----
|
|
47
54
|
function SectionGroup({ section, files, onOpen }) {
|
|
48
55
|
return html`
|
|
@@ -59,7 +66,10 @@ function SectionGroup({ section, files, onOpen }) {
|
|
|
59
66
|
key=${f.name}
|
|
60
67
|
onClick=${f.exists ? () => onOpen(f) : undefined}
|
|
61
68
|
>
|
|
62
|
-
<div class="item-title"
|
|
69
|
+
<div class="item-title">
|
|
70
|
+
${status} ${f.name}
|
|
71
|
+
<${AgePill} ageDays=${f.ageDays} ageBand=${f.ageBand} />
|
|
72
|
+
</div>
|
|
63
73
|
<div class="item-meta">${meta} · ${f.bytes || 0} bytes</div>
|
|
64
74
|
</div>
|
|
65
75
|
`;
|
|
@@ -69,7 +79,7 @@ function SectionGroup({ section, files, onOpen }) {
|
|
|
69
79
|
`;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
// ---- Generic list group (
|
|
82
|
+
// ---- Generic list group (change records, archive, post-mortems) ----
|
|
73
83
|
function ListGroup({ label, items, onOpen }) {
|
|
74
84
|
if (!items || !items.length) return null;
|
|
75
85
|
return html`
|
|
@@ -86,6 +96,51 @@ function ListGroup({ label, items, onOpen }) {
|
|
|
86
96
|
`;
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
// ---- Distillates — freshness vs their source files (#968) ----
|
|
100
|
+
function DistillateGroup({ items, onOpen }) {
|
|
101
|
+
if (!items || !items.length) return null;
|
|
102
|
+
return html`
|
|
103
|
+
<div>
|
|
104
|
+
<div class="memory-group-header">Distillates (${items.length})</div>
|
|
105
|
+
<div class="decision-list">
|
|
106
|
+
${items.map(d => html`
|
|
107
|
+
<div class="item item-clickable" key=${d.name} onClick=${() => onOpen(d)}>
|
|
108
|
+
<div class="item-title">
|
|
109
|
+
${d.name}
|
|
110
|
+
<${AgePill} ageDays=${d.ageDays} ageBand=${d.ageBand} />
|
|
111
|
+
${d.stale ? html`<span class="memory-age-pill stale">source changed since generation</span>` : null}
|
|
112
|
+
</div>
|
|
113
|
+
<div class="item-meta">
|
|
114
|
+
${d.generatedAt ? `generated ${d.generatedAt}` : 'no generated-at recorded'}
|
|
115
|
+
${d.sourceDigest ? ` · digest ${d.sourceDigest.slice(0, 10)}…` : ''}
|
|
116
|
+
${d.staleSources && d.staleSources.length ? ` · changed: ${d.staleSources.join(', ')}` : ''}
|
|
117
|
+
${d.missingSources && d.missingSources.length ? ` · missing: ${d.missingSources.join(', ')}` : ''}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
`)}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- Drift — same heuristics as `rcode-hooks drift` (#968) ----
|
|
127
|
+
function DriftSection({ drift }) {
|
|
128
|
+
const drifts = (drift && drift.drifts) || [];
|
|
129
|
+
if (!drifts.length) return null;
|
|
130
|
+
return html`
|
|
131
|
+
<div>
|
|
132
|
+
<div class="memory-group-header">⚠ Drift (${drifts.length})</div>
|
|
133
|
+
${drifts.map((d, i) => html`
|
|
134
|
+
<div class="memory-drift-item" key=${d.kind + '-' + i}>
|
|
135
|
+
<div class="memory-drift-kind">${d.kind}</div>
|
|
136
|
+
<div class="memory-drift-claim">${d.claim}</div>
|
|
137
|
+
<div class="memory-drift-evidence">${d.evidence}</div>
|
|
138
|
+
</div>
|
|
139
|
+
`)}
|
|
140
|
+
</div>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
// ---- Root MemoryView ----
|
|
90
145
|
export function MemoryView() {
|
|
91
146
|
const [memory, setMemory] = useState(null);
|
|
@@ -154,10 +209,11 @@ export function MemoryView() {
|
|
|
154
209
|
<span style="color:var(--text-muted);font-size:var(--text-sm);">Last scanned: ${memory.lastScanned || '—'}</span>
|
|
155
210
|
</div>
|
|
156
211
|
<div id="memory-sections">
|
|
212
|
+
<${DriftSection} drift=${memory.drift} />
|
|
157
213
|
${Object.entries(sections).map(([section, files]) => html`
|
|
158
214
|
<${SectionGroup} key=${section} section=${section} files=${files} onOpen=${setReader} />
|
|
159
215
|
`)}
|
|
160
|
-
<${
|
|
216
|
+
<${DistillateGroup} items=${memory.distillates} onOpen=${setReader} />
|
|
161
217
|
<${ListGroup} label="Change Records" items=${memory.changeRecords} onOpen=${setReader} />
|
|
162
218
|
<${ListGroup} label="Milestone Archive" items=${memory.archive} onOpen=${setReader} />
|
|
163
219
|
<${ListGroup} label="Post-mortems" items=${memory.postMortems} onOpen=${setReader} />
|
package/server/lib/html/css.js
CHANGED
|
@@ -958,6 +958,46 @@ section .body {
|
|
|
958
958
|
letter-spacing: -0.006em;
|
|
959
959
|
}
|
|
960
960
|
|
|
961
|
+
/* ── Memory freshness / drift (#968) ───────────────────────────────── */
|
|
962
|
+
.memory-age-pill {
|
|
963
|
+
display: inline-flex;
|
|
964
|
+
align-items: center;
|
|
965
|
+
height: 16px;
|
|
966
|
+
padding: 0 6px;
|
|
967
|
+
border-radius: var(--radius-2);
|
|
968
|
+
font-size: var(--text-2xs);
|
|
969
|
+
font-weight: 600;
|
|
970
|
+
border: 1px solid transparent;
|
|
971
|
+
}
|
|
972
|
+
.memory-age-pill.fresh { color: var(--green); background: color-mix(in srgb, var(--green) 14%, transparent); border-color: color-mix(in srgb, var(--green) 30%, transparent); }
|
|
973
|
+
.memory-age-pill.aging { color: var(--amber); background: color-mix(in srgb, var(--amber) 14%, transparent); border-color: color-mix(in srgb, var(--amber) 30%, transparent); }
|
|
974
|
+
.memory-age-pill.stale { color: var(--red); background: color-mix(in srgb, var(--red) 14%, transparent); border-color: color-mix(in srgb, var(--red) 30%, transparent); }
|
|
975
|
+
|
|
976
|
+
.memory-drift-item {
|
|
977
|
+
border-left: 2px solid var(--red);
|
|
978
|
+
padding: var(--space-2) var(--space-3);
|
|
979
|
+
margin: var(--space-2) 0;
|
|
980
|
+
background: var(--bg-elev-2);
|
|
981
|
+
border-radius: 0 var(--radius-2) var(--radius-2) 0;
|
|
982
|
+
}
|
|
983
|
+
.memory-drift-kind {
|
|
984
|
+
font-size: var(--text-2xs);
|
|
985
|
+
font-weight: 700;
|
|
986
|
+
color: var(--red);
|
|
987
|
+
text-transform: uppercase;
|
|
988
|
+
letter-spacing: 0.02em;
|
|
989
|
+
}
|
|
990
|
+
.memory-drift-claim {
|
|
991
|
+
font-size: var(--text-xs);
|
|
992
|
+
color: var(--text-primary);
|
|
993
|
+
margin-top: 2px;
|
|
994
|
+
}
|
|
995
|
+
.memory-drift-evidence {
|
|
996
|
+
font-size: var(--text-2xs);
|
|
997
|
+
color: var(--text-muted);
|
|
998
|
+
margin-top: 2px;
|
|
999
|
+
}
|
|
1000
|
+
|
|
961
1001
|
/* ── Markdown render ────────────────────────────────────────────── */
|
|
962
1002
|
.md-render {
|
|
963
1003
|
font-size: var(--text-xs);
|
package/server/lib/html/shell.js
CHANGED
|
@@ -6,14 +6,20 @@ const { renderClientJs } = require('./client');
|
|
|
6
6
|
|
|
7
7
|
function esc(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
8
8
|
|
|
9
|
-
function renderHtml(state, orchToken, orchPort) {
|
|
9
|
+
function renderHtml(state, orchToken, orchPort, projectRoot, viewOnly) {
|
|
10
10
|
const projectName = state.projectName || 'No project initialized';
|
|
11
11
|
// #969 — the client used to hardcode port 7718 for the orchestrator API.
|
|
12
12
|
// A dashboard started with ORCH_PORT set (e.g. to test in isolation from a
|
|
13
13
|
// production instance) would silently talk to the wrong orchestrator. The
|
|
14
14
|
// actual port is injected here — both into the CSP connect-src allowlist
|
|
15
15
|
// and into window.__ORCH_PORT__ for orchestrator.js to read at runtime.
|
|
16
|
-
|
|
16
|
+
//
|
|
17
|
+
// #1037 — orchPort is null when this dashboard's orchestrator never bound
|
|
18
|
+
// (spawn failed, or its own free-port scan was exhausted). NEVER fall back
|
|
19
|
+
// to a constant like 7718 here — that port may belong to another project's
|
|
20
|
+
// orchestrator, and this dashboard did not spawn it. null stays null.
|
|
21
|
+
const port = orchPort == null ? null : (parseInt(orchPort, 10) || null);
|
|
22
|
+
const connectSrc = port ? ` http://localhost:${port} http://127.0.0.1:${port}` : '';
|
|
17
23
|
|
|
18
24
|
// Agent roster moved to server/lib/html/client/agents-data.js (Sprint 31.3).
|
|
19
25
|
// AgentsView.js renders it client-side; shell.js no longer needs it.
|
|
@@ -23,7 +29,7 @@ function renderHtml(state, orchToken, orchPort) {
|
|
|
23
29
|
<head>
|
|
24
30
|
<meta charset="UTF-8">
|
|
25
31
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
26
|
-
<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'
|
|
32
|
+
<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'${connectSrc} ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
|
|
27
33
|
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
|
28
34
|
<meta name="referrer" content="strict-origin-when-cross-origin">
|
|
29
35
|
<title>Majlis — ${esc(projectName)}</title>
|
|
@@ -32,7 +38,7 @@ function renderHtml(state, orchToken, orchPort) {
|
|
|
32
38
|
<script src="https://cdn.jsdelivr.net/npm/marked@18.0.4/lib/marked.umd.js" integrity="sha384-8RA8Ah4c9upJmKfg5nH01OgjZoQ3mRX+ngrKYWXQYj2dHYxFqYz8POSlii33f0wB" crossorigin="anonymous"><\/script>
|
|
33
39
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" integrity="sha384-/nfmYPUzWMS6v2atn8hbljz7NE0EI1iGx34lJaNzyVjWGDzMv+ciUZUeJpKA3Glc" crossorigin="anonymous"><\/script>
|
|
34
40
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" integrity="sha384-AQLWHRKAgdTxkolJcLOELg4E9rE89CPE2xMy3tIRFn08NcGKPTsELdvKomqji+DL" crossorigin="anonymous"><\/script>
|
|
35
|
-
<script>window.__ORCH_TOKEN__ = ${JSON.stringify(orchToken || '')}; window.__ORCH_PORT__ = ${port};<\/script>
|
|
41
|
+
<script>window.__ORCH_TOKEN__ = ${JSON.stringify(orchToken || '')}; window.__ORCH_PORT__ = ${port === null ? 'null' : port}; window.__PROJECT_ROOT__ = ${JSON.stringify(projectRoot || '')}; window.__VIEW_ONLY__ = ${JSON.stringify(!!viewOnly)};<\/script>
|
|
36
42
|
${renderCss()}
|
|
37
43
|
</head>
|
|
38
44
|
<body>
|
package/server/lib/scanner.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
6
7
|
|
|
7
8
|
function safeReadJson(filepath) {
|
|
8
9
|
let raw;
|
|
@@ -712,13 +713,127 @@ function scanState(rcodeDir) {
|
|
|
712
713
|
return state;
|
|
713
714
|
}
|
|
714
715
|
|
|
716
|
+
// Age-band thresholds (#968) — a memory file untouched this long has likely
|
|
717
|
+
// drifted from the codebase it describes. Matches the incident report's own
|
|
718
|
+
// "hasn't been updated in 2.5 months" framing: amber warns before red alarms.
|
|
719
|
+
const AGE_BAND_AMBER_DAYS = 30;
|
|
720
|
+
const AGE_BAND_RED_DAYS = 90;
|
|
721
|
+
|
|
722
|
+
function ageDaysFromMtime(mtimeMs) {
|
|
723
|
+
return Math.floor((Date.now() - mtimeMs) / 86400000);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** 'fresh' | 'aging' | 'stale', or null when age is unknown (file missing). */
|
|
727
|
+
function ageBand(ageDays) {
|
|
728
|
+
if (ageDays == null) return null;
|
|
729
|
+
if (ageDays > AGE_BAND_RED_DAYS) return 'stale';
|
|
730
|
+
if (ageDays > AGE_BAND_AMBER_DAYS) return 'aging';
|
|
731
|
+
return 'fresh';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Read one frontmatter scalar by key. Distillate frontmatter uses hyphenated
|
|
736
|
+
* keys (source-digest, generated-at) that parseSimpleYaml's identifier-only
|
|
737
|
+
* key pattern does not match, so this is a small standalone reader scoped to
|
|
738
|
+
* the distillate use case rather than a change to the shared parser.
|
|
739
|
+
*/
|
|
740
|
+
function extractFrontmatterValue(frontmatterText, key) {
|
|
741
|
+
const m = frontmatterText.match(new RegExp('^' + key + '\\s*:\\s*(.+)$', 'm'));
|
|
742
|
+
return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Last-commit timestamp for a repo-relative path, or null when git is
|
|
747
|
+
* unavailable / the path has no history (untracked, shallow clone). Used in
|
|
748
|
+
* preference to fs mtime for staleness comparisons: a fresh `git clone` or
|
|
749
|
+
* `git worktree add` stamps every checked-out file with the checkout time,
|
|
750
|
+
* which would make every distillate look freshly-edited relative to its
|
|
751
|
+
* (much older) generated-at — git's own commit history isn't affected by that.
|
|
752
|
+
*/
|
|
753
|
+
function gitLastCommitMs(projectRoot, repoRelPath) {
|
|
754
|
+
let out;
|
|
755
|
+
try {
|
|
756
|
+
out = execSync(`git log -1 --format=%cI -- ${JSON.stringify(repoRelPath)}`, {
|
|
757
|
+
cwd: projectRoot,
|
|
758
|
+
encoding: 'utf8',
|
|
759
|
+
timeout: 3000,
|
|
760
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
761
|
+
}).trim();
|
|
762
|
+
} catch {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
if (!out) return null;
|
|
766
|
+
const ms = Date.parse(out);
|
|
767
|
+
return Number.isFinite(ms) ? ms : null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Distillates carry `source-digest` / `generated-at` / `source-files` in
|
|
772
|
+
* their frontmatter (see rcode/workflows/memory-distill.md), but the digest
|
|
773
|
+
* is produced by an LLM-run workflow step with no deterministic script in
|
|
774
|
+
* this repo to reproduce it byte-for-byte — recomputing and diffing it here
|
|
775
|
+
* would just be a guess dressed up as a check. What IS verifiable: whether
|
|
776
|
+
* any source file has changed (by git commit history, not fs mtime — see
|
|
777
|
+
* gitLastCommitMs) more recently than the distillate claims to have been
|
|
778
|
+
* generated. That's the real signal "is this distillate stale" is asking
|
|
779
|
+
* for, so that's what drives `stale` — `sourceDigest` is still surfaced
|
|
780
|
+
* as-is for reference.
|
|
781
|
+
*/
|
|
782
|
+
function scanDistillateFreshness(memoryDir, projectRoot, subdir, entry) {
|
|
783
|
+
const full = path.join(memoryDir, subdir, entry.name);
|
|
784
|
+
let distillateMtimeMs = null;
|
|
785
|
+
try { distillateMtimeMs = fs.statSync(full).mtimeMs; } catch { /* unreadable — leave null */ }
|
|
786
|
+
|
|
787
|
+
const text = safeReadText(full) || '';
|
|
788
|
+
const fmEnd = text.startsWith('---') ? text.indexOf('\n---', 3) : -1;
|
|
789
|
+
const frontmatter = fmEnd !== -1 ? text.slice(0, fmEnd) : '';
|
|
790
|
+
|
|
791
|
+
const sourceDigest = extractFrontmatterValue(frontmatter, 'source-digest');
|
|
792
|
+
const generatedAt = extractFrontmatterValue(frontmatter, 'generated-at');
|
|
793
|
+
const sourceFiles = parseYamlList(frontmatter, 'source-files');
|
|
794
|
+
|
|
795
|
+
// Anchor staleness to generated-at when it parses (explicit "as-of" claim);
|
|
796
|
+
// fall back to the distillate file's own mtime otherwise.
|
|
797
|
+
const generatedAtMs = generatedAt ? Date.parse(generatedAt) : NaN;
|
|
798
|
+
const anchorMs = Number.isFinite(generatedAtMs) ? generatedAtMs : distillateMtimeMs;
|
|
799
|
+
|
|
800
|
+
const staleSources = [];
|
|
801
|
+
const missingSources = [];
|
|
802
|
+
for (const rel of sourceFiles) {
|
|
803
|
+
const srcFull = path.join(memoryDir, rel);
|
|
804
|
+
if (!fs.existsSync(srcFull)) { missingSources.push(rel); continue; }
|
|
805
|
+
const repoRelPath = path.relative(projectRoot, srcFull);
|
|
806
|
+
let changedMs = gitLastCommitMs(projectRoot, repoRelPath);
|
|
807
|
+
if (changedMs == null) {
|
|
808
|
+
try { changedMs = fs.statSync(srcFull).mtimeMs; } catch { changedMs = null; }
|
|
809
|
+
}
|
|
810
|
+
if (anchorMs != null && changedMs != null && changedMs > anchorMs) staleSources.push(rel);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const ageDays = distillateMtimeMs != null ? ageDaysFromMtime(distillateMtimeMs) : null;
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
name: entry.name,
|
|
817
|
+
path: entry.path,
|
|
818
|
+
sourceDigest,
|
|
819
|
+
generatedAt,
|
|
820
|
+
sourceFiles,
|
|
821
|
+
ageDays,
|
|
822
|
+
ageBand: ageBand(ageDays),
|
|
823
|
+
staleSources,
|
|
824
|
+
missingSources,
|
|
825
|
+
stale: staleSources.length > 0 || missingSources.length > 0,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
715
829
|
/**
|
|
716
830
|
* Scan the Memory Bank at .rcode/memory/. Returns structure suitable
|
|
717
831
|
* for the /api/memory endpoint and the dashboard /memory view.
|
|
718
832
|
* Returns { exists: false } when the Memory Bank has not been initialised.
|
|
719
833
|
*/
|
|
720
|
-
function
|
|
834
|
+
function scanMemoryBankUncached(rcodeDir) {
|
|
721
835
|
const memoryDir = path.join(rcodeDir, 'memory');
|
|
836
|
+
const projectRoot = path.dirname(rcodeDir);
|
|
722
837
|
const result = {
|
|
723
838
|
exists: false,
|
|
724
839
|
initialised: false,
|
|
@@ -728,6 +843,7 @@ function scanMemoryBank(rcodeDir) {
|
|
|
728
843
|
changeRecords: [],
|
|
729
844
|
archive: [],
|
|
730
845
|
postMortems: [],
|
|
846
|
+
drift: { drifts: [], error: null },
|
|
731
847
|
lastScanned: new Date().toISOString(),
|
|
732
848
|
};
|
|
733
849
|
|
|
@@ -752,11 +868,12 @@ function scanMemoryBank(rcodeDir) {
|
|
|
752
868
|
result.sections[section] = files.map(name => {
|
|
753
869
|
const full = path.join(sectionDir, name);
|
|
754
870
|
const exists = fs.existsSync(full);
|
|
755
|
-
let bytes = 0, populated = false;
|
|
871
|
+
let bytes = 0, populated = false, ageDays = null;
|
|
756
872
|
if (exists) {
|
|
757
873
|
try {
|
|
758
874
|
const stat = fs.statSync(full);
|
|
759
875
|
bytes = stat.size;
|
|
876
|
+
ageDays = ageDaysFromMtime(stat.mtimeMs);
|
|
760
877
|
const text = fs.readFileSync(full, 'utf8');
|
|
761
878
|
populated = !/\{\{[A-Z_]+\}\}/.test(text) && !/_\(e\.g\.\s/.test(text);
|
|
762
879
|
} catch { /* ignore */ }
|
|
@@ -767,6 +884,8 @@ function scanMemoryBank(rcodeDir) {
|
|
|
767
884
|
exists,
|
|
768
885
|
bytes,
|
|
769
886
|
populated,
|
|
887
|
+
ageDays,
|
|
888
|
+
ageBand: exists ? ageBand(ageDays) : null,
|
|
770
889
|
};
|
|
771
890
|
});
|
|
772
891
|
}
|
|
@@ -779,11 +898,39 @@ function scanMemoryBank(rcodeDir) {
|
|
|
779
898
|
.map(e => ({ name: e.name, path: `.rcode/memory/${subdir}/${e.name}` }));
|
|
780
899
|
}
|
|
781
900
|
|
|
782
|
-
result.distillates = listMd('distillates')
|
|
901
|
+
result.distillates = listMd('distillates')
|
|
902
|
+
.map(entry => scanDistillateFreshness(memoryDir, projectRoot, 'distillates', entry));
|
|
783
903
|
result.changeRecords = listMd('change-records');
|
|
784
904
|
result.archive = listMd('milestones/archive');
|
|
785
905
|
result.postMortems = listMd('incidents/post-mortems');
|
|
786
906
|
|
|
907
|
+
// #968 — same heuristics as `rcode-hooks drift` (rcode/bin/lib/memory-drift.cjs):
|
|
908
|
+
// dependency contradictions, memory referencing paths that no longer exist,
|
|
909
|
+
// and a stale INDEX.md "Last updated" stamp. Advisory only — never throws.
|
|
910
|
+
try {
|
|
911
|
+
const { checkDrift } = require(path.join(__dirname, '..', '..', 'rcode', 'bin', 'lib', 'memory-drift.cjs'));
|
|
912
|
+
result.drift = checkDrift(projectRoot);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
result.drift = { drifts: [], error: err.message };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return result;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// TTL cache — checkDrift() shells out to git (up to two `git log`/`git show`
|
|
921
|
+
// calls), so an unthrottled /api/memory would re-run those on every request.
|
|
922
|
+
// Mirrors scanState's cache (see below) at a coarser interval since memory
|
|
923
|
+
// content changes far less often than sprint/task state.
|
|
924
|
+
let _memoryCache = null; // { rcodeDir, result, ts }
|
|
925
|
+
const MEMORY_SCAN_TTL_MS = 5000;
|
|
926
|
+
|
|
927
|
+
function scanMemoryBank(rcodeDir) {
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
if (_memoryCache && _memoryCache.rcodeDir === rcodeDir && now - _memoryCache.ts < MEMORY_SCAN_TTL_MS) {
|
|
930
|
+
return _memoryCache.result;
|
|
931
|
+
}
|
|
932
|
+
const result = scanMemoryBankUncached(rcodeDir);
|
|
933
|
+
_memoryCache = { rcodeDir, result, ts: now };
|
|
787
934
|
return result;
|
|
788
935
|
}
|
|
789
936
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* view_only gate (#967) — shared between dashboard.js (hides Run/Stop/Clean
|
|
3
|
+
* affordances in the UI) and orchestrator.js (the actual server-side
|
|
4
|
+
* enforcement on POST /api/run). Hiding the button alone is not a fix: any
|
|
5
|
+
* automation that can drive the browser or read the orchestrator token can
|
|
6
|
+
* still POST /api/run directly and spawn a --dangerously-skip-permissions
|
|
7
|
+
* agent against the repo, so the refusal has to live on the server.
|
|
8
|
+
*
|
|
9
|
+
* `dashboard.view_only: true` in .rcode/config.yaml (or VIEW_ONLY=1/true in
|
|
10
|
+
* the environment) turns it on. Off by default — solo devs are unaffected.
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Re-read on every call (not cached) so toggling config.yaml takes effect
|
|
18
|
+
* without restarting the dashboard/orchestrator processes. config.yaml is a
|
|
19
|
+
* few hundred bytes, so the extra fs read per call is negligible.
|
|
20
|
+
*/
|
|
21
|
+
function isViewOnly(projectRoot) {
|
|
22
|
+
const envFlag = String(process.env.VIEW_ONLY || '').toLowerCase();
|
|
23
|
+
if (envFlag === '1' || envFlag === 'true') return true;
|
|
24
|
+
try {
|
|
25
|
+
const cfg = require(path.join(__dirname, '..', '..', 'rcode', 'bin', 'lib', 'config.cjs'));
|
|
26
|
+
return cfg.cmdGet(projectRoot, 'dashboard.view_only') === 'true';
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { isViewOnly };
|