@ijfw/memory-server 1.4.3 → 1.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ijfw/memory-server",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Cross-platform persistent memory server for IJFW. 10 MCP tools (memory + admin/update). Works with 13 MCP-using platforms (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, Copilot, Hermes, Wayland, OpenCode, QwenCode, Cline, KimiCode, OpenClaw) plus Aider via rules-only tier.",
5
5
  "author": "Sean Donahoe",
6
6
  "license": "MIT",
@@ -15,8 +15,10 @@
15
15
 
16
16
  import { spawn } from 'node:child_process';
17
17
  import * as readline from 'node:readline';
18
- import { pickAuditors, isReachable } from './audit-roster.js';
19
- import { loadSwarmConfig } from './swarm-config.js';
18
+ import { readdirSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
+ import { pickAuditors, isReachable, ROSTER } from './audit-roster.js';
21
+ import { loadSwarmConfig, DEFAULT_AUDITORS } from './swarm-config.js';
20
22
  import { buildRequest, parseResponse, mergeResponses, checkBudget } from './cross-dispatcher.js';
21
23
  import { writeReceipt, readReceipts } from './receipts.js';
22
24
  import { runViaApi } from './api-client.js';
@@ -381,6 +383,158 @@ function countItems(p) {
381
383
  return 0;
382
384
  }
383
385
 
386
+ // ---------------------------------------------------------------------------
387
+ // phase-e-auto helpers (v1.4.4 N10)
388
+ // ---------------------------------------------------------------------------
389
+
390
+ // Resolve the next CROSS-AUDIT-r<N>.md path under .planning/<phase>/.
391
+ // Scans for existing r<N> files and increments the highest N found.
392
+ function resolveAuditOutputPath(projectDir, phase) {
393
+ const planningDir = join(projectDir, '.planning', phase);
394
+ let maxN = 0;
395
+ if (existsSync(planningDir)) {
396
+ try {
397
+ const files = readdirSync(planningDir);
398
+ for (const f of files) {
399
+ const m = f.match(/^CROSS-AUDIT-r(\d+)\.md$/);
400
+ if (m) {
401
+ const n = parseInt(m[1], 10);
402
+ if (n > maxN) maxN = n;
403
+ }
404
+ }
405
+ } catch { /* non-fatal */ }
406
+ }
407
+ return join(planningDir, `CROSS-AUDIT-r${maxN + 1}.md`);
408
+ }
409
+
410
+ // Classify a merged audit result into PASS / CONDITIONAL / FAIL.
411
+ // HIGH severity finding → FAIL; any finding → CONDITIONAL; none → PASS.
412
+ function classifyVerdict(items) {
413
+ if (!Array.isArray(items) || items.length === 0) return 'PASS';
414
+ const hasHigh = items.some(item => {
415
+ const sev = (item.severity || item.level || '').toString().toUpperCase();
416
+ return sev === 'HIGH' || sev === 'CRITICAL';
417
+ });
418
+ return hasHigh ? 'FAIL' : 'CONDITIONAL';
419
+ }
420
+
421
+ // Pick the auditor roster for phase-e-auto from swarm.json (or defaults).
422
+ // Filters to entries that are reachable (CLI or API); missing CLI AND no
423
+ // apiFallback → skipped with a NOTE entry in the return value.
424
+ function resolvePhaseEAuditors(swarmConfig, env) {
425
+ const requestedIds = (Array.isArray(swarmConfig.auditors) && swarmConfig.auditors.length > 0)
426
+ ? swarmConfig.auditors
427
+ : [...DEFAULT_AUDITORS];
428
+
429
+ const picks = [];
430
+ const skipped = [];
431
+
432
+ for (const id of requestedIds) {
433
+ const entry = ROSTER.find(e => e.id === id);
434
+ if (!entry) {
435
+ skipped.push({ id, reason: 'not in roster' });
436
+ continue;
437
+ }
438
+ const reach = isReachable(id, env);
439
+ if (!reach.any) {
440
+ // CLI missing AND no apiFallback (or key not set) → skip with NOTE
441
+ skipped.push({ id, reason: 'CLI missing and no apiFallback configured' });
442
+ continue;
443
+ }
444
+ // Annotate API-only picks
445
+ const pick = (!reach.cli && reach.api) ? { ...entry, preferredSource: 'api' } : { ...entry };
446
+ picks.push(pick);
447
+ }
448
+
449
+ return { picks, skipped };
450
+ }
451
+
452
+ // Run the phase-e-auto branch. Does NOT use process.exit / uxGate / budget
453
+ // guard — it is a programmatic call from the orchestrator, not a CLI call.
454
+ async function runPhaseEAuto({ projectDir, phase, target, env, quiet }) {
455
+ const swarmConfig = loadSwarmConfig(projectDir);
456
+ const { picks, skipped } = resolvePhaseEAuditors(swarmConfig, env);
457
+
458
+ const notes = skipped.map(s => `NOTE: skipped auditor '${s.id}' — ${s.reason}`);
459
+
460
+ if (!quiet && notes.length > 0) {
461
+ process.stderr.write(notes.join('\n') + '\n');
462
+ }
463
+
464
+ if (picks.length === 0) {
465
+ const outputPath = resolveAuditOutputPath(projectDir, phase);
466
+ const content = `# Cross-Audit Phase E\n\nNo auditors available.\n\n${notes.map(n => `- ${n}`).join('\n')}\n`;
467
+ const dir = dirname(outputPath);
468
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
469
+ writeFileSync(outputPath, content, 'utf8');
470
+ return { verdict: 'CONDITIONAL', findings: [], outputPath, notes };
471
+ }
472
+
473
+ const auditTarget = target || 'HEAD~1..HEAD';
474
+ const resolvedTimeoutSec = null;
475
+
476
+ const requests = picks.map(pick => ({
477
+ pick,
478
+ payload: buildRequest('audit', auditTarget, pick.id, 'general', null),
479
+ }));
480
+
481
+ const tasks = requests.map(({ pick, payload }) => () =>
482
+ fireExternal(pick, payload, timeoutForPick(pick, resolvedTimeoutSec), env)
483
+ );
484
+ const rawResults = await fanOut(tasks, 3);
485
+
486
+ const auditorResults = rawResults.map((raw, i) => {
487
+ const pick = picks[i];
488
+ if (raw === null) {
489
+ return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
490
+ }
491
+ const { stdout, exitCode, status: rawStatus } = raw;
492
+ if (rawStatus === 'timeout') return { status: 'timeout', parsed: { items: [], prose: `[${pick.id}: timeout]` } };
493
+ if (rawStatus === 'failed') return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: failed]` } };
494
+ if (rawStatus === 'aborted') return { status: 'aborted', parsed: { items: [], prose: `[${pick.id}: aborted]` } };
495
+ if (rawStatus === 'fallback-used') {
496
+ const p = parseResponse('audit', stdout);
497
+ return { status: 'fallback-used', parsed: p };
498
+ }
499
+ if (exitCode !== 0) return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
500
+ const p = parseResponse('audit', stdout);
501
+ return { status: 'ok', parsed: p };
502
+ });
503
+
504
+ const parsed = auditorResults.map(r => r.parsed);
505
+ const merged = mergeResponses('audit', parsed);
506
+ const items = Array.isArray(merged) ? merged : [];
507
+ const verdict = classifyVerdict(items);
508
+
509
+ // Write synthesis to .planning/<phase>/CROSS-AUDIT-r<N>.md
510
+ const outputPath = resolveAuditOutputPath(projectDir, phase);
511
+ const dir = dirname(outputPath);
512
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
513
+
514
+ const auditorSummary = picks.map((p, i) => `- ${p.id}: ${auditorResults[i].status}`).join('\n');
515
+ const findingsSection = items.length > 0
516
+ ? items.map(item => `- [${(item.severity || item.level || 'INFO').toUpperCase()}] ${item.text || item.description || JSON.stringify(item)}`).join('\n')
517
+ : '_(none)_';
518
+ const content = [
519
+ `# Cross-Audit Phase E — ${phase}`,
520
+ '',
521
+ `**Verdict:** ${verdict}`,
522
+ `**Auditors:** ${picks.map(p => p.id).join(', ')}`,
523
+ '',
524
+ '## Auditor Status',
525
+ auditorSummary,
526
+ '',
527
+ '## Findings',
528
+ findingsSection,
529
+ '',
530
+ ...(notes.length > 0 ? ['## Notes', ...notes, ''] : []),
531
+ ].join('\n');
532
+
533
+ writeFileSync(outputPath, content, 'utf8');
534
+
535
+ return { verdict, findings: items, outputPath, notes };
536
+ }
537
+
384
538
  export async function runCrossOp({
385
539
  mode,
386
540
  target,
@@ -400,6 +554,14 @@ export async function runCrossOp({
400
554
  runStamp = runStamp ?? new Date().toISOString();
401
555
  env = env ?? process.env;
402
556
 
557
+ // v1.4.4 N10: phase-e-auto branch — programmatic orchestrator call.
558
+ // Reads .ijfw/swarm.json for auditor roster; graceful CLI-missing skip;
559
+ // writes .planning/<phase>/CROSS-AUDIT-r<N>.md; returns {verdict, findings, outputPath}.
560
+ if (mode === 'phase-e-auto') {
561
+ const phase = target || 'current';
562
+ return runPhaseEAuto({ projectDir, phase, target, env, quiet });
563
+ }
564
+
403
565
  const start = Date.now();
404
566
 
405
567
  // Shared abort controller for this run -- used by minResponsesFanOut to kill stragglers.
@@ -0,0 +1,273 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>IJFW · Planning Docs</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ * { box-sizing: border-box; }
10
+ body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fafafa; color: #222; }
11
+ header { padding: 12px 20px; border-bottom: 1px solid #ddd; background: #fff; }
12
+ header h1 { margin: 0; font-size: 18px; font-weight: 600; }
13
+ header .sub { color: #777; font-size: 12px; margin-top: 4px; }
14
+ main { padding: 20px; max-width: 900px; margin: 0 auto; }
15
+ .path-input { width: 100%; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; border: 1px solid #ccc; border-radius: 4px; }
16
+ .roots { color: #777; font-size: 12px; margin: 8px 0 16px; }
17
+ .roots code { background: #eee; padding: 1px 5px; border-radius: 3px; }
18
+ .doc { background: #fff; padding: 24px 28px; border: 1px solid #ddd; border-radius: 6px; min-height: 300px; }
19
+ .doc h1 { font-size: 22px; margin: 0 0 12px; }
20
+ .doc h2 { font-size: 18px; margin: 24px 0 10px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
21
+ .doc h3 { font-size: 15px; margin: 20px 0 8px; }
22
+ .doc pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
23
+ .doc code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
24
+ .doc pre code { background: transparent; padding: 0; }
25
+ .doc table { border-collapse: collapse; margin: 12px 0; }
26
+ .doc th, .doc td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 13px; }
27
+ .doc th { background: #f4f4f4; }
28
+ .doc blockquote { border-left: 3px solid #ccc; padding-left: 12px; color: #666; margin: 12px 0; }
29
+ .err { color: #c00; font-style: italic; }
30
+ .hint { color: #777; font-style: italic; }
31
+ @media (prefers-color-scheme: dark) {
32
+ body { background: #1a1a1a; color: #ddd; }
33
+ header { background: #222; border-color: #333; }
34
+ .doc, .path-input { background: #222; border-color: #333; color: #ddd; }
35
+ .doc pre, .doc code, .roots code, .doc th { background: #2a2a2a; }
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <header>
41
+ <h1>IJFW · Planning Docs</h1>
42
+ <div class="sub">Browse .planning/, .ijfw/memory/, and .ijfw/wave-*/ docs from this project.</div>
43
+ </header>
44
+ <main>
45
+ <input type="text" id="path" class="path-input" placeholder=".planning/1.4.4/HANDOFF-1.4.4.md" autofocus>
46
+ <div class="roots">Allowed roots: .planning/ · .ijfw/memory/ · .ijfw/wave-*/STATE.md · .ijfw/wave-*/SUMMARY.md</div>
47
+ <div id="doc" class="doc"></div>
48
+ </main>
49
+ <script>
50
+ // Tiny markdown shim — produces a safe DOMFragment via DOMParser, no innerHTML.
51
+ // Renders the subset used in IJFW planning docs: headings, paragraphs, code blocks,
52
+ // inline code/bold/italic, links, lists, blockquotes, tables. Not a full CommonMark.
53
+
54
+ function setText(el, text) {
55
+ while (el.firstChild) el.removeChild(el.firstChild);
56
+ el.appendChild(document.createTextNode(text));
57
+ }
58
+ function setStatus(el, text, cls) {
59
+ while (el.firstChild) el.removeChild(el.firstChild);
60
+ const div = document.createElement('div');
61
+ div.className = cls;
62
+ div.textContent = text;
63
+ el.appendChild(div);
64
+ }
65
+
66
+ function makeNode(tag, text) {
67
+ const el = document.createElement(tag);
68
+ if (text !== undefined) el.appendChild(document.createTextNode(text));
69
+ return el;
70
+ }
71
+
72
+ // Renders a single line of markdown-inline content into an array of DOM nodes.
73
+ // Handles `code`, **bold**, *italic*, [text](url). All text is added via
74
+ // createTextNode — no HTML parsing of user content.
75
+ function renderInlineToNodes(s) {
76
+ const nodes = [];
77
+ let i = 0;
78
+ while (i < s.length) {
79
+ // Code span: `...`
80
+ if (s[i] === '`') {
81
+ const end = s.indexOf('`', i + 1);
82
+ if (end !== -1) {
83
+ nodes.push(makeNode('code', s.slice(i + 1, end)));
84
+ i = end + 1;
85
+ continue;
86
+ }
87
+ }
88
+ // Bold: **...**
89
+ if (s[i] === '*' && s[i + 1] === '*') {
90
+ const end = s.indexOf('**', i + 2);
91
+ if (end !== -1) {
92
+ nodes.push(makeNode('strong', s.slice(i + 2, end)));
93
+ i = end + 2;
94
+ continue;
95
+ }
96
+ }
97
+ // Italic: *...*
98
+ if (s[i] === '*') {
99
+ const end = s.indexOf('*', i + 1);
100
+ if (end !== -1 && end - i > 1) {
101
+ nodes.push(makeNode('em', s.slice(i + 1, end)));
102
+ i = end + 1;
103
+ continue;
104
+ }
105
+ }
106
+ // Link: [text](url)
107
+ if (s[i] === '[') {
108
+ const close = s.indexOf(']', i + 1);
109
+ if (close !== -1 && s[close + 1] === '(') {
110
+ const urlEnd = s.indexOf(')', close + 2);
111
+ if (urlEnd !== -1) {
112
+ const a = document.createElement('a');
113
+ a.textContent = s.slice(i + 1, close);
114
+ // r13-L-01: tightened URL guard.
115
+ // ALLOW: http://, https://, and same-origin relative paths (no protocol).
116
+ // BLOCK: javascript:, data:, mailto:, vbscript:, file:, AND protocol-relative
117
+ // URLs starting with `//` (would open cross-origin without scheme).
118
+ const url = s.slice(close + 2, urlEnd);
119
+ const isAllowed = (
120
+ /^https?:\/\//.test(url) || // explicit http/https
121
+ (!url.startsWith('//') && !/^[^:/?#]+:/.test(url)) // relative, no protocol, no `//`
122
+ );
123
+ if (isAllowed) {
124
+ a.href = url;
125
+ a.target = '_blank';
126
+ a.rel = 'noopener';
127
+ }
128
+ nodes.push(a);
129
+ i = urlEnd + 1;
130
+ continue;
131
+ }
132
+ }
133
+ }
134
+ // Plain text — accumulate until next special marker
135
+ let j = i;
136
+ while (j < s.length && !'`*['.includes(s[j])) j++;
137
+ if (j === i) j = i + 1;
138
+ nodes.push(document.createTextNode(s.slice(i, j)));
139
+ i = j;
140
+ }
141
+ return nodes;
142
+ }
143
+
144
+ function renderMarkdownToFragment(md) {
145
+ const frag = document.createDocumentFragment();
146
+ const lines = md.split('\n');
147
+ let i = 0;
148
+ while (i < lines.length) {
149
+ const line = lines[i];
150
+ // Fenced code block
151
+ if (/^```/.test(line)) {
152
+ const buf = [];
153
+ i++;
154
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
155
+ i++;
156
+ const pre = document.createElement('pre');
157
+ const code = document.createElement('code');
158
+ code.textContent = buf.join('\n');
159
+ pre.appendChild(code);
160
+ frag.appendChild(pre);
161
+ continue;
162
+ }
163
+ // Headings
164
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
165
+ if (h) {
166
+ const tag = 'h' + h[1].length;
167
+ const el = document.createElement(tag);
168
+ for (const n of renderInlineToNodes(h[2])) el.appendChild(n);
169
+ frag.appendChild(el);
170
+ i++;
171
+ continue;
172
+ }
173
+ // Table: header row + separator row + rows
174
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*-/.test(lines[i + 1])) {
175
+ const tbl = document.createElement('table');
176
+ const head = line.split('|').slice(1, -1).map((c) => c.trim());
177
+ const tr = document.createElement('tr');
178
+ for (const c of head) {
179
+ const th = document.createElement('th');
180
+ for (const n of renderInlineToNodes(c)) th.appendChild(n);
181
+ tr.appendChild(th);
182
+ }
183
+ tbl.appendChild(tr);
184
+ i += 2;
185
+ while (i < lines.length && /^\s*\|/.test(lines[i])) {
186
+ const cells = lines[i].split('|').slice(1, -1).map((c) => c.trim());
187
+ const row = document.createElement('tr');
188
+ for (const c of cells) {
189
+ const td = document.createElement('td');
190
+ for (const n of renderInlineToNodes(c)) td.appendChild(n);
191
+ row.appendChild(td);
192
+ }
193
+ tbl.appendChild(row);
194
+ i++;
195
+ }
196
+ frag.appendChild(tbl);
197
+ continue;
198
+ }
199
+ // Bullet list
200
+ if (/^\s*[-*]\s+/.test(line)) {
201
+ const ul = document.createElement('ul');
202
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
203
+ const li = document.createElement('li');
204
+ for (const n of renderInlineToNodes(lines[i].replace(/^\s*[-*]\s+/, ''))) li.appendChild(n);
205
+ ul.appendChild(li);
206
+ i++;
207
+ }
208
+ frag.appendChild(ul);
209
+ continue;
210
+ }
211
+ // Blockquote
212
+ if (/^>\s?/.test(line)) {
213
+ const bq = document.createElement('blockquote');
214
+ let first = true;
215
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
216
+ if (!first) bq.appendChild(document.createElement('br'));
217
+ for (const n of renderInlineToNodes(lines[i].replace(/^>\s?/, ''))) bq.appendChild(n);
218
+ first = false;
219
+ i++;
220
+ }
221
+ frag.appendChild(bq);
222
+ continue;
223
+ }
224
+ // Blank
225
+ if (line.trim() === '') { i++; continue; }
226
+ // Paragraph
227
+ const p = document.createElement('p');
228
+ const buf = [line];
229
+ i++;
230
+ while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|>|\s*[-*]\s|\s*\|)/.test(lines[i])) {
231
+ buf.push(lines[i]);
232
+ i++;
233
+ }
234
+ for (const n of renderInlineToNodes(buf.join(' '))) p.appendChild(n);
235
+ frag.appendChild(p);
236
+ }
237
+ return frag;
238
+ }
239
+
240
+ async function load(path) {
241
+ const doc = document.getElementById('doc');
242
+ setStatus(doc, 'Loading…', 'hint');
243
+ try {
244
+ const r = await fetch('/api/planning?path=' + encodeURIComponent(path));
245
+ if (!r.ok) {
246
+ const err = await r.json().catch(() => ({ error: 'unknown' }));
247
+ setStatus(doc, r.status + ': ' + (err.error || 'failed'), 'err');
248
+ return;
249
+ }
250
+ const j = await r.json();
251
+ while (doc.firstChild) doc.removeChild(doc.firstChild);
252
+ doc.appendChild(renderMarkdownToFragment(j.body || ''));
253
+ } catch (e) {
254
+ setStatus(doc, e.message, 'err');
255
+ }
256
+ }
257
+
258
+ // Initial hint
259
+ setStatus(document.getElementById('doc'),
260
+ 'Enter a relative path above (e.g. .planning/1.4.4/HANDOFF-1.4.4.md) and press Enter.',
261
+ 'hint');
262
+
263
+ const input = document.getElementById('path');
264
+ input.addEventListener('keydown', (e) => {
265
+ if (e.key === 'Enter' && input.value.trim()) load(input.value.trim());
266
+ });
267
+ // Auto-load if ?path= in URL
268
+ const params = new URLSearchParams(location.search);
269
+ const init = params.get('path');
270
+ if (init) { input.value = init; load(init); }
271
+ </script>
272
+ </body>
273
+ </html>
@@ -1402,7 +1402,18 @@ async function loadExtensionActive() {
1402
1402
  permsEl.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
1403
1403
  var reads = (a.permissions.reads || []).join(', ') || 'none';
1404
1404
  var writes = (a.permissions.writes || []).join(', ') || 'none';
1405
- permsEl.innerHTML = '<b style="color:var(--fg)">reads:</b> ' + reads + ' &nbsp; <b style="color:var(--fg)">writes:</b> ' + writes;
1405
+ var bReads = document.createElement('b');
1406
+ bReads.setAttribute('style', 'color:var(--fg)');
1407
+ bReads.textContent = 'reads:';
1408
+ var textReads = document.createTextNode(' ' + reads + '\u00A0\u00A0');
1409
+ var bWrites = document.createElement('b');
1410
+ bWrites.setAttribute('style', 'color:var(--fg)');
1411
+ bWrites.textContent = 'writes:';
1412
+ var textWrites = document.createTextNode(' ' + writes);
1413
+ permsEl.appendChild(bReads);
1414
+ permsEl.appendChild(textReads);
1415
+ permsEl.appendChild(bWrites);
1416
+ permsEl.appendChild(textWrites);
1406
1417
  wrap.appendChild(permsEl);
1407
1418
  }
1408
1419
  el.appendChild(wrap);
@@ -434,6 +434,85 @@ export async function startServer(options = {}) {
434
434
  }));
435
435
  }],
436
436
 
437
+ // v1.4.4 N8 — Planning doc viewer. Same path-traversal guard as /api/memory/file,
438
+ // but allowed roots are .planning/, .ijfw/memory/, and .ijfw/wave-*/ all under REPO_ROOT.
439
+ ['/api/planning', (req, res, url) => {
440
+ const rawPath = url.searchParams.get('path') || '';
441
+ if (!rawPath) {
442
+ res.writeHead(400, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ error: 'path query param required' }));
444
+ return;
445
+ }
446
+ if (isAbsolute(rawPath) || rawPath.split(/[\\/]/).some((seg) => seg === '..')) {
447
+ res.writeHead(400, { 'Content-Type': 'application/json' });
448
+ res.end(JSON.stringify({ error: 'path traversal not allowed' }));
449
+ return;
450
+ }
451
+ const reqPath = resolve(REPO_ROOT, rawPath);
452
+ function canonOrNull(p) {
453
+ try { return realpathSync(p); } catch { return null; }
454
+ }
455
+ function isUnder(allowedRoot, canonChild) {
456
+ const canonRoot = canonOrNull(allowedRoot);
457
+ if (!canonRoot || !canonChild) return false;
458
+ const rel = relative(canonRoot, canonChild);
459
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
460
+ }
461
+ function isUnderWaveRoot(canonChild) {
462
+ // r13-M-05: restrict to STATE.md / SUMMARY.md filenames within
463
+ // .ijfw/wave-*/ subdirs. Previously allowed ANY file in a wave dir;
464
+ // wave directories may contain .tmp, lock files, or partial blackboard
465
+ // data that shouldn't be browser-readable.
466
+ const ijfwDir = canonOrNull(join(REPO_ROOT, '.ijfw'));
467
+ if (!ijfwDir || !canonChild) return false;
468
+ const rel = relative(ijfwDir, canonChild);
469
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false;
470
+ const first = rel.split(/[\\/]/)[0];
471
+ if (!first.startsWith('wave-') || first.length === 'wave-'.length) return false;
472
+ return rel.endsWith('STATE.md') || rel.endsWith('SUMMARY.md');
473
+ }
474
+ const canonPath = canonOrNull(reqPath);
475
+ if (!canonPath) {
476
+ res.writeHead(404, { 'Content-Type': 'application/json' });
477
+ res.end(JSON.stringify({ error: 'file not found' }));
478
+ return;
479
+ }
480
+ const allowed = (
481
+ isUnder(join(REPO_ROOT, '.planning'), canonPath) ||
482
+ isUnder(join(REPO_ROOT, '.ijfw', 'memory'), canonPath) ||
483
+ isUnderWaveRoot(canonPath)
484
+ );
485
+ if (!allowed) {
486
+ res.writeHead(403, { 'Content-Type': 'application/json' });
487
+ res.end(JSON.stringify({ error: 'outside allowed planning roots' }));
488
+ return;
489
+ }
490
+ try {
491
+ const body = readFileSync(canonPath, 'utf8');
492
+ res.writeHead(200, { 'Content-Type': 'application/json' });
493
+ res.end(JSON.stringify({ body: body.slice(0, 200000), path: rawPath }));
494
+ } catch (err) {
495
+ res.writeHead(500, { 'Content-Type': 'application/json' });
496
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/planning' }));
497
+ }
498
+ }],
499
+
500
+ // v1.4.4 N8 — Planning-docs viewer (HTML SPA).
501
+ ['/planning', async (req, res) => {
502
+ try {
503
+ const html = await readFile(join(__dirname, 'dashboard-client-planning.html'), 'utf8');
504
+ res.writeHead(200, {
505
+ 'Content-Type': 'text/html; charset=utf-8',
506
+ 'Cache-Control': 'no-store',
507
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'",
508
+ });
509
+ res.end(html);
510
+ } catch (err) {
511
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
512
+ res.end('Planning viewer not found: ' + err.message);
513
+ }
514
+ }],
515
+
437
516
  ['/api/memory/file', (req, res, url) => {
438
517
  const rawPath = url.searchParams.get('path') || '';
439
518
  if (!rawPath) {
@@ -613,11 +613,12 @@ async function cmdDeactivate() {
613
613
  let _v143Handlers = null;
614
614
  async function loadV143Handlers() {
615
615
  if (_v143Handlers !== null) return _v143Handlers;
616
- const [registry, signer, quota, active] = await Promise.all([
616
+ const [registry, signer, quota, active, wave] = await Promise.all([
617
617
  import('./registry-cli.js'),
618
618
  import('./signer-cli.js'),
619
619
  import('./quota-cli.js'),
620
620
  import('./active-cli.js'),
621
+ import('./wave-cli.js'), // v1.4.4 N9 — wave-status / wave-list
621
622
  ]);
622
623
  _v143Handlers = Object.assign(
623
624
  Object.create(null),
@@ -625,6 +626,7 @@ async function loadV143Handlers() {
625
626
  signer.handlers || {},
626
627
  quota.handlers || {},
627
628
  active.handlers || {},
629
+ wave.handlers || {},
628
630
  );
629
631
  return _v143Handlers;
630
632
  }