@cstart/coldstart 2.1.1 → 2.2.1
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 +45 -1
- 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 +114 -288
- package/hooks/kb-recall.mjs +43 -17
- package/hooks/trigger.mjs +140 -0
- package/package.json +1 -1
package/hooks/kb-elicit.mjs
CHANGED
|
@@ -1,35 +1,45 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* kb-elicit.mjs — Stop + SubagentStop hook. Notebook capture,
|
|
3
|
+
* kb-elicit.mjs — Stop + SubagentStop hook. Notebook capture, trigger-timed.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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).
|
|
5
|
+
* v5 (2026-07-17): the always-fire gate is gone. Every Stop updates per-file
|
|
6
|
+
* EVIDENCE RECORDS (hooks/evidence.mjs — edit/read/gs tiers; mentions and
|
|
7
|
+
* .coldstartignore'd files never count) and advances the TRIGGER state machine
|
|
8
|
+
* (hooks/trigger.mjs — score/arm, fire on descent/surge, cap, .git HEAD
|
|
9
|
+
* drift). Most stops exit silently. When the trigger fires:
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* descent/surge → NON-BLOCKING: the capture payload is written to a pending
|
|
12
|
+
* file; kb-recall.mjs (UserPromptSubmit) delivers it with the user's next
|
|
13
|
+
* prompt. The stop itself is never blocked — no more answer-then-homework
|
|
14
|
+
* agitation (upstream #76721 sidestepped).
|
|
15
|
+
* cap / head-drift → BLOCKING Stop (backlog rescue / commit boundary): the
|
|
16
|
+
* payload rides the classic block decision.
|
|
17
|
+
* SubagentStop → BLOCKING as before (a subagent has no next prompt); the
|
|
18
|
+
* restate-deliverable tail prevents the #61 return-value hijack.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* The payload (hooks/capture-payload.mjs) is the finalized v5 checklist:
|
|
21
|
+
* worklist + decide-time rules only; spec formats live behind `kb write`.
|
|
22
|
+
* Worklists annotate per file: evidence tier, existing-note state (from
|
|
23
|
+
* `kb status --json`), and "no consumers in import graph" (from `coldstart
|
|
24
|
+
* consumers --json`, fail-open — surfacing the graph's blind spot so agents
|
|
25
|
+
* know when an observed usage fact is worth recording).
|
|
23
26
|
*
|
|
24
|
-
* Hooks never author or parse markdown — all facts come from `coldstart
|
|
25
|
-
* Self-contained + fail-open: ANY error → exit 0 → the stop is allowed.
|
|
27
|
+
* Hooks never author or parse markdown — all note facts come from `coldstart
|
|
28
|
+
* kb`. 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 {
|
|
32
|
-
|
|
34
|
+
import { existsSync, writeFileSync, appendFileSync, readFileSync } from "node:fs";
|
|
35
|
+
|
|
36
|
+
import { extractEvidence, segmentStats } from "./evidence.mjs";
|
|
37
|
+
import { initialState, step } from "./trigger.mjs";
|
|
38
|
+
import { loadIgnore } from "./ignore.mjs";
|
|
39
|
+
import { buildCapturePayload } from "./capture-payload.mjs";
|
|
40
|
+
import {
|
|
41
|
+
worklistEntries, freshNotedSet, gitHead, logCaptureEvent, writePendingCapture,
|
|
42
|
+
} from "./elicit-core.mjs";
|
|
33
43
|
|
|
34
44
|
// hooks/ sits beside dist/ in both the repo and the published package.
|
|
35
45
|
const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
|
|
@@ -41,232 +51,7 @@ function log(msg) {
|
|
|
41
51
|
try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] elicit: ${msg}\n`); } catch { /* never fail logging */ }
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
// ---
|
|
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 a shell command: anything with an extension, plus
|
|
56
|
-
// whatever follows `coldstart gs`. Existence under root is checked by the
|
|
57
|
-
// caller — this only extracts candidates.
|
|
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
|
-
// EVERY repo file the agent touched this run, however it got there: Read
|
|
61
|
-
// (windowed or not), Edit/Write, `coldstart gs`, or a path mentioned in a
|
|
62
|
-
// Bash command (cat/sed/grep/head — the modalities the old deep-read gate was
|
|
63
|
-
// blind to). Whether any of it is WORTH capturing is the agent's call.
|
|
64
|
-
function touchedFiles(transcriptPath, root) {
|
|
65
|
-
const out = [];
|
|
66
|
-
const seen = new Set();
|
|
67
|
-
const add = (rel, mustExist) => {
|
|
68
|
-
if (!rel || seen.has(rel) || rel.startsWith(".coldstart/")) return;
|
|
69
|
-
if (mustExist) {
|
|
70
|
-
try { if (!statSync(join(root, rel)).isFile()) return; } catch { return; }
|
|
71
|
-
}
|
|
72
|
-
seen.add(rel);
|
|
73
|
-
out.push(rel);
|
|
74
|
-
};
|
|
75
|
-
let text = "";
|
|
76
|
-
try { text = readFileSync(transcriptPath, "utf8"); } catch { return out; }
|
|
77
|
-
for (const line of text.split("\n")) {
|
|
78
|
-
if (!line.trim() || line[0] !== "{") continue;
|
|
79
|
-
let rec;
|
|
80
|
-
try { rec = JSON.parse(line); } catch { continue; }
|
|
81
|
-
if (rec.type !== "assistant") continue;
|
|
82
|
-
const content = rec.message?.content;
|
|
83
|
-
if (!Array.isArray(content)) continue;
|
|
84
|
-
for (const b of content) {
|
|
85
|
-
if (!b || b.type !== "tool_use") continue;
|
|
86
|
-
const inp = b.input || {};
|
|
87
|
-
if (b.name === "Read" || b.name === "Edit" || b.name === "Write" || b.name === "NotebookEdit") {
|
|
88
|
-
add(normRel(root, inp.file_path), false);
|
|
89
|
-
} else if (b.name === "Bash") {
|
|
90
|
-
const cmd = String(inp.command || "");
|
|
91
|
-
for (const g of cmd.matchAll(/coldstart\s+gs\s+(\S+)/g)) add(normRel(root, g[1]), false);
|
|
92
|
-
let n = 0;
|
|
93
|
-
for (const m of cmd.matchAll(BASH_PATH_RE)) {
|
|
94
|
-
if (++n > 12) break; // a single huge command must not dominate
|
|
95
|
-
add(normRel(root, m[1]), true); // shell tokens are guesses — verify on disk
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// --- Per-file annotations from the core (hooks never parse md) -----------------
|
|
104
|
-
function noteAnnotations(root, files) {
|
|
105
|
-
try {
|
|
106
|
-
const raw = execFileSync(
|
|
107
|
-
"node", [CLI, "kb", "status", "--json", "--paths", files.join(","), "--root", root],
|
|
108
|
-
{ encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] },
|
|
109
|
-
);
|
|
110
|
-
const parsed = JSON.parse(raw);
|
|
111
|
-
const byPath = new Map();
|
|
112
|
-
for (const entry of parsed.paths || []) byPath.set(entry.path, entry.notes || []);
|
|
113
|
-
return byPath;
|
|
114
|
-
} catch (e) {
|
|
115
|
-
log(`kb status unavailable (${String(e).split("\n")[0]}) — annotating as no-notes`);
|
|
116
|
-
return new Map();
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Always-fire can surface long touch lists; the prompt stays bounded. Files
|
|
121
|
-
// WITH existing notes always make the cut (they carry the merge decision).
|
|
122
|
-
const MAX_PROMPT_FILES = 30;
|
|
123
|
-
|
|
124
|
-
function filesBlock(root, files) {
|
|
125
|
-
const notes = noteAnnotations(root, files);
|
|
126
|
-
let listed = files;
|
|
127
|
-
if (files.length > MAX_PROMPT_FILES) {
|
|
128
|
-
const noted = files.filter((f) => (notes.get(f) || []).length);
|
|
129
|
-
const bare = files.filter((f) => !(notes.get(f) || []).length);
|
|
130
|
-
listed = [...noted, ...bare].slice(0, MAX_PROMPT_FILES);
|
|
131
|
-
}
|
|
132
|
-
const lines = [];
|
|
133
|
-
for (const rel of listed) {
|
|
134
|
-
const anchored = notes.get(rel) || [];
|
|
135
|
-
if (!anchored.length) { lines.push(`- ${rel} [no notes yet]`); continue; }
|
|
136
|
-
const parts = anchored.map((n) => {
|
|
137
|
-
const flag = n.state === "changed" || n.state === "missing"
|
|
138
|
-
? ` — FLAGGED STALE: you just read this file, so fix or re-stamp it (list the path in "verified")`
|
|
139
|
-
: "";
|
|
140
|
-
return `${n.id} [${n.type} · ${n.state}]${flag} (.coldstart/notebook/notes/${n.id}.md)`;
|
|
141
|
-
});
|
|
142
|
-
lines.push(`- ${rel} has notes: ${parts.join("; ")}`);
|
|
143
|
-
}
|
|
144
|
-
if (listed.length < files.length) lines.push(`- …and ${files.length - listed.length} more touched files`);
|
|
145
|
-
return lines.join("\n");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// --- The capture prompt (v4, 2026-07-07 — user-authored opening; validation-run
|
|
149
|
-
// configuration: gates off via --force, capture-only) ---------------------------
|
|
150
|
-
function buildCapturePrompt(root, block, sid, isSubagent) {
|
|
151
|
-
return `You have completed a task now and have gathered knowledge as a part of that task or \
|
|
152
|
-
process — knowledge another agent in future could make use of.
|
|
153
|
-
|
|
154
|
-
But before writing any notes, we need to decide whether the task you completed deserves a note. \
|
|
155
|
-
If you were investigating on an older branch or doing a PR review, we may not need to save notes \
|
|
156
|
-
because that code is not in the present — it's in the past or it's in the future. The notes that \
|
|
157
|
-
we write are backed by the code in the present. This was an example to explain to you. As an \
|
|
158
|
-
agent who worked on the current task, you know its exact intent and are best suited to decide \
|
|
159
|
-
whether this task deserves a note. And if nothing about the current code is worth recording, \
|
|
160
|
-
then no note is the right answer.
|
|
161
|
-
|
|
162
|
-
Once you decide the task does deserve a note, we store it in a notebook format, and this \
|
|
163
|
-
notebook has to be backed by the codebase you are working on.
|
|
164
|
-
|
|
165
|
-
We need to save only the working knowledge of the codebase in a specific format so that it can \
|
|
166
|
-
be searched and served to future cold agents. We don't need to store any general interaction you \
|
|
167
|
-
had, just the knowledge about the codebase. As a part of your task, you must have done some \
|
|
168
|
-
investigation, file reading, new file/feature addition or updated existing files or features. It \
|
|
169
|
-
could have been a bug fix or any other operation on the codebase. We need to store it in the \
|
|
170
|
-
below format —
|
|
171
|
-
|
|
172
|
-
THE NOTEBOOK HAS THREE CONTAINERS. Put each piece of knowledge in its one home:
|
|
173
|
-
|
|
174
|
-
1. FILE notes (if you decided to write a note for the entire task) — write one for EVERY file \
|
|
175
|
-
you actually read and understood this session. No judgment call about whether it seems obvious. \
|
|
176
|
-
First decide the file's CHARACTER:
|
|
177
|
-
- hub = the file has no single purpose (models.py, helpers, utils). Knowledge lives per \
|
|
178
|
-
SYMBOL, as facets: one facet for each symbol you worked with this session. Only symbols you \
|
|
179
|
-
have firsthand knowledge of — never enumerate the rest.
|
|
180
|
-
- single = the file has one purpose. One summary, 1-3 sentences.
|
|
181
|
-
The best facet/summary says: what it does that the name doesn't tell you, what to watch out \
|
|
182
|
-
for when changing it, and which tests or checks matter.
|
|
183
|
-
|
|
184
|
-
2. FLOW notes — RARE. Not "my task touched several files": that is most tasks, and its \
|
|
185
|
-
knowledge belongs in the file notes. A flow exists only when the RELATIONSHIP between files is \
|
|
186
|
-
itself the knowledge — such as an enforcement gap, an asymmetry or inconsistency between files, \
|
|
187
|
-
a dead/unreachable path, or an evolution that explains a surprising present shape. The gate: \
|
|
188
|
-
name, in one sentence, the fact a reader of ALL the file notes would still be missing; if you \
|
|
189
|
-
cannot, there is no flow — put the facts in file notes. That sentence is the FIRST sentence of \
|
|
190
|
-
the flow's summary. Never a flow: a feature's parts-list ("the model, the view, the route, the \
|
|
191
|
-
config, the tests" — an index, not knowledge); a mechanism living in one file (that file's note); a call-chain or \
|
|
192
|
-
version history with no gotcha at the end. Steps are the minimal chain carrying the missing \
|
|
193
|
-
fact — not a tour of every file the task visited. A step never restates what a file note \
|
|
194
|
-
already says — the detail lives in the file's facet; the flow links to it.
|
|
195
|
-
|
|
196
|
-
3. LESSON notes — rare. Only one thing qualifies:
|
|
197
|
-
- a confirmed ABSENCE ("there is no X in this repo"), with the search terms that proved it.
|
|
198
|
-
If it is about one file or one symbol, it is a facet, not a lesson. Repo-wide rules and \
|
|
199
|
-
conventions are the human's to define (CLAUDE.md / coldstart.md / AGENTS.md) — do not mint them here.
|
|
200
|
-
|
|
201
|
-
Fixed a bug? The actual cause goes into the culpable file's facet, and the SYMPTOM words go \
|
|
202
|
-
into that file note's "aliases" — the symptom is what a future agent will search. If the cause \
|
|
203
|
-
spans files, the story is a flow.
|
|
204
|
-
|
|
205
|
-
Read a note this session that turned out WRONG? Correct it now — same spec with its "id" \
|
|
206
|
-
(fields merge; yours win), or op "retract" for a wrong claim. You are the warm agent; there is \
|
|
207
|
-
no "next".
|
|
208
|
-
|
|
209
|
-
RULES:
|
|
210
|
-
- Codebase knowledge only — never the interaction, the user, or your own process.
|
|
211
|
-
- Firsthand only: if it arrived secondhand (e.g. a subagent's report) and you did not verify it \
|
|
212
|
-
yourself, do not store it.
|
|
213
|
-
- Say only what you verified in THIS file, this session: never assert a method, branch, or \
|
|
214
|
-
config key you did not confirm is present, and never describe what a DIFFERENT file does (that \
|
|
215
|
-
is a flow, or that file's own note). A confident whole-file claim from a partial read is the \
|
|
216
|
-
single most common bad note.
|
|
217
|
-
- If a future agent would not act differently for knowing it, do not store it.
|
|
218
|
-
- SEARCH BEFORE YOU WRITE a flow or lesson: run \`node ${CLI} kb search "<your task words>" \
|
|
219
|
-
--root ${root}\` once. If an existing flow already tells this mechanism's story, UPDATE it \
|
|
220
|
-
(same spec with its "id") instead of writing a near-duplicate.
|
|
221
|
-
- Note ids are never composed by you. In facet "flows" backlinks, reference a flow by its \
|
|
222
|
-
EXACT title (as written in your flow spec) or by an id copied from kb search output — the \
|
|
223
|
-
tool resolves titles to ids at write time. A typo prints a WARNING (the ref is kept but \
|
|
224
|
-
dangling) — fix any warning the write prints, in this session. Never guess an id.
|
|
225
|
-
- "verified": list every anchor path you actually read THIS session — that re-stamps its \
|
|
226
|
-
freshness. Never list a file you did not open.
|
|
227
|
-
- Paths are join keys: always repo-relative, exactly as they appear in the repo. Fix any path \
|
|
228
|
-
warning the write prints — a wrong path is a silently dangling link.
|
|
229
|
-
|
|
230
|
-
Files you touched this run, with their existing notes (read one before writing if you need to \
|
|
231
|
-
see what it already says — never create a second note for the same file):
|
|
232
|
-
|
|
233
|
-
${block}
|
|
234
|
-
|
|
235
|
-
HOW TO WRITE — ONE Bash block TOTAL: author every spec with a heredoc and
|
|
236
|
-
chain every write in the SAME block, flows before the file notes that
|
|
237
|
-
reference them. Never author specs one-per-message with a file-editing tool —
|
|
238
|
-
that is the single biggest waste of turns here.
|
|
239
|
-
cat > /tmp/spec-1.json <<'SPEC'
|
|
240
|
-
{ ...flow... }
|
|
241
|
-
SPEC
|
|
242
|
-
cat > /tmp/spec-2.json <<'SPEC'
|
|
243
|
-
{ ...file note; facets reference the flow by its EXACT title... }
|
|
244
|
-
SPEC
|
|
245
|
-
node ${CLI} kb write /tmp/spec-1.json --root ${root} --session ${sid} --force && \\
|
|
246
|
-
node ${CLI} kb write /tmp/spec-2.json --root ${root} --session ${sid} --force
|
|
247
|
-
Chain the writes with && — if a flow write fails, its dependent file notes
|
|
248
|
-
must not run. Never write the same note id twice.
|
|
249
|
-
|
|
250
|
-
Spec shapes (only include fields you actually have):
|
|
251
|
-
file (hub): {"type":"file-hub","path":"src/x.py","aliases":["symptom or search words"],
|
|
252
|
-
"facets":[{"symbol":"ClassOrFn","detail":"the non-obvious thing about THIS symbol",
|
|
253
|
-
"flows":["<flow-note-id or the flow's exact title>"]}]}
|
|
254
|
-
file (single): {"type":"file-single","path":"src/x.py",
|
|
255
|
-
"summary":"its one purpose + how (1-3 sentences)"}
|
|
256
|
-
flow: {"type":"flow","title":"how X happens","aliases":["other words for X"],
|
|
257
|
-
"summary":"one paragraph",
|
|
258
|
-
"steps":[{"path":"src/a.py","symbols":["entry"],"role":"receives the request"}],
|
|
259
|
-
"invariants":["what must hold"],"verified":["src/a.py"]}
|
|
260
|
-
lesson: {"type":"lesson","kind":"absence","title":"the absence, e.g. no retry logic",
|
|
261
|
-
"body":"what you looked for + that it is not there",
|
|
262
|
-
"scope":{"terms":["search","terms"]}} (the search that proved it)
|
|
263
|
-
|
|
264
|
-
${isSubagent
|
|
265
|
-
? `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.`
|
|
266
|
-
: `When your notes are written, stop.`}`;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// --- stdin + guards -------------------------------------------------------------
|
|
54
|
+
// --- stdin ---------------------------------------------------------------------
|
|
270
55
|
function readStdin() {
|
|
271
56
|
return new Promise((res) => {
|
|
272
57
|
let data = "";
|
|
@@ -283,14 +68,6 @@ function readStdin() {
|
|
|
283
68
|
});
|
|
284
69
|
}
|
|
285
70
|
|
|
286
|
-
function logCaptureEvent(root, event) {
|
|
287
|
-
try {
|
|
288
|
-
const dir = join(root, ".coldstart", "notebook", ".metrics");
|
|
289
|
-
mkdirSync(dir, { recursive: true });
|
|
290
|
-
appendFileSync(join(dir, "capture.jsonl"), JSON.stringify({ ts: new Date().toISOString(), ...event }) + "\n");
|
|
291
|
-
} catch { /* metrics never wedge a stop */ }
|
|
292
|
-
}
|
|
293
|
-
|
|
294
71
|
process.on("uncaughtException", (e) => { log(`uncaught ${e?.stack || e}`); process.exit(0); });
|
|
295
72
|
process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); process.exit(0); });
|
|
296
73
|
|
|
@@ -312,25 +89,14 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
312
89
|
const sid = String(input.session_id || "").replace(/[^A-Za-z0-9_-]/g, "");
|
|
313
90
|
if (!sid) { log("SKIP no-session-id"); process.exit(0); }
|
|
314
91
|
|
|
315
|
-
// Guard 2: capture across a long session, but only files not yet offered.
|
|
316
|
-
// A boolean once-per-(session,agent) marker silently dropped ALL knowledge
|
|
317
|
-
// after the first Stop; instead we remember which files were already offered
|
|
318
|
-
// for capture and elicit only the new ones (the delta is computed below,
|
|
319
|
-
// once the SubagentStop transcript is resolved). Subagents share the parent
|
|
320
|
-
// session_id, so the record is scoped by agent too.
|
|
321
92
|
const aid = String(input.agent_id || "main").replace(/[^A-Za-z0-9_-]/g, "") || "main";
|
|
322
|
-
const
|
|
323
|
-
let offered = new Set();
|
|
324
|
-
try { offered = new Set(JSON.parse(readFileSync(marker, "utf8")).files || []); } catch { /* first Stop of this session */ }
|
|
93
|
+
const isSubagent = input.hook_event_name === "SubagentStop";
|
|
325
94
|
|
|
326
95
|
// On SubagentStop, transcript_path is the PARENT's transcript (confirmed:
|
|
327
|
-
// claude-code#11396)
|
|
328
|
-
//
|
|
329
|
-
// <parent-transcript-stem>/subagents/agent-<agent_id>.jsonl (verified on
|
|
330
|
-
// disk; agent_transcript_path in the payload is still unshipped, #16424).
|
|
331
|
-
// No sub transcript found → exit; capture falls to the main Stop.
|
|
96
|
+
// claude-code#11396); the sub's own lives at
|
|
97
|
+
// <parent-transcript-stem>/subagents/agent-<agent_id>.jsonl.
|
|
332
98
|
let transcriptPath = String(input.transcript_path || "");
|
|
333
|
-
if (
|
|
99
|
+
if (isSubagent) {
|
|
334
100
|
const own = String(input.agent_transcript_path || "") ||
|
|
335
101
|
(aid !== "main" && transcriptPath
|
|
336
102
|
? join(transcriptPath.replace(/\.jsonl$/, ""), "subagents", `agent-${aid}.jsonl`)
|
|
@@ -341,29 +107,89 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
341
107
|
}
|
|
342
108
|
transcriptPath = own;
|
|
343
109
|
}
|
|
344
|
-
|
|
345
|
-
// Delta: only files not already offered on an earlier Stop this session.
|
|
346
|
-
const newFiles = files.filter((f) => !offered.has(f));
|
|
110
|
+
if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
|
|
347
111
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
112
|
+
const ignore = loadIgnore(root);
|
|
113
|
+
const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
|
|
114
|
+
let state = null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(readFileSync(marker, "utf8"));
|
|
117
|
+
if (parsed && parsed.v === 2) state = parsed;
|
|
118
|
+
} catch { /* first Stop of this session (or a pre-v5 marker: start fresh) */ }
|
|
119
|
+
if (!state) state = initialState();
|
|
120
|
+
|
|
121
|
+
// This stop's transcript slice (everything since the last processed line).
|
|
122
|
+
const text = readFileSync(transcriptPath, "utf8");
|
|
123
|
+
const lines = text.split("\n");
|
|
124
|
+
// A transcript SHORTER than our stored offset was replaced out from under us
|
|
125
|
+
// — Claude Code's /compact rewrites it far shorter (also log rotation). The
|
|
126
|
+
// offset now points past the end, so slice() would return an empty segment
|
|
127
|
+
// and silently drop this turn's (and every later turn's) evidence until the
|
|
128
|
+
// line count grows back. Reset to reprocess the new transcript from its start.
|
|
129
|
+
if (state.lineCount > lines.length) state.lineCount = 0;
|
|
130
|
+
const segment = lines.slice(state.lineCount).join("\n");
|
|
131
|
+
state.lineCount = lines.length;
|
|
132
|
+
|
|
133
|
+
// Evidence: contentRead tiers only, ignore-filtered. Mentions never count.
|
|
134
|
+
const raw = extractEvidence(segment, root);
|
|
135
|
+
const delta = new Map();
|
|
136
|
+
for (const [rel, r] of raw) {
|
|
137
|
+
if (r.reads + r.edits + r.gs === 0) continue;
|
|
138
|
+
if (ignore(rel)) continue;
|
|
139
|
+
delta.set(rel, r);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Subagent path: one-shot, no trigger. Offer once, block-deliver. ------
|
|
143
|
+
if (isSubagent) {
|
|
144
|
+
const offered = new Set(Object.keys(state.files));
|
|
145
|
+
const fresh = [...delta.keys()].filter((rel) => !offered.has(rel));
|
|
146
|
+
for (const rel of fresh) state.files[rel] = { ...delta.get(rel), captured: true };
|
|
147
|
+
writeFileSync(marker, JSON.stringify(state));
|
|
148
|
+
if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
|
|
149
|
+
const entries = worklistEntries(CLI, root, fresh, Object.fromEntries(fresh.map((rel) => [rel, delta.get(rel)])), log);
|
|
150
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "subagent" });
|
|
151
|
+
logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length });
|
|
152
|
+
log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
|
|
153
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
|
|
354
154
|
process.exit(0);
|
|
355
155
|
}
|
|
356
156
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
157
|
+
// --- Main path: trigger state machine -------------------------------------
|
|
158
|
+
const head = gitHead(root);
|
|
159
|
+
const headDrift = Boolean(state.head && head && head !== state.head);
|
|
160
|
+
state.head = head || state.head;
|
|
161
|
+
|
|
162
|
+
const stats = segmentStats(segment);
|
|
163
|
+
const freshNoted = freshNotedSet(CLI, root, [...delta.keys()].filter((rel) => !state.files[rel]), log);
|
|
362
164
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
165
|
+
const { state: next, decision } = step(state, {
|
|
166
|
+
delta,
|
|
167
|
+
synthesis: stats.synthesis,
|
|
168
|
+
freshNoted,
|
|
169
|
+
headDrift,
|
|
170
|
+
});
|
|
171
|
+
writeFileSync(marker, JSON.stringify(next));
|
|
172
|
+
|
|
173
|
+
if (!decision) {
|
|
174
|
+
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}`);
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const entries = worklistEntries(CLI, root, decision.files, next.files, log);
|
|
179
|
+
logCaptureEvent(root, {
|
|
180
|
+
event: "fire", reason: decision.fire, mode: decision.mode, session: sid,
|
|
181
|
+
score: decision.score, files: decision.files.length, stop: next.stop, fires: next.fires,
|
|
182
|
+
});
|
|
183
|
+
log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
|
|
184
|
+
|
|
185
|
+
if (decision.mode === "block") {
|
|
186
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
|
|
187
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
|
|
188
|
+
} else {
|
|
189
|
+
// Non-blocking: kb-recall delivers this with the user's next prompt.
|
|
190
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
|
|
191
|
+
writePendingCapture(sid, decision.fire, payload);
|
|
192
|
+
}
|
|
367
193
|
} catch (e) {
|
|
368
194
|
log(`handler ${e?.stack || e}`); // fail-open: no stdout → stop allowed
|
|
369
195
|
}
|
package/hooks/kb-recall.mjs
CHANGED
|
@@ -28,6 +28,13 @@ import { join } from "node:path";
|
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
|
|
31
|
+
// Pending-capture delivery (v5 trigger): a descent/surge fire at the previous
|
|
32
|
+
// Stop wrote its worklist payload to a pending file instead of blocking the
|
|
33
|
+
// stop. It rides the SAME next-prompt channel as recall — capture first, then
|
|
34
|
+
// the user's request. Consumed (deleted) on delivery; stale pendings (>24h,
|
|
35
|
+
// e.g. a session resumed days later) are dropped.
|
|
36
|
+
import { takePendingCapture } from "./elicit-core.mjs";
|
|
37
|
+
|
|
31
38
|
// hooks/ sits beside dist/ in both the repo and the published package.
|
|
32
39
|
const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
|
|
33
40
|
|
|
@@ -71,27 +78,36 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
71
78
|
if (!root) process.exit(0);
|
|
72
79
|
setLogRoot(root);
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
const sid = String(input.session_id || "").replace(/[^\w-]/g, "");
|
|
82
|
+
|
|
83
|
+
// A pending capture (non-blocking fire at the previous Stop) is delivered
|
|
84
|
+
// regardless of recall hits — it must not depend on the notebook existing
|
|
85
|
+
// (the first capture is what creates it).
|
|
86
|
+
const pending = takePendingCapture(sid);
|
|
76
87
|
|
|
77
88
|
const prompt = String(input.prompt || "").slice(0, MAX_QUERY_CHARS).trim();
|
|
78
|
-
if (!prompt) process.exit(0);
|
|
79
89
|
|
|
80
90
|
let page = "";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
// No notebook / no prompt → no recall search, not even a child process.
|
|
92
|
+
if (prompt && existsSync(join(root, ".coldstart", "notebook", ".raw"))) {
|
|
93
|
+
try {
|
|
94
|
+
page = execFileSync("node", [CLI, "kb", "search", "--hook", "--max", "3", "--root", root, prompt], {
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
timeout: SEARCH_TIMEOUT_MS,
|
|
97
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
log(`search failed/timed out: ${String(e).split("\n")[0]}`);
|
|
101
|
+
page = "";
|
|
102
|
+
}
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
if (!page.trim() || page.startsWith("No notebook notes match") || page.startsWith("No notebook in")) {
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
if (!pending) {
|
|
107
|
+
log(`no hits (promptChars=${prompt.length})`);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
page = "";
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
// Pointer page (rulings 2026-07-06/08): titles + gists + an OPENABLE note
|
|
@@ -101,7 +117,8 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
101
117
|
// agents never used it). Trust framing: [fresh] content is reliable
|
|
102
118
|
// as-is; the caution that remains is about COMPLETENESS (a note names a
|
|
103
119
|
// finding, not necessarily your whole file set), not about content.
|
|
104
|
-
let block =
|
|
120
|
+
let block = "";
|
|
121
|
+
if (page) block =
|
|
105
122
|
`The repo's notebook (notes written by past agents after real tasks here) has entries ` +
|
|
106
123
|
`matching this request, below — each a title, a gist, and the note's file path. ` +
|
|
107
124
|
`A note is a past agent's verified overview of a file or flow. If one matches your task, ` +
|
|
@@ -123,6 +140,16 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
123
140
|
// never exceed 8.5KB.
|
|
124
141
|
if (block.length > 8500) block = block.slice(0, 8500) + "\n…(truncated)";
|
|
125
142
|
|
|
143
|
+
// Pending capture rides FIRST (capture, then the user's request). If the
|
|
144
|
+
// combination would spill past the host's 10KB hook cap, recall yields —
|
|
145
|
+
// the capture worklist must arrive whole.
|
|
146
|
+
if (pending) {
|
|
147
|
+
block = pending.length + block.length > 9500 || !block
|
|
148
|
+
? pending
|
|
149
|
+
: `${pending}\n\n---\n\n${block}`;
|
|
150
|
+
}
|
|
151
|
+
if (!block) process.exit(0);
|
|
152
|
+
|
|
126
153
|
// Arm the PostToolUse nudge detectors (nudge-handler.mjs gates its spiral
|
|
127
154
|
// detectors on seen_find so it never nags sessions that don't use coldstart).
|
|
128
155
|
// An injected session IS coldstart-aware even if it never runs `find` — the
|
|
@@ -130,7 +157,6 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
130
157
|
// grep-spiral unguarded otherwise. Path/shape must match the handler's state
|
|
131
158
|
// file: literal /tmp + main-agent key = session_id.
|
|
132
159
|
try {
|
|
133
|
-
const sid = String(input.session_id || "");
|
|
134
160
|
if (sid && /^[\w-]+$/.test(sid)) {
|
|
135
161
|
const sf = `/tmp/find_nudge_${sid}.json`;
|
|
136
162
|
let st = {};
|
|
@@ -140,7 +166,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
140
166
|
}
|
|
141
167
|
} catch { /* fail-open: arming is best-effort */ }
|
|
142
168
|
|
|
143
|
-
log(`INJECT bytes=${block.length}`);
|
|
169
|
+
log(`INJECT bytes=${block.length} pending=${pending ? "yes" : "no"}`);
|
|
144
170
|
process.stdout.write(JSON.stringify({
|
|
145
171
|
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: block },
|
|
146
172
|
}));
|