@cstart/coldstart 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,35 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * codex-kb-elicit.mjs — Codex Stop + SubagentStop notebook capture.
3
+ * codex-kb-elicit.mjs — Codex Stop + SubagentStop notebook capture, v5
4
+ * evidence (ported 2026-07-17; the v4 path-mention walker is gone).
4
5
  *
5
- * ALWAYS FIRES when the agent touched ANY repo file this session the old
6
- * deep-read gate (whole-file Reads + `gs` only) is gone: read-modality
7
- * classification proved unwinnable (windowed Reads, Bash cat/sed, MCP readers
8
- * are all invisible to it a q8-style session lost real knowledge to a
9
- * FAST-EXIT). The hook does mechanical extraction only; THE AGENT decides
10
- * whether anything is worth writing the prompt's gate and "write NOTHING
11
- * when" list carry that decision. FAST-EXIT remains only for sessions that
12
- * touched zero repo files (pure orchestrators / Q&A turns).
6
+ * HOST CONSTRAINT: Codex's Stop does NOT fire per turn in the TUI it fires
7
+ * once at session EXIT, and `codex exec` is one Stop by construction
8
+ * (confirmed live 2026-07-13). A multi-stop trigger (arm/descent/surge) has
9
+ * nothing to time against here, and a pending-file handoff has no next prompt
10
+ * to ride. So Codex capture is ONE-SHOT: at the session's single Stop, build
11
+ * the v5 worklist and deliver it BLOCKING ({decision:"block"} re-prompts the
12
+ * agent before exit). If Codex ever moves to per-turn Stops, port the trigger
13
+ * machine from cursor-kb-elicit.mjs the evidence/state plumbing here is
14
+ * already shaped for it.
13
15
  *
