@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.
@@ -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>