@cstart/coldstart 2.1.1 → 2.2.0

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.
@@ -1,38 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * cursor-kb-elicit.mjs — Cursor stop + subagentStop notebook capture.
3
+ * cursor-kb-elicit.mjs — Cursor stop + subagentStop notebook capture, v5
4
+ * TRIGGER-TIMED (ported from kb-elicit.mjs 2026-07-17; the v4 always-fire
5
+ * walker is gone).
4
6
  *
5
- * ALWAYS FIRES when the agent touched ANY repo file this turn (mechanical
6
- * extraction only; THE AGENT decides what's worth writing). Same policy as
7
- * codex-kb-elicit.mjs — see it for the full rationale. The shared helpers
8
- * (buildCapturePrompt, filesBlock, noteAnnotations, the path scan, normRel) are
9
- * copied verbatim; only three things are Cursor-specific:
7
+ * Cursor's stop fires per turn (like Claude's Stop), so the FULL trigger
8
+ * machine applies: every stop updates per-file evidence records
9
+ * (evidence.mjs extractCursorEvidence edit/read/gs tiers; mentions and
10
+ * .coldstartignore'd files never count) and advances trigger.mjs. Most stops
11
+ * exit silently. Fires:
10
12
  *
11
- * 1. TRANSCRIPT WALK Cursor's transcript is JSONL of
12
- * {role, message:{content:[{type:"tool_use", input}]}} (+ {type:"turn_ended"})
13
- * not Codex's response_item rollout. We stringify each tool_use.input and run
14
- * the SAME path-token scan (Read input.path, Shell input.command, MCP
15
- * tool-specific), so detection stays tool-agnostic. Scoped to the CURRENT
16
- * turn via the free `turn_ended` boundary.
17
- * 2. RE-ENTRANCY GUARD Cursor's followup_message re-fires `stop` on a NEW
18
- * generation, so a per-generation marker alone can't stop the loop. But
19
- * loop_count increments (0 = the user's own turn, >0 = a hook-continued
20
- * turn), so we capture ONLY when loop_count === 0. Proven empirically
21
- * 2026-07-08. The generation_id marker is a belt-and-suspenders against a
22
- * double-fire within one turn.
23
- * 3. OUTPUT Cursor's stop/subagentStop "continue" channel is
24
- * `{followup_message}` (auto-submits the next turn), not Codex's
25
- * `{decision:"block", reason}`.
13
+ * descent/surge NON-BLOCKING: payload pending file; cursor-kb-recall
14
+ * (beforeSubmitPrompt) delivers it with the user's next prompt via
15
+ * additional_context.
16
+ * cap also non-blocking (same rationale as the Claude hook: replay showed
17
+ * dense sessions starve descent; blocking each cap re-created v4 agitation).
18
+ * head-drift → BLOCKING via {followup_message} (commit boundary: the work
19
+ * just landed, capture before it goes stale).
20
+ * subagentStop one-shot BLOCKING with the restate-deliverable tail (#61).
21
+ *
22
+ * Cursor specifics vs the Claude hook:
23
+ * - Input: root from workspace_roots (cursor-input.mjs); loop_count>0 =
24
+ * hook-continued turn never process (replaces stop_hook_active).
25
+ * - Transcript: conversation JSONL with NO tool_result records — evidence is
26
+ * call-level, compensated by stat-existence checks on every claim (see
27
+ * extractCursorEvidence). Sliced incrementally by lineCount, same as Claude.
28
+ * - Output: block = {followup_message} (auto-submits a continuation turn);
29
+ * silence = plain exit 0.
26
30
  *
27
- * Hooks never author or parse markdown — all facts come from `coldstart kb`.
28
31
  * Self-contained + fail-open: ANY error → exit 0 → the stop is allowed.
29
32
  */
30
33
 
31
34
  import { tmpdir } from "node:os";
32
35
  import { join } from "node:path";
33
36
  import { fileURLToPath } from "node:url";
34
- import { execFileSync } from "node:child_process";
35
- import { existsSync, writeFileSync, appendFileSync, readFileSync, mkdirSync, statSync } from "node:fs";
37
+ import { existsSync, writeFileSync, appendFileSync, readFileSync } from "node:fs";
38
+
39
+ import { extractCursorEvidence, segmentStatsCursor } from "./evidence.mjs";
40
+ import { initialState, step } from "./trigger.mjs";
41
+ import { loadIgnore } from "./ignore.mjs";
42
+ import { buildCapturePayload } from "./capture-payload.mjs";
43
+ import {
44
+ worklistEntries, freshNotedSet, gitHead, logCaptureEvent, writePendingCapture,
45
+ } from "./elicit-core.mjs";
36
46
  import { cursorRoot } from "./cursor-input.mjs";
37
47
 
38
48
  // hooks/ sits beside dist/ in both the repo and the published package.
@@ -45,222 +55,7 @@ function log(msg) {
45
55
  try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] elicit: ${msg}\n`); } catch { /* never fail logging */ }
46
56
  }
47
57
 
48
- // --- Touched-file detection ----------------------------------------------------
49
- function normRel(root, p) {
50
- let s = String(p || "").trim();
51
- if (!s) return "";
52
- if (s.startsWith("/")) {
53
- if (root && s.startsWith(root + "/")) return s.slice(root.length + 1);
54
- return "";
55
- }
56
- return s.replace(/^\.\//, "");
57
- }
58
-
59
- // Path-like tokens inside any tool input. Existence under root is checked by the
60
- // caller, so structured paths (Read input.path), shell command strings (Shell
61
- // input.command), and MCP JSON can all be scanned without depending on a
62
- // particular tool implementation.
63
- const BASH_PATH_RE = /(?:^|[\s"'`=(:;|])((?:\.{1,2}\/|\/)?[A-Za-z0-9_][A-Za-z0-9_.\/-]*\.[A-Za-z0-9]{1,8})(?=$|[\s"'`):;,|>])/gm;
64
-
65
- // The transcript accumulates the whole conversation; capture only the CURRENT
66
- // turn. Turns are delimited by {type:"turn_ended"}; the final such record is
67
- // this turn's own terminator, so scope to records AFTER the previous one.
68
- function currentTurnLines(lines) {
69
- let boundary = -1;
70
- for (let i = 0; i < lines.length - 1; i++) {
71
- if (lines[i].includes('"turn_ended"')) boundary = i;
72
- }
73
- return lines.slice(boundary + 1);
74
- }
75
-
76
- function touchedFiles(transcriptPath, root) {
77
- const out = [];
78
- const seen = new Set();
79
- const add = (rel, mustExist) => {
80
- if (!rel || seen.has(rel) || rel.startsWith(".coldstart/")) return;
81
- if (mustExist) {
82
- try { if (!statSync(join(root, rel)).isFile()) return; } catch { return; }
83
- }
84
- seen.add(rel);
85
- out.push(rel);
86
- };
87
- let text = "";
88
- try { text = readFileSync(transcriptPath, "utf8"); } catch { return out; }
89
- const lines = text.split("\n").filter((l) => l.trim() && l[0] === "{");
90
- for (const line of currentTurnLines(lines)) {
91
- let rec;
92
- try { rec = JSON.parse(line); } catch { continue; }
93
- const content = rec && rec.message && rec.message.content;
94
- if (!Array.isArray(content)) continue;
95
- for (const item of content) {
96
- if (!item || item.type !== "tool_use") continue;
97
- const source =
98
- item.input && typeof item.input === "object" ? JSON.stringify(item.input) : String(item.input || "");
99
- if (!source) continue;
100
- for (const g of source.matchAll(/coldstart\s+gs\s+([^\s"'`]+)/g)) add(normRel(root, g[1]), true);
101
- let n = 0;
102
- for (const m of source.matchAll(BASH_PATH_RE)) {
103
- if (++n > 40) break;
104
- add(normRel(root, m[1]), true);
105
- }
106
- }
107
- }
108
- return out;
109
- }
110
-
111
- // --- Per-file annotations from the core (hooks never parse md) -----------------
112
- function noteAnnotations(root, files) {
113
- try {
114
- const raw = execFileSync(
115
- "node", [CLI, "kb", "status", "--json", "--paths", files.join(","), "--root", root],
116
- { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] },
117
- );
118
- const parsed = JSON.parse(raw);
119
- const byPath = new Map();
120
- for (const entry of parsed.paths || []) byPath.set(entry.path, entry.notes || []);
121
- return byPath;
122
- } catch (e) {
123
- log(`kb status unavailable (${String(e).split("\n")[0]}) — annotating as no-notes`);
124
- return new Map();
125
- }
126
- }
127
-
128
- const MAX_PROMPT_FILES = 30;
129
-
130
- function filesBlock(root, files) {
131
- const notes = noteAnnotations(root, files);
132
- let listed = files;
133
- if (files.length > MAX_PROMPT_FILES) {
134
- const noted = files.filter((f) => (notes.get(f) || []).length);
135
- const bare = files.filter((f) => !(notes.get(f) || []).length);
136
- listed = [...noted, ...bare].slice(0, MAX_PROMPT_FILES);
137
- }
138
- const lines = [];
139
- for (const rel of listed) {
140
- const anchored = notes.get(rel) || [];
141
- if (!anchored.length) { lines.push(`- ${rel} [no notes yet]`); continue; }
142
- const parts = anchored.map((n) => {
143
- const flag = n.state === "changed" || n.state === "missing"
144
- ? ` — FLAGGED STALE: you just read this file, so fix or re-stamp it (list the path in "verified")`
145
- : "";
146
- return `${n.id} [${n.type} · ${n.state}]${flag} (.coldstart/notebook/notes/${n.id}.md)`;
147
- });
148
- lines.push(`- ${rel} has notes: ${parts.join("; ")}`);
149
- }
150
- if (listed.length < files.length) lines.push(`- …and ${files.length - listed.length} more touched files`);
151
- return lines.join("\n");
152
- }
153
-
154
- // --- The capture prompt (kept identical to the Codex/Claude capture prompt) ----
155
- function buildCapturePrompt(root, block, sid, isSubagent) {
156
- return `You have completed a task now and have gathered knowledge as a part of that task or \
157
- process — knowledge another agent in future could make use of.
158
-
159
- But before writing any notes, we need to decide whether the task you completed deserves a note. \
160
- If you were investigating on an older branch or doing a PR review, we may not need to save notes \
161
- because that code is not in the present — it's in the past or it's in the future. The notes that \
162
- we write are backed by the code in the present. This was an example to explain to you. As an \
163
- agent who worked on the current task, you know its exact intent and are best suited to decide \
164
- whether this task deserves a note. And if nothing about the current code is worth recording, \
165
- then no note is the right answer.
166
-
167
- Once you decide the task does deserve a note, we store it in a notebook format, and this \
168
- notebook has to be backed by the codebase you are working on.
169
-
170
- We need to save only the working knowledge of the codebase in a specific format so that it can \
171
- be searched and served to future cold agents. We don't need to store any general interaction you \
172
- had, just the knowledge about the codebase. As a part of your task, you must have done some \
173
- investigation, file reading, new file/feature addition or updated existing files or features. It \
174
- could have been a bug fix or any other operation on the codebase. We need to store it in the \
175
- below format —
176
-
177
- THE NOTEBOOK HAS THREE CONTAINERS. Put each piece of knowledge in its one home:
178
-
179
- 1. FILE notes (if you decided to write a note for the entire task) — write one for EVERY file \
180
- you actually read and understood this session. No judgment call about whether it seems obvious. \
181
- First decide the file's CHARACTER:
182
- - hub = the file has no single purpose (models.py, helpers, utils). Knowledge lives per \
183
- SYMBOL, as facets: one facet for each symbol you worked with this session. Only symbols you \
184
- have firsthand knowledge of — never enumerate the rest.
185
- - single = the file has one purpose. One summary, 1-3 sentences.
186
- The best facet/summary says: what it does that the name doesn't tell you, what to watch out \
187
- for when changing it, and which tests or checks matter.
188
-
189
- 2. FLOW notes — when your task traced how something works ACROSS files: the ordered story. Each \
190
- step points at a file (path + symbols) with its role in the story. A step never restates what a \
191
- file note already says — the detail lives in the file's facet; the flow links to it.
192
-
193
- 3. LESSON notes — rare. Only one thing qualifies:
194
- - a confirmed ABSENCE ("there is no X in this repo"), with the search terms that proved it.
195
- If it is about one file or one symbol, it is a facet, not a lesson. Repo-wide rules and \
196
- conventions are the human's to define (CLAUDE.md / coldstart.md / AGENTS.md) — do not mint them here.
197
-
198
- Fixed a bug? The actual cause goes into the culpable file's facet, and the SYMPTOM words go \
199
- into that file note's "aliases" — the symptom is what a future agent will search. If the cause \
200
- spans files, the story is a flow.
201
-
202
- Read a note this session that turned out WRONG? Correct it now — same spec with its "id" \
203
- (fields merge; yours win), or op "retract" for a wrong claim. You are the warm agent; there is \
204
- no "next".
205
-
206
- RULES:
207
- - Codebase knowledge only — never the interaction, the user, or your own process.
208
- - Firsthand only: if it arrived secondhand (e.g. a subagent's report) and you did not verify it \
209
- yourself, do not store it.
210
- - If a future agent would not act differently for knowing it, do not store it.
211
- - SEARCH BEFORE YOU WRITE a flow or lesson: run \`node ${CLI} kb search "<your task words>" \
212
- --root ${root}\` once. If an existing flow already tells this mechanism's story, UPDATE it \
213
- (same spec with its "id") instead of writing a near-duplicate.
214
- - Note ids are never composed by you. In facet "flows" backlinks, reference a flow by its \
215
- EXACT title (as written in your flow spec) or by an id copied from kb search output — the \
216
- tool resolves titles to ids at write time. A typo prints a WARNING (the ref is kept but \
217
- dangling) — fix any warning the write prints, in this session. Never guess an id.
218
- - "verified": list every anchor path you actually read THIS session — that re-stamps its \
219
- freshness. Never list a file you did not open.
220
- - Paths are join keys: always repo-relative, exactly as they appear in the repo. Fix any path \
221
- warning the write prints — a wrong path is a silently dangling link.
222
-
223
- Files you touched this run, with their existing notes (read one before writing if you need to \
224
- see what it already says — never create a second note for the same file):
225
-
226
- ${block}
227
-
228
- HOW TO WRITE — ONE terminal block TOTAL: author every spec with a heredoc and
229
- chain every write in the SAME block, flows before the file notes that
230
- reference them. Never author specs one-per-message with a file-editing tool —
231
- that is the single biggest waste of turns here.
232
-
233
- cat > /tmp/spec-1.json <<'SPEC'
234
- { ...flow... }
235
- SPEC
236
- cat > /tmp/spec-2.json <<'SPEC'
237
- { ...file note; facets reference the flow by its EXACT title... }
238
- SPEC
239
- node ${CLI} kb write /tmp/spec-1.json --root ${root} --session ${sid} --force && \\
240
- node ${CLI} kb write /tmp/spec-2.json --root ${root} --session ${sid} --force
241
- Chain the writes with && — if a flow write fails, its dependent file notes
242
- must not run. Never write the same note id twice.
243
-
244
- Spec shapes (only include fields you actually have):
245
- file (hub): {"type":"file-hub","path":"src/x.py","aliases":["symptom or search words"],
246
- "facets":[{"symbol":"ClassOrFn","detail":"the non-obvious thing about THIS symbol",
247
- "flows":["<flow-note-id or the flow's exact title>"]}]}
248
- file (single): {"type":"file-single","path":"src/x.py",
249
- "summary":"its one purpose + how (1-3 sentences)"}
250
- flow: {"type":"flow","title":"how X happens","aliases":["other words for X"],
251
- "summary":"one paragraph",
252
- "steps":[{"path":"src/a.py","symbols":["entry"],"role":"receives the request"}],
253
- "invariants":["what must hold"],"verified":["src/a.py"]}
254
- lesson: {"type":"lesson","kind":"absence","title":"the absence, e.g. no retry logic",
255
- "body":"what you looked for + that it is not there",
256
- "scope":{"terms":["search","terms"]}} (the search that proved it)
257
-
258
- ${isSubagent
259
- ? `Once you have handled the notebook — whether you wrote notes or decided none were needed — remember you were spawned as a subagent. The coordinator that spawned you receives ONLY your final message, so your last message must repeat, in full, the result you produced for it — your findings, not the notebook decision.`
260
- : `When your notes are written, stop.`}`;
261
- }
262
-
263
- // --- stdin + guards -------------------------------------------------------------
58
+ // --- stdin ---------------------------------------------------------------------
264
59
  function readStdin() {
265
60
  return new Promise((res) => {
266
61
  let data = "";
@@ -277,14 +72,6 @@ function readStdin() {
277
72
  });
278
73
  }
279
74
 
280
- function logCaptureEvent(root, event) {
281
- try {
282
- const dir = join(root, ".coldstart", "notebook", ".metrics");
283
- mkdirSync(dir, { recursive: true });
284
- appendFileSync(join(dir, "capture.jsonl"), JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
285
- } catch { /* metrics never wedge a stop */ }
286
- }
287
-
288
75
  process.on("uncaughtException", (e) => { log(`uncaught ${e?.stack || e}`); process.exit(0); });
289
76
  process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); process.exit(0); });
290
77
 
@@ -300,25 +87,30 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
300
87
  setLogRoot(root);
301
88
  if (!root) { log("SKIP no-root"); process.exit(0); }
302
89
 
303
- // Re-entrancy guard (see header): capture only on the user's own turn.
90
+ // Hook-continued turn (followup_message re-fires stop on a new generation):
91
+ // never process — the capture turn's own stop must not advance the trigger.
304
92
  const lc = typeof input.loop_count === "number" ? input.loop_count : 0;
305
93
  if (lc > 0) { log(`SKIP hook-continuation loop_count=${lc}`); process.exit(0); }
306
94
 
307
95
  const sid = String(input.session_id || "").replace(/[^A-Za-z0-9_-]/g, "");
308
96
  if (!sid) { log("SKIP no-session-id"); process.exit(0); }
309
- // generation_id is unique per turn; dedupe a double-fire within one turn.
310
- const tid = String(input.generation_id || sid).replace(/[^A-Za-z0-9_-]/g, "") || sid;
311
97
 
312
- const event = String(input.hook_event_name || "");
313
98
  const aid = String(input.subagent_id || input.agent_id || "main").replace(/[^A-Za-z0-9_-]/g, "") || "main";
314
- const marker = join(tmpdir(), `coldstart-cursor-kb-${tid}-${aid}.done`);
315
- if (existsSync(marker)) { log(`SKIP already-elicited session=${sid} agent=${aid}`); process.exit(0); }
316
- try { writeFileSync(marker, String(Date.now())); } catch { /* best effort */ }
99
+ const isSubagent = String(input.hook_event_name || "") === "subagentStop";
100
+
101
+ // Belt-and-suspenders vs a double-fire within one turn: generation_id is
102
+ // unique per turn; a second stop for the same generation is a duplicate.
103
+ const tid = String(input.generation_id || "").replace(/[^A-Za-z0-9_-]/g, "");
104
+ if (tid) {
105
+ const turnMarker = join(tmpdir(), `coldstart-cursor-kb-turn-${tid}-${aid}.done`);
106
+ if (existsSync(turnMarker)) { log(`SKIP duplicate-generation ${tid}`); process.exit(0); }
107
+ try { writeFileSync(turnMarker, String(Date.now())); } catch { /* best effort */ }
108
+ }
317
109
 
318
110
  // subagentStop supplies the child's own transcript as agent_transcript_path;
319
111
  // stop's transcript_path is the main conversation JSONL.
320
112
  let transcriptPath = String(input.transcript_path || "");
321
- if (event === "subagentStop") {
113
+ if (isSubagent) {
322
114
  const own = String(input.agent_transcript_path || "");
323
115
  if (!own || !existsSync(own)) {
324
116
  log(`SKIP subagent-transcript-missing session=${sid} agent=${aid} tried=${own || "n/a"}`);
@@ -326,20 +118,83 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
326
118
  }
327
119
  transcriptPath = own;
328
120
  }
329
- // transcript_path is null on a brand-new conversation's first events. Fail
330
- // open: nav/recall still work, capture just has no evidence this turn.
331
- const files = transcriptPath && existsSync(transcriptPath) ? touchedFiles(transcriptPath, root) : [];
121
+ if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
122
+
123
+ const ignore = loadIgnore(root);
124
+ const marker = join(tmpdir(), `coldstart-cursor-kb-${sid}-${aid}.json`);
125
+ let state = null;
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(marker, "utf8"));
128
+ if (parsed && parsed.v === 2) state = parsed;
129
+ } catch { /* first stop of this session (or a pre-v5 marker: start fresh) */ }
130
+ if (!state) state = initialState();
131
+
132
+ // This stop's transcript slice (everything since the last processed line).
133
+ const text = readFileSync(transcriptPath, "utf8");
134
+ const lines = text.split("\n");
135
+ const segment = lines.slice(state.lineCount).join("\n");
136
+ state.lineCount = lines.length;
137
+
138
+ // Evidence: contentRead tiers only, ignore-filtered. Mentions never count.
139
+ const raw = extractCursorEvidence(segment, root);
140
+ const delta = new Map();
141
+ for (const [rel, r] of raw) {
142
+ if (r.reads + r.edits + r.gs === 0) continue;
143
+ if (ignore(rel)) continue;
144
+ delta.set(rel, r);
145
+ }
146
+
147
+ // --- Subagent path: one-shot, no trigger. Offer once, block-deliver. ------
148
+ if (isSubagent) {
149
+ const offered = new Set(Object.keys(state.files));
150
+ const fresh = [...delta.keys()].filter((rel) => !offered.has(rel));
151
+ for (const rel of fresh) state.files[rel] = { ...delta.get(rel), captured: true };
152
+ writeFileSync(marker, JSON.stringify(state));
153
+ if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
154
+ const entries = worklistEntries(CLI, root, fresh, Object.fromEntries(fresh.map((rel) => [rel, delta.get(rel)])), log);
155
+ const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "subagent" });
156
+ logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length, host: "cursor" });
157
+ log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
158
+ process.stdout.write(JSON.stringify({ followup_message: payload }));
159
+ process.exit(0);
160
+ }
161
+
162
+ // --- Main path: trigger state machine -------------------------------------
163
+ const head = gitHead(root);
164
+ const headDrift = Boolean(state.head && head && head !== state.head);
165
+ state.head = head || state.head;
332
166
 
