Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sid>-<aid>.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"}
48 changes: 48 additions & 0 deletions hooks/elicit-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 34 additions & 18 deletions hooks/kb-elicit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading