@cstart/coldstart 2.2.14 → 2.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/init.d.ts +16 -3
  2. package/dist/init.d.ts.map +1 -1
  3. package/dist/init.js +49 -7
  4. package/dist/init.js.map +1 -1
  5. package/dist/kb/cli.d.ts.map +1 -1
  6. package/dist/kb/cli.js +72 -49
  7. package/dist/kb/cli.js.map +1 -1
  8. package/dist/kb/durable-worklist.d.ts +20 -0
  9. package/dist/kb/durable-worklist.d.ts.map +1 -0
  10. package/dist/kb/durable-worklist.js +175 -0
  11. package/dist/kb/durable-worklist.js.map +1 -0
  12. package/dist/kb/store.d.ts.map +1 -1
  13. package/dist/kb/store.js +24 -3
  14. package/dist/kb/store.js.map +1 -1
  15. package/dist/kb/write-batch.d.ts +21 -0
  16. package/dist/kb/write-batch.d.ts.map +1 -0
  17. package/dist/kb/write-batch.js +77 -0
  18. package/dist/kb/write-batch.js.map +1 -0
  19. package/dist/kb/write-guide.d.ts.map +1 -1
  20. package/dist/kb/write-guide.js +23 -13
  21. package/dist/kb/write-guide.js.map +1 -1
  22. package/dist/kb/write.d.ts +4 -0
  23. package/dist/kb/write.d.ts.map +1 -1
  24. package/dist/kb/write.js +10 -1
  25. package/dist/kb/write.js.map +1 -1
  26. package/dist/server/mcp.d.ts +12 -1
  27. package/dist/server/mcp.d.ts.map +1 -1
  28. package/dist/server/mcp.js +53 -35
  29. package/dist/server/mcp.js.map +1 -1
  30. package/hooks/capture-payload.mjs +229 -85
  31. package/hooks/capture-sentinel.d.mts +4 -0
  32. package/hooks/capture-sentinel.mjs +10 -0
  33. package/hooks/codex-kb-elicit.mjs +6 -2
  34. package/hooks/codex-kb-recall.mjs +54 -5
  35. package/hooks/cursor-kb-elicit.mjs +8 -4
  36. package/hooks/cursor-kb-recall.mjs +34 -1
  37. package/hooks/kb-elicit.mjs +81 -34
  38. package/package.json +1 -1
  39. package/dist/kb/session-worklist.d.ts +0 -10
  40. package/dist/kb/session-worklist.d.ts.map +0 -1
  41. package/dist/kb/session-worklist.js +0 -92
  42. package/dist/kb/session-worklist.js.map +0 -1
@@ -0,0 +1,10 @@
1
+ /**
2
+ * capture-sentinel.mjs — the marker `/capture-notes` command bodies carry so a
3
+ * host's own pre-prompt recall hook can recognize "this submitted prompt IS
4
+ * the capture command" and run `kb-elicit.mjs --manual` itself with the real
5
+ * session id it already has (Cursor/Codex have no way to pass a session id
6
+ * INTO the command the way Claude's `!`-expansion does). Wording in the
7
+ * command body can drift; this string must not — it is grepped verbatim
8
+ * against the raw submitted prompt.
9
+ */
10
+ export const CAPTURE_SENTINEL = "<!-- coldstart:capture-notes -->";
@@ -105,7 +105,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
105
105
  const ignore = loadIgnore(root);
106
106
  // Session-cumulative v2 state: which files were already offered, and how
107
107
  // far into the rollout the last Stop read (a resumed thread appends).
108
- const marker = join(tmpdir(), `coldstart-codex-kb-${sid}-${aid}.json`);
108
+ // Unified marker namespace (2026-08-06): manual /capture-notes always ran
109
+ // the SHARED kb-elicit.mjs --manual, which reads coldstart-kb-<sid>-<aid>.json
110
+ // — a per-host prefix here meant it could never see this host's own
111
+ // automatic evidence. One prefix for all three hosts fixes that outright.
112
+ const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
109
113
  let state = null;
110
114
  try {
111
115
  const parsed = JSON.parse(readFileSync(marker, "utf8"));
@@ -140,7 +144,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
140
144
 
141
145
  const entries = worklistEntries(CLI, root, fresh, state.files, log);
142
146
  const payload = buildCapturePayload({
143
- root, cli: CLI, sid, entries,
147
+ root, cli: CLI, sid, aid, entries,
144
148
  envelope: isSubagent ? "subagent" : "block",
145
149
  });
146
150
  logCaptureEvent(root, {
@@ -18,6 +18,17 @@
18
18
  * arriving via PRs are a prompt-injection surface; the framing line is the
19
19
  * cheap mitigation.
20
20
  *
21
+ * MANUAL CAPTURE ON-DEMAND (2026-08-06): Codex's `/capture-notes` skill has no
22
+ * way to pass its own real session id into `kb-elicit.mjs --manual` the way
23
+ * Claude's `!`-expansion does — the skill body used to just ask the agent to
24
+ * run the command itself, blind to which session it's in. But THIS hook
25
+ * receives a real `input.session_id` on every prompt. So: when the submitted
26
+ * prompt IS the capture skill (its body carries a stable sentinel,
27
+ * CAPTURE_SENTINEL), run `--manual --session <real sid>` right here and inject
28
+ * its output — deterministic, no LLM has to type a correct `--session <id>`.
29
+ * The skill body keeps "run this in the terminal" as a fallback for when this
30
+ * hook is disabled or fails. See hooks/capture-sentinel.mjs.
31
+ *
21
32
  * Self-contained + fail-open: ANY error → exit 0 with no stdout → nothing
22
33
  * injected, the prompt proceeds untouched.
23
34
  */
@@ -29,6 +40,7 @@ import { tmpdir } from "node:os";
29
40
  import { fileURLToPath } from "node:url";
30
41
  // Strip host telemetry wrappers before the query is built — see recall-query.mjs.
31
42
  import { recallQuery } from "./recall-query.mjs";
43
+ import { CAPTURE_SENTINEL } from "./capture-sentinel.mjs";
32
44
  // Per-session dedup shared with the other recall hooks — see recall-seen.mjs.
33
45
  // Hosts with no transcript path never reset; dedup still holds for the session.
34
46
  import { readSeen, writeSeen, idsInPage, seenArgs } from "./recall-seen.mjs";
@@ -78,11 +90,43 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
78
90
 
79
91
  const sid = String(input.session_id || "").replace(/[^\w-]/g, "");
80
92
 
81
- // No notebook no tax, not even a child process.
82
- if (!existsSync(join(root, ".coldstart", "notebook", ".raw"))) process.exit(0);
93
+ // The submitted prompt IS /capture-notes (its skill body carries this
94
+ // sentinel) run the on-demand capture ourselves with the real session id
95
+ // this hook already has, instead of leaving the agent to guess one.
96
+ let manual = "";
97
+ if (sid && String(input.prompt || "").includes(CAPTURE_SENTINEL)) {
98
+ try {
99
+ const elicit = fileURLToPath(new URL("./kb-elicit.mjs", import.meta.url));
100
+ manual = execFileSync("node", [elicit, "--manual", "--session", sid, "--root", root], {
101
+ encoding: "utf8",
102
+ timeout: 8000,
103
+ stdio: ["ignore", "pipe", "ignore"],
104
+ }).trim();
105
+ if (manual) log(`MANUAL-INJECT session=${sid} bytes=${manual.length}`);
106
+ } catch (e) {
107
+ log(`manual-capture-inject failed: ${String(e).split("\n")[0]}`);
108
+ }
109
+ }
110
+
111
+ // Below this point, every early exit falls back to delivering the manual
112
+ // capture alone (if we have one) instead of exiting silently — a recall
113
+ // miss must never swallow an explicit /capture-notes.
114
+ const deliverManualOnlyAndExit = (why) => {
115
+ if (!manual) process.exit(0);
116
+ log(`INJECT bytes=${manual.length} (manual only, ${why})`);
117
+ process.stdout.write(JSON.stringify({
118
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: manual },
119
+ }));
120
+ process.exit(0);
121
+ };
122
+
123
+ // No notebook → no recall tax, not even a child process (the manual
124
+ // capture above is independent of the notebook existing — it's what
125
+ // creates the first note).
126
+ if (!existsSync(join(root, ".coldstart", "notebook", ".raw"))) deliverManualOnlyAndExit("no notebook yet");
83
127
 
84
128
  const prompt = recallQuery(input.prompt, MAX_QUERY_CHARS);
85
- if (!prompt) process.exit(0);
129
+ if (!prompt) deliverManualOnlyAndExit("empty query");
86
130
 
87
131
  // Ephemeral Codex runs expose no rollout path; readSeen then never resets,
88
132
  // which is the safe direction — dedup simply holds for the whole session.
@@ -99,12 +143,12 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
99
143
  });
100
144
  } catch (e) {
101
145
  log(`search failed/timed out: ${String(e).split("\n")[0]}`);
102
- process.exit(0);
146
+ deliverManualOnlyAndExit("search failed");
103
147
  }
104
148
 
105
149
  if (!page.trim() || page.startsWith("No notebook notes match") || page.startsWith("No notebook in")) {
106
150
  log(`no hits (promptChars=${prompt.length})`);
107
- process.exit(0);
151
+ deliverManualOnlyAndExit("no recall hits");
108
152
  }
109
153
 
110
154
  // Record what this page hands over before the size guards can trim it — a
@@ -140,6 +184,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
140
184
  // never exceed 8.5KB.
141
185
  if (block.length > 8500) block = block.slice(0, 8500) + "\n…(truncated)";
142
186
 
187
+ // An explicit /capture-notes rides FIRST — it's what the user asked for
188
+ // this turn. If the combination would spill past the host's payload cap,
189
+ // recall yields — the capture checklist must arrive whole.
190
+ if (manual) block = manual.length + block.length > 9500 ? manual : `${manual}\n\n---\n\n${block}`;
191
+
143
192
  // Arm the PostToolUse nudge detectors (nudge-handler.mjs gates its spiral
144
193
  // detectors on seen_find so it never nags sessions that don't use coldstart).
145
194
  // An injected session IS coldstart-aware even if it never runs `find` — the
@@ -121,7 +121,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
121
121
  if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
122
122
 
123
123
  const ignore = loadIgnore(root);
124
- const marker = join(tmpdir(), `coldstart-cursor-kb-${sid}-${aid}.json`);
124
+ // Unified marker namespace (2026-08-06): manual /capture-notes always ran
125
+ // the SHARED kb-elicit.mjs --manual, which reads coldstart-kb-<sid>-<aid>.json
126
+ // — a per-host prefix here meant it could never see this host's own
127
+ // automatic evidence. One prefix for all three hosts fixes that outright.
128
+ const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
125
129
  let state = null;
126
130
  try {
127
131
  const parsed = JSON.parse(readFileSync(marker, "utf8"));
@@ -152,7 +156,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
152
156
  writeFileSync(marker, JSON.stringify(state));
153
157
  if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
154
158
  const entries = worklistEntries(CLI, root, fresh, Object.fromEntries(fresh.map((rel) => [rel, delta.get(rel)])), log);
155
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "subagent" });
159
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "subagent" });
156
160
  logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length, host: "cursor" });
157
161
  log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
158
162
  process.stdout.write(JSON.stringify({ followup_message: payload }));
@@ -188,11 +192,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
188
192
  log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
189
193
 
190
194
  if (decision.mode === "block") {
191
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
195
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "block" });
192
196
  process.stdout.write(JSON.stringify({ followup_message: payload }));
193
197
  } else {
194
198
  // Non-blocking: cursor-kb-recall delivers this with the user's next prompt.
195
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
199
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "inject" });
196
200
  writePendingCapture(sid, decision.fire, payload);
