@cstart/coldstart 2.2.15 → 2.2.16

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.
@@ -16,7 +16,7 @@
16
16
  * (the coordinator only sees the final message — #61).
17
17
  */
18
18
 
19
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
19
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  // The spec shapes are NOT written here any more. They come from the one table
22
22
  // the write guide, the MCP tool description and `kb repair` also render from —
@@ -32,27 +32,93 @@ import { shapesBlock } from "./note-shape.mjs";
32
32
  * checklist + the write contract) was delivered one-shot and lost to compaction
33
33
  * — the agent had to re-derive the note shape from memory 80 tool-calls later.
34
34
  *
35
- * So the pair now lives in the repo notebook, keyed by root, and holds BOTH:
36
- * .worklist.json structured scope (the coverage source of truth)
37
- * .worklist.md the full rendered payload worklist + write contract
35
+ * So the pair now lives in the repo notebook and holds BOTH:
36
+ * .worklist-<sid>-<aid>.json structured scope (the coverage source of truth)
37
+ * .worklist-<sid>-<aid>.md today's worklist + a pointer (see below)
38
38
  * The agent re-Reads the .md whenever this scrolls away; `kb write` credits and
39
39
  * trims/clears the pair (src/kb/durable-worklist.ts) so a stale snapshot never
40
40
  * lingers, and the next capture fire regenerates it against live freshness.
41
41
  *
42
+ * The .md used to hold the FULL rendered payload — the ~150 lines of static
43
+ * DECIDE-FIRST/per-note/FLOWS/WRITE rules, byte-identical every session,
44
+ * duplicated onto disk every single fire. Since 2026-08-06 that static text
45
+ * lives once in a PERMANENT file, `.capture-instructions.md` (also gitignored,
46
+ * but never deleted/regenerated per fire — only rewritten if its version tag is
47
+ * stale, see INSTRUCTIONS_VERSION below); the per-session .md now holds only
48
+ * the worklist + a pointer to it. The payload delivered to the agent THIS turn
49
+ * (buildCapturePayload's return value) is unaffected — it still inlines the
50
+ * full instructions text so nothing changes for the current fire, only what's
51
+ * persisted for a later re-Read.
52
+ *
53
+ * KEYED BY <sid>-<aid>, the SAME identity as the tmpdir evidence marker — NOT by
54
+ * root, and NOT by sid alone. A subagent shares its parent's session id, so a
55
+ * sid-only (or root-only) key would let a subagent's capture clobber the main
56
+ * agent's worklist. Each agent stream owns its own pair; the printed `kb write`
57
+ * carries `--session <sid> --agent <aid>` so finalize trims the right one.
58
+ *
42
59
  * CONTRACT TWIN: src/kb/durable-worklist.ts — keep the paths and the .json shape
43
- * (ts, sid, files:[{path,tier,needsNote}], wrote:[]) in step. Both gitignored
44
- * (src/kb/store.ts initSkeleton). One active worklist per repo (concurrent
45
- * sessions last-write-wins, an accepted edge). Best-effort: capture must never
46
- * fail because these could not be written.
60
+ * (ts, sid, aid, files:[{path,tier,needsNote}], wrote:[]) in step. Both gitignored
61
+ * (src/kb/store.ts initSkeleton). Best-effort: capture must never fail because
62
+ * these could not be written.
47
63
  */
48
- export function worklistJsonPath(root) {
49
- return join(root, ".coldstart", "notebook", ".worklist.json");
64
+ export function worklistJsonPath(root, sid, aid) {
65
+ const k = worklistKey(sid, aid);
66
+ return join(root, ".coldstart", "notebook", k ? `.worklist-${k}.json` : ".worklist.json");
67
+ }
68
+ export function worklistMdPath(root, sid, aid) {
69
+ const k = worklistKey(sid, aid);
70
+ return join(root, ".coldstart", "notebook", k ? `.worklist-${k}.md` : ".worklist.md");
71
+ }
72
+ function worklistKey(sid, aid) {
73
+ const s = String(sid ?? "").replace(/[^A-Za-z0-9_-]/g, "");
74
+ return s ? `${s}-${String(aid ?? "").replace(/[^A-Za-z0-9_-]/g, "") || "main"}` : "";
50
75
  }
51
- export function worklistMdPath(root) {
52
- return join(root, ".coldstart", "notebook", ".worklist.md");
76
+
77
+ // Abandoned sessions' worklist pairs would otherwise accrue forever (a session
78
+ // that fires once then never captures again leaves its pair behind). Prune any
79
+ // sibling worklist file whose json is older than this on each fire — long enough
80
+ // that a still-live session is never touched, short enough to stay tidy.
81
+ const STALE_WORKLIST_MS = 3 * 24 * 60 * 60 * 1000;
82
+ function pruneStaleWorklists(dir, keepKey) {
83
+ try {
84
+ for (const n of readdirSync(dir)) {
85
+ const m = /^\.worklist-([A-Za-z0-9_-]+)\.json$/.exec(n);
86
+ if (!m || m[1] === keepKey) continue;
87
+ // Age by the json's own ts; fall back to file mtime when ts is absent or
88
+ // unreadable, NEVER to 0 — a ts-less-but-recent file must not read as 1970
89
+ // and get deleted out from under a live session.
90
+ let ts = 0;
91
+ try { ts = JSON.parse(readFileSync(join(dir, n), "utf8"))?.ts || statMtime(join(dir, n)); } catch { ts = statMtime(join(dir, n)); }
92
+ if (Date.now() - ts < STALE_WORKLIST_MS) continue;
93
+ for (const p of [join(dir, n), join(dir, n.replace(/\.json$/, ".md"))]) {
94
+ try { unlinkSync(p); } catch { /* best-effort */ }
95
+ }
96
+ }
97
+ } catch { /* best-effort */ }
98
+ }
99
+ function statMtime(p) { try { return statSync(p).mtimeMs; } catch { return 0; } }
100
+
101
+ /**
102
+ * The .md half of the durable pair used to hold the FULL rendered payload
103
+ * (instructions + worklist) — meaning the ~150-line static instructions were
104
+ * duplicated onto disk once per session, every session, forever. Since
105
+ * 2026-08-06 that static text lives once in the permanent
106
+ * .capture-instructions.md (below); the per-session .md only needs to point at
107
+ * it plus carry the part that's actually per-session, the worklist.
108
+ */
109
+ function renderDurableMd(sid, aid, entries) {
110
+ return `**Notebook capture point.** Full instructions: \`.coldstart/notebook/.capture-instructions.md\` \
111
+ (stable — re-Read it once; it does not change per session). This file holds only today's worklist.
112
+
113
+ WORKLIST — files you actually read this session, most-worked first:
114
+
115
+ ${worklistLines(entries)}
116
+
117
+ Write command: node <cli> kb write /tmp/notes.json --root <root> --session ${sid} --agent ${aid} --force
118
+ `;
53
119
  }
54
120
 
55
- function writeDurableWorklist(root, sid, entries, mdBody) {
121
+ function writeDurableWorklist(root, sid, aid, entries) {
56
122
  if (!root || !entries?.length) return;
57
123
  try {
58
124
  // needsNote: no note yet, or the note it has is stale. A file whose note is
@@ -63,16 +129,18 @@ function writeDurableWorklist(root, sid, entries, mdBody) {
63
129
  tier: e.tier,
64
130
  needsNote: !e.notes?.length || e.notes.some((n) => n.state === "changed" || n.state === "missing"),
65
131
  }));
66
- mkdirSync(join(root, ".coldstart", "notebook"), { recursive: true });
67
- writeFileSync(worklistJsonPath(root), JSON.stringify({ ts: Date.now(), sid, files, wrote: [] }));
68
- writeFileSync(worklistMdPath(root), mdBody);
132
+ const dir = join(root, ".coldstart", "notebook");
133
+ mkdirSync(dir, { recursive: true });
134
+ writeFileSync(worklistJsonPath(root, sid, aid), JSON.stringify({ ts: Date.now(), sid, aid: aid || "main", files, wrote: [] }));
135
+ writeFileSync(worklistMdPath(root, sid, aid), renderDurableMd(sid, aid || "main", entries));
136
+ pruneStaleWorklists(dir, worklistKey(sid, aid));
69
137
  } catch { /* best-effort */ }
70
138
  }
71
139
 
72
140
  /**
73
141
  * SPIKE (experimental, undocumented): a repo can replace the shipped checklist
74
142
  * with its own `.coldstart/checklist.md`. Placeholders {{WORKLIST}}, {{CLI}},
75
- * {{ROOT}}, {{SID}} are substituted. The worklist is LOAD-BEARING (it is the
143
+ * {{ROOT}}, {{SID}}, {{AID}} are substituted. The worklist is LOAD-BEARING (it is the
76
144
  * scope rule) — an override that omits {{WORKLIST}} gets it appended anyway.
77
145
  * Trigger mechanics stay in code; only the prompt text is overridable. Merge
78
146
  * semantics when the shipped default evolves = none (the override wins
@@ -86,72 +154,31 @@ function loadChecklistOverride(root) {
86
154
  }
87
155
 
88
156
  /**
89
- * worklist entry: { path, tier, retouches, notes: [{id, type, state}], noConsumers }
90
- * tier: "edited ×N" | "read" | "skimmed"
157
+ * The shipped instructions (DECIDE-FIRST rules, per-note rules, FLOWS, WRITE
158
+ * mechanics) are identical every fire, every session — only the worklist and a
159
+ * handful of interpolated values change. Since 2026-08-06 this static text is
160
+ * generated ONCE into a permanent file (`.capture-instructions.md`, gitignored
161
+ * like the rest of the durable-worklist pair, but never deleted/regenerated per
162
+ * fire) instead of being duplicated onto disk in every session's .md — same
163
+ * placeholder mechanism as the user-authored checklist override above, so both
164
+ * paths share one substitution pass in renderCapturePayload.
165
+ *
166
+ * VERSIONED so an upgrade that changes the shipped text self-heals an existing
167
+ * repo's stale copy instead of silently drifting; bump when the template below
168
+ * changes.
91
169
  */
92
- function worklistLines(entries) {
93
- const lines = [];
94
- for (const e of entries) {
95
- const ann = [];
96
- ann.push(`[${e.tier}]`);
97
- if (!e.notes?.length) {
98
- ann.push("no note yet");
99
- } else {
100
- for (const n of e.notes) {
101
- if (n.state === "fresh") {
102
- ann.push(`note ${n.id} (fresh) → update ONLY if this session taught something the note lacks (.coldstart/notebook/notes/${n.id}.md)`);
103
- } else if (n.state === "changed" || n.state === "missing") {
104
- ann.push(`note ${n.id} (STALE) → fix or re-stamp; list the path in "verified" (.coldstart/notebook/notes/${n.id}.md)`);
105
- } else {
106
- ann.push(`note ${n.id} → read it first, update by its "id" (.coldstart/notebook/notes/${n.id}.md)`);
107
- }
108
- }
109
- }
110
- if (e.noConsumers) ann.push("no consumers in import graph");
111
- lines.push(`- ${e.path} ${ann.join(" · ")}`);
112
- }
113
- return lines.join("\n");
114
- }
170
+ const INSTRUCTIONS_VERSION = 1;
171
+ const INSTRUCTIONS_VERSION_TAG = `<!-- coldstart:capture-instructions:v${INSTRUCTIONS_VERSION} -->`;
115
172
 
116
- export function buildCapturePayload(args) {
117
- const payload = renderCapturePayload(args);
118
- // Persist the WHOLE payload durably so the agent can re-Read it later, and the
119
- // structured scope for `kb write` coverage. Best-effort; never blocks capture.
120
- writeDurableWorklist(args.root, args.sid, args.entries, payload);
121
- return payload;
173
+ function captureInstructionsPath(root) {
174
+ return join(root, ".coldstart", "notebook", ".capture-instructions.md");
122
175
  }
123
176
 
124
- function renderCapturePayload({ root, cli, sid, entries, envelope }) {
125
- const opening = envelope === "block"
126
- ? "Handle capture now, then stop."
127
- : envelope === "manual"
128
- ? "You invoked this capture yourself (/capture-notes) — handle it now, then carry on."
129
- : "Handle capture first, then continue with the user's request.";
130
-
131
- const tail = envelope === "subagent"
132
- ? `\nOnce you have handled the notebook — whether you wrote notes or decided none were \
133
- needed — remember you were spawned as a subagent. The coordinator that spawned you receives \
134
- ONLY your final message, so your last message must repeat, in full, the result you produced \
135
- for it — your findings, not the notebook decision.`
136
- : "";
137
-
138
- const override = loadChecklistOverride(root);
139
- if (override) {
140
- const worklist = worklistLines(entries);
141
- let body = override
142
- .replaceAll("{{CLI}}", String(cli))
143
- .replaceAll("{{ROOT}}", String(root))
144
- .replaceAll("{{SID}}", String(sid));
145
- body = body.includes("{{WORKLIST}}")
146
- ? body.replaceAll("{{WORKLIST}}", worklist)
147
- : `${body.trimEnd()}\n\nWORKLIST — files you actually read this session, most-worked first \
148
- (your scope; if you edited or deep-read a file that isn't listed, you can note it too):\n\n${worklist}`;
149
- return `**Notebook capture point.** ${opening}\n\n${body.trimEnd()}${tail}`;
150
- }
151
-
152
- return `**Notebook capture point.** You have completed work and gathered knowledge as part \
153
- of it — knowledge a future agent could use. This repo keeps that knowledge in a notebook: \
154
- notes are searched and served to future cold agents when their task matches. ${opening}
177
+ function shippedInstructionsTemplate() {
178
+ return `${INSTRUCTIONS_VERSION_TAG}
179
+ You have completed work and gathered knowledge as part of it — knowledge a future agent \
180
+ could use. This repo keeps that knowledge in a notebook: notes are searched and served to \
181
+ future cold agents when their task matches.
155
182
 
156
183
  DECIDE FIRST — as the agent who worked on this task, you know its exact intent:
157
184
  1. So you, not any rule, are best suited to judge whether this work was about the code in \
@@ -169,14 +196,15 @@ changed and what you had to understand to change it, and that is precisely the k
169
196
  cold agent lacks. The default for an edited file is a note. Walk the worklist top to bottom \
170
197
  and decide each one explicitly; do not stop at the first two or three. If you end up writing \
171
198
  notes for well under half the [edited] files, you have under-captured — say which files you \
172
- skipped and why, so the decision is visible instead of silent. This whole checklist (worklist \
173
- + the write contract below) is saved at \`.coldstart/notebook/.worklist.md\` re-Read that file \
174
- any time this session if this scrolls out of context. Your \`kb write\` call ends with a coverage \
175
- line naming any worked file still without a note; read it back before you finish.
199
+ skipped and why, so the decision is visible instead of silent. These instructions are \
200
+ permanent at \`.coldstart/notebook/.capture-instructions.md\`; today's worklist is at \
201
+ \`.coldstart/notebook/.worklist-{{SID}}-{{AID}}.md\` re-Read whichever scrolls out of context \
202
+ this session. Your \`kb write\` call ends with a coverage line naming any worked file still \
203
+ without a note; read it back before you finish.
176
204
 
177
205
  WORKLIST — files you actually read this session, most-worked first:
178
206
 
179
- ${worklistLines(entries)}
207
+ {{WORKLIST}}
180
208
 
181
209
  FOR EACH NOTE:
182
210
  4. Say only what you verified in THIS file, this session. A confident whole-file claim from \
@@ -256,14 +284,102 @@ found wrong: {"op":"retract","id":"<id>","target":{"kind":"note"}} (as one array
256
284
  cat > /tmp/notes.json <<'JSON'
257
285
  [ {…note 1…}, {…note 2…} ]
258
286
  JSON
259
- node ${cli} kb write /tmp/notes.json --root ${root} --session ${sid} --force
260
- Full shapes: run \`node ${cli} kb write --root ${root}\` with no spec — it prints the guide.
287
+ node {{CLI}} kb write /tmp/notes.json --root {{ROOT}} --session {{SID}} --agent {{AID}} --force
288
+ Full shapes: run \`node {{CLI}} kb write --root {{ROOT}}\` with no spec — it prints the guide.
289
+ (Keep --session/--agent exactly as written — they point coverage at THIS agent's worklist.)
261
290
 
262
291
  FLOW DECISION — record what you decided about FLOWS (created a new one, folded into an \
263
292
  existing one, or none) so the flow gate can be measured. Ride it on the SAME batch write — it \
264
293
  is a flag, not a second command:
265
- node ${cli} kb write /tmp/notes.json --root ${root} --session ${sid} --force \\
294
+ node {{CLI}} kb write /tmp/notes.json --root {{ROOT}} --session {{SID}} --agent {{AID}} --force \\
266
295
  --decision <none|new|update> [--id <flow id>] --why "<one clause>"
267
296
  Only when you wrote NO notes at all is there no write to carry it, and then run it alone:
268
- node ${cli} kb flow-decision --decision none --why "<one clause>" --root ${root} --session ${sid}${tail}`;
297
+ node {{CLI}} kb flow-decision --decision none --why "<one clause>" --root {{ROOT}} --session {{SID}}`;
298
+ }
299
+
300
+ /** Ensure the permanent instructions file exists and is current; return its
301
+ * body (without the version-tag line). Best-effort — a write failure still
302
+ * returns the shipped text so THIS fire's payload is unaffected; only the
303
+ * on-disk persistence for later re-Reads is at risk. */
304
+ function ensureCaptureInstructions(root) {
305
+ const p = captureInstructionsPath(root);
306
+ try {
307
+ const existing = readFileSync(p, "utf8");
308
+ if (existing.startsWith(INSTRUCTIONS_VERSION_TAG)) {
309
+ return existing.slice(existing.indexOf("\n") + 1);
310
+ }
311
+ } catch { /* missing — write below */ }
312
+ const text = shippedInstructionsTemplate();
313
+ try {
314
+ mkdirSync(join(root, ".coldstart", "notebook"), { recursive: true });
315
+ writeFileSync(p, text);
316
+ } catch { /* best-effort; still usable in-memory this fire */ }
317
+ return text.slice(text.indexOf("\n") + 1);
318
+ }
319
+
320
+ /**
321
+ * worklist entry: { path, tier, retouches, notes: [{id, type, state}], noConsumers }
322
+ * tier: "edited ×N" | "read" | "skimmed"
323
+ */
324
+ function worklistLines(entries) {
325
+ const lines = [];
326
+ for (const e of entries) {
327
+ const ann = [];
328
+ ann.push(`[${e.tier}]`);
329
+ if (!e.notes?.length) {
330
+ ann.push("no note yet");
331
+ } else {
332
+ for (const n of e.notes) {
333
+ if (n.state === "fresh") {
334
+ ann.push(`note ${n.id} (fresh) → update ONLY if this session taught something the note lacks (.coldstart/notebook/notes/${n.id}.md)`);
335
+ } else if (n.state === "changed" || n.state === "missing") {
336
+ ann.push(`note ${n.id} (STALE) → fix or re-stamp; list the path in "verified" (.coldstart/notebook/notes/${n.id}.md)`);
337
+ } else {
338
+ ann.push(`note ${n.id} → read it first, update by its "id" (.coldstart/notebook/notes/${n.id}.md)`);
339
+ }
340
+ }
341
+ }
342
+ if (e.noConsumers) ann.push("no consumers in import graph");
343
+ lines.push(`- ${e.path} ${ann.join(" · ")}`);
344
+ }
345
+ return lines.join("\n");
346
+ }
347
+
348
+ export function buildCapturePayload(args) {
349
+ const payload = renderCapturePayload(args);
350
+ // Persist the structured scope (+ a small pointer .md — the static
351
+ // instructions live in the permanent .capture-instructions.md, not
352
+ // duplicated per session) so the agent can re-Read it later and `kb write`
353
+ // has coverage to check against. Best-effort; never blocks capture.
354
+ writeDurableWorklist(args.root, args.sid, args.aid, args.entries);
355
+ return payload;
356
+ }
357
+
358
+ function renderCapturePayload({ root, cli, sid, aid, entries, envelope }) {
359
+ aid = String(aid ?? "main").replace(/[^A-Za-z0-9_-]/g, "") || "main";
360
+ const opening = envelope === "block"
361
+ ? "Handle capture now, then stop."
362
+ : envelope === "manual"
363
+ ? "You invoked this capture yourself (/capture-notes) — handle it now, then carry on."
364
+ : "Handle capture first, then continue with the user's request.";
365
+
366
+ const tail = envelope === "subagent"
367
+ ? `\nOnce you have handled the notebook — whether you wrote notes or decided none were \
368
+ needed — remember you were spawned as a subagent. The coordinator that spawned you receives \
369
+ ONLY your final message, so your last message must repeat, in full, the result you produced \
370
+ for it — your findings, not the notebook decision.`
371
+ : "";
372
+
373
+ const worklist = worklistLines(entries);
374
+ const source = loadChecklistOverride(root) ?? ensureCaptureInstructions(root);
375
+ let body = source
376
+ .replaceAll("{{CLI}}", String(cli))
377
+ .replaceAll("{{ROOT}}", String(root))
378
+ .replaceAll("{{SID}}", String(sid))
379
+ .replaceAll("{{AID}}", String(aid));
380
+ body = body.includes("{{WORKLIST}}")
381
+ ? body.replaceAll("{{WORKLIST}}", worklist)
382
+ : `${body.trimEnd()}\n\nWORKLIST — files you actually read this session, most-worked first \
383
+ (your scope; if you edited or deep-read a file that isn't listed, you can note it too):\n\n${worklist}`;
384
+ return `**Notebook capture point.** ${opening}\n\n${body.trimEnd()}${tail}`;
269
385
  }
@@ -0,0 +1,4 @@
1
+ /** Types for capture-sentinel.mjs — plain ESM so the capture hooks can import
2
+ * it without a build step; this declaration is what lets the TypeScript side
3
+ * (src/init.ts) import the same file instead of copying the string. */
4
+ export declare const CAPTURE_SENTINEL: string;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * capture-sentinel.mjs — the marker `/capture-notes` command bodies carry so a
3
+ * host's own pre-prompt recall hook can recognize "this submitted prompt IS
4
+ * the capture command" and run `kb-elicit.mjs --manual` itself with the real
5
+ * session id it already has (Cursor/Codex have no way to pass a session id
6
+ * INTO the command the way Claude's `!`-expansion does). Wording in the
7
+ * command body can drift; this string must not — it is grepped verbatim
8
+ * against the raw submitted prompt.
9
+ */
10
+ export const CAPTURE_SENTINEL = "<!-- coldstart:capture-notes -->";
@@ -105,7 +105,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
105
105
  const ignore = loadIgnore(root);
106
106
  // Session-cumulative v2 state: which files were already offered, and how
107
107
  // far into the rollout the last Stop read (a resumed thread appends).
108
- const marker = join(tmpdir(), `coldstart-codex-kb-${sid}-${aid}.json`);
108
+ // Unified marker namespace (2026-08-06): manual /capture-notes always ran
109
+ // the SHARED kb-elicit.mjs --manual, which reads coldstart-kb-<sid>-<aid>.json
110
+ // — a per-host prefix here meant it could never see this host's own
111
+ // automatic evidence. One prefix for all three hosts fixes that outright.
112
+ const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
109
113
  let state = null;
110
114
  try {
111
115
  const parsed = JSON.parse(readFileSync(marker, "utf8"));
@@ -140,7 +144,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
140
144
 
141
145
  const entries = worklistEntries(CLI, root, fresh, state.files, log);
142
146
  const payload = buildCapturePayload({
143
- root, cli: CLI, sid, entries,
147
+ root, cli: CLI, sid, aid, entries,
144
148
  envelope: isSubagent ? "subagent" : "block",
145
149
  });
146
150
  logCaptureEvent(root, {
@@ -18,6 +18,17 @@
18
18
  * arriving via PRs are a prompt-injection surface; the framing line is the
19
19
  * cheap mitigation.
20
20
  *
21
+ * MANUAL CAPTURE ON-DEMAND (2026-08-06): Codex's `/capture-notes` skill has no
22
+ * way to pass its own real session id into `kb-elicit.mjs --manual` the way
23
+ * Claude's `!`-expansion does — the skill body used to just ask the agent to
24
+ * run the command itself, blind to which session it's in. But THIS hook
25
+ * receives a real `input.session_id` on every prompt. So: when the submitted
26
+ * prompt IS the capture skill (its body carries a stable sentinel,
27
+ * CAPTURE_SENTINEL), run `--manual --session <real sid>` right here and inject
28
+ * its output — deterministic, no LLM has to type a correct `--session <id>`.
29
+ * The skill body keeps "run this in the terminal" as a fallback for when this
30
+ * hook is disabled or fails. See hooks/capture-sentinel.mjs.
31
+ *
21
32
  * Self-contained + fail-open: ANY error → exit 0 with no stdout → nothing
22
33
  * injected, the prompt proceeds untouched.
23
34
  */
@@ -29,6 +40,7 @@ import { tmpdir } from "node:os";
29
40
  import { fileURLToPath } from "node:url";
30
41
  // Strip host telemetry wrappers before the query is built — see recall-query.mjs.
31
42
  import { recallQuery } from "./recall-query.mjs";
43
+ import { CAPTURE_SENTINEL } from "./capture-sentinel.mjs";
32
44
  // Per-session dedup shared with the other recall hooks — see recall-seen.mjs.
33
45
  // Hosts with no transcript path never reset; dedup still holds for the session.
34
46
  import { readSeen, writeSeen, idsInPage, seenArgs } from "./recall-seen.mjs";
@@ -78,11 +90,43 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
78
90
 
79
91
  const sid = String(input.session_id || "").replace(/[^\w-]/g, "");
80
92
 
81
- // No notebook no tax, not even a child process.
82
- if (!existsSync(join(root, ".coldstart", "notebook", ".raw"))) process.exit(0);
93
+ // The submitted prompt IS /capture-notes (its skill body carries this
94
+ // sentinel) run the on-demand capture ourselves with the real session id
95
+ // this hook already has, instead of leaving the agent to guess one.
96
+ let manual = "";
97
+ if (sid && String(input.prompt || "").includes(CAPTURE_SENTINEL)) {
98
+ try {
99
+ const elicit = fileURLToPath(new URL("./kb-elicit.mjs", import.meta.url));
100
+ manual = execFileSync("node", [elicit, "--manual", "--session", sid, "--root", root], {
101
+ encoding: "utf8",
102
+ timeout: 8000,
103
+ stdio: ["ignore", "pipe", "ignore"],
104
+ }).trim();
105
+ if (manual) log(`MANUAL-INJECT session=${sid} bytes=${manual.length}`);
106
+ } catch (e) {
107
+ log(`manual-capture-inject failed: ${String(e).split("\n")[0]}`);
108
+ }
109
+ }
110
+
111
+ // Below this point, every early exit falls back to delivering the manual
112
+ // capture alone (if we have one) instead of exiting silently — a recall
113
+ // miss must never swallow an explicit /capture-notes.
114
+ const deliverManualOnlyAndExit = (why) => {
115
+ if (!manual) process.exit(0);
116
+ log(`INJECT bytes=${manual.length} (manual only, ${why})`);
117
+ process.stdout.write(JSON.stringify({
118
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: manual },
119
+ }));
120
+ process.exit(0);
121
+ };
122
+
123
+ // No notebook → no recall tax, not even a child process (the manual
124
+ // capture above is independent of the notebook existing — it's what
125
+ // creates the first note).
126
+ if (!existsSync(join(root, ".coldstart", "notebook", ".raw"))) deliverManualOnlyAndExit("no notebook yet");
83
127
 
84
128
  const prompt = recallQuery(input.prompt, MAX_QUERY_CHARS);
85
- if (!prompt) process.exit(0);
129
+ if (!prompt) deliverManualOnlyAndExit("empty query");
86
130
 
87
131
  // Ephemeral Codex runs expose no rollout path; readSeen then never resets,
88
132
  // which is the safe direction — dedup simply holds for the whole session.
@@ -99,12 +143,12 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
99
143
  });
100
144
  } catch (e) {
101
145
  log(`search failed/timed out: ${String(e).split("\n")[0]}`);
102
- process.exit(0);
146
+ deliverManualOnlyAndExit("search failed");
103
147
  }
104
148
 
105
149
  if (!page.trim() || page.startsWith("No notebook notes match") || page.startsWith("No notebook in")) {
106
150
  log(`no hits (promptChars=${prompt.length})`);
107
- process.exit(0);
151
+ deliverManualOnlyAndExit("no recall hits");
108
152
  }
109
153
 
110
154
  // Record what this page hands over before the size guards can trim it — a
@@ -140,6 +184,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
140
184
  // never exceed 8.5KB.
141
185
  if (block.length > 8500) block = block.slice(0, 8500) + "\n…(truncated)";
142
186
 
187
+ // An explicit /capture-notes rides FIRST — it's what the user asked for
188
+ // this turn. If the combination would spill past the host's payload cap,
189
+ // recall yields — the capture checklist must arrive whole.
190
+ if (manual) block = manual.length + block.length > 9500 ? manual : `${manual}\n\n---\n\n${block}`;
191
+
143
192
  // Arm the PostToolUse nudge detectors (nudge-handler.mjs gates its spiral
144
193
  // detectors on seen_find so it never nags sessions that don't use coldstart).
145
194
  // An injected session IS coldstart-aware even if it never runs `find` — the
@@ -121,7 +121,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
121
121
  if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
122
122
 
123
123
  const ignore = loadIgnore(root);
124
- const marker = join(tmpdir(), `coldstart-cursor-kb-${sid}-${aid}.json`);
124
+ // Unified marker namespace (2026-08-06): manual /capture-notes always ran
125
+ // the SHARED kb-elicit.mjs --manual, which reads coldstart-kb-<sid>-<aid>.json
126
+ // — a per-host prefix here meant it could never see this host's own
127
+ // automatic evidence. One prefix for all three hosts fixes that outright.
128
+ const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
125
129
  let state = null;
126
130
  try {
127
131
  const parsed = JSON.parse(readFileSync(marker, "utf8"));
@@ -152,7 +156,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
152
156
  writeFileSync(marker, JSON.stringify(state));
153
157
  if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
154
158
  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" });
159
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "subagent" });
156
160
  logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length, host: "cursor" });
157
161
  log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
158
162
  process.stdout.write(JSON.stringify({ followup_message: payload }));
@@ -188,11 +192,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
188
192
  log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
189
193
 
190
194
  if (decision.mode === "block") {
191
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
195
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "block" });
192
196
  process.stdout.write(JSON.stringify({ followup_message: payload }));