333
- // FAST-EXIT only when the turn touched NO repo file (pure Q&A / orchestration).
334
- if (!files.length) {
335
- log(`FAST-EXIT zero touched files session=${sid} agent=${aid} event=${event || "?"}`);
167
+ const stats = segmentStatsCursor(segment);
168
+ const freshNoted = freshNotedSet(CLI, root, [...delta.keys()].filter((rel) => !state.files[rel]), log);
169
+
170
+ const { state: next, decision } = step(state, {
171
+ delta,
172
+ synthesis: stats.synthesis,
173
+ freshNoted,
174
+ headDrift,
175
+ });
176
+ writeFileSync(marker, JSON.stringify(next));
177
+
178
+ if (!decision) {
179
+ log(`TICK session=${sid} stop=${next.stop} active=${next.activeStops} quiet=${next.quietRun} armed=${next.armed} files=${Object.keys(next.files).length} delta=${delta.size}`);
336
180
  process.exit(0);
337
181
  }
338
182
 
339
- const prompt = buildCapturePrompt(root, filesBlock(root, files), sid, event === "subagentStop");
340
- logCaptureEvent(root, { event: "elicit", session: sid, agent: aid, touched: files.length, hook: event });
341
- log(`ELICIT session=${sid} agent=${aid} touched=${files.length} promptBytes=${prompt.length} event=${event || "?"}`);
342
- process.stdout.write(JSON.stringify({ followup_message: prompt }));
183
+ const entries = worklistEntries(CLI, root, decision.files, next.files, log);
184
+ logCaptureEvent(root, {
185
+ event: "fire", reason: decision.fire, mode: decision.mode, session: sid,
186
+ score: decision.score, files: decision.files.length, stop: next.stop, fires: next.fires, host: "cursor",
187
+ });
188
+ log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
189
+
190
+ if (decision.mode === "block") {
191
+ const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
192
+ process.stdout.write(JSON.stringify({ followup_message: payload }));
193
+ } else {
194
+ // Non-blocking: cursor-kb-recall delivers this with the user's next prompt.
195
+ const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
196
+ writePendingCapture(sid, decision.fire, payload);
197
+ }
343
198
  } catch (e) {
344
199
  log(`handler ${e?.stack || e}`); // fail-open: no stdout → stop allowed
345
200
  }
