@ijfw/memory-server 1.4.1 → 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/active-extension-writer.js +284 -4
- package/src/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/dispatch/wave-cli.js +128 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -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/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-aggregator.js — IJFW v1.4.3 W9-C (B19)
|
|
3
|
+
*
|
|
4
|
+
* Server-side aggregation of ~/.ijfw/state/permission-events.jsonl for the
|
|
5
|
+
* dashboard's per-tool audit charts. Reads only the last TAIL_CHUNK bytes
|
|
6
|
+
* (same 2MB cap as the events endpoint) so this is bounded-memory even
|
|
7
|
+
* across rotations.
|
|
8
|
+
*
|
|
9
|
+
* Cache: 60s OR until the events file mtime changes, whichever first.
|
|
10
|
+
* Malformed JSONL lines are dropped silently — never crash the dashboard
|
|
11
|
+
* on a partial write.
|
|
12
|
+
*
|
|
13
|
+
* Returned shape:
|
|
14
|
+
* {
|
|
15
|
+
* hourly: { [hourISO]: count },
|
|
16
|
+
* by_extension: { [ext]: { allowed: number, denied: number } },
|
|
17
|
+
* by_tool_denied:{ [tool]: count }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Helper `computeWarnBashBypass(manifest)` implements ARCH-M-01: an extension
|
|
21
|
+
* with `tool:bash` or `tool:exec` in writes AND a strict files/bytes quota
|
|
22
|
+
* declared in the manifest gets a warning chip in the dashboard. The chip is
|
|
23
|
+
* an information channel — quota enforcement still applies, but bash content
|
|
24
|
+
* bypasses per-file accounting at the API surface.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
// Match dashboard-server's TAIL_CHUNK. Kept here so this module is
|
|
31
|
+
// self-contained for the test harness.
|
|
32
|
+
export const TAIL_CHUNK = 2 * 1024 * 1024; // 2MB
|
|
33
|
+
const CACHE_TTL_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
// Module-level cache. Key = canonical path; value = { mtimeMs, builtAt, result }.
|
|
36
|
+
const _cache = new Map();
|
|
37
|
+
|
|
38
|
+
export function _resetAggregatorCacheForTest() {
|
|
39
|
+
_cache.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _readTailLines(eventsPath) {
|
|
43
|
+
if (!existsSync(eventsPath)) return { lines: [], mtimeMs: 0 };
|
|
44
|
+
let st;
|
|
45
|
+
try { st = statSync(eventsPath); } catch { return { lines: [], mtimeMs: 0 }; }
|
|
46
|
+
if (!st.size) return { lines: [], mtimeMs: st.mtimeMs };
|
|
47
|
+
let buf;
|
|
48
|
+
try { buf = readFileSync(eventsPath); } catch { return { lines: [], mtimeMs: st.mtimeMs }; }
|
|
49
|
+
const slice = buf.subarray(Math.max(0, buf.length - TAIL_CHUNK));
|
|
50
|
+
let lines = slice.toString('utf8').split('\n').filter(Boolean);
|
|
51
|
+
// If we sliced mid-line, drop the partial leading element.
|
|
52
|
+
if (buf.length > TAIL_CHUNK) lines = lines.slice(1);
|
|
53
|
+
return { lines, mtimeMs: st.mtimeMs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _hourBucket(tsMs) {
|
|
57
|
+
const d = new Date(tsMs);
|
|
58
|
+
d.setUTCMinutes(0, 0, 0);
|
|
59
|
+
return d.toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Aggregate permission events within `windowMs` of `now`.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} eventsPath absolute path to permission-events.jsonl
|
|
66
|
+
* @param {{ windowMs?: number, now?: number }} [opts]
|
|
67
|
+
*/
|
|
68
|
+
export async function aggregateEvents(eventsPath, opts = {}) {
|
|
69
|
+
const windowMs = (opts && typeof opts.windowMs === 'number') ? opts.windowMs : 24 * 3600 * 1000;
|
|
70
|
+
const now = (opts && typeof opts.now === 'number') ? opts.now : Date.now();
|
|
71
|
+
|
|
72
|
+
const { lines, mtimeMs } = _readTailLines(eventsPath);
|
|
73
|
+
|
|
74
|
+
const cached = _cache.get(eventsPath);
|
|
75
|
+
if (cached
|
|
76
|
+
&& cached.mtimeMs === mtimeMs
|
|
77
|
+
&& (now - cached.builtAt) < CACHE_TTL_MS
|
|
78
|
+
&& cached.windowMs === windowMs) {
|
|
79
|
+
return cached.result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cutoff = now - windowMs;
|
|
83
|
+
const hourly = Object.create(null);
|
|
84
|
+
const byExt = Object.create(null);
|
|
85
|
+
const byToolDenied = Object.create(null);
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
let obj;
|
|
89
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
90
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
91
|
+
const t = typeof obj.ts === 'string' ? Date.parse(obj.ts) : (typeof obj.ts === 'number' ? obj.ts : NaN);
|
|
92
|
+
if (!Number.isFinite(t)) continue;
|
|
93
|
+
if (t < cutoff) continue;
|
|
94
|
+
|
|
95
|
+
const ext = (typeof obj.extension === 'string' && obj.extension) ? obj.extension : '<unknown>';
|
|
96
|
+
const tool = (typeof obj.tool === 'string' && obj.tool) ? obj.tool : (typeof obj.action === 'string' ? obj.action : '<unknown>');
|
|
97
|
+
const allowed = obj.allowed !== false; // anything other than explicit false is allowed.
|
|
98
|
+
|
|
99
|
+
// hourly
|
|
100
|
+
const hk = _hourBucket(t);
|
|
101
|
+
hourly[hk] = (hourly[hk] || 0) + 1;
|
|
102
|
+
|
|
103
|
+
// by_extension
|
|
104
|
+
if (!byExt[ext]) byExt[ext] = { allowed: 0, denied: 0 };
|
|
105
|
+
if (allowed) byExt[ext].allowed += 1;
|
|
106
|
+
else byExt[ext].denied += 1;
|
|
107
|
+
|
|
108
|
+
// by_tool_denied
|
|
109
|
+
if (!allowed) {
|
|
110
|
+
byToolDenied[tool] = (byToolDenied[tool] || 0) + 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = { hourly, by_extension: byExt, by_tool_denied: byToolDenied };
|
|
115
|
+
_cache.set(eventsPath, { mtimeMs, builtAt: now, windowMs, result });
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ARCH-M-01: compute whether an extension's manifest combines a bash/exec
|
|
121
|
+
* write permission with a strict files/bytes quota.
|
|
122
|
+
*
|
|
123
|
+
* Returns true iff:
|
|
124
|
+
* manifest.permissions.writes includes "tool:bash" or "tool:exec"
|
|
125
|
+
* AND (quotas.max_files_written OR quotas.max_bytes_written is set)
|
|
126
|
+
*/
|
|
127
|
+
export function computeWarnBashBypass(manifest) {
|
|
128
|
+
if (!manifest || typeof manifest !== 'object') return false;
|
|
129
|
+
const perms = manifest.permissions || {};
|
|
130
|
+
const writes = Array.isArray(perms.writes) ? perms.writes : [];
|
|
131
|
+
const hasBashOrExec = writes.some((w) => w === 'tool:bash' || w === 'tool:exec');
|
|
132
|
+
if (!hasBashOrExec) return false;
|
|
133
|
+
const q = manifest.quotas || {};
|
|
134
|
+
const hasStrictQuota =
|
|
135
|
+
(typeof q.max_files_written === 'number' && Number.isFinite(q.max_files_written)) ||
|
|
136
|
+
(typeof q.max_bytes_written === 'number' && Number.isFinite(q.max_bytes_written));
|
|
137
|
+
return Boolean(hasStrictQuota);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve `<scope>/<name>/manifest.json` and read it. Returns the parsed
|
|
142
|
+
* manifest object or `null` if the file is missing/unreadable/malformed.
|
|
143
|
+
*
|
|
144
|
+
* Scope→path map mirrors `active-extension-writer.js`:
|
|
145
|
+
* project: <projectRoot>/.ijfw/extensions/<name>/manifest.json
|
|
146
|
+
* org: <home>/.ijfw/extensions-org/<name>/manifest.json
|
|
147
|
+
* user: <home>/.ijfw/extensions-user/<name>/manifest.json
|
|
148
|
+
*/
|
|
149
|
+
export function readActiveManifest({ scope, name, home, projectRoot }) {
|
|
150
|
+
if (!scope || !name) return null;
|
|
151
|
+
let path = null;
|
|
152
|
+
if (scope === 'project' && projectRoot) {
|
|
153
|
+
path = join(projectRoot, '.ijfw', 'extensions', name, 'manifest.json');
|
|
154
|
+
} else if (scope === 'org' && home) {
|
|
155
|
+
path = join(home, '.ijfw', 'extensions-org', name, 'manifest.json');
|
|
156
|
+
} else if (scope === 'user' && home) {
|
|
157
|
+
path = join(home, '.ijfw', 'extensions-user', name, 'manifest.json');
|
|
158
|
+
}
|
|
159
|
+
if (!path) return null;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-charts.js — IJFW v1.4.3 W9-C (B19)
|
|
3
|
+
*
|
|
4
|
+
* Pure-JS canvas/DOM chart helpers for the dashboard. No external libs.
|
|
5
|
+
* Theme-aware: reads CSS custom properties `--ijfw-chart-fg`,
|
|
6
|
+
* `--ijfw-chart-bg`, `--ijfw-chart-warning` from the element's computed
|
|
7
|
+
* style. Falls back to sane defaults if the host page hasn't set them.
|
|
8
|
+
*
|
|
9
|
+
* All helpers are defensive against malformed input so a bad payload from
|
|
10
|
+
* the API can never throw inside the dashboard render loop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
fg: '#9ad2ff',
|
|
15
|
+
bg: 'rgba(154,210,255,0.18)',
|
|
16
|
+
warning: '#ff9b3a',
|
|
17
|
+
text: '#cfd6dd',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function _readTheme(el) {
|
|
21
|
+
// Both `canvas` and plain `div` go through `getComputedStyle`. In the test
|
|
22
|
+
// environment the element is a hand-rolled mock that doesn't reach the DOM
|
|
23
|
+
// — guard so we don't blow up if `getComputedStyle` is unavailable.
|
|
24
|
+
let cs = null;
|
|
25
|
+
try {
|
|
26
|
+
if (el && typeof globalThis.getComputedStyle === 'function') {
|
|
27
|
+
cs = globalThis.getComputedStyle(el);
|
|
28
|
+
}
|
|
29
|
+
} catch { cs = null; }
|
|
30
|
+
const read = (prop, fallback) => {
|
|
31
|
+
if (!cs || typeof cs.getPropertyValue !== 'function') return fallback;
|
|
32
|
+
const v = cs.getPropertyValue(prop);
|
|
33
|
+
return (v && v.trim()) ? v.trim() : fallback;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
fg: read('--ijfw-chart-fg', DEFAULTS.fg),
|
|
37
|
+
bg: read('--ijfw-chart-bg', DEFAULTS.bg),
|
|
38
|
+
warning: read('--ijfw-chart-warning', DEFAULTS.warning),
|
|
39
|
+
text: read('--ijfw-chart-text', DEFAULTS.text),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _safeNum(v, def = 0) {
|
|
44
|
+
return (typeof v === 'number' && Number.isFinite(v)) ? v : def;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* lineChart(canvas, points, opts)
|
|
49
|
+
* points: [{ x: number, y: number }, ...] OR [number, ...]
|
|
50
|
+
* opts: { xMin?, xMax?, yMax?, color?, fill? }
|
|
51
|
+
*
|
|
52
|
+
* Empty data renders nothing (clears the canvas) without throwing.
|
|
53
|
+
*/
|
|
54
|
+
export function lineChart(canvas, points, opts = {}) {
|
|
55
|
+
if (!canvas || typeof canvas.getContext !== 'function') return;
|
|
56
|
+
const ctx = canvas.getContext('2d');
|
|
57
|
+
if (!ctx) return;
|
|
58
|
+
const W = _safeNum(canvas.width, 200);
|
|
59
|
+
const H = _safeNum(canvas.height, 100);
|
|
60
|
+
const theme = _readTheme(canvas);
|
|
61
|
+
const color = opts.color || theme.fg;
|
|
62
|
+
const fill = opts.fill === false ? null : theme.bg;
|
|
63
|
+
|
|
64
|
+
try { ctx.clearRect(0, 0, W, H); } catch {}
|
|
65
|
+
|
|
66
|
+
const pts = Array.isArray(points) ? points : [];
|
|
67
|
+
const norm = pts.map((p, i) => {
|
|
68
|
+
if (typeof p === 'number') return { x: i, y: _safeNum(p, 0) };
|
|
69
|
+
return { x: _safeNum(p && p.x, i), y: _safeNum(p && p.y, 0) };
|
|
70
|
+
}).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
71
|
+
|
|
72
|
+
if (norm.length === 0) return;
|
|
73
|
+
|
|
74
|
+
let xMin = _safeNum(opts.xMin, norm[0].x);
|
|
75
|
+
let xMax = _safeNum(opts.xMax, norm[norm.length - 1].x);
|
|
76
|
+
if (xMax === xMin) xMax = xMin + 1;
|
|
77
|
+
const yMax = _safeNum(opts.yMax, Math.max(1, ...norm.map((p) => p.y)));
|
|
78
|
+
const yScale = yMax > 0 ? yMax : 1;
|
|
79
|
+
|
|
80
|
+
const pad = 4;
|
|
81
|
+
const innerW = Math.max(1, W - pad * 2);
|
|
82
|
+
const innerH = Math.max(1, H - pad * 2);
|
|
83
|
+
|
|
84
|
+
const px = (x) => pad + ((x - xMin) / (xMax - xMin)) * innerW;
|
|
85
|
+
const py = (y) => pad + (1 - (Math.max(0, y) / yScale)) * innerH;
|
|
86
|
+
|
|
87
|
+
// Filled area
|
|
88
|
+
if (fill) {
|
|
89
|
+
try {
|
|
90
|
+
ctx.beginPath();
|
|
91
|
+
ctx.moveTo(px(norm[0].x), H - pad);
|
|
92
|
+
for (const p of norm) ctx.lineTo(px(p.x), py(p.y));
|
|
93
|
+
ctx.lineTo(px(norm[norm.length - 1].x), H - pad);
|
|
94
|
+
ctx.closePath();
|
|
95
|
+
ctx.fillStyle = fill;
|
|
96
|
+
ctx.fill();
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Line stroke
|
|
101
|
+
try {
|
|
102
|
+
ctx.beginPath();
|
|
103
|
+
ctx.moveTo(px(norm[0].x), py(norm[0].y));
|
|
104
|
+
for (let i = 1; i < norm.length; i++) ctx.lineTo(px(norm[i].x), py(norm[i].y));
|
|
105
|
+
ctx.strokeStyle = color;
|
|
106
|
+
ctx.lineWidth = 1.5;
|
|
107
|
+
ctx.stroke();
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* barChart(canvas, bars, opts)
|
|
113
|
+
* bars: [{ label: string, value: number, color?: string }, ...]
|
|
114
|
+
* opts: { horizontal?: boolean, color?: string, maxValue?: number }
|
|
115
|
+
*
|
|
116
|
+
* Zero-value bars render as empty rails. Negative values are clamped to 0.
|
|
117
|
+
*/
|
|
118
|
+
export function barChart(canvas, bars, opts = {}) {
|
|
119
|
+
if (!canvas || typeof canvas.getContext !== 'function') return;
|
|
120
|
+
const ctx = canvas.getContext('2d');
|
|
121
|
+
if (!ctx) return;
|
|
122
|
+
const W = _safeNum(canvas.width, 200);
|
|
123
|
+
const H = _safeNum(canvas.height, 100);
|
|
124
|
+
const theme = _readTheme(canvas);
|
|
125
|
+
const defaultColor = opts.color || theme.fg;
|
|
126
|
+
const horizontal = Boolean(opts.horizontal);
|
|
127
|
+
|
|
128
|
+
try { ctx.clearRect(0, 0, W, H); } catch {}
|
|
129
|
+
|
|
130
|
+
const rows = Array.isArray(bars) ? bars.filter((b) => b && typeof b === 'object') : [];
|
|
131
|
+
if (rows.length === 0) return;
|
|
132
|
+
|
|
133
|
+
const values = rows.map((b) => Math.max(0, _safeNum(b.value, 0)));
|
|
134
|
+
const maxVal = _safeNum(opts.maxValue, Math.max(1, ...values));
|
|
135
|
+
const scale = maxVal > 0 ? maxVal : 1;
|
|
136
|
+
|
|
137
|
+
const pad = 4;
|
|
138
|
+
const labelGutter = horizontal ? 80 : 14;
|
|
139
|
+
const innerW = Math.max(1, W - pad * 2 - (horizontal ? labelGutter : 0));
|
|
140
|
+
const innerH = Math.max(1, H - pad * 2 - (horizontal ? 0 : labelGutter));
|
|
141
|
+
const slot = (horizontal ? innerH : innerW) / rows.length;
|
|
142
|
+
const barW = Math.max(1, slot * 0.7);
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < rows.length; i++) {
|
|
145
|
+
const b = rows[i];
|
|
146
|
+
const v = values[i];
|
|
147
|
+
const color = b.color || defaultColor;
|
|
148
|
+
if (horizontal) {
|
|
149
|
+
const y = pad + i * slot + (slot - barW) / 2;
|
|
150
|
+
const len = (v / scale) * innerW;
|
|
151
|
+
try {
|
|
152
|
+
ctx.fillStyle = theme.bg;
|
|
153
|
+
ctx.fillRect(pad + labelGutter, y, innerW, barW);
|
|
154
|
+
ctx.fillStyle = color;
|
|
155
|
+
ctx.fillRect(pad + labelGutter, y, len, barW);
|
|
156
|
+
ctx.fillStyle = theme.text;
|
|
157
|
+
ctx.fillText(String(b.label || ''), pad, y + barW * 0.75);
|
|
158
|
+
} catch {}
|
|
159
|
+
} else {
|
|
160
|
+
const x = pad + i * slot + (slot - barW) / 2;
|
|
161
|
+
const len = (v / scale) * innerH;
|
|
162
|
+
try {
|
|
163
|
+
ctx.fillStyle = theme.bg;
|
|
164
|
+
ctx.fillRect(x, pad, barW, innerH);
|
|
165
|
+
ctx.fillStyle = color;
|
|
166
|
+
ctx.fillRect(x, pad + (innerH - len), barW, len);
|
|
167
|
+
ctx.fillStyle = theme.text;
|
|
168
|
+
ctx.fillText(String(b.label || '').slice(0, 8), x, H - pad);
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* progressBar(div, data)
|
|
176
|
+
* data: { current: number, limit: number | null, label?: string, warning?: boolean }
|
|
177
|
+
*
|
|
178
|
+
* Mutates `div` in place. Renders an "unlimited" placeholder when limit is
|
|
179
|
+
* null. Applies a warning class when `warning` is truthy.
|
|
180
|
+
*/
|
|
181
|
+
export function progressBar(div, data = {}) {
|
|
182
|
+
if (!div) return;
|
|
183
|
+
const theme = _readTheme(div);
|
|
184
|
+
const cur = Math.max(0, _safeNum(data.current, 0));
|
|
185
|
+
const lim = (data.limit === null || data.limit === undefined) ? null : _safeNum(data.limit, null);
|
|
186
|
+
const label = typeof data.label === 'string' ? data.label : '';
|
|
187
|
+
const warn = Boolean(data.warning);
|
|
188
|
+
|
|
189
|
+
// Clear children.
|
|
190
|
+
try {
|
|
191
|
+
while (div.firstChild) div.removeChild(div.firstChild);
|
|
192
|
+
} catch {
|
|
193
|
+
// Some test mocks omit firstChild/removeChild. Skip cleanup.
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const setClass = (extra) => {
|
|
197
|
+
try {
|
|
198
|
+
div.className = ['ijfw-progress', extra, warn ? 'ijfw-progress--warn' : '']
|
|
199
|
+
.filter(Boolean).join(' ');
|
|
200
|
+
} catch {}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Helper to create child elements safely.
|
|
204
|
+
function appendEl(tag, opts) {
|
|
205
|
+
try {
|
|
206
|
+
if (typeof div.ownerDocument === 'object' && div.ownerDocument && typeof div.ownerDocument.createElement === 'function') {
|
|
207
|
+
const el = div.ownerDocument.createElement(tag);
|
|
208
|
+
if (opts && opts.text) el.textContent = opts.text;
|
|
209
|
+
if (opts && opts.style) el.setAttribute('style', opts.style);
|
|
210
|
+
if (opts && opts.cls) el.className = opts.cls;
|
|
211
|
+
if (typeof div.appendChild === 'function') div.appendChild(el);
|
|
212
|
+
return el;
|
|
213
|
+
}
|
|
214
|
+
} catch {}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (lim === null) {
|
|
219
|
+
setClass('ijfw-progress--unlimited');
|
|
220
|
+
appendEl('span', { cls: 'ijfw-progress-label', text: label });
|
|
221
|
+
appendEl('span', { cls: 'ijfw-progress-val', text: cur + ' / unlimited' });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
setClass('');
|
|
226
|
+
const denom = lim > 0 ? lim : 1;
|
|
227
|
+
const pct = Math.max(0, Math.min(100, (cur / denom) * 100));
|
|
228
|
+
appendEl('span', { cls: 'ijfw-progress-label', text: label });
|
|
229
|
+
const rail = appendEl('span', { cls: 'ijfw-progress-rail', style: 'background:' + theme.bg + ';display:inline-block;height:6px;width:120px;border-radius:3px;overflow:hidden;vertical-align:middle;margin:0 6px' });
|
|
230
|
+
if (rail) {
|
|
231
|
+
try {
|
|
232
|
+
const fill = (rail.ownerDocument || div.ownerDocument).createElement('span');
|
|
233
|
+
fill.className = 'ijfw-progress-fill';
|
|
234
|
+
fill.setAttribute('style', 'display:block;height:100%;width:' + pct.toFixed(1) + '%;background:' + (warn ? theme.warning : theme.fg));
|
|
235
|
+
rail.appendChild(fill);
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
appendEl('span', { cls: 'ijfw-progress-val', text: cur + ' / ' + lim });
|
|
239
|
+
}
|
|
@@ -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>
|