193
197
  } else {
194
198
  // Non-blocking: cursor-kb-recall delivers this with the user's next prompt.
195
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
199
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "inject" });
196
200
  writePendingCapture(sid, decision.fire, payload);
197
201
  }
198
202
  } catch (e) {
@@ -16,6 +16,17 @@
16
16
  * injection channel used here. If a future Cursor build stops honoring it,
17
17
  * recall silently no-ops (fail-open) — nav + capture are unaffected.
18
18
  *
19
+ * MANUAL CAPTURE ON-DEMAND (2026-08-06): Cursor's `/capture-notes` command has
20
+ * no `!`-expansion (unlike Claude), so it can't pass its own real session id to
21
+ * `kb-elicit.mjs --manual` the way Claude does — the command body used to just
22
+ * ask the agent to run the command itself, blind to which session it's in. But
23
+ * THIS hook receives a real `input.session_id` on every prompt. So: when the
24
+ * submitted prompt IS the capture command (its body carries a stable sentinel,
25
+ * CAPTURE_SENTINEL), run `--manual --session <real sid>` right here and inject
26
+ * its output the same way a pending automatic fire is injected — deterministic,
27
+ * no LLM has to type a correct `--session <id>`. The command body keeps "run
28
+ * this in the terminal" as a fallback for when this hook is disabled/fails.
29
+ *
19
30
  * Self-contained + fail-open: ANY error → exit 0, no stdout → nothing injected.
20
31
  */
21
32
 
@@ -31,6 +42,7 @@ import { cursorRoot } from "./cursor-input.mjs";
31
42
  import { takePendingCapture } from "./elicit-core.mjs";
32
43
  // Strip host telemetry wrappers before the query is built — see recall-query.mjs.
33
44
  import { recallQuery } from "./recall-query.mjs";
45
+ import { CAPTURE_SENTINEL } from "./capture-sentinel.mjs";
34
46
  // Per-session dedup shared with the other recall hooks — see recall-seen.mjs.
35
47
  // Hosts with no transcript path never reset; dedup still holds for the session.
36
48
  import { readSeen, writeSeen, idsInPage, seenArgs } from "./recall-seen.mjs";
@@ -83,7 +95,28 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
83
95
  // A pending capture (non-blocking fire at a previous stop) is delivered
84
96
  // regardless of recall hits — it must not depend on the notebook existing
85
97
  // (the first capture is what creates it).
86
- const pending = takePendingCapture(sid);
98
+ let pending = takePendingCapture(sid);
99
+
100
+ // The submitted prompt IS /capture-notes (its command body carries this
101
+ // sentinel) → run the on-demand capture ourselves with the real session id
102
+ // this hook already has, instead of leaving the agent to guess one. See
103
+ // the file header and hooks/capture-sentinel.mjs.
104
+ if (sid && String(input.prompt || "").includes(CAPTURE_SENTINEL)) {
105
+ try {
106
+ const elicit = fileURLToPath(new URL("./kb-elicit.mjs", import.meta.url));
107
+ const manual = execFileSync("node", [elicit, "--manual", "--session", sid, "--root", root], {
108
+ encoding: "utf8",
109
+ timeout: 8000,
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ });
112
+ if (manual.trim()) {
113
+ pending = pending ? `${manual.trim()}\n\n---\n\n${pending}` : manual.trim();
114
+ log(`MANUAL-INJECT session=${sid} bytes=${manual.length}`);
115
+ }
116
+ } catch (e) {
117
+ log(`manual-capture-inject failed: ${String(e).split("\n")[0]}`);
118
+ }
119
+ }
87
120
 
88
121
  const prompt = recallQuery(input.prompt, MAX_QUERY_CHARS);
89
122