@@ -25,6 +25,10 @@ import { join } from "node:path";
25
25
  import { tmpdir } from "node:os";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { cursorRoot } from "./cursor-input.mjs";
28
+ // Pending-capture delivery (v5 trigger): a descent/surge/cap fire at a previous
29
+ // stop wrote its worklist payload to a pending file instead of blocking; it
30
+ // rides this same next-prompt channel — capture first, then the user's request.
31
+ import { takePendingCapture } from "./elicit-core.mjs";
28
32
 
29
33
  // hooks/ sits beside dist/ in both the repo and the published package.
30
34
  const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
@@ -69,33 +73,43 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
69
73
  if (!root) process.exit(0);
70
74
  setLogRoot(root);
71
75
 
72
- // No notebook no tax, not even a child process.
73
- if (!existsSync(join(root, ".coldstart", "notebook", ".raw"))) process.exit(0);
76
+ const sid = String(input.session_id || "").replace(/[^\w-]/g, "");
77
+
78
+ // A pending capture (non-blocking fire at a previous stop) is delivered
79
+ // regardless of recall hits — it must not depend on the notebook existing
80
+ // (the first capture is what creates it).
81
+ const pending = takePendingCapture(sid);
74
82
 
75
83
  const prompt = String(input.prompt || "").slice(0, MAX_QUERY_CHARS).trim();
