eve-hub 0.16.30 → 0.16.32

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/kit-modules.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "memory/workspace",
12
12
  "schedules/prune",
13
13
  "schedules/wake",
14
+ "skills/deep-dive",
14
15
  "skills/fanout",
15
16
  "skills/forms",
16
17
  "skills/local-onboarding",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.16.30",
3
+ "version": "0.16.32",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/build-onboarding.mjs from deep-dive/. Do not edit.
2
+ export default {"description":"How to build a deep-dive explainer page for a code path (a call stack, a dataflow or a protocol) from a recorded run of the real code. Load when someone asks for a tech explainer, a walkthrough of how a flow works, a stepped repro of a bug, or a page that shows what a PR changes in a flow.","content":"# Deep-dive explainer pages\n\nA deep-dive page explains one flow in real code by stepping through a recorded run of it. Every step is a breakpoint pause: its call stack, its variables, the rows its stores hold and the requests in flight. A note per step says what happens there in plain words. The page is one HTML file with no JavaScript. It works in the Mac doc pane and under 3 MiB.\n\nThe kit is the files next to this one:\n\n- `record.mjs` runs the topic's scenario under the V8 inspector and writes the recording.\n- `render.mjs` turns the topic, the recording and the notes into the page.\n- `check.mjs` fails when a note or the topic's authored text says something the recording does not show.\n- `carry-notes.mjs` carries notes from one recording to another of the same topic, for a two-recording page.\n- `shot.mjs` screenshots a page at a step, a level and a width, so you can look at it.\n- `pin.mjs` makes a recording repeat its ids and times, for a topic with `pin` (step 3).\n- `lib.mjs` holds what the others share.\n\nInstall the render dependencies once, in this skill's directory. Its `package.json` declares them at exact versions:\n\n```\nnpm i --prefix \"$SKILL_DIR\"\nnpx --prefix \"$SKILL_DIR\" playwright install --with-deps chromium\n```\n\n`$SKILL_DIR` is the directory this file is in (`$HOME/.agents/skills/deep-dive`). `record.mjs` and `check.mjs` need only Node 24. `shiki` and `geist` are for `render.mjs`, and `playwright` is for `shot.mjs` only. `render.mjs` also downloads Vercel's Geist foundation stylesheet, `https://vercel.com/geist/vercel-brand.css`, and inlines it, so it needs network access. The page's own styles use only that stylesheet's tokens and type sizes, so the page follows Geist in light and dark.\n\n## Who the page is for\n\nA page is done when a cold critic, a fresh session that has never seen the code, passes all five story checks on it:\n\n| # | Reader | Check |\n|---|---|---|\n| 1 | New engineer | After 5 minutes, page closed, draws the actors and crossings and names the stores written. 4 of 5 answers right. |\n| 2 | Debugger | Reaches the write site and the deciding rule from page open in under 60 seconds. |\n| 3 | PR reviewer | Given base and head recordings, the page marks every step whose calls, rows or values differ. |\n| 4 | The human | Each invariant is stated in one sentence, with `file:line`, linked to the step that enforces it, before any data. |\n| 5 | Authoring bot | A new topic renders with no renderer edits, and every note's `file:line` and value matches the recording. |\n\nA second cold reader lists every question the page raises. Fewer than 40 is the bar.\n\n## The method\n\nDo these in order. Each one is cheap to redo, and each later step depends on the earlier ones being right.\n\n### 1. Read the code and pick actors and rules first\n\nBefore writing anything, read the flow end to end and write down two lists.\n\n- **Actors.** The processes, modules, stores and fakes that take part. Each is one lifeline on the page. Name them as the code does.\n- **Invariants.** The rules this flow keeps, each one sentence with the `file:line` that enforces it. A bug is stated as the rule the code breaks, with the line that breaks it. Never label a rule \"Broken:\".\n\nThese two lists are the page's skeleton. If you can't name the rules, you don't understand the flow yet, and no amount of recording will fix that.\n\n### 2. Write the scenario\n\nThe scenario is a module that drives one run of the flow through the real code:\n\n```js\nexport async function scenario(mark, { root }) {\n const { thing } = await import(`${root}/src/thing.ts`);\n mark('ready', { file: dbPath }); // passed to the stores reader\n await thing(input);\n mark('done', { result });\n}\n```\n\n- Import the code under test from `root`, so the same scenario records against another checkout (`--root`).\n- Fake only at the network edge: a fake token service, an in-memory Cosmos, a recording broker. The real code runs everywhere else. Put fakes in the topic folder, and say on the page (in `story` and the fake actor's `about`) which parts are fakes.\n- Make it deterministic. Stub `crypto.randomUUID` and clocks so two recordings of the same code match.\n- When a package can't be installed in the sandbox, map its import to a stub with a loader hook (`module.registerHooks`) and pass the hook with `import` in the topic or `--import`. A stub that is asked for a case it doesn't model throws \"not modelled\"; it never silently returns.\n\n### 3. Write `topic.json`\n\nPaths are relative to the topic file's directory.\n\n```json\n{\n \"title\": \"How a post reaches a bot\",\n \"root\": \"../../hosted-hub\",\n \"scenario\": \"scenario.ts\",\n \"stores\": \"stores.mjs\",\n \"import\": \"stubs/register.mjs\",\n \"points\": [{ \"id\": \"p1\", \"file\": \"src/api/workspace.ts\", \"line\": 348, \"watch\": [\"req.url\"] }],\n \"actors\": [{ \"name\": \"hub\", \"kind\": \"process\", \"role\": \"server\", \"frames\": \"^src/\", \"urls\": \"/api/\", \"about\": \"...\" }],\n \"invariants\": [{ \"rule\": \"...\", \"file\": \"src/store/messages.ts\", \"line\": 84, \"point\": \"p5\", \"hit\": 0, \"values\": [] }],\n \"chapters\": [{ \"title\": \"...\", \"from\": \"p1\", \"values\": [] }],\n \"story\": \"...\", \"storyValues\": [],\n \"outcome\": \"...\", \"outcomeValues\": [],\n \"glossary\": [{ \"term\": \"clientId\", \"def\": \"...\" }]\n}\n```\n\n- **`points`.** One breakpoint per moment worth a step. A point in a file outside `root` starts with `./` (the scenario's own lines). `watch` expressions are evaluated at the pause and shown in the inspector. A list of SQL statements (`{sql}` items) in a watch is drawn as a batch sent to the first store.\n- **`actors`.** `frames` is a regex over the frame's path relative to `root`, and `urls` matches the requests it serves. `kind` is `process`, `module` or `store`. `role` is the short label under its name, and `about` is one sentence for the cast table. An actor with `key` (a JS expression, such as `sessionId`) gets one lifeline per value, for two sessions running at once.\n- **`stores`.** A module exporting `rows(ready)`. It gets the `ready` mark's data and returns `{ table: rows[] }`, read-only. The page diffs rows between steps and shows each write. A topic with no database leaves it out.\n- **`invariants`.** `point` and `hit` (0-based) choose the step that enforces the rule. The cited line must sit inside that step's source window (the paused line plus or minus 4), or `check.mjs` fails.\n- **`chapters`.** Each starts at a point (`from`, with an optional `hit`). Titles are one sentence about what happens, not a function name.\n- **`story`** is the start view's first paragraph: what the flow is for, who starts it, what the reader will watch happen, and which parts are fakes. **`outcome`** closes the chapter list with what the run ended with.\n- **`values`** on any authored text (`storyValues`, `outcomeValues`, a chapter's or an invariant's `values`) list the run values it states, as `{ name, value }`. `check.mjs` fails when the recording doesn't have them, and the head page of a two-recording pair tags text written for the base run.\n- **`pin`** and **`generated`**, for a topic that gets a base and a head recording. With `\"pin\": true` the recorder seeds random bytes, UUIDs and `Math.random` and starts the clock at a fixed instant that moves 1 ms per read, so two runs of the same code write the same ids and times. A diff compares values exactly. Anything that still differs per run, such as a temp directory the code makes with `mkdtemp`, goes in `generated` as a regex, and its matches compare equal: `\"generated\": [\"/tmp/app-[A-Za-z0-9]{6}\"]`. Under `pin`, a duration counts clock reads, so a note never presents it as time.\n- **`glossary`.** Every backticked name in a chapter title or an invariant is a glossary term or a function in the recording. Terms open in place where they appear.\n\n### 4. Record\n\n```\nnode \"$SKILL_DIR/record.mjs\" topic.json recording.json\n```\n\nIt prints the points that were never hit and the breakpoints V8 moved to another line (a breakpoint inside a multi-line statement pauses at the statement's end). Move points until every one is hit where you meant. The first step's paused function is the page's entry: frames outside it are left out of the diagram, so make step 1 a pause in the outermost function you want drawn.\n\nThe recorder also counts each function's entries and returns around its points, and every pause stores them as `call`. The inspector shows \"Since step N, the last pause in this call\" only when no new call of the function started between the two pauses and only one call of it was running. Otherwise the step shows no Before column. A second call from the same line, or two async calls running at once, never share one.\n\nThe recording stores `provenance` (the commit, the files changed against it, the scenario, the date and the pause count) and `sources`, the text of every file a step pauses in or a rule cites. `check.mjs` and `render.mjs` read source from there, so a recording checks and renders without the checkout it came from.\n\n### 5. Write the notes\n\n`notes.json` has one note per step: `{ point, hit, text, refs: [{ file, line }], values: [{ name, value }] }`.\n\n- Say what happens at this step and why, in plain words, with the values that decide it. \"`prepareMessage` tests `existing` at `src/store/messages.ts:84`. It is undefined, so the post is written as new.\"\n- Every `file:line` in the text is in `refs`, and every ref is a frame of this step or a breakpoint that was hit.\n- Every value the text states is in `values`, named by its scope path (`posted.to`), watch expression (`queue.length`) or row cell (`messages.kind`). `check.mjs` compares each one with the recording.\n\n### 6. Check, render and look\n\n```\nnode \"$SKILL_DIR/check.mjs\" topic.json recording.json notes.json\nnode \"$SKILL_DIR/render.mjs\" topic.json recording.json notes.json page.html\nnode \"$SKILL_DIR/shot.mjs\" page.html start.png 1040 light\nnode \"$SKILL_DIR/shot.mjs\" page.html seq.png 1040 light '#step-9' seq\n```\n\nFix every check failure at its source: a wrong note, a point on the wrong line, a rule cited outside its step. Never loosen a note until it passes. Then open the screenshots yourself: the start view, the sequence level at a busy step, the steps level, and dark mode. The page fits 1040 px with no horizontal scroll, and nothing overlaps.\n\n### 7. Two recordings, for a PR\n\nTo show what a change does to the flow, set `pin` in the topic (see step 3), record the base, record the same topic against the head checkout and carry the notes. Before the head, record the base a second time and render it with `--base recording.json`: the banner must say no step differs. A step that differs between two runs of the same code names a value to add to `generated`.\n\n```\nnode \"$SKILL_DIR/record.mjs\" topic.json recording-head.json --root /path/to/head\nnode \"$SKILL_DIR/carry-notes.mjs\" recording.json notes.json recording-head.json > notes-head.json\nnode \"$SKILL_DIR/check.mjs\" topic.json recording-head.json notes-head.json --base recording.json\nnode \"$SKILL_DIR/render.mjs\" topic.json recording-head.json notes-head.json pr.html --base recording.json\n```\n\n`carry-notes.mjs` gives each note's values the head run's values and replaces each changed value where the note shows it in backticks. Reread every carried note at a step the page marks as differing, because a sentence that explains why a value was set can stop being true. The page marks each step whose calls, rows or values differ, and tags authored text whose `values` held only in the base run.\n\n### 8. Hand it to cold readers\n\nSend the page to two fresh sessions that have never seen the code. Give the critic the five story checks above and ask for pass or fail, the time taken and the answers given. Ask the reader to list every question the page raises. Fix what they find in the topic and the notes, re-render and send it again until all five pass and the reader lists fewer than 40 questions.\n\n## Register and layout checklist\n\n- Every sentence on the page is a whole sentence in plain words. No arrows, no `≠`, no em dashes, no \"Broken:\" label.\n- Counts agree with their nouns (\"1 file\", \"2 files\").\n- Name code by its exact name in backticks. Define every name the reader can't know in the glossary.\n- A note says what the step shows. It never says \"as we saw\" or points at another step for its meaning.\n- Chapter titles say what happens (\"A bot author's request waits until its delivery is tried\"), never a function name alone.\n- The page has 0 `<script>` tags, is under 3 MiB and fits 1040 px.\n\n## Examples\n\n`examples/` in the hub repo (`packages/agent-kit/deep-dive/examples/`) has three complete topics: `hub` (a post reaching a bot, with SQLite rows and a two-recording steer change), `eve` (the GitHub credential lease, two sessions on one sandbox, with fakes) and `api` (a deployment from create to READY, with loader-hook stubs for packages the sandbox can't install). Read the one closest to your flow before writing a topic.\n","files":{"record.mjs":"// Records a real run of topic.scenario: breakpoints set from a worker over the V8 inspector at topic.points; each pause captures the stack,\n// async stack, scopes, watch values and, when topic.stores is set, the rows its rows(ready) returns.\n// Usage: node record.mjs topic.json recording.json [--root /other/checkout] [--import hooks.mjs]\n// --root records the same topic against another copy of topic.root (a patched head for a two-recording page).\n// --import loads a module before the scenario, for loader hooks (registerHooks) that map imports the sandbox can't install.\n// An actor with `key` (a JS expression) gets it evaluated on every frame of that actor, so the page can draw one lifeline per key value.\n// `pauseKey` is evaluated on the paused frame of every pause and stored as pauses[].keys, for pauses with no frame of the actor.\n// Each pause stores `call` (the paused function's entry count and live calls; see track) and, with `pin` in the topic, ids and times repeat.\nimport { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';\nimport { pathToFileURL } from 'node:url';\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { execFileSync } from 'node:child_process';\nimport { loadTopic, pointPath, relTo, urlOf } from './lib.mjs';\nconst SELF = import.meta.url;\nif (isMainThread) {\n const args = process.argv.slice(2);\n const flag = (n) => { const k = args.indexOf(n); if (k < 0) return null; const v = args[k + 1]; args.splice(k, 2); return v; };\n const rootOverride = flag('--root'), hooks = flag('--import');\n const [topicFile, out] = args;\n if (!topicFile || !out) { console.error('usage: node record.mjs topic.json recording.json [--root dir] [--import hooks.mjs]'); process.exit(2); }\n const base = loadTopic(topicFile), topic = loadTopic(topicFile, rootOverride);\n const root = topic.root;\n const scenarioPath = rootOverride && base.scenario.startsWith(base.root + '/') ? root + base.scenario.slice(base.root.length) : topic.scenario;\n const hookPath = hooks ? new URL(hooks, pathToFileURL(process.cwd() + '/')).href : topic.import ? urlOf(topic.import) : null;\n if (hookPath) await import(hookPath);\n const date = new Date().toISOString().slice(0, 10);\n if (topic.pin) await import('./pin.mjs');\n const points = topic.points.map((p) => ({ ...p, url: urlOf(pointPath(topic, root, p)) }));\n const keys = topic.actors.filter((a) => a.key || a.pauseKey).map((a) => ({ name: a.name, frames: a.frames, key: a.key, pauseKey: a.pauseKey }));\n const w = new Worker(new URL(SELF), { workerData: { points, root, dir: topic.dir, keys, stores: topic.stores ?? null } });\n w.on('error', (e) => { console.error('recorder worker', e); process.exit(1); });\n const keep = setInterval(() => {}, 50);\n await new Promise((r) => w.once('message', r));\n const { scenario } = await import(pathToFileURL(scenarioPath).href);\n const marks = [];\n const mark = (phase, info) => { marks.push({ phase, info }); if (phase === 'ready') w.postMessage({ arm: info ?? {} }); if (phase === 'done') w.postMessage({ disarm: true }); };\n await scenario(mark, { root });\n const got = new Promise((r) => w.once('message', r));\n w.postMessage({ dump: true });\n const { pauses, unresolved } = await got;\n clearInterval(keep);\n const commit = execFileSync('git', ['-C', root, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();\n const dirty = execFileSync('git', ['-C', root, 'diff', '--name-only', '--relative', 'HEAD'], { encoding: 'utf8' }).split('\\n').filter(Boolean);\n const provenance = { commit, changed: dirty, scenario: relTo(root, topic.dir)(urlOf(scenarioPath)), date, pauses: pauses.length };\n // Every file a step's source panel shows or an invariant cites, as it was when recorded.\n const srcUrls = new Set([...pauses.map((p) => p.frames[0].url), ...(topic.invariants ?? []).map((v) => urlOf(`${root}/${v.file}`))]);\n const sources = Object.fromEntries([...srcUrls].map((u) => [u, readFileSync(u.replace(/^file:\\/\\//, ''), 'utf8')]));\n writeFileSync(out, JSON.stringify({ root, scenario: scenarioPath, provenance, marks, pauses, sources }, (k, v) => typeof v === 'bigint' ? String(v) : v, 1));\n console.log(`pauses ${pauses.length}`);\n const hitIds = new Set(pauses.map((p) => p.point));\n const missed = topic.points.filter((p) => !hitIds.has(p.id)).map((p) => `${p.id} ${p.file}:${p.line}`);\n if (unresolved.length) console.log('breakpoints that resolved to another line:', unresolved.join(', '));\n if (missed.length) console.log('points never hit:', missed.join(', '));\n process.exit(0);\n} else {\n setInterval(() => {}, 1000);\n const { Session } = await import('node:inspector');\n const { points, root, dir, keys, stores } = workerData;\n const rel = relTo(root, dir), KEYS = keys.map((k) => ({ ...k, re: new RegExp(k.frames) }));\n const rowsOf = stores ? (await import(pathToFileURL(stores).href)).rows : null;\n const s = new Session(); s.connectToMainThread();\n const call = (m, p = {}) => new Promise((res, rej) => s.post(m, p, (e, r) => e ? rej(e) : res(r)));\n let armed = null; const pauses = []; const scripts = new Map(); const bpPoint = new Map(); const unresolved = [];\n const resolved = new Map(), counter = new Map(), placed = new Map(), fnOfPoint = new Map(), calls = new Map();\n s.on('Debugger.scriptParsed', ({ params }) => scripts.set(params.scriptId, params.url));\n const WATCH = new Map(points.filter((b) => b.watch).map((b) => [b.id, b.watch]));\n const preview = async (obj, depth) => {\n if (!obj) return null;\n if (obj.type !== 'object' || obj.subtype === 'null') return { t: obj.subtype ?? obj.type, v: obj.value ?? obj.unserializableValue ?? obj.description ?? null };\n if ((obj.subtype === 'map' || obj.subtype === 'set') && depth > 0) {\n const { internalProperties = [] } = await call('Runtime.getProperties', { objectId: obj.objectId, ownProperties: true });\n const entries = internalProperties.find((p) => p.name === '[[Entries]]');\n const out = [];\n if (entries) { const { result } = await call('Runtime.getProperties', { objectId: entries.value.objectId, ownProperties: true });\n for (const p of result.filter((p) => /^\\d+$/.test(p.name)).slice(0, 40)) out.push(await preview(p.value, depth - 1)); }\n return { t: obj.subtype, v: Object.fromEntries(out.map((e, i) => [i, e])), n: out.length, d: obj.description };\n }\n if (depth <= 0 || obj.subtype === 'promise' || /^(Function|Db|Client)/.test(obj.className ?? '')) return { t: obj.className ?? obj.subtype, v: obj.description };\n const { result } = await call('Runtime.getProperties', { objectId: obj.objectId, ownProperties: true });\n const props = {};\n for (const p of result.filter((p) => p.enumerable !== false && p.value).slice(0, 40)) props[p.name] = await preview(p.value, depth - 1);\n return { t: obj.subtype === 'array' ? 'array' : (obj.className ?? 'Object'), v: props, n: obj.subtype === 'array' ? result.filter(p => /^\\d+$/.test(p.name)).length : undefined };\n };\n const frameOf = (url, fn, line, col) => ({ fn: fn || '(anonymous)', url, line: line + 1, col: col + 1 });\n s.on('Debugger.paused', async ({ params }) => {\n try {\n if (params.reason === 'instrumentation') return await track(params.data.scriptId);\n const hit = params.hitBreakpoints ?? [];\n const counts = hit.flatMap((b) => (counter.get(b) ?? []).map((k) => ({ ...k, b })));\n for (const { fn, b } of counts.filter((k) => k.kind === 'entry')) {\n const c = calls.get(fn);\n // The first candidate a call reaches is the one every call reaches first; the other candidates go.\n if (!c.entry) {\n c.entry = b;\n for (const o of c.candidates.filter((o) => o !== b)) {\n const rest = counter.get(o).filter((k) => !(k.kind === 'entry' && k.fn === fn));\n if (rest.length) counter.set(o, rest); else { counter.delete(o); for (const [at, id] of placed) if (id === o) placed.delete(at); await call('Debugger.removeBreakpoint', { breakpointId: o }); }\n }\n }\n c.entries++;\n }\n const exits = counts.filter((k) => k.kind === 'exit');\n const point = hit.map((b) => bpPoint.get(b)).find(Boolean) ?? null;\n try {\n if (!point || !armed) return;\n await record(params, point, hit.map((b) => fnOfPoint.get(b)?.get(params.callFrames[0].location.scriptId)).find(Boolean));\n } finally { for (const c of exits) calls.get(c.fn).exits++; }\n } finally { await call('Debugger.resume'); }\n });\n const record = async (params, point, fn) => {\n const top = params.callFrames[0];\n const frames = params.callFrames.map((f) => frameOf(scripts.get(f.location.scriptId) ?? f.url, f.functionName, f.location.lineNumber, f.location.columnNumber));\n const asyncFrames = [];\n for (let a = params.asyncStackTrace; a; a = a.parent) asyncFrames.push({ description: a.description, frames: a.callFrames.map((f) => frameOf(scripts.get(f.scriptId) ?? f.url, f.functionName, f.lineNumber, f.columnNumber)).filter((f) => f.url !== SELF) });\n const scopes = [];\n for (const sc of top.scopeChain.filter((x) => x.type === 'local' || x.type === 'block' || x.type === 'closure').slice(0, 3)) {\n const { result } = await call('Runtime.getProperties', { objectId: sc.object.objectId, ownProperties: true });\n const vars = {};\n for (const p of result) if (p.value) vars[p.name] = await preview(p.value, 3);\n scopes.push({ type: sc.type, name: sc.name ?? null, vars });\n }\n for (const [k, f] of params.callFrames.entries()) {\n const url = scripts.get(f.location.scriptId) ?? f.url, ks = KEYS.filter((a) => a.re.test(rel(url)) || a.re.test(url));\n for (const a of ks.filter((x) => x.key)) { const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: f.callFrameId, expression: a.key, returnByValue: true, silent: true });\n if (!r.exceptionDetails && r.result.value !== undefined) { (frames[k].keys ??= {})[a.name] = r.result.value; } }\n }\n // pauseKey: evaluated on the paused frame whatever actor it belongs to (an AsyncLocalStorage lookup, say), for pauses whose stack has no\n // frame of the actor to read `key` from, such as a fake resumed inside one session's await.\n const pkeys = {};\n for (const a of KEYS.filter((x) => x.pauseKey)) { const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: top.callFrameId, expression: a.pauseKey, returnByValue: true, silent: true });\n if (!r.exceptionDetails && r.result.value !== undefined) pkeys[a.name] = r.result.value; }\n const watch = [];\n for (const expression of WATCH.get(point) ?? []) {\n const r = await call('Debugger.evaluateOnCallFrame', { callFrameId: top.callFrameId, expression, returnByValue: true });\n watch.push({ expression, value: r.exceptionDetails ? { error: r.exceptionDetails.exception?.description ?? r.exceptionDetails.text } : r.result.value });\n }\n let rows = {};\n if (rowsOf) try { rows = await rowsOf(armed); } catch (e) { rows = { error: String(e?.message ?? e) }; }\n pauses.push({ at: performance.now(), wall: Date.now(), point, call: fn ? { fn, entries: calls.get(fn).entries, live: calls.get(fn).entries - calls.get(fn).exits } : null, hit: params.hitBreakpoints, frames: frames.filter((f) => f.url !== SELF), asyncFrames, ...(Object.keys(pkeys).length ? { keys: pkeys } : {}), scopes, watch, rows });\n };\n // Which call a pause belongs to. Before a script with a point runs, find the function around each point: scanning breakable locations\n // from the top of the script, the first one whose function-restricted range reaches the point starts that range. Breakpoints on the\n // function's first location and on its returns count entries and exits; a pause stores both, so check and render can treat two pauses\n // as one call only when no entry came between them and exactly one call was live.\n const texts = new Map();\n const source = async (scriptId) => texts.get(scriptId) ?? texts.set(scriptId, (await call('Debugger.getScriptSource', { scriptId })).scriptSource).get(scriptId);\n // Entry candidates are the function's locations outside the parameter list (a default value runs only for some calls), in source order,\n // up to the first that isn't a `call` location: V8 breaks at a call after its arguments, so any of them may run first. A parameter location is inside parentheses\n // that close into `{` or `=>` and don't follow `if`, `for`, `while`, `switch`, `catch` or `with`.\n const inParams = (text, l) => {\n let i = 0;\n for (let n = 0; n < l.lineNumber; n++) i = text.indexOf('\\n', i) + 1;\n i += l.columnNumber;\n for (let depth = 0; i < text.length; i++) {\n const c = text[i];\n if (c === '\"' || c === \"'\" || c === '`') { for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\\\') i++; }\n else if (c === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) { i = text.indexOf(text[i + 1] === '/' ? '\\n' : '*/', i + 2); if (i < 0) return false; }\n else if ('([{'.includes(c)) depth++;\n else if (')]}'.includes(c) && depth) depth--;\n else if (c !== ')' && ')]}'.includes(c)) return false;\n else if (c === ')') {\n if (!/^\\s*(\\{|=>)/.test(text.slice(i + 1, i + 200))) return false;\n let open = i - 1;\n for (let d = 0; open >= 0; open--) { if (text[open] === ')') d++; else if (text[open] === '(') { if (!d) break; d--; } }\n return !/\\b(if|for|while|switch|catch|with)\\s*$/.test(text.slice(Math.max(0, open - 20), open));\n }\n }\n return false;\n };\n const track = async (scriptId) => {\n const loc = (l) => `${l.lineNumber}:${l.columnNumber}`;\n for (const [bp, locs] of resolved) for (const at of locs.filter((l) => l.scriptId === scriptId)) {\n const end = { ...at, columnNumber: at.columnNumber + 1 };\n const { locations: before } = await call('Debugger.getPossibleBreakpoints', { start: { scriptId, lineNumber: 0, columnNumber: 0 }, end });\n let first = null;\n for (const q of before) {\n const { locations } = await call('Debugger.getPossibleBreakpoints', { start: q, end, restrictToFunction: true });\n if (locations.some((l) => loc(l) === loc(at))) { first = locations[0]; break; }\n }\n if (!first) continue;\n const fn = `${scriptId}:${loc(first)}`;\n if (!calls.has(fn)) {\n const { locations: body } = await call('Debugger.getPossibleBreakpoints', { start: first, restrictToFunction: true });\n const text = await source(scriptId), own = body.filter((l) => !inParams(text, l)), last = own.findIndex((l) => l.type !== 'call');\n if (last < 0) continue;\n const c = { entries: 0, exits: 0, candidates: [], entry: null };\n calls.set(fn, c);\n for (const [kind, l] of [...own.slice(0, last + 1).map((l) => ['entry', l]), ...body.filter((l) => l.type === 'return').map((l) => ['exit', l])]) {\n const at = `${scriptId}:${loc(l)}`, id = placed.get(at) ?? (await call('Debugger.setBreakpoint', { location: l })).breakpointId;\n placed.set(at, id);\n counter.set(id, [...(counter.get(id) ?? []), { kind, fn }]);\n if (kind === 'entry') c.candidates.push(id);\n }\n }\n if (!fnOfPoint.has(bp)) fnOfPoint.set(bp, new Map());\n fnOfPoint.get(bp).set(scriptId, fn);\n }\n };\n await call('Debugger.enable');\n await call('Debugger.setInstrumentationBreakpoint', { instrumentation: 'beforeScriptExecution' });\n await call('Debugger.setAsyncCallStackDepth', { maxDepth: 32 });\n for (const b of points) {\n const r = await call('Debugger.setBreakpointByUrl', { url: b.url, lineNumber: b.line - 1, columnNumber: b.col ? b.col - 1 : 0 });\n bpPoint.set(r.breakpointId, b.id);\n }\n s.on('Debugger.breakpointResolved', ({ params }) => { resolved.set(params.breakpointId, [...(resolved.get(params.breakpointId) ?? []), params.location]); const id = bpPoint.get(params.breakpointId); const p = points.find((x) => x.id === id); if (p && params.location.lineNumber + 1 !== p.line) unresolved.push(`${id} ${p.file}:${p.line} -> :${params.location.lineNumber + 1}`); });\n parentPort.on('message', (m) => { if (m.arm) armed = m.arm; if (m.disarm) armed = null; if (m.dump) parentPort.postMessage({ pauses, unresolved }); });\n parentPort.postMessage('ready');\n}\n","render.mjs":"// The page: a top bar with a stepper, a navigator (chapters, sequence diagram or steps) and an inspector for the current step, all without JS.\n// Usage: node render.mjs topic.json recording.json notes.json out.html [--base base-recording.json]\n// VIEWER_DEFAULTS='level=seq;show=note,stack;inspector=off' changes which inputs start checked (level: ch, seq, steps; show: note stack vars values wire source inv; inspector: on, off).\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { basename, dirname } from 'node:path';\nimport { codeToTokens } from 'shiki';\nimport { readJson, loadTopic, rootOf, relTo, stackOf, hitsOf, plain, invStep, authoredOf, chaptersOf, plural, sourceOf, diffOf, previousInCall } from './lib.mjs';\nconst require = createRequire(import.meta.url);\nconst argv = process.argv.slice(2);\nconst flag = (n) => { const k = argv.indexOf(n); if (k < 0) return null; const v = argv[k + 1]; argv.splice(k, 2); return v; };\nconst baseFile = flag('--base');\nconst [topicFile, recFile, notesFile, out] = argv;\nif (!out) { console.error('usage: node render.mjs topic.json recording.json notes.json out.html [--base base-recording.json]'); process.exit(2); }\nconst topic = loadTopic(topicFile), rec = readJson(recFile), notes = readJson(notesFile);\nconst P = rec.pauses, N = P.length;\nconst ROOT = rootOf(topic, rec);\nconst rel = relTo(ROOT, topic.dir);\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst code = (s) => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');\nconst base = (url) => rel(url).split('/').pop();\nconst same = (a, b) => JSON.stringify(a) === JSON.stringify(b);\nconst href = (i) => `#step-${i + 1}`;\nconst A = (i, cls, body, title) => `<a class=\"${cls}\" href=\"${href(i)}\"${title ? ` title=\"${esc(title)}\"` : ''}>${body}</a>`;\n\n// Actors. A frame belongs to the first actor whose `frames` regex matches its path relative to topic.root (its full url when outside it).\nconst ACT = topic.actors.map((a) => ({ ...a, fre: a.frames ? new RegExp(a.frames) : null, ure: a.urls ? new RegExp(a.urls) : null }));\nconst frameActor = new Map();\nconst actorOfUrl = (url) => { if (!frameActor.has(url)) { const r = rel(url); frameActor.set(url, ACT.find((a) => a.fre && (a.fre.test(r) || a.fre.test(url)))?.name ?? null); } return frameActor.get(url); };\nconst STORES = ACT.filter((a) => a.kind === 'store').map((a) => a.name);\n// Lanes: an actor with `key` gets one lifeline per value record.mjs evaluated on its frames. At each step the actor's lane is the value on\n// its innermost frame that has one, else its lane at the step before.\nconst keyed = new Set(ACT.filter((a) => a.key || a.pauseKey).map((a) => a.name));\n// A pause's lane key: from a frame of the actor (key), else from the pause itself (pauseKey), else the last one seen.\nconst keyAt = (p, n) => p.frames.find((x) => x.keys?.[n] !== undefined)?.keys[n] ?? p.keys?.[n];\nconst laneVal = (() => { const last = {}; return P.map((p) => { for (const n of keyed) { const v = keyAt(p, n); if (v !== undefined) last[n] = String(v); } return { ...last }; }); })();\nconst lane = (name, i) => name && keyed.has(name) && laneVal[i][name] != null ? `${name}#${laneVal[i][name]}` : name;\nconst laneLabel = (id) => id?.includes('#') ? `${id.slice(0, id.indexOf('#'))} (${id.slice(id.indexOf('#') + 1)})` : id;\nconst laneSub = (id) => id?.includes('#') ? id.slice(id.indexOf('#') + 1) : '';\n\nconst stacks = P.map(stackOf);\nconst hits = hitsOf(P);\nconst noteOf = P.map((p, i) => notes.find((n) => n.point === p.point && (n.hit ?? 0) === hits[i]));\nP.forEach((p, i) => { if (!noteOf[i]) throw new Error(`no note for step ${i + 1} (${p.point} hit ${hits[i]}); run check.mjs`); });\nconst invAt = (v) => invStep(P, hits, v);\n// Frames from the first step's paused function inward; everything outside it is the scenario's own calls and request routing.\nconst entry = P[0].frames[0];\nconst depth = stacks.map((s) => { const i = s.findLastIndex((f) => f.url === entry.url && f.fn === entry.fn); return i < 0 ? s.length : i + 1; });\nconst flat = (x, p = '', out = []) => { if (x && typeof x === 'object' && Object.keys(x).length) { for (const [k, v] of Object.entries(x)) flat(v, p ? `${p}.${k}` : k, out); } else out.push([p, x]); return out; };\nconst kvLine = (x) => flat(x).map(([k, v]) => `${k} = ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ');\nconst keyOf = (p) => `${rel(p.frames[0].url)}:${p.frames[0].line}`;\n\n// Values.\nconst isPrim = (v) => !v || typeof v.v !== 'object' || v.v === null;\nconst fmtPrim = (v) => {\n if (!v) return '<span class=\"v-u\">undefined</span>';\n if (v.t === 'string') { const ls = String(v.v).split('\\n'); return ls.length > 3 ? `<span class=\"v-s\">\"${esc(ls[0])}</span><details class=\"more\"><summary>${ls.length - 1} more lines</summary><span class=\"v-s pre\">${esc(ls.slice(1).join('\\n'))}\"</span></details>` : `<span class=\"v-s\">\"${esc(ls.join('⏎'))}\"</span>`; }\n if (v.t === 'number' || v.t === 'bigint') return `<span class=\"v-n\">${esc(v.v)}</span>`;\n if (v.t === 'boolean') return `<span class=\"v-b\">${v.v}</span>`;\n if (v.t === 'null' || v.t === 'undefined') return `<span class=\"v-u\">${v.t}</span>`;\n if (v.t === 'function') return `<span class=\"v-f\">ƒ</span>`;\n return `<span class=\"v-o\">${esc(String(v.v ?? v.t).split('\\n')[0])}</span>`;\n};\nconst label = (v) => v.t === 'array' ? `Array(${v.n})` : v.t === 'map' || v.t === 'set' ? (v.d ?? `${v.t}(${v.n})`) : v.t;\nconst inline = (v, d = 0) => {\n if (isPrim(v)) return fmtPrim(v);\n if (d > 0) return `<span class=\"v-o\">${esc(label(v))}</span>`;\n const e = Object.entries(v.v);\n if (v.t === 'array' && e.length && e.every(([, x]) => !isPrim(x))) return `<span class=\"v-o\">${esc(label(v))}</span>`;\n const body = e.map(([k, x]) => v.t === 'array' || v.t === 'set' ? inline(x, 1) : `${esc(k)}: ${inline(x, 1)}`).join(', ');\n return `<span class=\"v-o\">${v.t === 'array' ? `(${v.n}) [` : '{'}</span>${body}<span class=\"v-o\">${v.t === 'array' ? ']' : '}'}</span>`;\n};\nconst json = (x) => JSON.stringify(x, null, 1);\nconst varsAt = (i) => Object.assign({}, ...P[i].scopes.filter((s) => s.type !== 'closure').map((s) => s.vars));\nconst prevSame = previousInCall(P);\nconst done = rec.marks.find((m) => m.phase === 'done')?.info;\n\n// HTTP: a watch value with `method` and `url` is a request, recorded where it is received. Its receiver is the actor whose `urls` matches the url\n// (else the paused frame's actor); its sender is the nearest outer frame of another actor. Its response is the next watch value with a numeric `status`\n// paused in the sender, or, when none is, the scenario's done mark ({status, body}) at the last step.\nconst isReq = (v) => v && typeof v === 'object' && typeof v.method === 'string' && typeof v.url === 'string';\nconst isResp = (v) => v && typeof v === 'object' && typeof v.status === 'number' && !('method' in v);\nconst reqs = [];\nlet doneUsed = false;\nP.forEach((p, h) => p.watch.forEach((w) => {\n if (!isReq(w.value)) return;\n const v = w.value, u = new URL(v.url);\n const to = ACT.find((a) => a.ure && a.ure.test(v.url))?.name ?? actorOfUrl(p.frames[0].url) ?? 'peer';\n const from = stacks[h].map((f) => actorOfUrl(f.url)).find((a) => a && a !== to) ?? ACT.find((a) => a.kind !== 'store' && a.name !== to)?.name ?? 'caller';\n let e = P.findIndex((q, j) => j > h && q.watch.some((x) => isResp(x.value)) && actorOfUrl(q.frames[0].url) === from);\n let status, got;\n if (e >= 0) { const r = P[e].watch.find((x) => isResp(x.value)).value; status = r.status; got = r.body ?? r.text; if (typeof got === 'string') { try { got = JSON.parse(got); } catch {} } }\n else if (done && done.status !== undefined && !doneUsed) { doneUsed = true; e = N - 1; status = done.status; got = done.body; }\n else e = null;\n reqs.push({ from: lane(from, h), to: lane(to, h), method: v.method, path: u.pathname, url: v.url, start: h, end: e, status, sent: v.body, got, viaDone: e === N - 1 && got === done?.body });\n}));\n\n// SQL: a watch value that is a list of statements ({sql} or {other}) is a batch sent to the first store; the next watch with `changed` is its answer.\nconst tableOfSql = (s) => s.match(/^\\s*(?:WITH[\\s\\S]*?\\)\\s*)?(?:INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|UPDATE|DELETE\\s+FROM|REPLACE\\s+INTO)\\s+(\\w+)/i)?.[1] ?? s.match(/\\bFROM\\s+(\\w+)/i)?.[1] ?? '?';\nconst isStmts = (v) => Array.isArray(v) && v.length && v.every((x) => x && typeof x === 'object' && ('sql' in x || 'other' in x));\nconst batch = (() => { const j = P.findIndex((p) => p.watch.some((w) => isStmts(w.value))); if (j < 0 || !STORES.length) return null;\n const list = P[j].watch.find((w) => isStmts(w.value)).value, pw = P[j].watch.find((w) => typeof w.value === 'number'), prepared = pw?.value ?? list.length, preparedExpr = pw?.expression ?? 'the batch';\n const d = P.findIndex((p, k) => k > j && p.watch.some((w) => Array.isArray(w.value?.changed)));\n const changed = d < 0 ? null : P[d].watch.find((w) => Array.isArray(w.value?.changed)).value.changed;\n return { at: j, done: d, prepared, preparedExpr, changed, store: STORES[0], from: lane(actorOfUrl(P[j].frames[0].url), j), stmts: list.map((x) => {\n if (x.sql) { const w = /^\\s*(INSERT|UPDATE|DELETE|REPLACE)/i.test(x.sql); return { op: w ? x.sql.trim().split(/\\s+/)[0].toLowerCase() : 'select', table: tableOfSql(x.sql), name: x.name, full: x.sql }; }\n const o = JSON.parse(x.other); return { op: o.kind, table: o.table, name: String(o.row?.id ?? (o.set ? 'set ' + Object.keys(o.set).join(', ') : '')), full: json(o) }; }) }; })();\n\n// Rows: topic.stores' tables. A table key \"<store>.<table>\" belongs to that store actor, any other key to the first store.\nconst TABLES = Object.keys(P[0].rows ?? {}).filter((t) => Array.isArray(P[0].rows[t]));\nif (TABLES.length && !STORES.length) { ACT.push({ name: 'rows', kind: 'store' }); STORES.push('rows'); }\nconst storeOf = (t) => STORES.find((s) => t.startsWith(s + '.')) ?? STORES[0];\nconst pkCol = new Map();\nconst pk = (t, r) => { if (!pkCol.has(t)) pkCol.set(t, 'id' in r ? 'id' : Object.keys(r).find((k) => /(^|_)id$/i.test(k)) ?? null); const c = pkCol.get(t); return c ? String(r[c]) : JSON.stringify(r); };\nconst rowsOf = (i, t) => Array.isArray(P[i].rows?.[t]) ? P[i].rows[t] : [];\nconst tdiff = (i) => i === 0 ? [] : TABLES.map((t) => {\n const a = new Map(rowsOf(i - 1, t).map((r) => [pk(t, r), r])), b = new Map(rowsOf(i, t).map((r) => [pk(t, r), r]));\n const added = [...b.keys()].filter((k) => !a.has(k)), removed = [...a.keys()].filter((k) => !b.has(k));\n const changed = [...b.keys()].filter((k) => a.has(k) && !same(a.get(k), b.get(k))).map((k) => ({ id: k, cols: Object.keys(b.get(k)).filter((c) => !same(a.get(k)[c], b.get(k)[c])).map((c) => ({ c, from: a.get(k)[c], to: b.get(k)[c] })) }));\n return { t, added: added.map((k) => b.get(k)), removed, changed };\n}).filter((d) => d.added.length || d.removed.length || d.changed.length);\nconst diffs = P.map((_, i) => tdiff(i));\n// The writer of a row change is the actor paused at the step before it (the code that ran in between).\n// Parts: each source file with a breakpoint, on its actor's lifeline.\nconst bpFiles = new Set(P.map((p) => p.frames[0].url));\nconst partOf = (url) => bpFiles.has(url) ? rel(url) : null;\nconst chainOf = (i) => { const out = []; for (const f of stacks[i].slice(0, depth[i]).reverse()) { const p = partOf(f.url); if (!p) continue; if (out.at(-1)?.p === p) { out.at(-1).fns.push(f.fn); out.at(-1).line = f.line; } else out.push({ p, a: lane(actorOfUrl(f.url), i), fns: [f.fn], line: f.line }); } return out; };\nconst chains = P.map((_, i) => chainOf(i));\nconst fnName = (c) => c.fns.find((f) => f !== '(anonymous)') ?? c.fns[0];\nconst topFn = (i) => P[i].frames[0].fn === '(anonymous)' ? (P[i].scopes.find((s) => s.type === 'closure')?.name || '(anonymous)') : P[i].frames[0].fn;\n\n// Chapters: topic.chapters, each starting at the first hit of its point at or after the previous chapter's start.\nconst CH = chaptersOf(topic, P);\nif (!CH) throw new Error('a chapter\\'s point is not hit at or after the previous chapter\\'s start; run check.mjs');\nconst chapOf = P.map((_, i) => CH.find((c) => i >= c.from && i <= c.to).k);\nconst rowsChanged = batch?.changed ? batch.changed.reduce((a, b) => a + (b || 0), 0) : 0;\n// Rows the batch changed, per table, from its answer: the store square, the store rows list and the arrow all count from this.\nconst batchRows = batch?.changed ? batch.stmts.reduce((m, st, k) => (batch.changed[k] ? m.set(st.table, (m.get(st.table) ?? 0) + batch.changed[k]) : m), new Map()) : new Map();\nconst batchRowsTxt = () => [...batchRows].map(([t, c]) => `${t} ${c}`).join(', ');\nconst opsOf = (d) => [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} updated`, d.removed.length && `${d.removed.length} deleted`].filter(Boolean).join(', ');\nconst tableOps = (d) => `${d.t} ${opsOf(d)}`;\nconst respLine = (st, body) => { const l = flat(body ?? {}); const nested = l.filter(([k]) => k.includes('.')); return `${st}${l.length ? ' ' + kvLine(Object.fromEntries(nested.length ? nested : l)) : ''}`; };\nconst cd = (x) => `<code>${esc(x)}</code>`;\nconst fileLine = (i) => keyOf(P[i]);\n// The operation that defines a chapter, from the step data: an HTTP exchange, the SQL batch, a response, or the tables written.\nconst whoOf = (c) => `${[...new Set(c.idx.map(topFn))].slice(0, 3).map(cd).join(', ')} ${cd(fileLine(c.from))}`;\nconst titleOf = (c) => c.title ? code(c.title) : whoOf(c);\n\nconst DIFF = baseFile ? diffOf(topic, rec, readJson(baseFile), basename(baseFile)) : null;\nconst differs = (i) => DIFF && DIFF.steps[i].kinds.length > 0;\n\n// Changes at each step: requests and responses, the batch, table row counts, frames new to the run, and variables changed since the last pause in the same call.\n// Lifelines: every declared actor in topic order, including one no step pauses in; a keyed actor gets one per key value, in order of first use.\nconst LIFE = ACT.flatMap((a) => { const vs = keyed.has(a.name) ? [...new Set(laneVal.map((v) => v[a.name]).filter((v) => v != null))] : []; return vs.length ? vs.map((v) => ({ ...a, id: `${a.name}#${v}`, sub: v })) : [{ ...a, id: a.name, sub: '' }]; });\n// Each lifeline gets an equal column with its line in the middle, so every header fits above its own line.\nconst LX = Object.fromEntries(LIFE.map((a, k) => [a.id, (100 / LIFE.length) * (k + 0.5)]));\nconst seqRows = [];\nconst add = (o, r) => seqRows.push({ o, ...r });\nconst reqAt = (i, to) => reqs.some((r) => r.start === i && r.to === to);\nconst respAt = (i, from) => reqs.some((r) => r.end === i && r.to === from);\n// A keyed actor runs one stack per key (two sessions at once). A step is compared with the last step of its own stack, so a pause in another\n// session's stack draws no return for calls that are still running.\nconst ownKey = P.map((p) => JSON.stringify([...keyed].map((n) => keyAt(p, n) ?? null).some((v) => v != null) ? [...keyed].map((n) => keyAt(p, n) ?? null) : null));\nfor (let i = 0; i < N; i++) {\n const j = ownKey[i] === 'null' ? i - 1 : P.findLastIndex((_, k) => k < i && ownKey[k] === ownKey[i]);\n const prev = j >= 0 ? chains[j] : chains[0].slice(0, 1), cur = chains[i];\n let c = 0; while (c < prev.length && c < cur.length && prev[c].p === cur[c].p && prev[c].a === cur[c].a && prev[c].fns[0] === cur[c].fns[0]) c++;\n for (let k = prev.length - 1; k >= Math.max(c, 1); k--) {\n if (prev[k].a !== prev[k - 1].a) { if (!respAt(i, prev[k].a)) add(i, { kind: 'arr call ret', from: prev[k].a, to: prev[k - 1].a, label: `ret ${fnName(prev[k])}`, short: `ret ${fnName(prev[k])}` }); }\n else add(i, { kind: 'self ret', at: prev[k].a, d: k - 1, label: `ret <code>${esc(fnName(prev[k]))}</code> <code>${esc(base(prev[k].p))}:${prev[k].line}</code>` });\n }\n for (const r of reqs) if (r.end === i && !r.viaDone) add(i, { kind: 'arr http ret', from: r.to, to: r.from, label: `${r.status}${r.got === undefined ? '' : ' ' + (typeof r.got === 'string' ? r.got : kvLine(r.got))}`, short: String(r.status) });\n for (const r of reqs) if (r.start === i) add(i, { kind: 'arr http', from: r.from, to: r.to, label: `${r.method} ${r.path}`, short: `${r.method} ${r.path}` });\n for (let k = Math.max(c, 1); k < cur.length; k++) {\n if (cur[k].a !== cur[k - 1].a) { if (!reqAt(i, cur[k].a)) add(i, { kind: 'arr call', from: cur[k - 1].a, to: cur[k].a, label: `call ${fnName(cur[k])} ${base(cur[k].p)}:${cur[k].line}`, short: `call ${fnName(cur[k])}` }); }\n else add(i, { kind: 'self', at: cur[k].a, d: k - 1, label: `call <code>${esc(fnName(cur[k]))}</code> <code>${esc(base(cur[k].p))}:${cur[k].line}</code>` });\n }\n if (batch && i === batch.at) add(i, { kind: 'arr sql', from: batch.from, to: batch.store, label: `db.batch ${batch.stmts.length} stmts`, short: `db.batch ${batch.stmts.length} stmts` });\n if (batch && i === batch.done) add(i, { kind: 'arr sql ret', from: batch.store, to: batch.from, label: `${rowsChanged} rows changed`, short: `${rowsChanged} rows changed` });\n if (batch && i === batch.done && batchRows.size) add(i, { kind: 'mk', at: batch.store, label: esc(batchRowsTxt()), short: batchRowsTxt() });\n else if (diffs[i].length) for (const s of STORES) { const ds = diffs[i].filter((d) => storeOf(d.t) === s); if (ds.length) add(i, { kind: 'mk', at: s, label: esc(ds.map(tableOps).join(', ')), short: ds.map(tableOps).join(', ') }); }\n}\nfor (const r of reqs) if (r.viaDone) add(N - 1, { kind: 'arr http ret', from: r.to, to: r.from, label: respLine(r.status, r.got), short: respLine(r.status, r.got) });\nseqRows.forEach((r) => { if (r.kind.startsWith('arr') && (!(r.from in LX) || !(r.to in LX))) r.drop = true; });\nfor (let k = seqRows.length - 1; k >= 0; k--) if (seqRows[k].drop) seqRows.splice(k, 1);\nseqRows.forEach((r) => { r.ch = chapOf[r.o]; r.steps = []; });\n// Call stack: outermost first, indented by depth; frames new since the previous step and frames whose line moved are marked.\n// Source + scope: source around the paused line, highlighted once per file; token colours become shared classes.\nconst colours = new Map(); const cls = (l, d) => { const k = `${l}|${d}`; if (!colours.has(k)) colours.set(k, `k${colours.size}`); return colours.get(k); };\nconst tokens = new Map();\nconst langOf = (f) => /\\.(m?js|cjs|jsx)$/.test(f) ? 'javascript' : /\\.tsx$/.test(f) ? 'tsx' : /\\.go$/.test(f) ? 'go' : /\\.py$/.test(f) ? 'python' : /\\.rs$/.test(f) ? 'rust' : 'typescript';\nfor (const url of new Set(P.map((p) => p.frames[0].url))) {\n const f = url.replace('file://', '');\n const src = sourceOf(rec, url) ?? '';\n const { tokens: t } = await codeToTokens(src, { lang: langOf(f), themes: { light: 'github-light', dark: 'github-dark' }, defaultColor: false });\n tokens.set(url, { lines: src.split('\\n'), t });\n}\nconst tokHtml = (t) => t.map((x) => { const st = x.htmlStyle ?? {}; return `<span class=\"${cls(st['--shiki-light'] ?? x.color, st['--shiki-dark'])}\">${esc(x.content)}</span>`; }).join('');\nconst unpack = (x) => { if (x && typeof x === 'object' && typeof x.other === 'string') { try { return JSON.parse(x.other); } catch {} } return x; };\nconst linesHtml = (i) => {\n const f = P[i].frames[0], { lines, t } = tokens.get(f.url), L = f.line, vars = varsAt(i), before = prevSame[i] == null ? null : varsAt(prevSame[i]);\n const mark = (n) => !before ? '' : !(n in before) ? 'new' : same(before[n], vars[n]) ? '' : 'chg';\n const names = Object.keys(vars).filter((n) => n.length > 1 && vars[n]?.t !== 'function' && n !== 'this');\n const from = Math.max(1, L - 4), to = Math.min(lines.length, L + 4);\n let rows = '';\n const re = (x) => x.replace(/\\$/g, '\\\\$');\n const useRe = (x) => new RegExp(`(^|[^\\\\w$.])${re(x)}(?![\\\\w$])(?!\\\\s*:(?!:))`);\n for (let n = from; n <= to; n++) {\n const used = n === L ? names.filter((x) => useRe(x).test(lines[n - 1])) : [];\n const iv = used.map((x) => `<span class=\"iv ${mark(x)}\">${esc(x)} = ${isPrim(vars[x]) ? fmtPrim(vars[x]) : inline(vars[x])}</span>`).join('');\n if (n === L && DIFF?.steps[i].was != null) rows += `<div class=\"ln was\"><span class=\"no\">base</span><span class=\"src\"><del>${esc(DIFF.steps[i].was)}</del></span></div>`;\n rows += `<div class=\"ln ${n === L ? 'cur' : ''}\"><span class=\"no\">${n}</span><span class=\"src\">${tokHtml(t[n - 1] ?? [])}${iv}</span></div>`;\n }\n return `<div class=\"file\"><code>${esc(rel(f.url))}:${L}</code> in <code>${esc(topFn(i))}</code>${prevSame[i] != null ? `, last paused in this call at ${A(prevSame[i], 'go', `step ${prevSame[i] + 1}`)}` : ''}</div><div class=\"code\">${rows}</div>`;\n};\n\n// Page, table first.\nconst sv = (x) => x === undefined ? 'undefined' : typeof x === 'string' ? x : JSON.stringify(x);\nconst clip = (s, n = 120) => s.length > n ? `<details class=\"clip\"><summary>${esc(s.slice(0, n))}…</summary><span class=\"full\">${esc(s)}</span></details>` : esc(s);\nconst valCell = (v) => isPrim(v) ? fmtPrim(v) : inline(v);\n// Actors: what each did in this run, from the crossings it sent and received and the frames it paused in.\n// Step detail tables.\nconst flatTable = (x) => x === undefined ? '<span class=\"dim\">not recorded</span>' : `<table class=\"t sm kvt\"><tbody>${flat(x).map(([k, v]) => `<tr><th>${esc(k)}</th><td>${clip(sv(v), 240)}</td></tr>`).join('')}</tbody></table>`;\n// Statement ops that change rows; the rest read. At the batch's answer only the statements that changed rows are listed, the full list is at the batch step.\nconst WRITE_OPS = /^(put|insert|patch|update|delete|upsert|replace)/i;\nconst stmtList = (i, changedOnly = false) => { const raw = P[batch.at].watch.find((w) => isStmts(w.value)).value;\n return `<div class=\"stl\"><div class=\"sth\"><span>Statement</span><span>Table</span><span>Rows changed</span></div>${raw.map((x, k) => { const st = batch.stmts[k], o = unpack(x) ?? {}; const { kind, table, ...rest } = o; if (changedOnly && !(batch.changed?.[k])) return '';\n const body = 'sql' in o ? `<div class=\"pre\">${esc(o.sql.trim())}</div>${o.args?.length ? `<div class=\"sub\">Arguments</div><div class=\"pre\">${esc(JSON.stringify(o.args))}</div>` : ''}` : `<table class=\"t sm kvt\"><tbody>${flat(rest).map(([p, v]) => `<tr><th>${esc(p)}</th><td class=\"pre\">${esc(typeof v === 'string' ? v : JSON.stringify(v))}</td></tr>`).join('')}</tbody></table>`;\n return `${k === batch.prepared && !changedOnly ? `<div class=\"stsplit\">Statements ${k} to ${raw.length - 1}, after the ${batch.prepared} in <code>${esc(batch.preparedExpr)}</code>, in the same transaction</div>` : ''}<details class=\"stq\"><summary><span><span class=\"dim\">${k}</span> ${esc(st.op)}</span><span><code>${esc(st.table)}</code></span><span class=\"num\">${batch.changed ? batch.changed[k] ?? 0 : ''}</span></summary><div class=\"stb\">${st.name ? `<div class=\"sub\">Name <code>${esc(st.name)}</code></div>` : ''}${body}</div></details>`; }).join('')}</div>`; };\nconst wireTables = (i) => {\n const out = [];\n for (const r of reqs) {\n if (r.start === i) out.push(`<h4>${esc(laneLabel(r.from))} to ${esc(laneLabel(r.to))}, ${esc(r.method)} <code>${esc(r.path)}</code>${r.end !== null ? `, answered at ${A(r.end, 'go', 'step ' + (r.end + 1))}` : ''}</h4>${flatTable(r.sent)}`);\n if (r.end === i) out.push(`<h4>${esc(laneLabel(r.to))} answers ${esc(laneLabel(r.from))}, ${esc(r.status)} for ${esc(r.method)} <code>${esc(r.path)}</code>, sent at ${A(r.start, 'go', 'step ' + (r.start + 1))}</h4>${flatTable(r.got)}`);\n }\n const pw = P[i].watch.filter((w) => !isReq(w.value) && !isStmts(w.value) && !(Array.isArray(w.value?.changed)));\n if (batch && i === batch.at) out.push(`<h4>${esc(laneLabel(batch.from))} to ${esc(batch.store)}, <code>db.batch</code> of ${plural(batch.stmts.length, 'statement')}, ${batch.stmts.filter((x) => WRITE_OPS.test(x.op)).length} that write and ${batch.stmts.filter((x) => !WRITE_OPS.test(x.op)).length} that read</h4>${stmtList(i)}<p class=\"dim\">Rows changed come from the answer at ${A(batch.done, 'go', 'step ' + (batch.done + 1))}.</p>`);\n if (batch && i === batch.done) out.push(`<h4>${esc(batch.store)} answers ${esc(laneLabel(batch.from))}, ${plural(rowsChanged, 'row')} changed in ${plural(batchRows.size, 'table')}</h4>${stmtList(i, true)}<p class=\"dim\">The other ${plural(batch.stmts.length - batch.changed.filter(Boolean).length, 'statement')} changed no rows. ${A(batch.at, 'go', `Step ${batch.at + 1}`, open(batch.at))} lists all ${batch.stmts.length}.</p>`);\n for (const w of pw) out.push(`<h4>Recorded expression <code>${clip(w.expression, 80)}</code></h4>${w.value === null || typeof w.value !== 'object' ? `<p><code>${esc(sv(w.value))}</code></p>` : flatTable(w.value)}`);\n if (i === N - 1 && done) out.push(`<h4>Scenario result</h4>${flatTable(done)}`);\n return out.join('');\n};\n// Glossary terms: the first use of a term in a text becomes a <details class=\"term\"> with its definition. A term matches a backticked name\n// exactly, and, when it is plain lowercase words, the same words (or their plural) in the prose.\nconst GL = (topic.glossary ?? []).map((g) => ({ ...g, re: /^[a-z][a-z ]*$/.test(g.term) ? new RegExp(`\\\\b${g.term}s?\\\\b`, 'i') : null }));\nconst termHtml = (label, g) => `<details class=\"term\"><summary>${label}</summary><span class=\"def\"><b>${esc(g.term)}</b> ${esc(g.def)}</span></details>`;\nconst termify = (text) => {\n const used = new Set(); let out = '';\n String(text).split('`').forEach((seg, k) => {\n if (k % 2) { const g = GL.find((x) => x.term === seg && !used.has(x.term)); if (g) used.add(g.term); out += g ? termHtml(`<code>${esc(seg)}</code>`, g) : `<code>${esc(seg)}</code>`; return; }\n let rest = seg;\n for (;;) {\n let best = null;\n for (const g of GL) { if (!g.re || used.has(g.term)) continue; const m = g.re.exec(rest); if (m && (!best || m.index < best.m.index || (m.index === best.m.index && m[0].length > best.m[0].length))) best = { g, m }; }\n if (!best) { out += esc(rest); break; }\n out += esc(rest.slice(0, best.m.index)) + termHtml(esc(best.m[0]), best.g); used.add(best.g.term); rest = rest.slice(best.m.index + best.m[0].length);\n }\n });\n return out;\n};\nconst kindsTxt = (k) => k.length > 1 ? `${k.slice(0, -1).join(', ')} and ${k.at(-1)}` : k[0];\nconst dfTag = (i) => differs(i) ? `<span class=\"dk\">Differs from base in ${esc(kindsTxt(DIFF.steps[i].kinds))}</span>` : '';\nconst chDf = (c) => { const n = DIFF ? c.idx.filter(differs).length : 0; return n ? `<span class=\"dk\">${n} ${n > 1 ? 'steps differ' : 'step differs'} from base</span>` : ''; };\nconst DSTEPS = DIFF ? P.map((_, i) => i).filter(differs) : [];\nconst andList = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}` : xs[0];\nconst banner = DIFF ? `<p class=\"banner\"><b>Showing <code>${esc(basename(recFile))}</code>, compared with <code>${esc(DIFF.file)}</code>.</b> ${DSTEPS.length ? `${A(DSTEPS[0], 'bl', `${DSTEPS.length} ${DSTEPS.length > 1 ? 'steps differ' : 'step differs'}`)} in ${esc(andList([...new Set(DSTEPS.flatMap((i) => DIFF.steps[i].kinds))]))}. Steps ${andList(DSTEPS.map((i) => A(i, 'bl', i + 1)))}.` : `No step differs from the base recording, <code>${esc(DIFF.file)}</code>.`}${DIFF.missingBase.length ? ` Steps only in the base, ${DIFF.missingBase.map((k) => k + 1).join(', ')}.` : ''}</p>` : '';\n// Authored text (story, outcome, chapter titles, invariants) whose `values` this run does not have but the base run does, as check.mjs --base\n// reports it. Only these get the tag; text without such values is checked against this run and needs none.\nconst STALE = (() => {\n if (!DIFF) return {};\n const B = readJson(baseFile).pauses, base = authoredOf(topic, B, hitsOf(B), chaptersOf(topic, B)) ?? [];\n return Object.fromEntries(authoredOf(topic, P, hits, CH).map((a, k) => [a.key, a.missing.filter((v) => base[k] && !base[k].missing.includes(v))]).filter(([, vs]) => vs.length));\n})();\nconst baseTag = (key) => STALE[key] ? `<div class=\"btag\">Written for the base recording, where ${andList(STALE[key].map((v) => `<code>${esc(v.name)}</code> is <code>${esc(v.value)}</code>`))}. This run differs.</div>` : '';\nconst ruleTag = (v) => baseTag(`inv${INV.indexOf(v)}`);\nconst wherePath = (x) => esc(x).replace(/\\//g, '/<wbr>');\nconst open = (i) => `Open step ${i + 1}`;\n\n// Sequence diagram: only crossings (arrows between actors and store writes), grouped by chapter. A chapter with no crossing gets one self-call row\n// in its lane, labelled with the function and the note of the chapter's last step. A chapter whose first steps come before its first crossing\n// gets a self-call row for them at its top, so their dots never sit on a later row. Each step's dot sits on the latest row of its chapter at or before it.\n// A self-call row's label: the step's function, unless its note already starts with it, then the note.\nconst selfLabel = (i) => `${noteOf[i].text.startsWith('`' + topFn(i) + '`') ? '' : `<code>${esc(topFn(i))}</code> `}${code(noteOf[i].text)}`;\nconst xRows = seqRows.filter((r) => !r.kind.startsWith('self'));\nfor (const c of CH) if (!xRows.some((r) => r.ch === c.k)) { const row = { o: c.from, ch: c.k, steps: [], kind: 'self', at: lane(actorOfUrl(P[c.from].frames[0].url), c.from) ?? LIFE[0].id, label: selfLabel(c.to) }; const at = xRows.findIndex((r) => r.ch > c.k); xRows.splice(at < 0 ? xRows.length : at, 0, row); }\nfor (const c of CH) { const at = xRows.findIndex((r) => r.ch === c.k); if (at >= 0 && xRows[at].o > c.from) xRows.splice(at, 0, { o: c.from, ch: c.k, steps: [], kind: 'self', at: lane(actorOfUrl(P[c.from].frames[0].url), c.from) ?? LIFE[0].id, label: selfLabel(c.from) }); }\nP.forEach((_, i) => { const inCh = xRows.filter((r) => r.ch === chapOf[i]); (inCh.filter((r) => r.o <= i).at(-1) ?? inCh[0]).steps.push(i); });\nconst lls = LIFE.map((a) => `<span class=\"ll\" style=\"left:${LX[a.id]}%\"></span>`).join('');\nconst atX = (x, cls, label) => x > 60 ? `<span class=\"${cls} rt\" style=\"margin-right:${100 - x}%\"><span class=\"lab\">${label}</span></span>` : `<span class=\"${cls}\" style=\"margin-left:${x}%\"><span class=\"lab\">${label}</span></span>`;\n// Run ids (a token of 20 or more letters and digits, with a digit) are cut to their first 6 characters after any prefix in diagram labels; the inspector shows them whole.\nconst shortIds = (x) => String(x).replace(/\\b([a-z]+_)?(?=[0-9A-Za-z]*\\d)([0-9A-Za-z]{6})[0-9A-Za-z]{14,}\\b/g, '$1$2\\u2026');\nconst seqRow = (r) => {\n let body;\n if (r.kind === 'mk') body = atX(LX[r.at], r.kind, r.label);\n else if (r.kind.startsWith('self')) body = `<span class=\"${r.kind}\" style=\"margin-left:${LX[r.at] ?? 0}%\"><span class=\"lab\">${r.label}</span></span>`;\n else { const a = LX[r.from], b = LX[r.to], sl = shortIds(r.label); body = `<span class=\"alab ${r.kind.replace('arr ', '')}\" style=\"margin-left:${Math.min(a, b)}%\"><span class=\"lab\"${sl === r.label ? '' : ` title=\"${esc(r.label)}\"`}>${esc(sl)}</span></span><span class=\"${r.kind} ${b < a ? 'rtl' : 'ltr'}\" style=\"margin-left:${Math.min(a, b)}%;width:${Math.abs(b - a)}%\"></span>`; }\n const s0 = r.steps[0] ?? r.o;\n return `<div class=\"row${r.steps.map((s) => ' h' + s).join('')}${r.steps.some(differs) ? ' rdf' : ''}\"><span class=\"gut\">${r.steps.map((s) => A(s, `sd sd${s}${differs(s) ? ' df' : ''}`, s + 1, open(s) + (differs(s) ? `, differs from base in ${kindsTxt(DIFF.steps[s].kinds)}` : ''))).join('')}</span>${A(s0, 'area', lls + body, open(s0))}</div>`;\n};\nconst actorOf = (n) => topic.actors.find((a) => a.name === n), aboutOf = (n) => actorOf(n)?.about ?? '', roleOf = (n) => actorOf(n)?.role ?? actorOf(n)?.kind ?? '';\nconst rng = (c) => `${c.from + 1}${c.to > c.from ? '-' + (c.to + 1) : ''}`;\nconst stepsWord = (c) => c.to > c.from ? `Steps ${c.from + 1} to ${c.to + 1}` : `Step ${c.from + 1}`;\nconst chHead = (c) => `<a class=\"chh\" href=\"${href(c.from)}\" title=\"Go to step ${c.from + 1}\"><span class=\"rng\">${c.k + 1}</span> ${titleOf(c)} <span class=\"dim\">${stepsWord(c)}</span> ${chDf(c)}${c.src != null && STALE[`ch${c.src}`] ? ' <span class=\"dk\">Title written for the base recording</span>' : ''}</a>`;\nconst seqHtml = `<div class=\"seq\"><div class=\"row heads\"><span class=\"gut\"></span><span class=\"area\">${LIFE.map((a) => `<span class=\"lh\" title=\"${esc([roleOf(a.name), aboutOf(a.name)].filter(Boolean).join('. '))}\"><b>${esc(a.name)}</b>${a.sub ? `<span><code>${esc(a.sub)}</code></span>` : ''}</span>`).join('')}</span></div>${CH.map((c) => chHead(c) + xRows.filter((r) => r.ch === c.k).map(seqRow).join('')).join('')}</div>`;\nconst stepsHtml = `<div class=\"steps\"><div class=\"sh\"><span>Step</span><span>Where</span><span>What happens</span></div>${CH.map((c) => chHead(c) + `<div class=\"chg chg${c.k}\">` + c.idx.map((i) => `<a class=\"step sr sr${i}\" id=\"step-${i + 1}\" href=\"${href(i)}\" title=\"${open(i)}\"><span class=\"sn\">${i + 1}</span><span class=\"wh\"><b title=\"${esc(aboutOf(actorOfUrl(P[i].frames[0].url)))}\">${esc(actorOfUrl(P[i].frames[0].url) ?? '')}</b> <span class=\"role\">${esc(roleOf(actorOfUrl(P[i].frames[0].url)))}${laneSub(lane(actorOfUrl(P[i].frames[0].url), i)) ? `, <code>${esc(laneSub(lane(actorOfUrl(P[i].frames[0].url), i)))}</code>` : ''}</span><br> <code>${esc(topFn(i))}</code><span class=\"loc\">${wherePath(keyOf(P[i]))}</span></span><span class=\"what\">${code(noteOf[i].text)}${dfTag(i)}</span></a>`).join('') + '</div>').join('')}</div>`;\n// The chapter list (start view and Chapters level): a row per chapter with a stretched \"Go to step N\" link, so terms stay clickable above it.\nconst chapterList = `<div class=\"chs\">${CH.map((c) => `<div class=\"chr chr${c.k}\"><span class=\"cnum\">${c.k + 1}</span><div class=\"ct\">${c.title ? termify(c.title) : whoOf(c)} ${chDf(c)}${c.src != null ? baseTag(`ch${c.src}`) : ''}<span class=\"rng2\">${stepsWord(c)}</span></div><a class=\"go2\" href=\"${href(c.from)}\">Go to step ${c.from + 1}</a></div>`).join('')}${topic.outcome ? `<div class=\"chr out\"><span class=\"cnum\"></span><div class=\"ct\"><b>Outcome</b> ${termify(topic.outcome)}${baseTag('outcome')}</div></div>` : ''}</div>`;\nconst INV = topic.invariants ?? [];\nconst lower1 = (x) => x.charAt(0).toLowerCase() + x.slice(1);\nconst rulesHtml = INV.length ? `<details class=\"rules\" open><summary>Rules this flow is checked against (${INV.length})</summary><ol>${INV.map((v) => { const j = invAt(v); return `<li><div class=\"ir\">${termify(v.rule)}</div>${ruleTag(v)}<div class=\"irf\"><span class=\"loc\">${wherePath(`${v.file}:${v.line}`)}</span>${j >= 0 ? A(j, 'go', `Step ${j + 1}`, open(j)) : ''}</div></li>`; }).join('')}</ol></details>` : '';\nconst pv = rec.provenance;\nconst provenance = pv ? `A recorded run of the real code at commit <code>${esc(pv.commit)}</code>${pv.changed?.length ? ` with uncommitted changes to ${andList(pv.changed.map((f) => `<code>${esc(f)}</code>`))}` : ''}, scenario <code>${esc(rel('file://' + pv.scenario))}</code>, recorded ${esc(pv.date)}, ${pv.pauses} pauses.`\n : `A recorded run of the real code under <code>${esc(ROOT)}</code>, scenario <code>${esc(rel('file://' + (rec.scenario ?? topic.scenario)))}</code>, ${N} pauses. This recording predates provenance, so its commit and date are not stored.`;\nconst startHtml = `<section class=\"start\"><div class=\"sw\">\n<div class=\"story\">${topic.story ? termify(topic.story) : code(topic.summary ?? '')}</div>${baseTag('story')}<p class=\"prov\">${provenance}</p>\n<h2 class=\"vbg-heading-16\">Who takes part</h2><table class=\"cast\"><tbody>${topic.actors.map((a) => `<tr><td><b>${esc(a.name)}</b></td><td class=\"dim\">${esc(a.role ?? a.kind)}</td><td>${code(a.about ?? '')}</td></tr>`).join('')}</tbody></table>\n<div class=\"go1\"><a class=\"vbg-button\" data-variant=\"primary\" href=\"#step-1\">Start at step 1</a><span class=\"dim\">or pick a chapter below</span></div>\n${rulesHtml}<h2 class=\"vbg-heading-16\">Chapters</h2>${chapterList}<footer class=\"ft\"><span class=\"vbg-logo\" role=\"img\" aria-label=\"Vercel\"></span></footer></div></section>`;\n\n// Inspector panels, in a fixed order.\nconst full = (v) => { if (isPrim(v) || longStr(v)) return fmtPrim(v); const j = JSON.stringify(plain(v), null, 1).replace(/\\[\\s+([^[\\]{}]*?)\\s+\\]/g, (m, a) => `[${a.replace(/\\s*\\n\\s*/g, ' ')}]`), n = j.split('\\n').length;\n return n > 12 ? `<details class=\"more\"><summary>${Array.isArray(plain(v)) ? 'array' : 'object'}, ${n} lines</summary><span class=\"v-o pre\">${esc(j)}</span></details>` : `<span class=\"v-o pre\">${esc(j)}</span>`; };\nconst inScope = (b) => Object.keys(b).filter((n) => b[n]?.t !== 'function' && n !== 'this');\nconst changedPanel = (i) => {\n const b = varsAt(i), j = prevSame[i];\n let vt;\n if (j == null) vt = `<p class=\"dim\">First pause in this call of <code>${esc(topFn(i))}</code>, so nothing here has an earlier value. ${plural(inScope(b).length, 'variable')} in scope, listed under Variables in Panels.</p>`;\n else { const a = varsAt(j), c = inScope(b).filter((n) => !same(a[n], b[n]));\n vt = c.length ? `<p class=\"sub\">Since ${A(j, 'go', `step ${j + 1}`, open(j))}, the last pause in this call</p><table class=\"t sm\"><thead><tr><th>Name</th><th>Before</th><th>After</th></tr></thead><tbody>${c.map((n) => `<tr><td><code>${esc(n)}</code></td><td class=\"val\">${n in a ? (holderOf(a, n) ? `<span class=\"dim\">Included in <code>${esc(holderOf(a, n))}</code></span>` : full(a[n])) : '<span class=\"dim\">new</span>'}</td><td class=\"val\">${holderOf(b, n) ? `<span class=\"dim\">Included in <code>${esc(holderOf(b, n))}</code></span>` : full(b[n])}</td></tr>`).join('')}</tbody></table>` : `<p class=\"dim\">No variable changed since ${A(j, 'go', `step ${j + 1}`, open(j))}, the last pause in this call.</p>`; }\n const rw = diffs[i].map((d) => { const parts = [];\n if (d.added.length) parts.push(`<details class=\"more\"><summary>${d.added.length} ${d.added.length > 1 ? 'rows' : 'row'} added</summary><table class=\"t sm kvt\"><tbody>${d.added.map((r) => `<tr><th>${esc(pk(d.t, r))}</th><td class=\"pre\">${esc(Object.entries(r).filter(([, v]) => v !== null).map(([k, v]) => `${k} = ${sv(v)}`).join(', '))}</td></tr>`).join('')}</tbody></table></details>`);\n for (const ch of d.changed) parts.push(`row <code>${esc(ch.id)}</code> ${ch.cols.map((y) => `<code>${esc(y.c)}</code> from <span class=\"pre\">${esc(sv(y.from))}</span> to <span class=\"pre\">${esc(sv(y.to))}</span>`).join(', ')}`);\n if (d.removed.length) parts.push(`${d.removed.length} ${d.removed.length > 1 ? 'rows' : 'row'} deleted (${d.removed.map(esc).join(', ')})`);\n return `<li><code>${esc(d.t)}</code> ${parts.join('; ')}</li>`; });\n if (batch && i === batch.done) for (const [t, c] of batchRows) if (!diffs[i].some((d) => d.t === t)) rw.push(`<li><code>${esc(t)}</code> ${c} ${c > 1 ? 'rows' : 'row'} written. The recording doesn't snapshot this table, so its rows aren't shown.</li>`);\n return `${vt}${rw.length ? `<p class=\"sub\">Store rows written since the previous step${batch && i === batch.done ? `, ${rowsChanged} in all, the same count as the batch answer` : ''}</p><ul class=\"rowl\">${rw.join('')}</ul>` : ''}`;\n};\nconst stackPanel = (i) => { const s = stacks[i], showA = new Set(s.map((f) => actorOfUrl(f.url) ?? '')).size > 1;\n const tbl = (fs, head) => `<table class=\"t sm\">${head ? `<thead><tr><th>Function</th><th>File and line</th>${showA ? '<th>Actor</th>' : ''}</tr></thead>` : ''}<tbody>${fs.map((f) => `<tr><td><code>${esc(f.fn)}</code>${f.async ? ' <span class=\"dim\">awaited</span>' : ''}</td><td class=\"loc\">${esc(rel(f.url))}:${f.line}</td>${showA ? `<td>${esc(actorOfUrl(f.url) ?? '')}</td>` : ''}</tr>`).join('')}</tbody></table>`;\n return `${tbl(s.slice(0, 3), true)}${s.length > 3 ? `<details class=\"more\"><summary>${s.length - 3} more frames</summary>${tbl(s.slice(3), false)}</details>` : ''}${showA ? '' : `<p class=\"dim\">Every frame is in <b>${esc(actorOfUrl(s[0].url) ?? 'one place')}</b>.</p>`}`; };\nconst longStr = (v) => v?.t === 'string' && String(v.v).split('\\n').length > 3;\nconst holderOf = (b, n) => longStr(b[n]) ? inScope(b).find((m) => m !== n && b[m]?.t === 'string' && String(b[m].v).length > String(b[n].v).length && String(b[m].v).includes(String(b[n].v))) : undefined;\nconst varsPanel = (i) => { const b = varsAt(i), a = prevSame[i] != null ? varsAt(prevSame[i]) : null;\n return `<table class=\"t sm\"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>${inScope(b).map((n) => { const ch = a && !same(a[n], b[n]), h = holderOf(b, n);\n const v = h ? `<span class=\"dim\">Included in <code>${esc(h)}</code>, shown in that row.</span>` : valCell(b[n]);\n return `<tr${ch ? ' class=\"dupv\"' : ''}><td><code>${esc(n)}</code></td><td class=\"val\">${ch ? '<span class=\"seewc dim\">Changed, see What changed.</span><span class=\"vfull\">' + v + '</span>' : v}</td></tr>`; }).join('')}</tbody></table>`; };\nconst crossAt = (j) => reqs.some((r) => r.start === j) ? 'request' : reqs.some((r) => r.end === j) ? 'response' : batch && j === batch.at ? 'SQL batch' : batch && j === batch.done ? 'SQL answer' : null;\nconst wirePanel = (i) => { const w = wireTables(i); if (w) return w; let j = i + 1; while (j < N && !crossAt(j)) j++;\n return `<p class=\"dim\">Nothing crosses a boundary here. ${j < N ? `The next ${crossAt(j)} is at ${A(j, 'go', `step ${j + 1}`, open(j))}.` : 'No later step crosses one.'}</p>`; };\nconst rulesAt = (i) => INV.map((v, k) => ({ v, k })).filter(({ v }) => invAt(v) === i);\nconst invPanel = (i) => { const vs = rulesAt(i); return vs.length ? `<ul class=\"invl\">${vs.map(({ v, k }) => `<li>Rule ${k + 1}. ${termify(v.rule)} <span class=\"loc\">${esc(v.file)}:${v.line}</span>${ruleTag(v)}</li>`).join('')}</ul>` : `<p class=\"dim\">This step shows none of the ${INV.length} rules.</p>`; };\n// On a head page, a note at a step whose source changed was carried from the base run: its values are this run's, but its reasoning may not be.\nconst notePanel = (i) => `<div class=\"note\">${termify(noteOf[i].text)}</div>${DIFF && DIFF.steps[i].kinds.includes('source') ? `<div class=\"btag\">Written for the base recording. The source at <code>${esc(rel(P[i].frames[0].url))}:${DIFF.steps[i].at}</code> changed, so check this note against the head code.</div>` : ''}`;\nconst SHOW = [['note', 'Note', 'what this step shows, in one sentence', notePanel], ['source', 'Source', 'the code around the paused line', linesHtml], ['changed', 'What changed', 'the values that changed since the last pause in the same call, before and after', changedPanel], ['stack', 'Call stack', 'the functions that led here, innermost first', stackPanel], ['vars', 'Variables', 'every variable in scope at this line', varsPanel], ['wire', 'SQL, wire and recorded expressions', 'requests, responses and SQL that cross a boundary here, and the recorded expressions', wirePanel], ['inv', 'Rules', 'the rules from the start page that this step shows', invPanel]];\nconst LEVELS = [['ch', 'Chapters', chapterList + rulesHtml, 'Pick a chapter to go to its first step.'], ['seq', 'Sequence diagram', seqHtml, 'Click a numbered circle or an arrow to open that step.'], ['steps', 'Steps', stepsHtml, 'Click a row to open that step.']];\nconst defs = Object.fromEntries((process.env.VIEWER_DEFAULTS ?? '').split(';').filter(Boolean).map((kv) => kv.split('=')));\nconst lvOn = defs.level ?? 'ch', inspOn = (defs.inspector ?? 'on') !== 'off', showOn = new Set((defs.show ?? 'note,changed').split(','));\nif (!LEVELS.some(([k]) => k === lvOn) || [...showOn].some((k) => !SHOW.some(([s]) => s === k))) { console.error(`bad VIEWER_DEFAULTS ${process.env.VIEWER_DEFAULTS}`); process.exit(2); }\nconst diffBlock = (i) => { if (!differs(i)) return ''; const d = DIFF.steps[i];\n return `<div class=\"dfl\">${dfTag(i)}${d.was != null ? `<div class=\"srcdiff\"><div class=\"ln was\"><span class=\"no\">base</span><span class=\"src\"><del>${esc(d.was.trim())}</del></span></div><div class=\"ln now\"><span class=\"no\">head</span><span class=\"src\">${esc(d.now.trim())}</span></div><div class=\"dim\">${esc(rel(P[i].frames[0].url))}:${d.at}</div></div>` : ''}${d.detail.length ? `<div class=\"dd2\">${d.detail.map(esc).join('; ')}</div>` : ''}</div>`; };\nconst badge = (i) => rulesAt(i).map(({ v, k }) => `<div class=\"badge\">This step shows rule ${k + 1}: ${termify(lower1(v.rule))}${ruleTag(v)}</div>`).join('');\nconst article = (i) => `<article class=\"art art${i}\">${diffBlock(i)}<p class=\"emp\">No panel is on. Pick some under Panels above.</p>${SHOW.map(([k, n, , f]) => `<section class=\"pn p-${k}\"><h3 class=\"vbg-heading-16\">${n}</h3>${f(i)}</section>${k === 'note' ? badge(i) : ''}`).join('')}</article>`;\nconst bar = P.map((_, i) => { const c = CH[chapOf[i]]; return `<div class=\"stp s${i}\"><span class=\"cnt\">Step ${i + 1} of ${N}</span><span class=\"chn\">Chapter ${c.k + 1} of ${CH.length}, ${c.title ? code(c.title) : whoOf(c)}</span><span class=\"btns\">${i ? A(i - 1, 'vbg-button', 'Previous', open(i - 1)) : '<span class=\"vbg-button btn off\">Previous</span>'}${i < N - 1 ? `<a class=\"vbg-button\" data-variant=\"primary\" href=\"${href(i + 1)}\" title=\"${esc(open(i + 1))}\">Next</a>` : '<span class=\"vbg-button btn off\" data-variant=\"primary\">Next</span>'}</span></div>`; }).join('');\nconst legend = `<details class=\"dd\"><summary class=\"vbg-button\">How to read this</summary><div class=\"ddc\"><ul class=\"lgd\">\n<li><i class=\"lg arr1\"></i>A solid arrow is a call or a request from one actor to another.${reqs.length || batch ? ` Its label names the ${andList([reqs.length && 'HTTP request', batch && 'SQL statement'].filter(Boolean))}.` : ''}</li>\n<li><i class=\"lg arr2\"></i>A dashed arrow is the response to a request.</li>\n${TABLES.length ? '<li><i class=\"lg sq\"></i>A square is a write to a store the run made between two steps, with the tables and rows written.</li>' : ''}\n<li><span class=\"sd\">7</span>A numbered circle is a step. Click it to open that step.</li>\n${DIFF ? '<li><span class=\"sd df\">7</span><span class=\"dk\">Differs</span>A red ring or a red tag means the step differs from the base recording.</li>' : ''}\n<li><i class=\"lg hl\"></i>A highlighted row is the current step.</li>\n<li><span class=\"term-demo\">term</span>A dotted underline is a term. Click it to read its definition.</li>\n<li>A step is one pause of the recorded run at a line the page's author chose, in the order the run reached them. The page replays the recording; nothing runs live.</li>\n<li>A recorded expression is a value the page's author asked the recorder to evaluate at that pause, shown in What changed and in SQL, wire and recorded expressions.</li>\n</ul></div></details>`;\nconst glossary = GL.length ? `<details class=\"dd\"><summary class=\"vbg-button\">Glossary</summary><div class=\"ddc\"><dl class=\"gl\">${[...GL].sort((x, y) => x.term.localeCompare(y.term, 'en', { sensitivity: 'base' })).map((g) => `<dt>${/^[a-z][a-z ]*$/.test(g.term) ? esc(g.term) : `<code>${esc(g.term)}</code>`}</dt><dd>${esc(g.def)}</dd>`).join('')}</dl></div></details>` : '';\nconst sel = (i) => `body:has(#step-${i + 1}:target)`;\nconst masks = Array.from({ length: 1 << SHOW.length }, (_, m) => `.ictl${SHOW.map(([k], b) => (m >> b) & 1 ? `:has(#sh-${k}:checked)` : `:not(:has(#sh-${k}:checked))`).join('')} .pc${[...m.toString(2)].filter((x) => x === '1').length}{display:inline}`);\n// Vercel's published Geist foundation, inlined with its logo images embedded as data URLs so the page opens offline: its tokens, type and controls.\nconst VBG_URL = 'https://vercel.com/geist/vercel-brand.css';\nconst vbgRes = await fetch(VBG_URL, { signal: AbortSignal.timeout(30_000) });\nif (!vbgRes.ok || !/text\\/css/.test(vbgRes.headers.get('content-type') ?? '')) throw new Error(`${VBG_URL}: HTTP ${vbgRes.status} ${vbgRes.headers.get('content-type')}`);\nconst vbgRemote = await vbgRes.text();\nconst vbgAssets = new Map(await Promise.all([...new Set(vbgRemote.match(/https:\\/\\/[^\"')\\s]+/g) ?? [])].map(async (url) => {\n const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });\n const type = res.headers.get('content-type')?.split(';')[0] ?? '';\n if (!res.ok || !type.startsWith('image/')) throw new Error(`${url}: HTTP ${res.status} ${type}`);\n return [url, `data:${type};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`];\n})));\nconst vbgCss = vbgRemote.replace(/https:\\/\\/[^\"')\\s]+/g, (url) => vbgAssets.get(url));\nconst mix = (token, pct) => `color-mix(in oklch,var(${token}) ${pct}%,var(--vbg-surface-primary))`;\nconst SEL = mix('--vbg-text-primary', 10), ADD = mix('--vbg-color-success', 14), REM = mix('--vbg-color-error', 12), REM_ROW = mix('--vbg-color-error', 6), CHG = mix('--vbg-color-warning', 16);\nconst CHEVRON = `url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M14.06 5.5l-.53.53-4.82 4.82a1 1 0 0 1-1.42 0L2.47 6.03 1.94 5.5 3 4.44l.53.53L8 9.44l4.47-4.47.53-.53 1.06 1.06z'/%3E%3C/svg%3E\")`;\nconst rules = [\n 'body:has(.step:target) .start{display:none}body:not(:has(.step:target)) :is(.cols,.stepbar,.help){display:none}',\n ...LEVELS.map(([k]) => `body:has(#lv-${k}:checked) .h-${k}{display:block}body:has(#lv-${k}:checked) .lv-${k}{visibility:visible;z-index:1}`),\n ...SHOW.map(([k]) => `body:not(:has(#sh-${k}:checked)) .p-${k}{display:none}`),\n 'body:not(:has(.show:checked)) .emp{display:block}',\n 'body:has(#sh-changed:checked) .dupv .vfull{display:none}body:has(#sh-changed:checked) .dupv .seewc{display:inline}',\n 'body:has(#lv-steps:checked) .allsteps{display:inline-flex}body:has(#sh-all:checked) .chg .sr{display:grid}',\n ...CH.map((c) => `:is(${c.idx.map(sel).join(',')}) .chg${c.k} .sr{display:grid}`),\n ...masks,\n 'body:not(:has(#sh-insp:checked)) .cols{grid-template-columns:minmax(0,1fr)}body:not(:has(#sh-insp:checked)) .col.insp{display:none}body:not(:has(#sh-insp:checked)) .col.nav{border-right:0}',\n ...CH.map((c) => `:is(${c.idx.map(sel).join(',')}) .cols .chr${c.k}{background:${SEL}}`),\n ...P.map((_, i) => { const S = sel(i); return `${S} .stp.s${i}{display:flex}${S} .art${i}{display:block}${S} .sr${i}{background:${SEL}}${S} .sr${i} .sn,${S} .sd${i}{background:var(--vbg-text-primary);color:var(--vbg-surface-primary);border-color:var(--vbg-text-primary)}${S} .seq .h${i},${sel(i)} .h${i} .lab{background:${SEL}}`; }),\n].join('\\n');\nconst colourCss = [...colours].map(([k, c]) => { const [l] = k.split('|'); return `.${c}{color:${l}}`; }).join('') + '@media (prefers-color-scheme:dark){' + [...colours].map(([k, c]) => `.${c}{color:${k.split('|')[1]}}`).join('') + '}';\nconst fontDir = `${dirname(require.resolve('geist/font'))}/fonts`;\nconst b64 = (f) => readFileSync(`${fontDir}/${f}`).toString('base64');\nconst html = `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>${esc(topic.title)}</title><style>${vbgCss}</style><style>\n@font-face{font-family:Geist;src:url(data:font/woff2;base64,${b64('geist-sans/Geist-Variable.woff2')}) format(\"woff2\");font-weight:100 900}\n@font-face{font-family:\"Geist Mono\";src:url(data:font/woff2;base64,${b64('geist-mono/GeistMono-Regular.woff2')}) format(\"woff2\")}\nhtml,body{margin:0;height:100%}body{overflow:hidden;font-size:var(--vbg-type-compact);line-height:var(--vbg-leading-compact)}\na{color:inherit;text-decoration:none}\ncode{overflow-wrap:anywhere}.loc,.code,.pre{font-family:\"Geist Mono\",ui-monospace,monospace;font-size:12px}\n.dim{color:var(--vbg-text-secondary);font-weight:var(--vbg-weight-regular)}.loc{color:var(--vbg-text-secondary);overflow-wrap:anywhere}\n.app{height:100vh;display:grid;grid-template-rows:auto minmax(0,1fr);grid-template-columns:minmax(0,1fr)}\n.top{border-bottom:1px solid var(--vbg-border-subtle);padding:var(--vbg-space-3) var(--vbg-space-6);background:var(--vbg-surface-primary);z-index:5;position:relative}\n.trow{display:flex;align-items:center;gap:var(--vbg-space-4)}.top h1{flex:1;min-width:0;margin:0;overflow-wrap:anywhere}\n.tools{display:flex;gap:var(--vbg-space-2);flex:none}\n.dd{position:relative}.dd>summary{list-style:none;white-space:nowrap;box-sizing:border-box}.dd>summary::-webkit-details-marker{display:none}\n.term,.dd,.stq,.clip,.more,.rules{border-top:0;padding-block:0}.term>summary,.stq>summary,.clip>summary,.more>summary{font-weight:inherit}\n.more>summary,.rules>summary{list-style:none;display:flex;white-space:nowrap;align-items:center;gap:var(--vbg-space-1)}.more>summary::-webkit-details-marker,.rules>summary::-webkit-details-marker{display:none}\n.more>summary::before,.rules>summary::before{transform:rotate(-90deg)}.more[open]>summary::before,.rules[open]>summary::before{transform:none}.more>summary::before{width:12px;height:12px}\n.dd>summary::after,.stq>summary>span:first-child::before,.more>summary::before,.rules>summary::before{content:\"\";display:inline-block;flex:none;width:16px;height:16px;background:currentColor;-webkit-mask:${CHEVRON} center/contain no-repeat;mask:${CHEVRON} center/contain no-repeat}\n.ddc{position:absolute;right:0;top:calc(100% + var(--vbg-space-2));z-index:30;width:440px;max-height:70vh;overflow:auto;background:var(--vbg-surface-primary);border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius);box-shadow:0 4px 8px -4px rgba(0,0,0,.08),0 16px 24px -8px rgba(0,0,0,.12);padding:var(--vbg-space-3) var(--vbg-space-4)}\n.lgd{list-style:none;margin:0;padding:0}.lgd li{display:flex;gap:var(--vbg-space-3);align-items:center;padding:var(--vbg-space-1) 0}.lgd li>:first-child{flex:none}\n.lg{display:inline-block;width:32px;height:12px;position:relative;color:var(--vbg-text-secondary)}.lg.arr1{border-bottom:1.5px solid currentColor;height:7px}.lg.arr1::after,.lg.arr2::after{content:\"\";position:absolute;right:-2px;bottom:-5px;border:4px solid transparent;border-left:7px solid currentColor;border-right:0}\n.lg.arr2{border-bottom:1.5px dashed currentColor;height:7px}.lg.sq{width:12px;height:12px;border-radius:2px;background:currentColor;margin:0 10px}.lg.hl{background:${SEL};height:16px}\n.term-demo{text-decoration:underline dotted var(--vbg-border-strong);text-underline-offset:3px}\n.gl{margin:0}.gl dt{font-weight:var(--vbg-weight-semibold);margin-top:var(--vbg-space-2)}.gl dt:first-child{margin-top:0}.gl dd{margin:var(--vbg-space-1) 0 0;color:var(--vbg-text-secondary)}\n.stepbar{margin-top:var(--vbg-space-2)}.stp{display:none;align-items:center;gap:var(--vbg-space-4);min-width:0}.cnt{flex:none;font-weight:var(--vbg-weight-semibold);font-variant-numeric:tabular-nums}\n.chn{flex:1;min-width:0;color:var(--vbg-text-secondary);font-size:var(--vbg-type-label)}.chn code{font-size:12px}\n.btns{flex:none;display:flex;gap:var(--vbg-space-2)}.btn.off{opacity:.5;cursor:not-allowed;pointer-events:none}\n.help{margin:var(--vbg-space-1) 0 0;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}.help>span{display:none}.help>span.hn{display:inline}\n.banner{margin:var(--vbg-space-1) 0 0;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}.banner b{color:var(--vbg-text-primary);font-weight:var(--vbg-weight-medium)}.banner .bl{color:var(--vbg-text-primary);text-decoration:underline;text-decoration-color:var(--vbg-border-strong);text-underline-offset:3px}\n.start{overflow:auto;min-height:0}.sw{max-width:760px;margin:0 auto;padding:var(--vbg-space-8) var(--vbg-space-6) var(--vbg-space-10)}\n.story{font-size:var(--vbg-type-lede);line-height:var(--vbg-leading-lede)}.prov{font-size:var(--vbg-type-metadata);line-height:var(--vbg-leading-caption);color:var(--vbg-text-secondary);margin:var(--vbg-space-3) 0 0}.prov code{background:none;border:0;padding:0}\n.start h2{margin:var(--vbg-space-8) 0 var(--vbg-space-2)}\n.cast{border-collapse:collapse;width:100%}.cast td{padding:var(--vbg-space-2) var(--vbg-space-3) var(--vbg-space-2) 0;border-bottom:1px solid var(--vbg-border-subtle);vertical-align:baseline}.cast td:first-child{white-space:nowrap;width:1%}.cast td:nth-child(2){width:1%;padding-right:var(--vbg-space-4)}\n.go1{display:flex;align-items:center;gap:var(--vbg-space-4);margin:var(--vbg-space-6) 0 var(--vbg-space-1)}\n.chs{border-top:1px solid var(--vbg-border-default)}\n.chr{position:relative;display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:0 var(--vbg-space-3);align-items:baseline;padding:var(--vbg-space-3);border-bottom:1px solid var(--vbg-border-subtle)}.chr:hover{background:var(--vbg-surface-secondary)}\n.cnum{font-size:var(--vbg-type-label);font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);text-align:right}.ct{font-weight:var(--vbg-weight-medium);overflow-wrap:anywhere}.rng2{display:block;font-weight:var(--vbg-weight-regular);font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}\n.go2{font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);white-space:nowrap}.go2::after{content:\"\";position:absolute;inset:0}.chr:hover .go2{color:var(--vbg-text-primary);text-decoration:underline;text-underline-offset:3px}\n.chr .term,.ir .term,.note .term,.story .term,.badge .term,.invl .term{position:relative;z-index:2}\n.chr.out .ct{font-weight:var(--vbg-weight-regular)}.chr.out b{margin-right:var(--vbg-space-2)}\n.term{display:inline-block}.term[open]{display:block}.term>summary{display:inline;list-style:none;cursor:help;text-decoration:underline dotted var(--vbg-border-strong);text-underline-offset:3px}.term>summary::-webkit-details-marker{display:none}.term>summary:hover,.term[open]>summary{text-decoration-color:currentColor}\n.term .def{display:block;margin:var(--vbg-space-1) 0 var(--vbg-space-2);padding:var(--vbg-space-2) var(--vbg-space-3);font-size:var(--vbg-type-compact);line-height:var(--vbg-leading-compact);font-weight:var(--vbg-weight-regular);color:var(--vbg-text-primary);background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small)}.term .def b{margin-right:var(--vbg-space-2)}\n.rules{margin:var(--vbg-space-6) 0 0;border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius);padding:0 var(--vbg-space-4)}.rules>summary{cursor:pointer;font-weight:var(--vbg-weight-semibold);padding:var(--vbg-space-3) 0}.rules ol{margin:0 0 var(--vbg-space-3);padding-left:var(--vbg-space-6)}.rules li{margin:0 0 var(--vbg-space-3)}.irf{display:flex;gap:var(--vbg-space-3);align-items:baseline;margin-top:var(--vbg-space-1)}\n.lv .chs{border-top:0}.lv .rules{margin:var(--vbg-space-3)}\n.cols{display:grid;grid-template-columns:minmax(0,45fr) minmax(0,55fr);min-height:0}\n.col{display:flex;flex-direction:column;min-height:0;min-width:0}.col.nav{border-right:1px solid var(--vbg-border-subtle)}\n.ctl{flex:none;display:flex;flex-wrap:wrap;align-items:center;gap:var(--vbg-space-2) var(--vbg-space-3);padding:var(--vbg-space-2) var(--vbg-space-4);border-bottom:1px solid var(--vbg-border-subtle)}\n.clab{font-size:var(--vbg-type-label);font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary)}\n.spec{display:flex;flex-direction:column;gap:var(--vbg-space-1)}.seg{display:flex;gap:2px;padding:2px;border:1px solid var(--vbg-border-default);border-radius:var(--vbg-radius-small);background:var(--vbg-surface-secondary)}\n.seg input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}\n.seg label{padding:var(--vbg-space-1) var(--vbg-space-3);font-size:var(--vbg-type-label);font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);cursor:pointer;border-radius:4px}\n.seg label:hover{color:var(--vbg-text-primary)}.seg input:checked+label{background:var(--vbg-surface-primary);color:var(--vbg-text-primary);box-shadow:0 0 0 1px var(--vbg-border-default)}.seg input:focus-visible+label{outline:2px solid var(--vbg-focus);outline-offset:1px}\n.cap{font-size:12px;color:var(--vbg-text-secondary);text-align:center}\n.chk{display:inline-flex;align-items:center;gap:var(--vbg-space-2);font-size:var(--vbg-type-label);cursor:pointer;white-space:nowrap}.chk input{accent-color:var(--vbg-text-primary);margin:0;width:16px;height:16px;cursor:pointer;flex:none}\n.navshow{margin-left:auto}.allsteps{display:none}.chg .sr{display:none}.role{font-size:12px;color:var(--vbg-text-secondary)}\n.seewc{display:none}.btag{margin:var(--vbg-space-1) 0 2px;padding:2px var(--vbg-space-2);font-size:12px;line-height:16px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-warning);background:${CHG};border-radius:4px;display:block}\n.stl{font-size:12px;border-top:1px solid var(--vbg-border-default)}.sth,.stq>summary{display:grid;grid-template-columns:minmax(76px,22%) minmax(0,1fr) 92px;gap:0 var(--vbg-space-3);padding:var(--vbg-space-1) var(--vbg-space-2)}.sth{font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-secondary);border-bottom:1px solid var(--vbg-border-default)}.sth span:last-child{text-align:right}\n.stq{border-bottom:1px solid var(--vbg-border-subtle)}.stq>summary{list-style:none;cursor:pointer;font-family:\"Geist Mono\",monospace}.stq>summary::-webkit-details-marker{display:none}.stq>summary:hover{background:var(--vbg-surface-secondary)}.stq>summary>span:first-child::before{width:12px;height:12px;margin-right:var(--vbg-space-1);vertical-align:-2px;color:var(--vbg-text-secondary);transform:rotate(-90deg)}.stq[open]>summary>span:first-child::before{transform:none}\n.stq>summary code{background:none;border:0;padding:0;overflow-wrap:normal;word-break:keep-all;white-space:nowrap}.stq .num{text-align:right}.stb{padding:var(--vbg-space-1) var(--vbg-space-2) var(--vbg-space-3) var(--vbg-space-6)}.stb .pre{background:var(--vbg-surface-secondary);border-radius:4px;padding:var(--vbg-space-1) var(--vbg-space-2)}\n.stsplit{font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);padding:var(--vbg-space-2) var(--vbg-space-2) var(--vbg-space-1);border-bottom:1px solid var(--vbg-border-default)}\n.ictl{justify-content:space-between}.pc{display:none}\n.pnl .ddc{left:auto;right:0;width:400px}.pnl label{display:flex;align-items:flex-start;gap:var(--vbg-space-2);padding:var(--vbg-space-1) 0;cursor:pointer;white-space:normal}.pnl label input{margin-top:3px;accent-color:var(--vbg-text-primary)}.pnl label b{font-weight:var(--vbg-weight-medium);display:block}.pnl label span{font-size:var(--vbg-type-label);color:var(--vbg-text-secondary)}\n.scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain}\n/* Each detail level scrolls on its own and stays laid out while hidden, so switching levels keeps each one's scroll position. */\n.nav .scroll{position:relative;overflow:hidden}.lv{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;visibility:hidden}\n.art,.emp{display:none}\n.chh{display:block;font-weight:var(--vbg-weight-semibold);padding:var(--vbg-space-3) var(--vbg-space-3) var(--vbg-space-1);border-bottom:1px solid var(--vbg-border-default);cursor:pointer;overflow-wrap:anywhere}.chh:hover{text-decoration:underline;text-underline-offset:3px}.chh .dim{font-size:12px;margin-left:var(--vbg-space-1)}.rng{font-size:var(--vbg-type-label);font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);margin-right:var(--vbg-space-2)}\n.steps{font-size:var(--vbg-type-label)}.sh,.sr{display:grid;grid-template-columns:36px minmax(min-content,34%) minmax(0,1fr);gap:0 var(--vbg-space-3);padding:var(--vbg-space-2) var(--vbg-space-3)}.sh{font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-primary);border-bottom:1px solid var(--vbg-border-default);position:sticky;top:0;z-index:2}\n.sr{border-bottom:1px solid var(--vbg-border-subtle);cursor:pointer;scroll-margin-top:120px}.sr:hover{background:var(--vbg-surface-secondary)}.sr>span{min-width:0;overflow-wrap:anywhere}.sr>span.wh{overflow-wrap:normal}.sr .wh b{font-weight:var(--vbg-weight-medium)}.sr .wh .loc{display:block;margin-top:2px;overflow-wrap:normal;word-break:normal}\n.sn{display:inline-block;align-self:start;min-width:28px;text-align:center;font-size:12px;line-height:20px;font-variant-numeric:tabular-nums;border:1px solid var(--vbg-border-strong);border-radius:4px;color:var(--vbg-text-secondary)}.sr:hover .sn{border-color:var(--vbg-text-primary);color:var(--vbg-text-primary)}\n.seq{font-size:12px}.seq .row{display:flex;cursor:pointer}.seq .row:hover{background:var(--vbg-surface-secondary)}.seq .gut{display:flex;flex-wrap:wrap;gap:var(--vbg-space-1);align-content:flex-start;justify-content:flex-end;flex:0 0 60px;padding:var(--vbg-space-1) var(--vbg-space-2) 0 0}\n.seq .area{position:relative;flex:1;min-width:0;display:flex;flex-direction:column;padding:2px 0;min-height:24px;margin-right:var(--vbg-space-3)}.seq a.area{color:inherit}\n.sd{display:inline-block;min-width:20px;height:18px;text-align:center;border:1px solid var(--vbg-border-strong);border-radius:9px;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;color:var(--vbg-text-secondary);background:var(--vbg-surface-primary);cursor:pointer;padding:0 var(--vbg-space-1)}.sd:hover{border-color:var(--vbg-text-primary);color:var(--vbg-text-primary)}\n.ll{position:absolute;top:0;bottom:0;border-left:1px dashed var(--vbg-border-default)}.heads{position:sticky;top:0;z-index:3;background:var(--vbg-surface-primary);cursor:default!important;border-bottom:1px solid var(--vbg-border-subtle)}.heads:hover{background:var(--vbg-surface-primary)!important}.heads .area{min-height:40px}\n.seq .heads .area{flex-direction:row;align-items:flex-start}.lh{flex:1 1 0;min-width:0;text-align:center;padding:var(--vbg-space-1) var(--vbg-space-1) var(--vbg-space-2)}.lh b{display:block;font-size:12px;line-height:16px;font-weight:var(--vbg-weight-semibold);overflow-wrap:anywhere}.lh code{font-size:12px}.lh span{display:block;font-size:12px;line-height:16px;color:var(--vbg-text-secondary)}\n.lab{position:relative;background:var(--vbg-surface-primary);overflow-wrap:anywhere;font-family:\"Geist Mono\",monospace;font-size:12px;padding:0 var(--vbg-space-1);border-radius:4px}.row:hover .lab,.lab code{background:none;border:0;padding:0;font-size:12px;font-weight:var(--vbg-weight-semibold)}\n.alab{display:block;padding:0 var(--vbg-space-2)}\n.arr{position:relative;display:block;height:0;border-bottom:1.5px solid var(--vbg-text-secondary);margin:1px 0 var(--vbg-space-1);color:var(--vbg-text-secondary)}.arr.ret{border-bottom-style:dashed}\n.arr::after{content:\"\";position:absolute;bottom:-5px;border:4px solid transparent}.arr.ltr::after{right:-2px;border-left:7px solid currentColor;border-right:0}.arr.rtl::after{left:-2px;border-right:7px solid currentColor;border-left:0}\n.self{display:block;border-left:2px solid var(--vbg-border-default);padding-left:var(--vbg-space-2);color:var(--vbg-text-secondary)}.self .lab{font-family:Geist,sans-serif}.self .lab code{font-family:\"Geist Mono\",monospace;font-weight:var(--vbg-weight-medium)}\n.mk{display:block;position:relative;padding-left:var(--vbg-space-3)}.mk::before{content:\"\";position:absolute;left:-5px;top:4px;width:10px;height:10px;border-radius:2px;background:var(--vbg-text-secondary)}.mk.rt{padding-left:0;padding-right:var(--vbg-space-3);text-align:right}.mk.rt::before{left:auto;right:-5px}\n.dk{display:inline-block;font-family:Geist,sans-serif;font-size:12px;line-height:16px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-error);background:${REM};border-radius:4px;padding:0 var(--vbg-space-1);margin-left:var(--vbg-space-2);white-space:nowrap;vertical-align:1px}\n.what .dk{display:table;margin:var(--vbg-space-1) 0 0}.sd.df{border-color:var(--vbg-color-error);box-shadow:0 0 0 1px var(--vbg-color-error);color:var(--vbg-color-error)}.seq .row.rdf{background:${REM_ROW}}.lgd .dk{margin:0}\n.insp .scroll{padding:0 var(--vbg-space-6) var(--vbg-space-10)}\n.emp{color:var(--vbg-text-secondary);margin:var(--vbg-space-6) 0}\n.pn{padding:var(--vbg-space-3) 0;border-bottom:1px solid var(--vbg-border-subtle)}.pn h3{margin:0 0 var(--vbg-space-2)}.sub{font-size:12px;color:var(--vbg-text-secondary);margin:var(--vbg-space-2) 0 var(--vbg-space-1)}\n.note{font-size:var(--vbg-type-body);line-height:var(--vbg-leading-body)}\n.badge{margin:var(--vbg-space-2) 0 var(--vbg-space-1);padding:var(--vbg-space-2) var(--vbg-space-3);font-size:var(--vbg-type-label);background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small)}\n.dfl{margin:var(--vbg-space-3) 0 0;padding:var(--vbg-space-2) var(--vbg-space-3);border:1px solid var(--vbg-color-error);border-radius:var(--vbg-radius-small)}.dfl>.dk{margin:0 0 var(--vbg-space-2)}.dd2{font:12px/18px \"Geist Mono\",monospace;color:var(--vbg-text-secondary);overflow-wrap:anywhere;margin-top:var(--vbg-space-1)}\n.srcdiff{font:12px/18px \"Geist Mono\",monospace;margin-top:var(--vbg-space-2)}.srcdiff .dim{margin-top:2px}\n.ln.was{background:${REM}}.ln.was del{text-decoration:line-through;text-decoration-color:var(--vbg-color-error);color:var(--vbg-text-secondary)}.ln.now{background:${ADD}}.ln.was .no,.ln.now .no{font-family:Geist,sans-serif}\n.t{border-collapse:collapse;width:100%}.t th,.t td{text-align:left;vertical-align:baseline;padding:var(--vbg-space-1) var(--vbg-space-2);border-bottom:1px solid var(--vbg-border-subtle)}.t thead th{font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);background:var(--vbg-surface-secondary);border-bottom:1px solid var(--vbg-border-default)}\n.sm{font-size:12px}.sm td{overflow-wrap:anywhere;font-family:\"Geist Mono\",monospace}.sm td code{background:none;border:0;padding:0}.sm td:first-child code{white-space:nowrap}.kvt th{font:var(--vbg-weight-medium) 12px \"Geist Mono\",monospace;color:var(--vbg-text-secondary);width:1%;overflow-wrap:break-word;word-break:normal}\n.tagc{font-family:Geist,sans-serif;font-size:12px;font-weight:var(--vbg-weight-medium);color:var(--vbg-color-warning);background:${CHG};border-radius:4px;padding:0 var(--vbg-space-1)}\n.stmts tr.split td{border-top:2px solid var(--vbg-border-strong)}.num{text-align:right}.stmts td:nth-child(-n+2){white-space:nowrap}.stmts .sql{min-width:18ch}\n.pre{white-space:pre-wrap;overflow-wrap:anywhere}.val{overflow-wrap:anywhere}\n.rowl{margin:0;padding-left:var(--vbg-space-5);font-size:12px}.rowl li{margin:2px 0}\n.more summary,.clip summary{cursor:pointer;color:var(--vbg-text-secondary);font-size:12px;padding:var(--vbg-space-1) 0}.more summary:hover,.clip summary:hover{color:var(--vbg-text-primary)}.clip{display:inline}.clip[open] summary{display:none}.full{white-space:pre-wrap}\n.go{display:inline-block;font-size:var(--vbg-type-label);color:var(--vbg-text-secondary);text-decoration:underline;text-decoration-color:var(--vbg-border-strong);text-underline-offset:3px;white-space:nowrap}.go:hover{color:var(--vbg-text-primary);text-decoration-color:currentColor}\n.invl{margin:0;padding-left:var(--vbg-space-5)}.invl li{margin:0 0 var(--vbg-space-2)}\n.file{margin:0 0 var(--vbg-space-2);font-size:var(--vbg-type-label);overflow-wrap:anywhere}.file code{background:none;border:0;padding:0}.code{border:1px solid var(--vbg-border-subtle);border-radius:var(--vbg-radius-small);padding:var(--vbg-space-1) 0;overflow:hidden}\n.ln{display:flex;white-space:pre-wrap;overflow-wrap:anywhere;line-height:18px}.src{min-width:0;flex:1;padding-right:var(--vbg-space-2)}.ln.cur{background:${SEL}}.no{width:40px;flex:none;text-align:right;padding-right:var(--vbg-space-3);color:var(--vbg-text-secondary)}.ln.cur .no{color:var(--vbg-text-primary);font-weight:var(--vbg-weight-semibold)}\n.kv{border-collapse:collapse;font:12px/18px \"Geist Mono\",monospace;width:100%}.kv th{text-align:left;font-weight:var(--vbg-weight-medium);color:var(--vbg-text-secondary);padding:1px var(--vbg-space-3) 1px 0;vertical-align:baseline;overflow-wrap:break-word;word-break:normal;border:0}.kv td{overflow-wrap:anywhere;padding:1px 0;border:0}\n.iv{margin-left:var(--vbg-space-3);padding:0 var(--vbg-space-1);border-radius:4px;background:var(--vbg-surface-secondary);border:1px solid var(--vbg-border-subtle);font-size:12px;color:var(--vbg-text-secondary);white-space:normal;overflow-wrap:anywhere;display:inline-block;max-width:100%;vertical-align:top}.iv.new,.iv.chg{background:${CHG}}\n.v-s,.v-n,.v-b{color:var(--vbg-text-primary)}.v-u,.v-o,.v-f{color:var(--vbg-text-secondary)}\n.ft{display:flex;align-items:center;gap:var(--vbg-space-3);margin-top:var(--vbg-space-10);color:var(--vbg-text-secondary);font-size:var(--vbg-type-metadata)}\n@media (max-width:900px){body{overflow:auto}.app{height:auto;display:block}.top{position:sticky;top:0;padding:var(--vbg-space-3) var(--vbg-space-4)}.trow .vbg-identity{display:none}.cols{grid-template-columns:minmax(0,1fr)}.col.nav{border-right:0;border-bottom:1px solid var(--vbg-border-subtle)}.col.nav .scroll{height:60vh}.insp .scroll{overflow:visible}.sr{scroll-margin-top:160px}.stp{flex-wrap:wrap}}\n${colourCss}\n${rules}\n</style></head><body class=\"vbg-report\"><div class=\"app\">\n<header class=\"top\"><div class=\"trow\"><span class=\"vbg-identity\"><span class=\"vbg-wordmark\" role=\"img\" aria-label=\"Vercel\"></span></span><h1 class=\"vbg-heading-16\">${code(topic.title)}</h1><div class=\"tools\">${legend}${glossary}</div></div>${banner}<div class=\"stepbar\">${bar}</div><p class=\"help\">${LEVELS.map(([k, , , h]) => `<span class=\"h-${k}\">${h}</span>`).join('')} <span class=\"hn\">Use Previous and Next to step through.</span></p></header>\n${startHtml}\n<div class=\"cols\">\n<section class=\"col nav\" aria-label=\"Navigator\"><div class=\"ctl\"><span class=\"clab\">Detail</span><div class=\"spec\"><div class=\"seg\" role=\"radiogroup\" aria-label=\"Detail\">${LEVELS.map(([k, n]) => `<input type=\"radio\" name=\"level\" id=\"lv-${k}\"${k === lvOn ? ' checked' : ''}><label for=\"lv-${k}\">${n}</label>`).join('')}</div><div class=\"cap\">Less detail to more detail</div></div><label class=\"chk allsteps\"><input type=\"checkbox\" id=\"sh-all\">Show all ${N} steps</label><label class=\"chk navshow\"><input type=\"checkbox\" id=\"sh-insp\"${inspOn ? ' checked' : ''}>Show inspector</label></div>\n<div class=\"scroll\">${LEVELS.map(([k, , h]) => `<div class=\"lv lv-${k}\">${h}</div>`).join('')}</div></section>\n<section class=\"col insp\" aria-label=\"Inspector\"><div class=\"ctl ictl\"><span class=\"clab\">Inspector, the current step in detail</span><details class=\"dd pnl\"><summary class=\"vbg-button\">${SHOW.map((_, k) => `<span class=\"pc pc${k}\">Panels, ${k} of ${SHOW.length}</span>`).join('')}<span class=\"pc pc${SHOW.length}\">Panels, ${SHOW.length} of ${SHOW.length}</span></summary><div class=\"ddc\">${SHOW.map(([k, n, d]) => `<label><input type=\"checkbox\" class=\"show\" id=\"sh-${k}\"${showOn.has(k) ? ' checked' : ''}><span><b>${n}</b>${d}</span></label>`).join('')}</div></details></div>\n<div class=\"scroll\">${P.map((_, i) => article(i)).join('')}</div></section>\n</div></div></body></html>`;\nwriteFileSync(out, html);\nconsole.log(out, Buffer.byteLength(html));\n","check.mjs":"// Fails (exit 1) when a note or the topic's authored text says something the recording does not show.\n// Usage: node check.mjs topic.json recording.json notes.json [--base base-recording.json]\n// --base: for the head page of a two-recording pair. Authored values that hold in the base but not here are reported as\n// \"written for the base recording\" (the page tags them) instead of failing.\nimport { readFileSync } from 'node:fs';\nimport { readJson, loadTopic, rootOf, relTo, pointPath, urlOf, stackOf, hitsOf, stepAt, leavesOf, nameMatches, CITE, sameFile, WINDOW, invStep, chaptersOf, authoredOf, sourceOf } from './lib.mjs';\nconst argv = process.argv.slice(2);\nconst flag = (n) => { const k = argv.indexOf(n); if (k < 0) return null; const v = argv[k + 1]; argv.splice(k, 2); return v; };\nconst baseFile = flag('--base');\nconst [topicFile, recFile, notesFile] = argv;\nif (!notesFile) { console.error('usage: node check.mjs topic.json recording.json notes.json [--base base-recording.json]'); process.exit(2); }\nconst topic = loadTopic(topicFile), rec = readJson(recFile), notes = readJson(notesFile);\nconst P = rec.pauses, root = rootOf(topic, rec), rel = relTo(root, topic.dir), hits = hitsOf(P);\nconst fail = [], warn = [];\nconst points = new Map(topic.points.map((p) => [p.id, p]));\nconst hitPoints = new Set(P.map((p) => p.point));\n// A breakpoint position counts as recorded when its point was hit; so does the line V8 actually paused on.\nconst bpLines = new Set(topic.points.filter((p) => hitPoints.has(p.id)).map((p) => `${rel(urlOf(pointPath(topic, root, p)))}:${p.line}`));\nfor (const p of P) bpLines.add(`${rel(p.frames[0].url)}:${p.frames[0].line}`);\nconst covered = new Set();\nlet refs = 0, values = 0;\n// Register: no em dash and no ≠ in anything the page prints from the author.\nconst register = (s, at) => { for (const [ch, name] of [['\\u2014', 'an em dash'], ['\\u2260', 'the \\u2260 glyph']]) if (String(s).includes(ch)) fail.push(`${at}: uses ${name}; write a sentence instead`); };\nnotes.forEach((n, k) => {\n const at = `note ${k} (${n.point}#${n.hit ?? 0})`;\n register(n.text, at);\n if (!points.has(n.point)) return fail.push(`${at}: point ${n.point} is not in topic.points`);\n const i = stepAt(P, hits, n.point, n.hit ?? 0);\n if (i < 0) return fail.push(`${at}: the recording has no hit ${n.hit ?? 0} at ${n.point}`);\n if (covered.has(i)) fail.push(`${at}: step ${i + 1} already has a note`);\n covered.add(i);\n const frames = new Set(stackOf(P[i]).map((f) => `${rel(f.url)}:${f.line}`));\n for (const r of n.refs ?? []) {\n refs++;\n const key = `${r.file}:${r.line}`;\n if (!frames.has(key) && !bpLines.has(key)) fail.push(`${at}: ref ${key} is neither a frame of step ${i + 1} nor a breakpoint that was hit`);\n }\n for (const m of String(n.text).matchAll(CITE)) if (!(n.refs ?? []).some((r) => sameFile(r.file, m[1]) && r.line === +m[2])) fail.push(`${at}: text cites ${m[1]}:${m[2]}, which is not in its refs`);\n const leaves = leavesOf(P[i]);\n for (const v of n.values ?? []) {\n values++;\n if (!leaves.some((l) => nameMatches(l.path, v.name) && l.str === String(v.value))) {\n const near = leaves.filter((l) => nameMatches(l.path, v.name)).slice(0, 3).map((l) => `${l.path}=${l.str.slice(0, 60)}`);\n fail.push(`${at}: value ${v.name} = ${JSON.stringify(v.value)} is not in step ${i + 1}'s scopes, watch or rows${near.length ? ` (it has ${near.join('; ')})` : ''}`);\n }\n }\n});\nP.forEach((p, i) => { if (!covered.has(i)) fail.push(`step ${i + 1} (${p.point}#${hits[i]}, ${rel(p.frames[0].url)}:${p.frames[0].line}) has no note`); });\n// Invariants: a rule, a cited line that exists, and a linked step (point and hit) whose source window shows that line.\n(topic.invariants ?? []).forEach((v, k) => {\n const at = `invariant ${k}`;\n if (!v.rule) fail.push(`${at}: no rule`);\n register(v.rule ?? '', at);\n if (/^\\s*[A-Z]\\w*:/.test(v.rule ?? '')) fail.push(`${at}: opens with a label (\"${v.rule.split(':')[0]}:\"); state the rule the code keeps, or the rule it breaks`);\n if (!v.file || !v.line) return fail.push(`${at}: no file or line`);\n const src = sourceOf(rec, urlOf(`${root}/${v.file}`));\n if (src == null) fail.push(`${at}: ${v.file} is not in the recording's sources, or does not exist under ${root}`);\n else { const lines = src.split('\\n'); if (v.line > lines.length || !lines[v.line - 1].trim()) fail.push(`${at}: ${v.file}:${v.line} is past the end or blank`); }\n if (!v.point || !points.has(v.point)) return fail.push(`${at}: point ${v.point} is not in topic.points`);\n const i = invStep(P, hits, v);\n if (i < 0) return fail.push(`${at}: the recording has no hit ${v.hit ?? 0} at ${v.point}`);\n const top = P[i].frames[0], file = rel(top.url);\n if (file !== v.file || Math.abs(v.line - top.line) > WINDOW) fail.push(`${at}: cites ${v.file}:${v.line}, but its step ${i + 1} shows ${file}:${Math.max(1, top.line - WINDOW)}-${top.line + WINDOW}; link a step whose source window holds the line, or add a point there`);\n});\nconst CH = chaptersOf(topic, P);\nif (!CH) fail.push('chapters: a chapter\\'s point is not hit at or after the previous chapter\\'s start');\n(topic.chapters ?? []).forEach((c, k) => { register(c.title, `chapter ${k}`); if (!hitPoints.has(c.from)) fail.push(`chapter ${k} \"${c.title}\": point ${c.from} was never hit`); });\n// Authored fields the viewer shows: story, outcome, actors[].about and role, and a glossary that covers every code name in chapter titles and invariants.\nconst fnNames = new Set(P.map((p) => p.frames[0].fn));\nfor (const k of ['story', 'outcome']) { if (typeof topic[k] !== 'string' || !topic[k].trim()) fail.push(`topic: no ${k}`); else register(topic[k], k); }\n(topic.actors ?? []).forEach((a) => { for (const k of ['about', 'role']) if (typeof a[k] !== 'string' || !a[k].trim()) fail.push(`actor ${a.name}: no ${k}`); });\nconst terms = new Set();\nif (!Array.isArray(topic.glossary) || !topic.glossary.length) fail.push('topic: no glossary');\nelse topic.glossary.forEach((g, k) => { if (!g?.term || !g?.def) fail.push(`glossary ${k}: needs term and def`); else if (terms.has(g.term)) fail.push(`glossary: ${g.term} is listed twice`); else { terms.add(g.term); register(g.def, `glossary ${g.term}`); } });\nconst needTerms = (s, at) => { for (const m of String(s).matchAll(/`([^`]+)`/g)) if (!terms.has(m[1]) && !fnNames.has(m[1])) fail.push(`${at}: \\`${m[1]}\\` has no glossary entry and is not a step's function name`); };\n(topic.chapters ?? []).forEach((c, k) => needTerms(c.title, `chapter ${k} \"${c.title}\"`));\n(topic.invariants ?? []).forEach((v, k) => needTerms(v.rule, `invariant ${k}`));\n// Authored values: story, outcome, chapter titles and invariants that state run values must match this recording.\nconst B = baseFile ? readJson(baseFile).pauses : null;\nconst baseAuth = B && authoredOf(topic, B, hitsOf(B), chaptersOf(topic, B));\nlet authored = 0;\nfor (const [k, a] of (CH ? authoredOf(topic, P, hits, CH) : []).entries()) {\n authored += a.values.length;\n for (const v of a.missing) {\n const msg = `${a.at}: says ${v.name} = ${JSON.stringify(v.value)}, which no step it speaks for has`;\n if (baseAuth && !baseAuth[k].missing.includes(v)) warn.push(`${msg}; it holds in the base recording, so the page tags it \"Written for the base recording\"`); else fail.push(msg);\n }\n}\nif (!rec.provenance) fail.push('recording: no provenance; record it again with record.mjs');\nelse if (rec.provenance.pauses !== P.length) fail.push(`provenance: pauses ${rec.provenance.pauses} but the recording has ${P.length}`);\nif (warn.length) console.log(warn.join('\\n'));\nif (fail.length) { console.error(fail.join('\\n')); console.error(`check failed: ${fail.length} problems`); process.exit(1); }\nconsole.log(`check ok: ${P.length} steps, ${notes.length} notes, ${refs} refs, ${values} values, ${(topic.invariants ?? []).length} invariants, ${authored} authored values, ${terms.size} glossary terms${warn.length ? `, ${warn.length} written for the base` : ''}`);\n","carry-notes.mjs":"// Carries notes written against one recording to another recording of the same topic (a re-record, or a head run for a two-recording page):\n// each value takes the new run's value at the same path, and the note text takes the same substitution wherever the value is in backticks.\n// Usage: node carry-notes.mjs old-recording.json notes.json new-recording.json > new-notes.json\nimport { readJson, leavesOf, hitsOf, nameMatches } from './lib.mjs';\nconst [oldFile, notesFile, newFile] = process.argv.slice(2);\nif (!newFile) { console.error('usage: node carry-notes.mjs old-recording.json notes.json new-recording.json > new-notes.json'); process.exit(2); }\nconst B = readJson(oldFile), H = readJson(newFile), notes = readJson(notesFile);\nconst at = (R, n) => { const h = hitsOf(R.pauses); return R.pauses.find((p, i) => p.point === n.point && h[i] === (n.hit ?? 0)); };\nconst out = notes.map((n) => {\n const pb = at(B, n), ph = at(H, n);\n if (!pb || !ph) { console.error(`note ${n.point}#${n.hit ?? 0}: step missing in ${pb ? 'new' : 'old'} recording, kept as is`); return n; }\n const lb = leavesOf(pb), lh = leavesOf(ph);\n let text = n.text;\n const values = (n.values ?? []).map((v) => {\n const b = lb.find((l) => nameMatches(l.path, v.name) && l.str === String(v.value)), h = b && lh.find((l) => l.path === b.path);\n if (h && h.str !== String(v.value)) { text = text.split('`' + v.value + '`').join('`' + h.str + '`'); return { ...v, value: h.str }; }\n return v;\n });\n return { ...n, text, values };\n});\nconsole.log(JSON.stringify(out, null, 1));\n","shot.mjs":"// Screenshots a rendered page with JavaScript off. Usage: node shot.mjs page.html out.png [width=1440] [light|dark] [#step-N] [level ch|seq|steps] [full]\nimport { chromium } from 'playwright';\nconst [page, out, w = '1440', scheme = 'light', hash = '', level = '', full = ''] = process.argv.slice(2);\nconst b = await chromium.launch();\nconst ctx = await b.newContext({ javaScriptEnabled: false, viewport: { width: +w, height: 1000 }, colorScheme: scheme });\nconst p = await ctx.newPage();\nawait p.goto('file://' + new URL(page, 'file://' + process.cwd() + '/').pathname + hash);\nif (level) await p.click(`label[for=lv-${level}]`);\nif (hash && level) await p.evaluate((h) => document.querySelector(h).scrollIntoView(), hash);\nawait p.screenshot({ path: out, fullPage: full === 'full' });\nconsole.log(out, 'scrollWidth', await p.evaluate(() => document.documentElement.scrollWidth));\nawait b.close();\n","lib.mjs":"// Shared by record.mjs, render.mjs and check.mjs: how a topic's paths resolve and how a recording's frames, steps and values are named.\nimport { existsSync, readFileSync } from 'node:fs';\nimport { dirname, isAbsolute, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nexport const readJson = (f) => JSON.parse(readFileSync(f, 'utf8'));\n// topic.json with `root`, `scenario`, `stores` and `import` resolved against the topic file's directory, so a topic can sit next to its scenario.\nexport const loadTopic = (file, rootOverride) => {\n const t = readJson(file), dir = dirname(resolve(file)), at = (p) => p == null ? p : isAbsolute(p) ? p : resolve(dir, p);\n return { ...t, dir, root: (rootOverride ? resolve(rootOverride) : at(t.root)).replace(/\\/$/, ''), scenario: at(t.scenario), stores: at(t.stores), import: at(t.import) };\n};\n// Source text of a file the recording paused in or an invariant cites, from the copy the recorder stored, so check and render need no checkout.\n// A recording made before sources were stored falls back to the file on disk.\nexport const sourceOf = (rec, url) => { if (rec.sources) return rec.sources[url] ?? null; const f = url.replace(/^file:\\/\\//, ''); return existsSync(f) ? readFileSync(f, 'utf8') : null; };\nexport const rootOf = (topic, rec) => (rec?.root ?? topic.root).replace(/\\/$/, '');\n// A point's file is relative to topic.root; one that starts with ./ is relative to the topic's directory (fakes and stubs that ship with the topic).\nexport const pointPath = (topic, root, p) => isAbsolute(p.file) ? p.file : p.file.startsWith('./') ? resolve(topic.dir, p.file) : resolve(root, p.file);\n// How a frame url is shown: relative to the recorded root when under it, else relative to the topic's directory when under that, else the path.\nexport const relTo = (root, dir) => (url) => {\n const u = url || '', r = `file://${root}/`, d = dir && `file://${dir}/`;\n return u.startsWith(r) ? u.slice(r.length) : d && u.startsWith(d) ? u.slice(d.length) : u.replace(/^file:\\/\\//, '');\n};\nexport const urlOf = (path) => pathToFileURL(path).href;\nexport const stackOf = (p) => { const all = p.frames.map((f) => ({ ...f, async: false })); for (const a of p.asyncFrames) a.frames.forEach((f, i) => all.push({ ...f, async: i === 0 })); return all; };\n// Step k is the hit-th (0-based) pause at its point.\nexport const hitsOf = (P) => { const n = new Map(); return P.map((p) => { const h = n.get(p.point) ?? 0; n.set(p.point, h + 1); return h; }); };\nexport const stepAt = (P, hits, point, hit = 0) => P.findIndex((p, i) => p.point === point && hits[i] === hit);\n// A recorded value (the recorder's {t, v, n} preview) as plain JSON.\nexport const plain = (x) => {\n if (!x) return undefined;\n if (x.t === 'undefined') return undefined;\n if (x.t === 'null') return null;\n if (x.v === null || typeof x.v !== 'object') return x.t === 'number' && typeof x.v === 'string' && !isNaN(+x.v) ? +x.v : x.v;\n if (x.t === 'array' || x.t === 'set') return Object.values(x.v).map(plain);\n if (x.t === 'map') return Object.values(x.v).map(plain);\n return Object.fromEntries(Object.entries(x.v).map(([k, y]) => [k, plain(y)]));\n};\nconst str = (v) => v === undefined ? 'undefined' : typeof v === 'string' ? v : JSON.stringify(v);\n// Every value a step shows, at every depth: scope variables (name.path), watch values (watch.path for the first watch, watch#k.path for watch k,\n// and the watch expression itself, e.g. queue.length) and row cells (table.column).\nexport const leavesOf = (p) => {\n const out = [];\n const walk = (v, path, src) => {\n out.push({ path, str: str(v), val: v, src });\n if (v && typeof v === 'object') for (const [k, y] of Object.entries(v)) walk(y, `${path}.${k}`, src);\n else if (typeof v === 'string' && /^[[{]/.test(v)) { try { const j = JSON.parse(v); if (j && typeof j === 'object') for (const [k, y] of Object.entries(j)) walk(y, `${path}.${k}`, src); } catch {} }\n };\n for (const s of p.scopes) for (const [n, v] of Object.entries(s.vars)) walk(plain(v), n, 'scope');\n p.watch.forEach((w, k) => { walk(w.value, k ? `watch#${k}` : 'watch', 'watch'); if (w.expression) walk(w.value, w.expression, 'watch'); });\n for (const [t, rows] of Object.entries(p.rows ?? {})) if (Array.isArray(rows)) for (const r of rows) for (const [c, v] of Object.entries(r)) walk(v, `${t}.${c}`, 'row');\n return out;\n};\nexport const nameMatches = (path, name) => !name || path === name || path.endsWith('.' + name);\nexport const hasValue = (p, v) => leavesOf(p).some((l) => nameMatches(l.path, v.name) && l.str === String(v.value));\n// file:line citations in note text.\nexport const CITE = /(?<![\\w/.-])((?:[\\w.-]+\\/)*[\\w.-]+\\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|rs|sql)):(\\d+)(?!\\d)/g;\nexport const sameFile = (a, b) => a === b || a.endsWith('/' + b) || b.endsWith('/' + a);\n// The source window a step shows: this many lines either side of the paused line.\nexport const WINDOW = 4;\n// The step an invariant links to: the hit-th (0-based, default 0) pause at its point.\nexport const invStep = (P, hits, v) => stepAt(P, hits, v.point, v.hit ?? 0);\n// Chapters: topic.chapters, each starting at the first hit of its point at or after the previous chapter's start. `src` is the topic.chapters index\n// (null for the untitled chapter added when the first one starts after step 1). Returns null when a chapter's point is not hit in order.\nexport const chaptersOf = (topic, P) => {\n const N = P.length, starts = []; let from = 0;\n for (const [src, c] of (topic.chapters ?? []).entries()) { const i = P.findIndex((p, j) => j >= from && p.point === c.from); if (i < 0) return null; starts.push({ at: i, title: c.title, src }); from = i; }\n if (!starts.length || starts[0].at > 0) starts.unshift({ at: 0, title: null, src: null });\n return starts.map((s, k) => { const to = (starts[k + 1]?.at ?? N) - 1; return { k, src: s.src, from: s.at, to, title: s.title, idx: Array.from({ length: to - s.at + 1 }, (_, n) => s.at + n) }; });\n};\n// Authored text that states run values (story, outcome, a chapter title, an invariant rule) lists them in `values` (story and outcome in\n// `storyValues` and `outcomeValues`), and each must be in the recording at a step the text speaks for: a chapter's steps, an invariant's\n// linked step, any step for story and outcome. `missing` is what no such step has; `base` (with a base recording) is what the base has instead.\nexport const authoredOf = (topic, P, hits, CH) => {\n const all = P.map((_, i) => i), out = [];\n if (topic.story) out.push({ at: 'story', key: 'story', text: topic.story, values: topic.storyValues ?? [], steps: all });\n if (topic.outcome) out.push({ at: 'outcome', key: 'outcome', text: topic.outcome, values: topic.outcomeValues ?? [], steps: all });\n (topic.chapters ?? []).forEach((c, k) => out.push({ at: `chapter ${k} \"${c.title}\"`, key: `ch${k}`, text: c.title, values: c.values ?? [], steps: CH?.find((x) => x.src === k)?.idx ?? [] }));\n (topic.invariants ?? []).forEach((v, k) => { const i = invStep(P, hits, v); out.push({ at: `invariant ${k}`, key: `inv${k}`, text: v.rule, values: v.values ?? [], steps: i < 0 ? [] : [i] }); });\n return out.map((a) => ({ ...a, missing: a.values.filter((v) => !a.steps.some((i) => hasValue(P[i], v))) }));\n};\nexport const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;\n// The last earlier pause in the same call. The recorder counts each function's entries and exits around its points: an earlier pause is in\n// this call when the function was entered no more times since and exactly one call of it was live then. Anything less shows no Before.\nexport const previousInCall = (P) => P.map((p, i) => {\n for (let j = i - 1; j >= 0; j--) if (P[j].call?.fn === p.call?.fn && p.call) return P[j].call.entries === p.call.entries && P[j].call.live === 1 ? j : null;\n return null;\n});\n// Diff against a base recording: the same step is the same point at the same hit, and values compare exactly. A topic records with `pin` so ids\n// and times repeat; whatever still differs per run (a temp directory, say) the topic names in `generated`, regexes whose matches compare equal.\nclass Container { constructor(container) { this.container = container; } }\nexport const diffOf = (topic, rec, B, file) => {\n const P = rec.pauses, hits = hitsOf(P), rel = relTo(rootOf(topic, rec), topic.dir), BR = relTo(rootOf(topic, B), topic.dir), bh = hitsOf(B.pauses);\n const GEN = (topic.generated ?? []).map((r) => new RegExp(r, 'g'));\n const norm = (v) => GEN.reduce((t, r) => t.replace(r, '(generated)'), v);\n const normDeep = (v) => typeof v === 'string' ? norm(v) : Array.isArray(v) ? v.map(normDeep) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, y]) => [k, normDeep(y)])) : v;\n const same = (x, y) => typeof x === typeof y && (x === y || JSON.stringify(normDeep(x)) === JSON.stringify(normDeep(y)));\n const show = (v) => v instanceof Container ? `(${v.container})` : v === undefined ? 'undefined' : JSON.stringify(v).slice(0, 80);\n const srcLine = (R, url, line) => { const s = sourceOf(R, url); return s == null ? null : s.split('\\n')[line - 1] ?? ''; };\n const rowWrites = (Q, i) => { if (i === 0) return []; const ts = Object.keys(Q[i].rows ?? {}).filter((t) => Array.isArray(Q[i].rows[t]));\n return ts.flatMap((t) => { const a = new Set((Q[i - 1].rows[t] ?? []).map((r) => JSON.stringify(r))); return Q[i].rows[t].filter((r) => !a.has(JSON.stringify(r))).map((r) => ({ t, r })); }); };\n // A container leaf compares by its kind (array, object, or a string holding JSON) and its children carry the content, so a type change shows.\n const jsonText = (v) => { try { const j = JSON.parse(v); return j !== null && typeof j === 'object'; } catch { return false; } };\n const valueLeaves = (p) => leavesOf({ ...p, rows: {} }).map((l) => ({ path: l.path, val: Array.isArray(l.val) ? new Container('array') : l.val && typeof l.val === 'object' ? new Container('object') : typeof l.val === 'string' && jsonText(l.val) ? new Container('JSON string') : l.val }));\n const rowDelta = (x, y) => `${y.t} ${[...new Set([...Object.keys(x.r), ...Object.keys(y.r)])].filter((c) => !same(x.r[c], y.r[c])).map((c) => `${c} ${show(x.r[c])} → ${show(y.r[c])}`).join(', ')}`;\n const out = P.map((p, i) => {\n const j = B.pauses.findIndex((q, k) => q.point === p.point && bh[k] === hits[i]);\n if (j < 0) return { i, kinds: ['step'], detail: ['no such step in base'], names: [] };\n const q = B.pauses[j], kinds = [], detail = []; let was = null, now = null;\n // Source: the first line of the step's source window (the paused line plus or minus WINDOW) that the two checkouts differ on.\n let at = null;\n for (let d = -WINDOW; d <= WINDOW && at == null; d++) {\n const ls = srcLine(rec, p.frames[0].url, p.frames[0].line + d), lb = srcLine(B, q.frames[0].url, q.frames[0].line + d);\n if (ls !== null && lb !== null && ls.trim() !== lb.trim()) { kinds.push('source'); was = lb; now = ls; at = p.frames[0].line + d; }\n }\n const fs = (s, R) => s.map((f) => `${f.fn} ${R(f.url)}:${f.line}`).join('\\n');\n if (fs(stackOf(p), rel) !== fs(stackOf(q), BR)) kinds.push('calls');\n const mh = new Map(valueLeaves(p).map((l) => [l.path, l.val])), mb = new Map(valueLeaves(q).map((l) => [l.path, l.val]));\n const vd = [...new Set([...mb.keys(), ...mh.keys()])].filter((k) => !(mh.has(k) && mb.has(k) && same(mb.get(k), mh.get(k))));\n if (vd.length) { kinds.push('values'); for (const k of vd.slice(0, 4)) detail.push(`${k} ${mb.has(k) ? show(mb.get(k)) : 'absent'} → ${mh.has(k) ? show(mh.get(k)) : 'absent'}`); if (vd.length > 4) detail.push(`+${vd.length - 4} more values`); }\n const rh = rowWrites(P, i), rb = rowWrites(B.pauses, j);\n const rk = rh.length !== rb.length ? -1 : rh.findIndex((x, k) => x.t !== rb[k].t || !same(rb[k].r, x.r));\n if (rh.length !== rb.length || rk >= 0) { kinds.push('rows'); detail.push(rk >= 0 ? `row ${rowDelta(rb[rk], rh[rk])}` : `row writes ${rb.length} → ${rh.length}`); }\n return { i, kinds, detail, was, now, at, names: [...new Set(vd.map((k) => k.split('.')[0]))] };\n });\n return { file, steps: out, missingBase: B.pauses.map((q, k) => k).filter((k) => !P.some((p, i) => p.point === B.pauses[k].point && hits[i] === bh[k])) };\n};\n","pin.mjs":"// Makes a recording reproducible: with `\"pin\": true` in the topic, record.mjs imports this before the scenario. Random bytes and UUIDs come from\n// a seeded generator, and the clock starts at a fixed instant and moves 1 ms per read, so two recordings of the same code write the same ids\n// and times and a base/head diff compares values exactly.\nimport crypto from 'node:crypto';\nimport { syncBuiltinESMExports } from 'node:module';\n\nlet state = 0x9e3779b9;\nconst next = () => {\n state = (state + 0x6d2b79f5) >>> 0;\n let t = state;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return (t ^ (t >>> 14)) >>> 0;\n};\nconst fill = (view) => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n for (let k = 0; k < bytes.length; k++) bytes[k] = next() & 255;\n return view;\n};\nconst uuid = () => {\n const b = fill(new Uint8Array(16));\n b[6] = (b[6] & 0x0f) | 0x40;\n b[8] = (b[8] & 0x3f) | 0x80;\n const h = Buffer.from(b).toString('hex');\n return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;\n};\n\ncrypto.randomBytes = (n, cb) => {\n const out = fill(Buffer.alloc(n));\n if (cb) { process.nextTick(cb, null, out); return undefined; }\n return out;\n};\ncrypto.randomUUID = uuid;\nObject.defineProperty(globalThis.crypto, 'getRandomValues', { value: fill, configurable: true, writable: true });\nObject.defineProperty(globalThis.crypto, 'randomUUID', { value: uuid, configurable: true, writable: true });\nsyncBuiltinESMExports();\nMath.random = () => next() / 2 ** 32;\n\nconst RealDate = Date, start = RealDate.UTC(2026, 0, 1);\nlet clock = start;\nconst now = () => ++clock;\nglobalThis.Date = class Date extends RealDate {\n constructor(...a) { if (a.length) super(...a); else super(now()); }\n static now() { return now(); }\n};\nperformance.now = () => clock - start;\n","package.json":"{\n \"name\": \"deep-dive-skill\",\n \"private\": true,\n \"description\": \"Render and screenshot dependencies of the deep-dive skill: shiki and geist for render.mjs, playwright for shot.mjs.\",\n \"dependencies\": {\n \"geist\": \"1.9.2\",\n \"playwright\": \"1.65.0\",\n \"shiki\": \"4.6.0\"\n }\n}\n"}};
@@ -0,0 +1,5 @@
1
+ import { defineSkill } from "eve/skills";
2
+ import skill from "./deep-dive.gen.mjs";
3
+ // The deep-dive skill: deep-dive/SKILL.md and its scripts (record, render, check), embedded at build time
4
+ // (scripts/build-onboarding.mjs). A bot whose roster lists it builds explainer pages from recorded runs.
5
+ export default defineSkill(skill);