@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 +1 -1
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +12 -1
- package/src/dashboard-server.js +79 -0
- package/src/dispatch/extension.js +3 -1
- package/src/dispatch/wave-cli.js +128 -0
- package/src/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/swarm-config.js +32 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ijfw/memory-server",
|
|
3
|
-
"version": "1.4.
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
-
|
|
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);
|
package/src/dashboard-server.js
CHANGED
|
@@ -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
|
}
|