76
- if (!prompt) process.exit(0);
77
84
 
78
85
  let page = "";
79
- try {
80
- page = execFileSync("node", [CLI, "kb", "search", "--hook", "--max", "3", "--root", root, prompt], {
81
- encoding: "utf8",
82
- timeout: SEARCH_TIMEOUT_MS,
83
- stdio: ["ignore", "pipe", "ignore"],
84
- });
85
- } catch (e) {
86
- log(`search failed/timed out: ${String(e).split("\n")[0]}`);
87
- process.exit(0);
86
+ // No notebook / no prompt → no recall search, not even a child process.
87
+ if (prompt && existsSync(join(root, ".coldstart", "notebook", ".raw"))) {
88
+ try {
89
+ page = execFileSync("node", [CLI, "kb", "search", "--hook", "--max", "3", "--root", root, prompt], {
90
+ encoding: "utf8",
91
+ timeout: SEARCH_TIMEOUT_MS,
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ });
94
+ } catch (e) {
95
+ log(`search failed/timed out: ${String(e).split("\n")[0]}`);
96
+ page = "";
97
+ }
88
98
  }
89
99
 
90
100
  if (!page.trim() || page.startsWith("No notebook notes match") || page.startsWith("No notebook in")) {
91
- log(`no hits (promptChars=${prompt.length})`);
92
- process.exit(0);
101
+ if (!pending) {
102
+ log(`no hits (promptChars=${prompt.length})`);
103
+ process.exit(0);
104
+ }
105
+ page = "";
93
106
  }
94
107
 
95
108
  // Pointer page — titles + gists + an OPENABLE note path, never a full body.
96
109
  // (Same framing as codex-kb-recall.mjs; notes are REFERENCE DATA, not
97
110
  // instructions.)
98
- let block =
111
+ let block = "";
112
+ if (page) block =
99
113
  `The repo's notebook (notes written by past agents after real tasks here) has entries ` +
100
114
  `matching this request, below — each a title, a gist, and the note's file path. ` +
101
115
  `A note is a past agent's verified overview of a file or flow. If one matches your task, ` +
@@ -114,6 +128,16 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
114
128
 
115
129
  if (block.length > 8500) block = block.slice(0, 8500) + "\n…(truncated)";
116
130
 
131
+ // Pending capture rides FIRST (capture, then the user's request). If the
132
+ // combination would spill past the host's hook cap, recall yields — the
133
+ // capture worklist must arrive whole.
134
+ if (pending) {
135
+ block = pending.length + block.length > 9500 || !block
136
+ ? pending
137
+ : `${pending}\n\n---\n\n${block}`;
138
+ }
139
+ if (!block) process.exit(0);
140
+
117
141
  // Arm the postToolUse nudge detectors (they gate their spiral detectors on
118
142
  // seen_find so they never nag sessions that don't use coldstart). An injected
119
143
  // session IS coldstart-aware even if it never runs `find`. State file path must
@@ -0,0 +1,126 @@
1
+ /**
2
+ * elicit-core.mjs — protocol-neutral v5 capture helpers, shared by the three
3
+ * host elicit hooks (kb-elicit / cursor-kb-elicit / codex-kb-elicit) and the
4
+ * recall hooks that deliver pending captures.
5
+ *
6
+ * Everything here is host-independent: worklist annotation (kb status +
7
+ * consumers, both fail-open), fresh-note discounting, the git-HEAD
8
+ * fingerprint, capture metrics, and the pending-file handoff between a
9
+ * non-blocking fire and the next-prompt recall channel. What stays per-host:
10
+ * input adaptation, the transcript walk (see evidence.mjs's per-host
11
+ * extractors), and the output envelope.
12
+ */
13
+
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync } from "node:fs";
18
+
19
+ export const MAX_WORKLIST = 30;
20
+
21
+ const noop = () => {};
22
+
23
+ // --- Annotation sources (fail-open: absence of data = absence of annotation) ---
24
+ export function noteAnnotations(cli, root, files, log = noop) {
25
+ try {
26
+ const raw = execFileSync(
27
+ "node", [cli, "kb", "status", "--json", "--paths", files.join(","), "--root", root],
28
+ { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] },
29
+ );
30
+ const byPath = new Map();
31
+ for (const entry of JSON.parse(raw).paths || []) byPath.set(entry.path, entry.notes || []);
32
+ return byPath;
33
+ } catch (e) {
34
+ log(`kb status unavailable (${String(e).split("\n")[0]}) — annotating as no-notes`);
35
+ return new Map();
36
+ }
37
+ }
38
+
39
+ export function consumerCounts(cli, root, files, log = noop) {
40
+ try {
41
+ const raw = execFileSync(
42
+ "node", [cli, "consumers", "--json", "--paths", files.join(","), "--root", root],
43
+ { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] },
44
+ );
45
+ const byPath = new Map();
46
+ for (const entry of JSON.parse(raw).paths || []) byPath.set(entry.path, entry.consumers);
47
+ return byPath;
48
+ } catch (e) {
49
+ log(`consumers unavailable (${String(e).split("\n")[0]}) — no graph annotation`);
50
+ return new Map();
51
+ }
52
+ }
53
+
54
+ export function worklistEntries(cli, root, files, stateFiles, log = noop) {
55
+ const listed = files.slice(0, MAX_WORKLIST);
56
+ const notes = noteAnnotations(cli, root, listed, log);
57
+ const consumers = consumerCounts(cli, root, listed, log);
58
+ return listed.map((rel) => {
59
+ const f = stateFiles[rel] || {};
60
+ const tier = f.edits > 0 ? `edited ×${f.edits}` : f.reads > 0 ? "read" : "skimmed";
61
+ return {
62
+ path: rel,
63
+ tier,
64
+ notes: (notes.get(rel) || []).map((n) => ({ id: n.id, type: n.type, state: n.state })),
65
+ noConsumers: consumers.get(rel) === 0,
66
+ };
67
+ });
68
+ }
69
+
70
+ /** Fresh-noted set for score discounting: files whose EVERY anchored note is fresh. */
71
+ export function freshNotedSet(cli, root, files, log = noop) {
72
+ if (!files.length) return new Set();
73
+ const notes = noteAnnotations(cli, root, files, log);
74
+ const fresh = new Set();
75
+ for (const rel of files) {
76
+ const anchored = notes.get(rel) || [];
77
+ if (anchored.length && anchored.every((n) => n.state === "fresh")) fresh.add(rel);
78
+ }
79
+ return fresh;
80
+ }
81
+
82
+ // --- Repo observation: HEAD fingerprint (catches MANUAL commits too) -----------
83
+ export function gitHead(root) {
84
+ try {
85
+ return execFileSync("git", ["rev-parse", "HEAD"], {
86
+ cwd: root, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
87
+ }).trim();
88
+ } catch { return ""; }
89
+ }
90
+
91
+ // --- Capture metrics -----------------------------------------------------------
92
+ export function logCaptureEvent(root, event) {
93
+ try {
94
+ const dir = join(root, ".coldstart", "notebook", ".metrics");
95
+ mkdirSync(dir, { recursive: true });
96
+ appendFileSync(join(dir, "capture.jsonl"), JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
97
+ } catch { /* metrics never wedge a stop */ }
98
+ }
99
+
100
+ // --- Pending-capture handoff ---------------------------------------------------
101
+ // A descent/surge fire writes its worklist payload here instead of blocking the
102
+ // stop; the host's next-prompt recall hook consumes it (capture first, then the
103
+ // user's request). One file per session id — a second fire before delivery
104
+ // overwrites (the later worklist supersedes). Same path scheme across hosts;
105
+ // host session ids never collide (host-distinct formats).
106
+ export function pendingPath(sid) {
107
+ return join(tmpdir(), `coldstart-kb-pending-${sid}.json`);
108
+ }
109
+
110
+ export function writePendingCapture(sid, reason, payload) {
111
+ writeFileSync(pendingPath(sid), JSON.stringify({ ts: Date.now(), reason, payload }));
112
+ }
113
+
114
+ /** Consume (delete) the pending capture for this session. Stale pendings
115
+ * (>24h — e.g. a session resumed days later) are dropped. */
116
+ export function takePendingCapture(sid) {
117
+ if (!sid) return "";
118
+ const pf = pendingPath(sid);
119
+ try {
120
+ if (!existsSync(pf)) return "";
121
+ const pending = JSON.parse(readFileSync(pf, "utf8"));
122
+ unlinkSync(pf);
123
+ if (Date.now() - (pending.ts || 0) > 24 * 3600 * 1000) return "";
124
+ return String(pending.payload || "");
125
+ } catch { return ""; }
126
+ }