@hanzlaa/rcode 4.12.1 → 4.14.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 (63) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/CONTRIBUTING.md +1 -0
  4. package/cli/doctor.js +40 -5
  5. package/cli/install.js +6 -1
  6. package/dist/rcode.js +87 -87
  7. package/package.json +1 -1
  8. package/rcode/agents/rcode-hussain-pm.md +37 -3
  9. package/rcode/agents/rcode-orchestrator.md +91 -0
  10. package/rcode/agents/rcode-project-researcher.md +19 -1
  11. package/rcode/agents/rules/executor/correctness-hazard-scan.md +98 -0
  12. package/rcode/agents/rules/executor/execution-flow.md +8 -0
  13. package/rcode/agents/rules/executor/self-check.md +8 -0
  14. package/rcode/agents/rules/orchestrator/contract.md +76 -0
  15. package/rcode/agents/rules/sprint-checker/dimensions.md +38 -0
  16. package/rcode/agents/rules/verifier/reachability-check.md +45 -2
  17. package/rcode/bin/lib/progress.cjs +41 -13
  18. package/rcode/bin/lib/roadmap.cjs +62 -22
  19. package/rcode/bin/lib/state-digest.cjs +88 -0
  20. package/rcode/bin/rcode-hooks.cjs +192 -23
  21. package/rcode/bin/rcode-tools.cjs +278 -7
  22. package/rcode/references/REFERENCES_INDEX.md +3 -1
  23. package/rcode/references/agent-shared-rules.md +123 -0
  24. package/rcode/references/code-reviewer-playbook.md +5 -0
  25. package/rcode/references/executor-playbook.md +2 -0
  26. package/rcode/references/github-comment-style.md +57 -0
  27. package/rcode/references/persona-executor-mode.md +61 -0
  28. package/rcode/references/planner-playbook.md +11 -0
  29. package/rcode/references/questioning.md +100 -2
  30. package/rcode/references/response-style.md +21 -4
  31. package/rcode/references/roadmapper-playbook.md +14 -0
  32. package/rcode/references/verifier-playbook.md +26 -0
  33. package/rcode/skills/SKILLS_INDEX.md +1 -1
  34. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/references.md +7 -0
  35. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/rules/merge-strategy.md +19 -3
  36. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/wave-prompt.md +3 -1
  37. package/rcode/skills/agents/{raees-orchestrator → orchestrator}/SKILL.md +1 -1
  38. package/rcode/team.yaml +20 -1
  39. package/rcode/workflows/audit-worktrees.md +15 -1
  40. package/rcode/workflows/execute-verify-phase-goal.md +58 -2
  41. package/rcode/workflows/execute.md +43 -8
  42. package/rcode/workflows/new-project-define-requirements.md +36 -0
  43. package/rcode/workflows/new-project-research-decision.md +61 -1
  44. package/rcode/workflows/new-project.md +95 -6
  45. package/rcode/workflows/plan-research-validation.md +8 -2
  46. package/rcode/workflows/plan-spawn-planner.md +32 -4
  47. package/rcode/workflows/plan.md +208 -18
  48. package/rcode/workflows/pr-branch.md +2 -0
  49. package/rcode/workflows/research-phase.md +12 -4
  50. package/rcode/workflows/resume-work.md +18 -0
  51. package/rcode/workflows/ship.md +4 -0
  52. package/rcode/workflows/verify-phase.md +40 -0
  53. package/server/dashboard.js +57 -17
  54. package/server/lib/html/client/components/OrchPanel.js +6 -2
  55. package/server/lib/html/client/components/XtermPanel.js +7 -2
  56. package/server/lib/html/client/orchestrator.js +58 -21
  57. package/server/lib/html/client/views/MemoryView.js +59 -3
  58. package/server/lib/html/css.js +40 -0
  59. package/server/lib/html/shell.js +10 -4
  60. package/server/lib/scanner.js +150 -3
  61. package/server/lib/view-only.js +32 -0
  62. package/server/orchestrator.js +63 -4
  63. /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 shell.js) as
18
- // window.__ORCH_PORT__, since a dashboard started with ORCH_PORT set (e.g. a
19
- // second instance under test) spawns its orchestrator on a non-default port.
20
- // A hardcoded 7718 here would silently drive the wrong orchestrator process.
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__) || 7718;
28
+ return (typeof window !== 'undefined' && window.__ORCH_PORT__) || null;
25
29
  }
26
30
 
27
- export function orchHttp() { return 'http://localhost:' + orchPort(); }
28
- export function orchWs() { return 'ws://localhost:' + orchPort(); }
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 && d.token) window.__ORCH_TOKEN__ = d.token;
46
- if (d && d.orchPort) window.__ORCH_PORT__ = d.orchPort;
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">${status} ${f.name}</div>
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 (distillates, change records, etc.) ----
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
- <${ListGroup} label="Distillates" items=${memory.distillates} onOpen=${setReader} />
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} />
@@ -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);
@@ -6,14 +6,20 @@ const { renderClientJs } = require('./client');
6
6
 
7
7
  function esc(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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
- const port = parseInt(orchPort, 10) || 7718;
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' http://localhost:${port} http://127.0.0.1:${port} ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'">
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>
@@ -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 scanMemoryBank(rcodeDir) {
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 };