197
201
  }
198
202
  } catch (e) {
@@ -16,6 +16,17 @@
16
16
  * injection channel used here. If a future Cursor build stops honoring it,
17
17
  * recall silently no-ops (fail-open) — nav + capture are unaffected.
18
18
  *
19
+ * MANUAL CAPTURE ON-DEMAND (2026-08-06): Cursor's `/capture-notes` command has
20
+ * no `!`-expansion (unlike Claude), so it can't pass its own real session id to
21
+ * `kb-elicit.mjs --manual` the way Claude does — the command body used to just
22
+ * ask the agent to run the command itself, blind to which session it's in. But
23
+ * THIS hook receives a real `input.session_id` on every prompt. So: when the
24
+ * submitted prompt IS the capture command (its body carries a stable sentinel,
25
+ * CAPTURE_SENTINEL), run `--manual --session <real sid>` right here and inject
26
+ * its output the same way a pending automatic fire is injected — deterministic,
27
+ * no LLM has to type a correct `--session <id>`. The command body keeps "run
28
+ * this in the terminal" as a fallback for when this hook is disabled/fails.
29
+ *
19
30
  * Self-contained + fail-open: ANY error → exit 0, no stdout → nothing injected.
20
31
  */
21
32
 
@@ -31,6 +42,7 @@ import { cursorRoot } from "./cursor-input.mjs";
31
42
  import { takePendingCapture } from "./elicit-core.mjs";
32
43
  // Strip host telemetry wrappers before the query is built — see recall-query.mjs.
33
44
  import { recallQuery } from "./recall-query.mjs";
45
+ import { CAPTURE_SENTINEL } from "./capture-sentinel.mjs";
34
46
  // Per-session dedup shared with the other recall hooks — see recall-seen.mjs.
35
47
  // Hosts with no transcript path never reset; dedup still holds for the session.
36
48
  import { readSeen, writeSeen, idsInPage, seenArgs } from "./recall-seen.mjs";
@@ -83,7 +95,28 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
83
95
  // A pending capture (non-blocking fire at a previous stop) is delivered
84
96
  // regardless of recall hits — it must not depend on the notebook existing
85
97
  // (the first capture is what creates it).
86
- const pending = takePendingCapture(sid);
98
+ let pending = takePendingCapture(sid);
99
+
100
+ // The submitted prompt IS /capture-notes (its command body carries this
101
+ // sentinel) → run the on-demand capture ourselves with the real session id
102
+ // this hook already has, instead of leaving the agent to guess one. See
103
+ // the file header and hooks/capture-sentinel.mjs.
104
+ if (sid && String(input.prompt || "").includes(CAPTURE_SENTINEL)) {
105
+ try {
106
+ const elicit = fileURLToPath(new URL("./kb-elicit.mjs", import.meta.url));
107
+ const manual = execFileSync("node", [elicit, "--manual", "--session", sid, "--root", root], {
108
+ encoding: "utf8",
109
+ timeout: 8000,
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ });
112
+ if (manual.trim()) {
113
+ pending = pending ? `${manual.trim()}\n\n---\n\n${pending}` : manual.trim();
114
+ log(`MANUAL-INJECT session=${sid} bytes=${manual.length}`);
115
+ }
116
+ } catch (e) {
117
+ log(`manual-capture-inject failed: ${String(e).split("\n")[0]}`);
118
+ }
119
+ }
87
120
 
88
121
  const prompt = recallQuery(input.prompt, MAX_QUERY_CHARS);
89
122
 
@@ -36,7 +36,7 @@ import { existsSync, writeFileSync, appendFileSync, readFileSync, readdirSync, s
36
36
  import { extractEvidence, segmentStats } from "./evidence.mjs";
37
37
  import { initialState, step } from "./trigger.mjs";
38
38
  import { loadIgnore } from "./ignore.mjs";
39
- import { buildCapturePayload } from "./capture-payload.mjs";
39
+ import { buildCapturePayload, worklistJsonPath } from "./capture-payload.mjs";
40
40
  import {
41
41
  worklistEntries, freshNotedSet, gitHead, logCaptureEvent, writePendingCapture, MAX_WORKLIST,
42
42
  } from "./elicit-core.mjs";
@@ -129,10 +129,12 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
129
129
 
130
130
  // --- Manual capture (`--manual --root <dir>`) --------------------------------
131
131
  // The `/capture-notes` command runs this to fire capture ON DEMAND, bypassing
132
- // the trigger score gate. There is no hook stdin, so we self-discover the
133
- // session: the freshest marker in tmpdir whose recorded (relative) files still
134
- // resolve under <root> is this repo's active session. We then emit the SAME
135
- // capture payload an automatic fire would, built from accumulated evidence.
132
+ // the trigger score gate. There is no hook stdin, so there is no session id to
133
+ // trust by default. When the host names it (Claude passes `--session
134
+ // ${CLAUDE_SESSION_ID}`), that session's marker is resolved exactly, never
135
+ // guessed among several. Hosts with no session-id variable on their command
136
+ // surface (Cursor, Codex) pass none; soleMarkerUnderRoot below covers the
137
+ // common single-session case for them without ever guessing wrong.
136
138
  //
137
139
  // It marks the files it LISTED as captured (and nothing else). Without that, a
138
140
  // manual capture left every file uncaptured, so the next automatic fire re-asked
@@ -144,41 +146,77 @@ function argValue(name) {
144
146
  const i = process.argv.indexOf(name);
145
147
  return i >= 0 ? process.argv[i + 1] : undefined;
146
148
  }
147
- function markerMtime(p) { try { return statSync(p).mtimeMs; } catch { return 0; } }
148
- function freshestMarkerUnderRoot(root) {
149
- let names;
150
- try { names = readdirSync(tmpdir()); } catch { return null; }
151
- const markers = names
152
- .filter((n) => /^coldstart-kb-.+-main\.json$/.test(n))
153
- .map((n) => ({ n, p: join(tmpdir(), n), mtime: 0 }))
154
- .map((m) => ({ ...m, mtime: markerMtime(m.p) }))
155
- .sort((a, b) => b.mtime - a.mtime);
156
- for (const m of markers) {
157
- let state;
158
- try { state = JSON.parse(readFileSync(m.p, "utf8")); } catch { continue; }
159
- const files = state && state.files ? Object.keys(state.files) : [];
160
- if (!files.length) continue;
161
- // Belongs to THIS repo iff a recorded file still resolves under root.
162
- if (files.some((rel) => existsSync(join(root, rel)))) {
163
- return { sid: m.n.slice("coldstart-kb-".length, -"-main.json".length), state, path: m.p };
164
- }
149
+ // The main-agent evidence marker for a KNOWN session id. Returns null when the
150
+ // sid is empty or its marker is absent/foreign (files don't resolve under root).
151
+ function markerForSession(root, sid) {
152
+ if (!sid) return null;
153
+ const p = join(tmpdir(), `coldstart-kb-${sid}-main.json`);
154
+ let state;
155
+ try { state = JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
156
+ const files = state && state.files ? Object.keys(state.files) : [];
157
+ if (!files.length || !files.some((rel) => existsSync(join(root, rel)))) return null;
158
+ return { sid, state, path: p };
159
+ }
160
+ // Hosts with no verified session-id variable on their command surface (Cursor,
161
+ // Codex their commands are plain prompt templates the agent itself runs, with
162
+ // no `!`-style substitution to carry a session id into the invocation) can't name
163
+ // a session at all. Guessing among several is the wrong-session risk `--session`
164
+ // exists to avoid, but when exactly ONE main-agent marker under this root exists
165
+ // there is nothing to disambiguate — restore that single-session case instead of
166
+ // refusing outright. `-main.json` only: subagent markers never own a worklist.
167
+ function soleMarkerUnderRoot(root) {
168
+ const re = /^coldstart-kb-(.+)-main\.json$/;
169
+ let entries;
170
+ try { entries = readdirSync(tmpdir()); } catch { return { marker: null, ambiguous: false }; }
171
+ const candidates = [];
172
+ for (const name of entries) {
173
+ const m = re.exec(name);
174
+ if (!m) continue;
175
+ const found = markerForSession(root, m[1]);
176
+ if (found) candidates.push(found);
165
177
  }
166
- return null;
178
+ if (candidates.length === 1) return { marker: candidates[0], ambiguous: false };
179
+ if (candidates.length > 1) return { marker: null, ambiguous: true };
180
+ return { marker: null, ambiguous: false };
167
181
  }
168
182
  if (process.argv.includes("--manual")) {
169
183
  try {
170
184
  const rootArg = argValue("--root");
171
185
  const root = rootArg ? resolve(rootArg) : process.cwd();
172
186
  setLogRoot(root);
173
- const found = freshestMarkerUnderRoot(root);
174
- if (!found) {
187
+ const sidArg = String(argValue("--session") || "").replace(/[^A-Za-z0-9_-]/g, "");
188
+ let found = sidArg ? markerForSession(root, sidArg) : null;
189
+ if (!sidArg) {
190
+ const sole = soleMarkerUnderRoot(root);
191
+ if (sole.ambiguous) {
192
+ process.stdout.write(
193
+ "Multiple sessions have notebook-capture evidence in this repo, and this host can't tell\n" +
194
+ "/capture-notes which one you mean — pass --session <id> if your host exposes one, or close\n" +
195
+ "the other sessions working here so only one remains.\n",
196
+ );
197
+ log(`MANUAL ambiguous-no-session root=${root}`);
198
+ process.exit(0);
199
+ }
200
+ found = sole.marker;
201
+ if (found) log(`MANUAL sole-marker-fallback sid=${found.sid} root=${root}`);
202
+ }
203
+ if (!sidArg && !found) {
175
204
  process.stdout.write(
176
205
  "No notebook-capture evidence for this repo yet — it accrues as you read and edit files.\n" +
177
206
  "Do a turn or two of real work here, then run /capture-notes again.\n",
178
207
  );
179
- log(`MANUAL no-marker root=${root}`);
208
+ log(`MANUAL no-session root=${root}`);
209
+ process.exit(0);
210
+ }
211
+ if (!found) {
212
+ process.stdout.write(
213
+ "No notebook-capture evidence for THIS session in this repo yet — it accrues as you read\n" +
214
+ "and edit files. Do a turn or two of real work here, then run /capture-notes again.\n",
215
+ );
216
+ log(`MANUAL no-marker-for-session sid=${sidArg} root=${root}`);
180
217
  process.exit(0);
181
218
  }
219
+ log(`MANUAL session sid=${found.sid} root=${root}`);
182
220
  const { sid, state, path: markerPath } = found;
183
221
  // A manual /capture-notes is an EXPLICIT request to write up THIS session's
184
222
  // work, so its scope is everything worked on — not merely what an automatic
@@ -189,10 +227,17 @@ if (process.argv.includes("--manual")) {
189
227
  // note unwritten and a now-stale note unfixed (the Bug-1 report). So:
190
228
  // include every edited file regardless of the flag, and drop only READ-ONLY
191
229
  // files that were already offered (re-listing those on demand is just
192
- // noise). Rank most-worked first (edits retouches reads) so new/edited
193
- // files lead; the per-file annotations flag when a fresh note needs nothing.
230
+ // noise) AND whose worklist is still live. A captured read-only file whose
231
+ // durable worklist pair is gone (write failure, or hand-deleted since) has
232
+ // no live checklist anywhere naming it — re-including it here is the only
233
+ // way it is ever offered again, and it can't double-nag since there is
234
+ // nothing else currently showing it. Rank most-worked first (edits →
235
+ // retouches → reads) so new/edited files lead; the per-file annotations
236
+ // flag when a fresh note needs nothing.
237
+ const worklistLost = !existsSync(worklistJsonPath(root, sid, "main"));
238
+ if (worklistLost) log(`MANUAL worklist-artifact-missing session=${sid} — recovering captured read-only files too`);
194
239
  const files = Object.entries(state.files)
195
- .filter(([, f]) => (f.reads + f.edits + f.gs) > 0 && (f.edits > 0 || !f.captured))
240
+ .filter(([, f]) => (f.reads + f.edits + f.gs) > 0 && (f.edits > 0 || !f.captured || worklistLost))
196
241
  .sort((a, b) => (b[1].edits - a[1].edits)
197
242
  || ((b[1].retouches || 0) - (a[1].retouches || 0))
198
243
  || (b[1].reads - a[1].reads))
@@ -203,7 +248,9 @@ if (process.argv.includes("--manual")) {
203
248
  process.exit(0);
204
249
  }
205
250
  const entries = worklistEntries(CLI, root, files, state.files, log);
206
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "manual" });
251
+ // Manual capture resolves the invoking session's MAIN marker (markerForSession
252
+ // targets `-main.json`), so its worklist is the main agent's.
253
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid: "main", entries, envelope: "manual" });
207
254
  // Mark ONLY the files actually LISTED (the worklist caps its display to
208
255
  // MAX_WORKLIST) captured — any overflow stays uncaptured so it rolls into
209
256
  // the next capture rather than being marked done but never shown. Re-read
@@ -345,7 +392,7 @@ if (process.argv.includes("--manual")) {
345
392
  writeFileSync(marker, JSON.stringify(state));
346
393
  if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
347
394
  const entries = worklistEntries(CLI, root, fresh, Object.fromEntries(fresh.map((rel) => [rel, delta.get(rel)])), log);
348
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "subagent" });
395
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "subagent" });
349
396
  logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length });
