@cstart/coldstart 2.2.15 → 2.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/init.d.ts +16 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +49 -7
- package/dist/init.js.map +1 -1
- package/dist/kb/cli.d.ts.map +1 -1
- package/dist/kb/cli.js +4 -1
- package/dist/kb/cli.js.map +1 -1
- package/dist/kb/durable-worklist.d.ts +8 -6
- package/dist/kb/durable-worklist.d.ts.map +1 -1
- package/dist/kb/durable-worklist.js +76 -22
- package/dist/kb/durable-worklist.js.map +1 -1
- package/dist/kb/store.d.ts.map +1 -1
- package/dist/kb/store.js +12 -5
- package/dist/kb/store.js.map +1 -1
- package/dist/kb/write-batch.js +1 -1
- package/dist/kb/write-batch.js.map +1 -1
- package/dist/kb/write-guide.d.ts.map +1 -1
- package/dist/kb/write-guide.js +6 -4
- package/dist/kb/write-guide.js.map +1 -1
- package/dist/kb/write.d.ts +4 -0
- package/dist/kb/write.d.ts.map +1 -1
- package/dist/kb/write.js.map +1 -1
- package/dist/server/mcp.d.ts +4 -0
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +5 -0
- package/dist/server/mcp.js.map +1 -1
- package/hooks/capture-payload.mjs +204 -88
- package/hooks/capture-sentinel.d.mts +4 -0
- package/hooks/capture-sentinel.mjs +10 -0
- package/hooks/codex-kb-elicit.mjs +6 -2
- package/hooks/codex-kb-recall.mjs +54 -5
- package/hooks/cursor-kb-elicit.mjs +8 -4
- package/hooks/cursor-kb-recall.mjs +34 -1
- package/hooks/kb-elicit.mjs +81 -34
- package/package.json +2 -2
|
@@ -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
|
|
36
|
-
* .worklist
|
|
37
|
-
* .worklist
|
|
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).
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
writeFileSync(
|
|
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
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
260
|
-
Full shapes: run \`node
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|