From f75925898226a42d9e05ed9336a2096dfe2660e8 Mon Sep 17 00:00:00 2001 From: Akash Goenka Date: Sat, 19 Sep 2026 23:48:42 +0530 Subject: [PATCH 1/2] fix(capture): resume from the last fire instead of skipping past 400 lines A single prompt that runs the agent through many turns was captured as nothing at all: the task finishes and /capture-notes reports it was never asked to write anything up. The trigger marker holds the transcript read offset and lives in the OS temp dir, which is swept every few days while the transcript survives. The guard for that read "transcript > 400 lines" as "already accounted for" and snapped the offset to the end of the file, discarding every read and edit since the sweep. Line count cannot answer that question. Every tool call writes a couple of transcript lines, so 62 of 121 transcripts in this repo pass 400 in one sitting, and ordinary long tasks were mistaken for stale history. Measured footprint: 24 discards across 8 sessions, all spanning 5 to 11 days, one hit six times. Ask the durable record instead of a proxy. capture.jsonl survives the sweep and stamps every fire with session + ts, so the last fire marks the point up to which this session was already asked for notes; resume there. Fire events only: a stop that merely processed evidence banked it in the swept marker, and baseline events mark discarded rather than offered history, so counting either would skip work nobody was ever asked about. Both unknowns fail towards replay, no fire on record and no placeable boundary each replay in full, because losing unasked work is the failure that matters. Costs 0.6ms + 25ms on the largest transcript here (39k lines) and skips ~25k already-offered lines, so it does less work than the replay it replaces. Rejected: relocating the marker to durable storage, which breaks soleMarkerUnderRoot (it resolves the session by finding exactly one marker, which only holds because temp is swept) and brings its own GC problem; and timestamp-gap detection, which trigger.mjs rules out by design. Co-Authored-By: Claude Opus 5 --- hooks/elicit-core.mjs | 48 ++++++++++++++++++++++ hooks/kb-elicit.mjs | 52 +++++++++++++++--------- tests/kb-elicit-hook.test.ts | 78 ++++++++++++++++++++++++++++-------- 3 files changed, 144 insertions(+), 34 deletions(-) diff --git a/hooks/elicit-core.mjs b/hooks/elicit-core.mjs index d43789e..c05130b 100644 --- a/hooks/elicit-core.mjs +++ b/hooks/elicit-core.mjs @@ -100,6 +100,54 @@ export function logCaptureEvent(root, event) { } catch { /* metrics never wedge a stop */ } } +// --- Marker recovery: where did this session last OFFER work? ------------------- +// The trigger marker lives in the OS temp dir, which is swept every few days, so +// a session resumed across days loses its read offset repeatedly (measured in +// this repo: 24 losses across 8 sessions, all spanning 5-11 days). capture.jsonl +// is the durable twin — in-repo, append-only, stamped with session + ts — so it +// still knows what happened after the marker is gone. +// +// FIRE events ONLY. A fire is the one event meaning "these files were actually +// put in front of the agent". A stop that merely PROCESSED evidence banked it in +// the marker, which is exactly what got swept; treating that as covered would +// skip work nobody was ever asked about. Same reason the old `baseline` events +// don't count: they mark discarded history, not offered history. +export function lastFireAt(root, sid) { + if (!sid) return null; + try { + const raw = readFileSync(join(root, ".coldstart", "notebook", ".metrics", "capture.jsonl"), "utf8"); + let last = null; + for (const line of raw.split("\n")) { + // Cheap pre-filter: skip JSON.parse on the (many) lines of other sessions. + if (!line || !line.includes(sid)) continue; + let d; + try { d = JSON.parse(line); } catch { continue; } + if (d.session !== sid || d.event !== "fire") continue; + if (typeof d.ts === "string" && (last === null || d.ts > last)) last = d.ts; + } + return last; + } catch { return null; } +} + +/** First transcript line stamped AFTER isoTs, i.e. the first line not yet offered. + * Both sides are `new Date().toISOString()` (fixed-width, UTC, Z-suffixed), so + * lexicographic order is chronological order and no Date parsing is needed. + * Returns 0 when NO line carries a timestamp at all: with no way to place the + * boundary the safe answer is replay, never skip. */ +// Whitespace-tolerant: Claude Code writes compact JSON, but a miss here degrades +// to a full replay, and no host should be able to cause that by pretty-printing. +const TRANSCRIPT_TS = /"timestamp"\s*:\s*"(\d[^"]*)"/; +export function lineIndexAfter(lines, isoTs) { + let sawTs = false; + for (let i = 0; i < lines.length; i++) { + const m = TRANSCRIPT_TS.exec(lines[i]); + if (!m) continue; + sawTs = true; + if (m[1] > isoTs) return i; + } + return sawTs ? lines.length : 0; +} + // --- Pending-capture handoff --------------------------------------------------- // A descent fire writes its worklist payload here instead of blocking the // stop; the host's next-prompt recall hook consumes it (capture first, then the diff --git a/hooks/kb-elicit.mjs b/hooks/kb-elicit.mjs index 52de900..022184e 100644 --- a/hooks/kb-elicit.mjs +++ b/hooks/kb-elicit.mjs @@ -39,6 +39,7 @@ import { loadIgnore } from "./ignore.mjs"; import { buildCapturePayload, worklistJsonPath } from "./capture-payload.mjs"; import { worklistEntries, freshNotedSet, gitHead, logCaptureEvent, writePendingCapture, MAX_WORKLIST, + lastFireAt, lineIndexAfter, } from "./elicit-core.mjs"; // hooks/ sits beside dist/ in both the repo and the published package. @@ -352,24 +353,39 @@ if (process.argv.includes("--manual")) { // line count grows back. Reset to reprocess the new transcript from its start. if (state.lineCount > lines.length) state.lineCount = 0; - // Fresh attach to an ALREADY-LARGE transcript → baseline, fire NOTHING. - // When the OS clears the tmp marker between days, the next Stop starts fresh - // but the on-disk transcript still holds the WHOLE session. Reprocessing it - // from line 0 treats all of history as this turn's work and dumps the entire - // file set into one cap "blob" (the stop=1 cap fires we saw on resumed - // sessions). A genuine first Stop, by contrast, has a tiny transcript (this - // turn only) and must still be processed so its evidence can build toward - // arming. So baseline ONLY when a fresh marker meets a large transcript: - // snapshot the offset + HEAD and start watching from here. Subagents keep - // their own one-shot path below (a fresh aid-marker is normal — never baseline). - const RESUMED_ATTACH_LINES = 400; // a first turn is tens of lines; a resume is thousands - if (freshMarker && !isSubagent && lines.length > RESUMED_ATTACH_LINES) { - state.lineCount = lines.length; - state.head = gitHead(root) || state.head; - writeFileSync(marker, JSON.stringify(state)); - logCaptureEvent(root, { event: "baseline", session: sid, lines: lines.length }); - log(`BASELINE fresh-marker-large-transcript session=${sid} lines=${lines.length}`); - process.exit(0); + // Fresh marker + a transcript that predates it: RECOVER the offset, don't guess it. + // + // The marker lives in the OS temp dir and is swept every few days, so a session + // resumed across days keeps losing its read offset while the transcript keeps the + // whole history. Until 2026-09-19 this was handled by "transcript > 400 lines ⇒ + // assume already accounted for ⇒ snap the offset to the END", which threw away + // every read and edit since the last sweep. Line count cannot answer that + // question: 62 of 121 transcripts in this repo pass 400 lines in ONE sitting, so + // an ordinary long task was read as stale history and silently dropped (the + // reported symptom: a long single-prompt task finishes, /capture-notes says it + // was never asked to write anything). + // + // So ask the durable record instead of a proxy. capture.jsonl survives the sweep + // and stamps every fire with session + ts, so the last fire marks the exact point + // up to which this session was already asked for notes. Resume THERE: everything + // before it was offered, everything after it never was. No fire on record ⇒ this + // session has never been asked for anything ⇒ replay in full, however large, + // because losing unasked work is the failure that matters. + // + // Known gap: files that a fire ranked past MAX_CAPTURE_FILES were read before + // that fire, so a sweep still forgets them. Bounded and far smaller than + // dropping the whole span; revisit only with evidence it bites. + // Subagents keep their own one-shot path below (a fresh aid-marker is normal). + if (freshMarker && !isSubagent) { + const since = lastFireAt(root, sid); + const resumeAt = since ? lineIndexAfter(lines, since) : 0; + if (resumeAt > 0) { + state.lineCount = resumeAt; + logCaptureEvent(root, { event: "reattach", session: sid, lines: lines.length, resumeAt, since }); + log(`REATTACH session=${sid} lines=${lines.length} resumeAt=${resumeAt} since=${since}`); + } else { + log(`REPLAY session=${sid} lines=${lines.length} (no fire on record for this session)`); + } } const segment = lines.slice(state.lineCount).join("\n"); diff --git a/tests/kb-elicit-hook.test.ts b/tests/kb-elicit-hook.test.ts index 0e960c0..a968186 100644 --- a/tests/kb-elicit-hook.test.ts +++ b/tests/kb-elicit-hook.test.ts @@ -45,6 +45,27 @@ function turn(tools: Array<{ name: string; input: Record }>): s ]; } +/** Like turn(), but stamped — the hook places the resume boundary by timestamp. */ +function turnAt(ts: string, tools: Array<{ name: string; input: Record }>): string[] { + const uses = tools.map((t) => ({ type: 'tool_use', id: `t${++toolId}`, name: t.name, input: t.input })); + return [ + JSON.stringify({ type: 'assistant', timestamp: ts, message: { content: uses } }), + ...uses.map((u) => JSON.stringify({ + type: 'user', + timestamp: ts, + message: { content: [{ type: 'tool_result', tool_use_id: u.id, is_error: false }] }, + })), + ]; +} + +/** Stamp a past fire in the durable metrics log (the marker's twin). */ +function recordFire(ts: string): void { + const dir = path.join(root, '.coldstart', 'notebook', '.metrics'); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, 'capture.jsonl'), + JSON.stringify({ ts, event: 'fire', reason: 'descent', mode: 'inject', session: sid, files: 3 }) + '\n'); +} + /** Append lines to the session transcript and invoke one Stop. */ function stop(lines: string[], opts: { event?: string; aid?: string; transcriptPath?: string; cwd?: string } = {}): string { const tp = opts.transcriptPath ?? transcript; @@ -188,11 +209,14 @@ describe('kb-elicit v5 trigger', () => { expect(marker.files['src/after.py']?.edits).toBe(1); }); - it('a fresh marker meeting a LARGE pre-existing transcript baselines instead of cap-firing a blob', () => { - // Resume scenario: the OS cleared the tmp marker between days, but the on-disk - // transcript still holds the whole prior session. A fresh marker reprocessing - // it from line 0 would treat all history as this-turn work and cap-fire a blob. - seed(['src/hist0.py']); // only the file edited after attach needs to exist + it('a fresh marker meeting a LARGE transcript with NO fire on record replays it in full', () => { + // The 2026-09-19 fix. The tmp marker is swept every few days; the transcript + // survives. The old rule read "> 400 lines" as "already accounted for" and + // snapped the offset to the END, discarding every read since the sweep — which + // is a LONG SINGLE TASK, not stale history (62 of 121 transcripts in this repo + // pass 400 lines in one sitting). Nothing was ever offered for this session, so + // nothing may be skipped. + seed(Array.from({ length: 210 }, (_, i) => `src/hist${i}.py`)); const histTurns = Array.from({ length: 210 }, (_, i) => turn([{ name: 'Read', input: { file_path: path.join(root, `src/hist${i}.py`) } }])).flat(); fs.writeFileSync(transcript, histTurns.join('\n') + '\n'); @@ -200,19 +224,41 @@ describe('kb-elicit v5 trigger', () => { const markerPath = path.join(os.tmpdir(), `coldstart-kb-${sid}-main.json`); expect(fs.existsSync(markerPath)).toBe(false); // fresh: no marker on disk - const out = stop([]); // process the already-large transcript - expect(out.trim()).toBe(''); // baseline → silent, NO blob fire - expect(fs.existsSync(pendingFile())).toBe(false); - const baselined = JSON.parse(fs.readFileSync(markerPath, 'utf8')); - expect(baselined.lineCount).toBeGreaterThan(400); // offset snapped to the end - expect(Object.keys(baselined.files)).toEqual([]); // nothing recorded — watch from here + stop([]); // process the already-large transcript + const m = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + expect(m.files['src/hist0.py']?.reads).toBe(1); // recorded, NOT discarded + expect(m.files['src/hist209.py']?.reads).toBe(1); + expect(m.lineCount).toBe(histTurns.length); + }); + + it('a fresh marker resumes from the last FIRE on record, not from the top', () => { + // capture.jsonl survives the sweep that ate the marker, and a fire is the one + // event meaning "these files were put in front of the agent". Work before that + // stamp was offered; work after it never was. + seed(['src/before.py', 'src/after.py']); + recordFire('2026-09-10T12:00:00.000Z'); + fs.writeFileSync(transcript, [ + ...turnAt('2026-09-10T11:00:00.000Z', [{ name: 'Read', input: { file_path: path.join(root, 'src/before.py') } }]), + ...turnAt('2026-09-11T09:00:00.000Z', [{ name: 'Read', input: { file_path: path.join(root, 'src/after.py') } }]), + ].join('\n') + '\n'); + + stop([]); + const m = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `coldstart-kb-${sid}-main.json`), 'utf8')); + expect(m.files['src/after.py']?.reads).toBe(1); // never offered → captured + expect(m.files['src/before.py']).toBeUndefined(); // already offered → skipped + }); + + it('a fire on record but NO timestamps to place it replays rather than skipping', () => { + // Fail-safe direction: if the boundary cannot be located, losing unasked work + // is the worse error, so replay. + seed(['src/notime.py']); + recordFire('2026-09-10T12:00:00.000Z'); + fs.writeFileSync(transcript, + turn([{ name: 'Read', input: { file_path: path.join(root, 'src/notime.py') } }]).join('\n') + '\n'); - // Real work AFTER the attach is captured normally (baseline didn't wedge it). - fs.appendFileSync(transcript, - turn([{ name: 'Edit', input: { file_path: path.join(root, 'src/hist0.py') } }]).join('\n') + '\n'); stop([]); - const after = JSON.parse(fs.readFileSync(markerPath, 'utf8')); - expect(after.files['src/hist0.py']?.edits).toBe(1); + const m = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `coldstart-kb-${sid}-main.json`), 'utf8')); + expect(m.files['src/notime.py']?.reads).toBe(1); }); it('a genuine first Stop with a small transcript still records evidence (not baselined)', () => { From 6ad8c9f5e292f609036d7167ad763133c86f13a9 Mon Sep 17 00:00:00 2001 From: Akash Goenka Date: Sat, 19 Sep 2026 23:48:47 +0530 Subject: [PATCH 2/2] kb: publish notebook notes (147 notes) --- .coldstart/notebook/.raw/hooks-elicit-core-mjs-f3f159c1.jsonl | 1 + .coldstart/notebook/.raw/hooks-kb-elicit-mjs-08de0677.jsonl | 1 + 2 files changed, 2 insertions(+) diff --git a/.coldstart/notebook/.raw/hooks-elicit-core-mjs-f3f159c1.jsonl b/.coldstart/notebook/.raw/hooks-elicit-core-mjs-f3f159c1.jsonl index c207605..87a9a72 100644 --- a/.coldstart/notebook/.raw/hooks-elicit-core-mjs-f3f159c1.jsonl +++ b/.coldstart/notebook/.raw/hooks-elicit-core-mjs-f3f159c1.jsonl @@ -3,3 +3,4 @@ {"anchors":[{"path":"hooks/elicit-core.mjs","symbols":["worklistEntries","noteAnnotations","consumerCounts","freshNotedSet","gitHead","logCaptureEvent","writePendingCapture","takePendingCapture","pendingPath","MAX_WORKLIST"]}],"id":"hooks-elicit-core-mjs-f3f159c1","type":"file","op":"put","verified":[],"character":"single","v":1,"ts":"2026-08-02T06:36:39.095Z","head":"13e61b0e548d"} {"aliasesVerified":true,"id":"hooks-elicit-core-mjs-f3f159c1","type":"file","op":"put","anchors":[{"path":"hooks/elicit-core.mjs","symbols":["worklistEntries","noteAnnotations","consumerCounts","freshNotedSet","gitHead","logCaptureEvent","writePendingCapture","takePendingCapture","pendingPath","MAX_WORKLIST"]}],"verified":[],"v":1,"ts":"2026-08-02T18:19:44.281Z","head":"8d5cc74d3cb9"} {"summary":"Host-neutral v5 capture helpers shared by all three elicit hooks AND the recall hooks: worklist annotation (kb status --json --paths + coldstart consumers --json, both fail-open to no-annotation), fresh-noted discount set, git-HEAD fingerprint, capture metrics (capture.jsonl), and the pending-capture handoff (writePendingCapture/takePendingCapture, one file per session id, 24h TTL). Consumed via relative import from sibling hook files — invisible to the TS import graph, which is why consumers reports zero for it. Re-read in full this session (unrelated task) and confirmed still accurate — re-stamping freshness only, no content change.","identityAliases":["shared elicit helpers","pending file handoff","worklist annotations","fail open annotation"],"incidentAliases":[],"anchors":[{"path":"hooks/elicit-core.mjs","symbols":["worklistEntries","noteAnnotations","consumerCounts","freshNotedSet","gitHead","logCaptureEvent","writePendingCapture","takePendingCapture","pendingPath","MAX_WORKLIST"],"hash":"sha256:cdd69e058357","head":"af19d7cf59b6"}],"id":"hooks-elicit-core-mjs-f3f159c1","type":"file","op":"put","verified":["hooks/elicit-core.mjs"],"character":"single","v":1,"ts":"2026-08-11T04:28:44.606Z","head":"af19d7cf59b6"} +{"summary":"Protocol-neutral v5 capture helpers shared by the three host elicit hooks and the recall hooks: worklist annotation (noteAnnotations/consumerCounts, both a SINGLE batched execFileSync with a 10s timeout, fail-open to no-annotation), freshNotedSet discounting, the git-HEAD fingerprint, capture metrics (logCaptureEvent -> .coldstart/notebook/.metrics/capture.jsonl), and the pending-file handoff. 2026-09-19 added the marker-RECOVERY pair that replaced kb-elicit's 400-line baseline: lastFireAt(root,sid) scans capture.jsonl - the durable in-repo twin of the temp-dir marker, 153KB/967 events here, 0.6ms - for the newest FIRE stamped with this session id, and lineIndexAfter(lines,isoTs) finds the first transcript line stamped after it (25ms on a 39k-line transcript). Both sides are new Date().toISOString(), fixed-width UTC, so lexicographic compare IS chronological and no Date parsing is needed. Two deliberate fail-safe directions, both toward replay: lastFireAt counts fire events ONLY (a 'baseline' marks discarded history and a merely-processed stop banked its evidence in the swept marker, so neither means 'the agent was shown these files'), and lineIndexAfter returns 0 - not lines.length - when NO line carries a timestamp, because with no way to place the boundary, re-offering work beats losing it.","identityAliases":["elicit-core","noteAnnotations","consumerCounts","freshNotedSet","logCaptureEvent","pendingPath","worklistEntries","capture metrics","lastFireAt","lineIndexAfter","marker recovery","capture.jsonl"],"incidentAliases":["resume boundary","last fire timestamp","durable capture record"],"anchors":[{"path":"hooks/elicit-core.mjs","symbols":["worklistEntries","noteAnnotations","consumerCounts","freshNotedSet","gitHead","logCaptureEvent","writePendingCapture","takePendingCapture","pendingPath","MAX_WORKLIST","lastFireAt","lineIndexAfter"],"hash":"sha256:b75cae124f09","head":"0abd48254b5b"}],"id":"hooks-elicit-core-mjs-f3f159c1","type":"file","op":"put","verified":["hooks/elicit-core.mjs"],"character":"single","v":1,"ts":"2026-09-19T18:11:10.463Z","head":"0abd48254b5b"} diff --git a/.coldstart/notebook/.raw/hooks-kb-elicit-mjs-08de0677.jsonl b/.coldstart/notebook/.raw/hooks-kb-elicit-mjs-08de0677.jsonl index fbe4aa2..01e7471 100644 --- a/.coldstart/notebook/.raw/hooks-kb-elicit-mjs-08de0677.jsonl +++ b/.coldstart/notebook/.raw/hooks-kb-elicit-mjs-08de0677.jsonl @@ -20,3 +20,4 @@ {"target":{"kind":"alias","key":"duplicate capture prompt"},"reason":"symptom-narrative that surfaced as a side effect of freeing cap slots","id":"hooks-kb-elicit-mjs-08de0677","type":"file","op":"retract","v":1,"ts":"2026-08-02T17:35:55.939Z","head":"bc2fe2ab8c2d"} {"aliasesVerified":true,"id":"hooks-kb-elicit-mjs-08de0677","type":"file","op":"put","anchors":[{"path":"hooks/kb-elicit.mjs","symbols":["findRepoRoot","freshestMarkerUnderRoot","resolveSubagentTranscript","markerMtime","argValue","readStdin"]}],"verified":[],"v":1,"ts":"2026-08-02T18:19:44.309Z","head":"8d5cc74d3cb9"} {"summary":"Claude Stop/SubagentStop capture orchestrator AND the shared --manual/on-demand entry point ALL THREE HOSTS invoke: reads the session marker, slices the transcript by stored lineCount, filters evidence through .coldstartignore, and steps the trigger state machine; most stops tick silently. Resume hazards handled: /compact shrinkage (stored lineCount > current lines -> reset) and a fresh marker meeting an already-large transcript (baseline, fire nothing). The repo root is FROZEN in the marker (state.root, via findRepoRoot walk-up to the nearest `.coldstart/notebook` ancestor) so a mid-session `cd` cannot admit a foreign absolute path into the worklist. --manual requires --session (2026-07-?? PR #133 tightened this — it no longer 'self-discovers the freshest marker', which guessed wrong across concurrent sessions), or falls back to `soleMarkerUnderRoot` when exactly one candidate marker exists under --root (refuses on 2+, ambiguous) — this is what lets Cursor/Codex's hook-injected /capture-notes work without a real --session on their command surface. --manual marks LISTED files captured while leaving armed/activeStops/stopsSinceFire/quietRun untouched, so it cannot change WHEN automatic capture next fires. 2026-08-06 FIX: the manual branch now also checks `worklistLost = !existsSync(worklistJsonPath(root, sid, 'main'))` (imported from hooks/capture-payload.mjs) and includes a captured READ-ONLY file in the listing when true — previously, hand-deleting `.worklist--.json` left those files permanently invisible to manual capture even though they still needed a note, since captured=true was otherwise treated as done.","identityAliases":["kb-elicit","capture orchestrator","Stop hook","SubagentStop hook","manual capture entry point","soleMarkerUnderRoot"],"incidentAliases":["worklist deletion loses coverage forever","hand-deleted worklist file"],"verified":["hooks/kb-elicit.mjs"],"id":"hooks-kb-elicit-mjs-08de0677","type":"file","op":"put","anchors":[{"path":"hooks/kb-elicit.mjs","symbols":["findRepoRoot","freshestMarkerUnderRoot","resolveSubagentTranscript","markerMtime","argValue","readStdin"],"hash":"sha256:a6d2a6103578","head":"b3b2134b6430"}],"character":"single","v":1,"ts":"2026-08-06T12:54:32.329Z","head":"b3b2134b6430"} +{"summary":"Claude Stop/SubagentStop capture orchestrator AND the shared --manual/on-demand entry point all three hosts invoke: reads the session marker, slices the transcript by stored lineCount, filters evidence through .coldstartignore, and steps the trigger state machine; most stops tick silently. 2026-09-19: the fresh-marker-meets-large-transcript BASELINE is GONE, replaced by marker RECOVERY (lastFireAt + lineIndexAfter in elicit-core.mjs). The marker lives in the OS temp dir and is swept every few days, so a session resumed across days loses its read offset repeatedly; the old rule read 'transcript > 400 lines' as 'already accounted for' and snapped lineCount to the END, discarding every read/edit since the sweep. Line count cannot answer that question - 62 of 121 transcripts in this repo pass 400 lines in ONE sitting - so an ordinary long single-prompt task was misread as stale history and silently dropped (reported symptom: task finishes, /capture-notes says it was never asked to write anything). Measured footprint before the fix: 24 discards across 8 sessions, all spanning 5-11 days of calendar time, one session hit 6 times. Now the hook asks the DURABLE record instead of a proxy: capture.jsonl survives the sweep and stamps every fire with session + ts, so the last fire is the exact point up to which this session was already asked for notes; resume there. FIRE events only - a stop that merely processed evidence banked it in the swept marker, and the old 'baseline' events mark discarded history, so counting either would skip work nobody was ever asked about. No fire on record means replay in full however large, because losing unasked work is the failure that matters; likewise an unplaceable boundary (no timestamps) replays rather than skips. Cost measured on the largest real transcript (39k lines/126MB): lastFireAt 0.6ms + lineIndexAfter 25ms, and reattaching SKIPS ~25k already-offered lines, so it is cheaper than the replay it replaces. Known gap: files a fire ranked past MAX_CAPTURE_FILES were read before that fire, so a sweep still forgets them. The repo root is FROZEN in the marker (state.root via findRepoRoot) so a mid-session cd cannot admit a foreign path into the worklist. --manual requires --session, or falls back to soleMarkerUnderRoot when exactly one candidate marker exists under --root - that fallback DEPENDS on the temp dir being swept (checked 2026-09-19: exactly 1 marker present across 121 sessions), which is why making the marker durable was rejected as the fix: it would make that guess permanently ambiguous and break Cursor/Codex manual capture.","identityAliases":["findRepoRoot","frozen root marker","acompact","manual capture","nested subagent transcripts","kb-elicit","capture orchestrator","Stop hook","SubagentStop hook","manual capture entry point","soleMarkerUnderRoot","reattach","marker recovery","lastFireAt","lineIndexAfter"],"incidentAliases":["capture-notes wrote nothing","long task captured nothing","400 line limit","RESUMED_ATTACH_LINES","baseline discarded evidence","tmp marker swept","resumed session lost evidence"],"anchors":[{"path":"hooks/kb-elicit.mjs","symbols":["findRepoRoot","freshestMarkerUnderRoot","resolveSubagentTranscript","markerMtime","argValue","readStdin","soleMarkerUnderRoot","markerForSession"],"hash":"sha256:62a62b5f4b52","head":"0abd48254b5b"}],"id":"hooks-kb-elicit-mjs-08de0677","type":"file","op":"put","verified":["hooks/kb-elicit.mjs"],"character":"single","v":1,"ts":"2026-09-19T18:11:10.409Z","head":"0abd48254b5b"}