350
397
  log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
351
398
  process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
@@ -381,11 +428,11 @@ if (process.argv.includes("--manual")) {
381
428
  log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
382
429
 
383
430
  if (decision.mode === "block") {
384
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
431
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "block" });
385
432
  process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
386
433
  } else {
387
434
  // Non-blocking: kb-recall delivers this with the user's next prompt.
388
- const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
435
+ const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "inject" });
389
436
  writePendingCapture(sid, decision.fire, payload);
390
437
  }
391
438
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cstart/coldstart",
3
- "version": "2.2.14",
3
+ "version": "2.2.16",
4
4
  "mcpName": "io.github.AkashGoenka/coldstart",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,10 +0,0 @@
1
- /** Paths this spec puts a note on. Flow steps do NOT count: a flow verifies
2
- * files, it does not give any of them the file note rule 5 asks for. */
3
- export declare function specPaths(spec: unknown): string[];
4
- /**
5
- * Record this write against the session worklist and return the coverage line
6
- * to print, or null when there is no manifest to compare against (no --session,
7
- * a manual `kb write` outside capture, a session that never armed).
8
- */
9
- export declare function noteCoverage(sid: string | undefined, spec: unknown): string | null;
10
- //# sourceMappingURL=session-worklist.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"session-worklist.d.ts","sourceRoot":"","sources":["../../src/kb/session-worklist.ts"],"names":[],"mappings":"AA8CA;yEACyE;AACzE,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,CAIjD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CA8BlF"}
@@ -1,92 +0,0 @@
1
- /**
2
- * Capture coverage — telling the writing agent how much of its worklist it noted.
3
- *
4
- * The gap this closes: the capture checklist hands the agent a worklist of every
5
- * file it worked on, but `kb write` only ever sees ONE spec, so nothing could
6
- * report "you wrote 8 notes for 30 worked files". The agent's own count is the
7
- * one thing it cannot check — it is mid-heredoc, and under-capture is silent by
8
- * construction. So the hook drops the worklist in tmpdir when it builds the
9
- * capture prompt, and every `kb write --session <sid>` prints the running ratio.
10
- *
11
- * CONTRACT TWIN: hooks/capture-payload.mjs (worklistManifestPath) writes this
12
- * file; keep the name and shape in step. Everything here is best-effort — a
13
- * missing, stale or malformed manifest prints nothing and never fails a write.
14
- *
15
- * Local only. The manifest lives in tmpdir, never in the repo: it is scratch
16
- * state for one session, not a record, and nothing about it is transmitted.
17
- */
18
- import { readFileSync, writeFileSync } from 'node:fs';
19
- import { tmpdir } from 'node:os';
20
- import { join } from 'node:path';
21
- /** Manifests older than this are a resumed/unrelated session — ignore them. */
22
- const MAX_AGE_MS = 24 * 60 * 60 * 1000;
23
- /** Below this share of outstanding files, name the skips. */
24
- const LOW_COVERAGE = 0.5;
25
- const LIST_MAX = 6;
26
- function manifestPath(sid) {
27
- return join(tmpdir(), `coldstart-kb-worklist-${sid}.json`);
28
- }
29
- function load(sid) {
30
- try {
31
- const m = JSON.parse(readFileSync(manifestPath(sid), 'utf8'));
32
- if (!Array.isArray(m?.files) || !m.files.length)
33
- return null;
34
- if (typeof m.ts === 'number' && Date.now() - m.ts > MAX_AGE_MS)
35
- return null;
36
- m.wrote = Array.isArray(m.wrote) ? m.wrote : [];
37
- return m;
38
- }
39
- catch {
40
- return null;
41
- }
42
- }
43
- /** Paths this spec puts a note on. Flow steps do NOT count: a flow verifies
44
- * files, it does not give any of them the file note rule 5 asks for. */
45
- export function specPaths(spec) {
46
- const s = spec;
47
- if (!s || typeof s.path !== 'string')
48
- return [];
49
- return s.type === 'file-single' || s.type === 'file-hub' ? [s.path] : [];
50
- }
51
- /**
52
- * Record this write against the session worklist and return the coverage line
53
- * to print, or null when there is no manifest to compare against (no --session,
54
- * a manual `kb write` outside capture, a session that never armed).
55
- */
56
- export function noteCoverage(sid, spec) {
57
- if (!sid)
58
- return null;
59
- const m = load(sid);
60
- if (!m)
61
- return null;
62
- for (const p of specPaths(spec))
63
- if (!m.wrote.includes(p))
64
- m.wrote.push(p);
65
- try {
66
- writeFileSync(manifestPath(sid), JSON.stringify(m));
67
- }
68
- catch { /* best-effort */ }
69
- const outstanding = m.files.filter((f) => f.needsNote);
70
- if (!outstanding.length)
71
- return null;
72
- const wrote = new Set(m.wrote);
73
- const done = outstanding.filter((f) => wrote.has(f.path));
74
- const left = outstanding.filter((f) => !wrote.has(f.path));
75
- const already = m.files.length - outstanding.length;
76
- const lines = [
77
- `capture coverage: ${done.length} of ${outstanding.length} worklist files noted` +
78
- (already ? ` (${already} more already had a fresh note)` : ''),
79
- ];
80
- if (left.length) {
81
- const shown = left.slice(0, LIST_MAX).map((f) => `${f.path} [${f.tier}]`).join(', ');
82
- lines.push(` not noted yet: ${shown}${left.length > LIST_MAX ? `, +${left.length - LIST_MAX} more` : ''}`);
83
- }
84
- // The nudge fires only on the low end, and asks for a REASON rather than more
85
- // notes: a deliberate skip is a fine answer, an invisible one is not. Chained
86
- // writes each print this, so the last line of the block carries the real tally.
87
- if (left.length && done.length / outstanding.length < LOW_COVERAGE) {
88
- lines.push(' if those need no note, say which and why in your reply — an unexplained skip reads as a forgotten one.');
89
- }
90
- return lines.join('\n');
91
- }
92
- //# sourceMappingURL=session-worklist.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"session-worklist.js","sourceRoot":"","sources":["../../src/kb/session-worklist.ts"],"names":[],"mappings":"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"}