14
- * Merge-vs-new is agent-curated: touched files are annotated with their
15
- * existing notes (id + note file path, from `coldstart kb status --json`) so
16
- * the agent can read a candidate and pass --into/--new on its FIRST kb write
17
- * the exit-3 candidates bounce is the safety net, not the mechanism.
16
+ * What v5 changes vs the old walker:
17
+ * - EVIDENCE TIERS, result-confirmed: extractCodexEvidence pairs
18
+ * custom_tool_call *_output by call_id and classifies the shell commands
19
+ * inside tools.exec_command (read verbs vs sed -i vs mentions) +
20
+ * apply_patch file headers as edits. Grep/path mentions NEVER make the
21
+ * worklist (v4's biggest noise source).
22
+ * - .coldstartignore filtering at the evidence layer.
23
+ * - The v5 checklist payload (capture-payload.mjs) with per-file tier +
24
+ * note-state + consumers annotations — same as Claude/Cursor.
25
+ * - Session-cumulative marker (v2 state, lineCount-sliced): a resumed thread
26
+ * only offers files not already offered.
18
27
  *
19
- * SubagentStop fires too (subagents often do the only real reads); duplication
20
- * is guarded by disjoint transcripts + firsthand-only + SubagentStop preceding
21
- * Stop (the sub's notes are on disk when the main agent's write runs, so they
22
- * surface as "candidates → reconcile, don't duplicate").
23
- *
24
- * Hooks never author or parse markdown — all facts come from `coldstart kb`.
25
28
  * Self-contained + fail-open: ANY error → exit 0 → the stop is allowed.
26
29
  */
27
30
 
28
31
  import { tmpdir } from "node:os";
29
32
  import { join } from "node:path";
30
33
  import { fileURLToPath } from "node:url";
31
- import { execFileSync } from "node:child_process";
32
- import { existsSync, writeFileSync, appendFileSync, readFileSync, mkdirSync, statSync } from "node:fs";
34
+ import { existsSync, writeFileSync, appendFileSync, readFileSync } from "node:fs";
35
+
36
+ import { extractCodexEvidence } from "./evidence.mjs";
37
+ import { loadIgnore } from "./ignore.mjs";
38
+ import { buildCapturePayload } from "./capture-payload.mjs";
39
+ import { worklistEntries, logCaptureEvent } from "./elicit-core.mjs";
33
40
 
34
41
  // hooks/ sits beside dist/ in both the repo and the published package.
35
42
  const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
@@ -41,218 +48,7 @@ function log(msg) {
41
48
  try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] elicit: ${msg}\n`); } catch { /* never fail logging */ }
42
49
  }
43
50
 
44
- // --- Touched-file detection ----------------------------------------------------
45
- function normRel(root, p) {
46
- let s = String(p || "").trim();
47
- if (!s) return "";
48
- if (s.startsWith("/")) {
49
- if (root && s.startsWith(root + "/")) return s.slice(root.length + 1);
50
- return "";
51
- }
52
- return s.replace(/^\.\//, "");
53
- }
54
-
55
- // Path-like tokens inside Codex tool input. Existence under root is checked by
56
- // the caller, so strings, shell commands, apply_patch payloads, and MCP JSON can
57
- // all be scanned without depending on a particular internal tool implementation.
58
- const BASH_PATH_RE = /(?:^|[\s"'`=(:;|])((?:\.{1,2}\/|\/)?[A-Za-z0-9_][A-Za-z0-9_.\/-]*\.[A-Za-z0-9]{1,8})(?=$|[\s"'`):;,|>])/gm;
59
-
60
- // Codex rollout JSONL stores tool calls as response_item payloads. Current
61
- // builds use custom_tool_call for the JS-backed exec/apply_patch tools and
62
- // function_call for other tools. Scan their serialized inputs; only paths that
63
- // exist as files under the repo survive, so protocol metadata cannot become a
64
- // notebook anchor.
65
- function touchedFiles(transcriptPath, root) {
66
- const out = [];
67
- const seen = new Set();
68
- const add = (rel, mustExist) => {
69
- if (!rel || seen.has(rel) || rel.startsWith(".coldstart/")) return;
70
- if (mustExist) {
71
- try { if (!statSync(join(root, rel)).isFile()) return; } catch { return; }
72
- }
73
- seen.add(rel);
74
- out.push(rel);
75
- };
76
- let text = "";
77
- try { text = readFileSync(transcriptPath, "utf8"); } catch { return out; }
78
- for (const line of text.split("\n")) {
79
- if (!line.trim() || line[0] !== "{") continue;
80
- let rec;
81
- try { rec = JSON.parse(line); } catch { continue; }
82
- if (rec.type !== "response_item") continue;
83
- const call = rec.payload || {};
84
- if (call.type !== "custom_tool_call" && call.type !== "function_call") continue;
85
- const sources = [];
86
- if (typeof call.input === "string") sources.push(call.input);
87
- if (typeof call.arguments === "string") sources.push(call.arguments);
88
- else if (call.arguments && typeof call.arguments === "object") sources.push(JSON.stringify(call.arguments));
89
- for (const source of sources) {
90
- for (const g of source.matchAll(/coldstart\s+gs\s+([^\s"'`]+)/g)) add(normRel(root, g[1]), true);
91
- let n = 0;
92
- for (const m of source.matchAll(BASH_PATH_RE)) {
93
- if (++n > 40) break;
94
- add(normRel(root, m[1]), true);
95
- }
96
- }
97
- }
98
- return out;
99
- }
100
-
101
- // --- Per-file annotations from the core (hooks never parse md) -----------------
102
- function noteAnnotations(root, files) {
103
- try {
104
- const raw = execFileSync(
105
- "node", [CLI, "kb", "status", "--json", "--paths", files.join(","), "--root", root],
106
- { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] },
107
- );
108
- const parsed = JSON.parse(raw);
109
- const byPath = new Map();
110
- for (const entry of parsed.paths || []) byPath.set(entry.path, entry.notes || []);
111
- return byPath;
112
- } catch (e) {
113
- log(`kb status unavailable (${String(e).split("\n")[0]}) — annotating as no-notes`);
114
- return new Map();
115
- }
116
- }
117
-
118
- // Always-fire can surface long touch lists; the prompt stays bounded. Files
119
- // WITH existing notes always make the cut (they carry the merge decision).
120
- const MAX_PROMPT_FILES = 30;
121
-
122
- function filesBlock(root, files) {
123
- const notes = noteAnnotations(root, files);
124
- let listed = files;
125
- if (files.length > MAX_PROMPT_FILES) {
126
- const noted = files.filter((f) => (notes.get(f) || []).length);
127
- const bare = files.filter((f) => !(notes.get(f) || []).length);
128
- listed = [...noted, ...bare].slice(0, MAX_PROMPT_FILES);
129
- }
130
- const lines = [];
131
- for (const rel of listed) {
132
- const anchored = notes.get(rel) || [];
133
- if (!anchored.length) { lines.push(`- ${rel} [no notes yet]`); continue; }
134
- const parts = anchored.map((n) => {
135
- const flag = n.state === "changed" || n.state === "missing"
136
- ? ` — FLAGGED STALE: you just read this file, so fix or re-stamp it (list the path in "verified")`
137
- : "";
138
- return `${n.id} [${n.type} · ${n.state}]${flag} (.coldstart/notebook/notes/${n.id}.md)`;
139
- });
140
- lines.push(`- ${rel} has notes: ${parts.join("; ")}`);
141
- }
142
- if (listed.length < files.length) lines.push(`- …and ${files.length - listed.length} more touched files`);
143
- return lines.join("\n");
144
- }
145
-
146
- // --- The capture prompt (v4, 2026-07-07 — user-authored opening; validation-run
147
- // configuration: gates off via --force, capture-only) ---------------------------
148
- function buildCapturePrompt(root, block, sid, isSubagent) {
149
- return `You have completed a task now and have gathered knowledge as a part of that task or \
150
- process — knowledge another agent in future could make use of.
151
-
152
- But before writing any notes, we need to decide whether the task you completed deserves a note. \
153
- If you were investigating on an older branch or doing a PR review, we may not need to save notes \
154
- because that code is not in the present — it's in the past or it's in the future. The notes that \
155
- we write are backed by the code in the present. This was an example to explain to you. As an \
156
- agent who worked on the current task, you know its exact intent and are best suited to decide \
157
- whether this task deserves a note. And if nothing about the current code is worth recording, \
158
- then no note is the right answer.
159
-
160
- Once you decide the task does deserve a note, we store it in a notebook format, and this \
161
- notebook has to be backed by the codebase you are working on.
162
-
163
- We need to save only the working knowledge of the codebase in a specific format so that it can \
164
- be searched and served to future cold agents. We don't need to store any general interaction you \
165
- had, just the knowledge about the codebase. As a part of your task, you must have done some \
166
- investigation, file reading, new file/feature addition or updated existing files or features. It \
167
- could have been a bug fix or any other operation on the codebase. We need to store it in the \
168
- below format —
169
-
170
- THE NOTEBOOK HAS THREE CONTAINERS. Put each piece of knowledge in its one home:
171
-
172
- 1. FILE notes (if you decided to write a note for the entire task) — write one for EVERY file \
173
- you actually read and understood this session. No judgment call about whether it seems obvious. \
174
- First decide the file's CHARACTER:
175
- - hub = the file has no single purpose (models.py, helpers, utils). Knowledge lives per \
176
- SYMBOL, as facets: one facet for each symbol you worked with this session. Only symbols you \
177
- have firsthand knowledge of — never enumerate the rest.
178
- - single = the file has one purpose. One summary, 1-3 sentences.
179
- The best facet/summary says: what it does that the name doesn't tell you, what to watch out \
180
- for when changing it, and which tests or checks matter.
181
-
182
- 2. FLOW notes — when your task traced how something works ACROSS files: the ordered story. Each \
183
- step points at a file (path + symbols) with its role in the story. A step never restates what a \
184
- file note already says — the detail lives in the file's facet; the flow links to it.
185
-
186
- 3. LESSON notes — rare. Only one thing qualifies:
187
- - a confirmed ABSENCE ("there is no X in this repo"), with the search terms that proved it.
188
- If it is about one file or one symbol, it is a facet, not a lesson. Repo-wide rules and \
189
- conventions are the human's to define (CLAUDE.md / coldstart.md / AGENTS.md) — do not mint them here.
190
-
191
- Fixed a bug? The actual cause goes into the culpable file's facet, and the SYMPTOM words go \
192
- into that file note's "aliases" — the symptom is what a future agent will search. If the cause \
193
- spans files, the story is a flow.
194
-
195
- Read a note this session that turned out WRONG? Correct it now — same spec with its "id" \
196
- (fields merge; yours win), or op "retract" for a wrong claim. You are the warm agent; there is \
197
- no "next".
198
-
199
- RULES:
200
- - Codebase knowledge only — never the interaction, the user, or your own process.
201
- - Firsthand only: if it arrived secondhand (e.g. a subagent's report) and you did not verify it \
202
- yourself, do not store it.
203
- - If a future agent would not act differently for knowing it, do not store it.
204
- - SEARCH BEFORE YOU WRITE a flow or lesson: run \`node ${CLI} kb search "<your task words>" \
205
- --root ${root}\` once. If an existing flow already tells this mechanism's story, UPDATE it \
206
- (same spec with its "id") instead of writing a near-duplicate.
207
- - Note ids are never composed by you. In facet "flows" backlinks, reference a flow by its \
208
- EXACT title (as written in your flow spec) or by an id copied from kb search output — the \
209
- tool resolves titles to ids at write time. A typo prints a WARNING (the ref is kept but \
210
- dangling) — fix any warning the write prints, in this session. Never guess an id.
211
- - "verified": list every anchor path you actually read THIS session — that re-stamps its \
212
- freshness. Never list a file you did not open.
213
- - Paths are join keys: always repo-relative, exactly as they appear in the repo. Fix any path \
214
- warning the write prints — a wrong path is a silently dangling link.
215
-
216
- Files you touched this run, with their existing notes (read one before writing if you need to \
217
- see what it already says — never create a second note for the same file):
218
-
219
- ${block}
220
-
221
- HOW TO WRITE — ONE Bash block TOTAL: author every spec with a heredoc and
222
- chain every write in the SAME block, flows before the file notes that
223
- reference them. Never author specs one-per-message with a file-editing tool —
224
- that is the single biggest waste of turns here.
225
- cat > /tmp/spec-1.json <<'SPEC'
226
- { ...flow... }
227
- SPEC
228
- cat > /tmp/spec-2.json <<'SPEC'
229
- { ...file note; facets reference the flow by its EXACT title... }
230
- SPEC
231
- node ${CLI} kb write /tmp/spec-1.json --root ${root} --session ${sid} --force && \\
232
- node ${CLI} kb write /tmp/spec-2.json --root ${root} --session ${sid} --force
233
- Chain the writes with && — if a flow write fails, its dependent file notes
234
- must not run. Never write the same note id twice.
235
-
236
- Spec shapes (only include fields you actually have):
237
- file (hub): {"type":"file-hub","path":"src/x.py","aliases":["symptom or search words"],
238
- "facets":[{"symbol":"ClassOrFn","detail":"the non-obvious thing about THIS symbol",
239
- "flows":["<flow-note-id or the flow's exact title>"]}]}
240
- file (single): {"type":"file-single","path":"src/x.py",
241
- "summary":"its one purpose + how (1-3 sentences)"}
242
- flow: {"type":"flow","title":"how X happens","aliases":["other words for X"],
243
- "summary":"one paragraph",
244
- "steps":[{"path":"src/a.py","symbols":["entry"],"role":"receives the request"}],
245
- "invariants":["what must hold"],"verified":["src/a.py"]}
246
- lesson: {"type":"lesson","kind":"absence","title":"the absence, e.g. no retry logic",
247
- "body":"what you looked for + that it is not there",
248
- "scope":{"terms":["search","terms"]}} (the search that proved it)
249
-
250
- ${isSubagent
251
- ? `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.`
252
- : `When your notes are written, stop.`}`;
253
- }
254
-
255
- // --- stdin + guards -------------------------------------------------------------
51
+ // --- stdin ---------------------------------------------------------------------
256
52
  function readStdin() {
257
53
  return new Promise((res) => {
258
54
  let data = "";
@@ -269,14 +65,6 @@ function readStdin() {
269
65
  });
270
66
  }
271
67
 
272
- function logCaptureEvent(root, event) {
273
- try {
274
- const dir = join(root, ".coldstart", "notebook", ".metrics");
275
- mkdirSync(dir, { recursive: true });
276
- appendFileSync(join(dir, "capture.jsonl"), JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
277
- } catch { /* metrics never wedge a stop */ }
278
- }
279
-
280
68
  process.on("uncaughtException", (e) => { log(`uncaught ${e?.stack || e}`); process.exit(0); });
281
69
  process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); process.exit(0); });
282
70
 
@@ -294,26 +82,16 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
294
82
  // Guard 1: already inside a hook-induced continuation → let it stop.
295
83
  if (input.stop_hook_active === true) { log("SKIP stop_hook_active"); process.exit(0); }
296
84
 
297
- // Codex session_id identifies a thread; turn_id identifies one user turn.
298
- // Capture across the whole thread, incrementally — a resumed thread keeps
299
- // contributing new knowledge.
300
85
  const sid = String(input.session_id || "").replace(/[^A-Za-z0-9_-]/g, "");
301
86
  if (!sid) { log("SKIP no-session-id"); process.exit(0); }
302
87
 
303
- // Guard 2: capture across the session, but only files not yet offered. The
304
- // old per-turn marker re-offered EVERY touched file on every turn; instead we
305
- // remember which files were already offered and elicit only the new ones (the
306
- // delta is computed below). Subagents share the parent session_id, so the
307
- // record is scoped by agent too.
308
88
  const aid = String(input.agent_id || "main").replace(/[^A-Za-z0-9_-]/g, "") || "main";
309
- const marker = join(tmpdir(), `coldstart-codex-kb-${sid}-${aid}.json`);
310
- let offered = new Set();
311
- try { offered = new Set(JSON.parse(readFileSync(marker, "utf8")).files || []); } catch { /* first Stop of this session */ }
89
+ const isSubagent = input.hook_event_name === "SubagentStop";
312
90
 
313
91
  // Codex supplies the child's own rollout as agent_transcript_path on
314
92
  // SubagentStop. Its transcript_path is the parent's rollout at that event.
315
93
  let transcriptPath = String(input.transcript_path || "");
316
- if (input.hook_event_name === "SubagentStop") {
94
+ if (isSubagent) {
317
95
  const own = String(input.agent_transcript_path || "");
318
96
  if (!own || !existsSync(own)) {
319
97
  log(`SKIP subagent-transcript-missing session=${sid} agent=${aid} tried=${own || "n/a"}`);
@@ -321,29 +99,56 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
321
99
  }
322
100
  transcriptPath = own;
323
101
  }
324
- // Ephemeral Codex runs deliberately expose no transcript path. Fail open:
325
- // navigation/recall still work, but capture has no trustworthy evidence.
326
- const files = transcriptPath ? touchedFiles(transcriptPath, root) : [];
327
- // Delta: only files not already offered on an earlier Stop this session.
328
- const newFiles = files.filter((f) => !offered.has(f));
102
+ // Ephemeral Codex runs deliberately expose no transcript path. Fail open.
103
+ if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
329
104
 
330
- // FAST-EXIT when there is nothing NEW to capture — either no repo file was
331
- // touched at all (pure orchestration / Q&A) or every touched file was already
332
- // offered on a previous Stop. No record is written on this path, so an early
333
- // no-op Stop can never burn the session's capture.
334
- if (!newFiles.length) {
335
- log(`FAST-EXIT no-new-files session=${sid} agent=${aid} touched=${files.length} event=${input.hook_event_name || "?"}`);
336
- process.exit(0);
105
+ const ignore = loadIgnore(root);
106
+ // Session-cumulative v2 state: which files were already offered, and how
107
+ // far into the rollout the last Stop read (a resumed thread appends).
108
+ const marker = join(tmpdir(), `coldstart-codex-kb-${sid}-${aid}.json`);
109
+ let state = null;
110
+ try {
111
+ const parsed = JSON.parse(readFileSync(marker, "utf8"));
112
+ if (parsed && parsed.v === 2) state = parsed;
113
+ } catch { /* first Stop of this session (or a pre-v5 marker: start fresh) */ }
114
+ if (!state) state = { v: 2, lineCount: 0, files: {} };
115
+
116
+ const text = readFileSync(transcriptPath, "utf8");
117
+ const lines = text.split("\n");
118
+ const segment = lines.slice(state.lineCount).join("\n");
119
+ state.lineCount = lines.length;
120
+
121
+ // Evidence: contentRead tiers only, ignore-filtered. Mentions never count.
122
+ const raw = extractCodexEvidence(segment, root);
123
+ const offered = new Set(Object.keys(state.files));
124
+ const fresh = [];
125
+ for (const [rel, r] of raw) {
126
+ if (r.reads + r.edits + r.gs === 0) continue;
127
+ if (ignore(rel)) continue;
128
+ if (offered.has(rel)) continue;
129
+ fresh.push(rel);
130
+ state.files[rel] = { ...r, captured: true };
337
131
  }
132
+ // Most-worked first, same ranking as contentReadFiles.
133
+ fresh.sort((a, b) => (state.files[b].edits - state.files[a].edits) || (state.files[b].events - state.files[a].events));
134
+ writeFileSync(marker, JSON.stringify(state));
338
135
 
339
- // Record everything now offered BEFORE returning the block, so the post-write
340
- // re-Stop and every later Stop skip these files.
341
- try { writeFileSync(marker, JSON.stringify({ files: [...offered, ...newFiles], ts: Date.now() })); } catch { /* best effort */ }
136
+ if (!fresh.length) {
137
+ log(`FAST-EXIT no-new-files session=${sid} agent=${aid} event=${input.hook_event_name || "?"}`);
138
+ process.exit(0);
139
+ }
342
140
 
343
- const prompt = buildCapturePrompt(root, filesBlock(root, newFiles), sid, input.hook_event_name === "SubagentStop");
344
- logCaptureEvent(root, { event: "elicit", session: sid, agent: aid, touched: files.length, new: newFiles.length, hook: input.hook_event_name });
345
- log(`ELICIT session=${sid} agent=${aid} new=${newFiles.length} touched=${files.length} promptBytes=${prompt.length} event=${input.hook_event_name || "?"}`);
346
- process.stdout.write(JSON.stringify({ decision: "block", reason: prompt }));
141
+ const entries = worklistEntries(CLI, root, fresh, state.files, log);
142
+ const payload = buildCapturePayload({
143
+ root, cli: CLI, sid, entries,
144
+ envelope: isSubagent ? "subagent" : "block",
145
+ });
146
+ logCaptureEvent(root, {
147
+ event: "fire", reason: isSubagent ? "subagent" : "session-end", session: sid,
148
+ agent: aid, files: fresh.length, host: "codex",
149
+ });
150
+ log(`FIRE ${isSubagent ? "subagent" : "session-end"} session=${sid} agent=${aid} files=${fresh.length} promptBytes=${payload.length}`);
151
+ process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
347
152
  } catch (e) {
348
153
  log(`handler ${e?.stack || e}`); // fail-open: no stdout → stop allowed
349
154
  }