@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.
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +1 -6
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +40 -0
- package/dist/init.js.map +1 -1
- package/dist/kb/cli.d.ts.map +1 -1
- package/dist/kb/cli.js +11 -4
- package/dist/kb/cli.js.map +1 -1
- package/dist/kb/lint.d.ts +6 -1
- package/dist/kb/lint.d.ts.map +1 -1
- package/dist/kb/lint.js +28 -0
- package/dist/kb/lint.js.map +1 -1
- package/dist/kb/write-guide.d.ts +15 -0
- package/dist/kb/write-guide.d.ts.map +1 -0
- package/dist/kb/write-guide.js +112 -0
- package/dist/kb/write-guide.js.map +1 -0
- package/dist/server/mcp.d.ts +5 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +15 -4
- package/dist/server/mcp.js.map +1 -1
- package/dist/unwire.d.ts.map +1 -1
- package/dist/unwire.js +2 -0
- package/dist/unwire.js.map +1 -1
- package/hooks/capture-payload.mjs +158 -0
- package/hooks/codex-kb-elicit.mjs +77 -272
- package/hooks/cursor-kb-elicit.mjs +121 -266
- package/hooks/cursor-kb-recall.mjs +39 -15
- package/hooks/elicit-core.mjs +126 -0
- package/hooks/evidence.mjs +318 -0
- package/hooks/ignore.mjs +84 -0
- package/hooks/kb-elicit.mjs +108 -288
- package/hooks/kb-recall.mjs +43 -17
- package/hooks/trigger.mjs +140 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
|
35
|
-
|
|
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
|
-
// ---
|
|
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
|
-
//
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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 (
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
340
|
-
logCaptureEvent(root, {
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
+
}
|