@cstart/coldstart 2.0.2 → 2.1.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.
Files changed (91) hide show
  1. package/README.md +21 -14
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +9 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/indexer/extractors/cpp.d.ts.map +1 -1
  6. package/dist/indexer/extractors/cpp.js +1 -5
  7. package/dist/indexer/extractors/cpp.js.map +1 -1
  8. package/dist/indexer/extractors/csharp.d.ts.map +1 -1
  9. package/dist/indexer/extractors/csharp.js +1 -5
  10. package/dist/indexer/extractors/csharp.js.map +1 -1
  11. package/dist/indexer/extractors/go.d.ts.map +1 -1
  12. package/dist/indexer/extractors/go.js +1 -5
  13. package/dist/indexer/extractors/go.js.map +1 -1
  14. package/dist/indexer/extractors/groovy.d.ts.map +1 -1
  15. package/dist/indexer/extractors/groovy.js +1 -5
  16. package/dist/indexer/extractors/groovy.js.map +1 -1
  17. package/dist/indexer/extractors/java.d.ts +16 -0
  18. package/dist/indexer/extractors/java.d.ts.map +1 -1
  19. package/dist/indexer/extractors/java.js +10 -12
  20. package/dist/indexer/extractors/java.js.map +1 -1
  21. package/dist/indexer/extractors/kotlin.d.ts.map +1 -1
  22. package/dist/indexer/extractors/kotlin.js +1 -5
  23. package/dist/indexer/extractors/kotlin.js.map +1 -1
  24. package/dist/indexer/extractors/node-helpers.d.ts +11 -0
  25. package/dist/indexer/extractors/node-helpers.d.ts.map +1 -1
  26. package/dist/indexer/extractors/node-helpers.js +13 -0
  27. package/dist/indexer/extractors/node-helpers.js.map +1 -1
  28. package/dist/indexer/extractors/parser-factory.d.ts +25 -1
  29. package/dist/indexer/extractors/parser-factory.d.ts.map +1 -1
  30. package/dist/indexer/extractors/parser-factory.js +126 -16
  31. package/dist/indexer/extractors/parser-factory.js.map +1 -1
  32. package/dist/indexer/extractors/php.d.ts.map +1 -1
  33. package/dist/indexer/extractors/php.js +2 -7
  34. package/dist/indexer/extractors/php.js.map +1 -1
  35. package/dist/indexer/extractors/python.d.ts.map +1 -1
  36. package/dist/indexer/extractors/python.js +3 -7
  37. package/dist/indexer/extractors/python.js.map +1 -1
  38. package/dist/indexer/extractors/ruby.d.ts +23 -0
  39. package/dist/indexer/extractors/ruby.d.ts.map +1 -1
  40. package/dist/indexer/extractors/ruby.js +6 -17
  41. package/dist/indexer/extractors/ruby.js.map +1 -1
  42. package/dist/indexer/extractors/rust.d.ts.map +1 -1
  43. package/dist/indexer/extractors/rust.js +1 -5
  44. package/dist/indexer/extractors/rust.js.map +1 -1
  45. package/dist/indexer/extractors/toml.d.ts.map +1 -1
  46. package/dist/indexer/extractors/toml.js +1 -5
  47. package/dist/indexer/extractors/toml.js.map +1 -1
  48. package/dist/indexer/extractors/xml.d.ts.map +1 -1
  49. package/dist/indexer/extractors/xml.js +2 -6
  50. package/dist/indexer/extractors/xml.js.map +1 -1
  51. package/dist/indexer/extractors/yaml.d.ts.map +1 -1
  52. package/dist/indexer/extractors/yaml.js +1 -5
  53. package/dist/indexer/extractors/yaml.js.map +1 -1
  54. package/dist/indexer/parser.d.ts.map +1 -1
  55. package/dist/indexer/parser.js +2 -0
  56. package/dist/indexer/parser.js.map +1 -1
  57. package/dist/indexer/resolvers/ruby.d.ts.map +1 -1
  58. package/dist/indexer/resolvers/ruby.js +7 -1
  59. package/dist/indexer/resolvers/ruby.js.map +1 -1
  60. package/dist/indexer/ts-parser.d.ts +7 -0
  61. package/dist/indexer/ts-parser.d.ts.map +1 -1
  62. package/dist/indexer/ts-parser.js +6 -31
  63. package/dist/indexer/ts-parser.js.map +1 -1
  64. package/dist/init.d.ts +39 -12
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +38 -33
  67. package/dist/init.js.map +1 -1
  68. package/dist/unwire.d.ts +20 -0
  69. package/dist/unwire.d.ts.map +1 -0
  70. package/dist/unwire.js +304 -0
  71. package/dist/unwire.js.map +1 -0
  72. package/hooks/codex-kb-elicit.mjs +26 -16
  73. package/hooks/codex-nudge-handler.mjs +79 -11
  74. package/hooks/kb-elicit.mjs +42 -17
  75. package/hooks/nudge-handler.mjs +79 -11
  76. package/package.json +4 -17
  77. package/vendor/wasm/tree-sitter-c-sharp.wasm +0 -0
  78. package/vendor/wasm/tree-sitter-cpp.wasm +0 -0
  79. package/vendor/wasm/tree-sitter-go.wasm +0 -0
  80. package/vendor/wasm/tree-sitter-groovy.wasm +0 -0
  81. package/vendor/wasm/tree-sitter-java.wasm +0 -0
  82. package/vendor/wasm/tree-sitter-kotlin.wasm +0 -0
  83. package/vendor/wasm/tree-sitter-php.wasm +0 -0
  84. package/vendor/wasm/tree-sitter-python.wasm +0 -0
  85. package/vendor/wasm/tree-sitter-ruby.wasm +0 -0
  86. package/vendor/wasm/tree-sitter-rust.wasm +0 -0
  87. package/vendor/wasm/tree-sitter-toml.wasm +0 -0
  88. package/vendor/wasm/tree-sitter-tsx.wasm +0 -0
  89. package/vendor/wasm/tree-sitter-typescript.wasm +0 -0
  90. package/vendor/wasm/tree-sitter-xml.wasm +0 -0
  91. package/vendor/wasm/tree-sitter-yaml.wasm +0 -0
@@ -37,6 +37,7 @@
37
37
 
38
38
  import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
39
39
  import { execFileSync } from "node:child_process";
40
+ import { createHash } from "node:crypto";
40
41
  import { join } from "node:path";
41
42
  import { fileURLToPath } from "node:url";
42
43
  import { canonicalFindKey } from "./canonical-find-key.mjs";
@@ -67,6 +68,48 @@ const SEARCH_RE =
67
68
  /(^|[;&|]|\s)(grep|egrep|fgrep|rg)\b|git\s+grep|git\s+log|(^|[;&|]|\s)find\s|(^|[;&|]|\s)ls\b|(^|[;&|]|\s)cat\b/;
68
69
  const GS_FILE_RE = /(?:coldstart|index\.js)\s+gs\s+(\S+)/;
69
70
 
71
+ // The checkpoint (detector 4) only makes sense when the agent may be SPINNING IN
72
+ // SEARCH — never mid-implementation. A write is the signal that it is producing,
73
+ // not spinning. Fast per-call detection: structured write tools + write-shaped
74
+ // shell (sed -i, redirections, cp/mv/rm, git apply/checkout…). This is the
75
+ // fallback; git ground-truth (below) is authoritative when the repo is a git repo.
76
+ const WRITE_TOOLS = new Set([
77
+ "Edit", "Write", "MultiEdit", "NotebookEdit", // Claude
78
+ "apply_patch", "edit_file", "write_file", "create_file", // Codex / Cursor / MCP writers
79
+ ]);
80
+ const WRITE_CMD_RE =
81
+ /(^|[\s;&|(])(sed\s+-i|tee|dd|truncate|patch|apply_patch|cp|mv|rm|mkdir|touch|install)\b|>>?(?!\s*\/dev\/null)|git\s+(apply|checkout|restore|stash|reset|revert|commit|mv|rm)\b/;
82
+
83
+ // Genuine, tool-agnostic write detection: hash the working-tree state so ANY
84
+ // modification (Edit, sed -i, >>, an MCP writer, a new or deleted file) changes
85
+ // it, while pure reads leave it untouched. Empty string when git is unavailable
86
+ // (no repo / no commits / no git) — callers fall back to the fast signal above.
87
+ // porcelain catches added/removed/untracked files; shortstat catches further
88
+ // edits to an already-dirty file. Verified across write modalities 2026-07-10.
89
+ function gitFingerprint(root) {
90
+ try {
91
+ const opts = { cwd: root, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
92
+ const status = execFileSync("git", ["status", "--porcelain=v1"], opts);
93
+ const stat = execFileSync("git", ["diff", "HEAD", "--shortstat"], opts);
94
+ return createHash("sha1").update(status + " " + stat).digest("hex").slice(0, 16);
95
+ } catch {
96
+ return "";
97
+ }
98
+ }
99
+
100
+ // Current HEAD (short sha), or "" when unavailable. The fingerprint above reads
101
+ // the working-tree DIFF against HEAD, so a HEAD move (branch switch / commit /
102
+ // rebase) can shift it without the agent writing anything — we compare HEAD
103
+ // across checkpoints and distrust the fingerprint whenever it moved.
104
+ function gitHead(root) {
105
+ try {
106
+ const opts = { cwd: root, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
107
+ return execFileSync("git", ["rev-parse", "HEAD"], opts).trim().slice(0, 12);
108
+ } catch {
109
+ return "";
110
+ }
111
+ }
112
+
70
113
  // Evidence store regexes — paths printed in ANY output, and files named as ARGS.
71
114
  const PATH_RE = /[\w./-]+\.(?:py|js|jsx|ts|tsx|htm|html|vue|json|scss|css|rb|java)/g;
72
115
  const FILE_ARG_RE =
@@ -161,6 +204,9 @@ export default function handle(input) {
161
204
  held_files: [],
162
205
  redundant_fired: 0,
163
206
  seen_find_queries: [],
207
+ wrote_since_ckpt: false, // (4) any write since the last checkpoint window
208
+ git_fp: "", // (4) working-tree fingerprint at the last checkpoint
209
+ git_head: "", // (4) HEAD at the last checkpoint — a move invalidates the fp diff
164
210
  };
165
211
  try {
166
212
  Object.assign(st, JSON.parse(readFileSync(stateFile, "utf8")));
@@ -185,6 +231,8 @@ export default function handle(input) {
185
231
  tool === "Glob" ||
186
232
  (tool === "Bash" && !isFind && !isGs && !isKb && SEARCH_RE.test(cmd));
187
233
  const isNonfindShell = isSearch;
234
+ // Producing, not spinning — used only to gate the checkpoint (detector 4).
235
+ const isWrite = WRITE_TOOLS.has(tool) || (tool === "Bash" && WRITE_CMD_RE.test(cmd));
188
236
 
189
237
  const _ob = out !== null && out !== undefined ? out.trim().toLowerCase() : null;
190
238
  const outEmpty =
@@ -195,6 +243,7 @@ export default function handle(input) {
195
243
  _ob === "no files found");
196
244
 
197
245
  st.total += 1;
246
+ if (isWrite) st.wrote_since_ckpt = true;
198
247
  // seen_find gates the spiral detectors (3/3b): they stay silent for sessions that
199
248
  // never touch coldstart at all (deliberate — this hook must not nag non-users).
200
249
  // Any coldstart surface proves awareness: find (set below on the find branch),
@@ -329,19 +378,38 @@ export default function handle(input) {
329
378
  st.last_gs_fallback = out && GS_FALLBACK_RE.test(out) ? gsFile : "";
330
379
  }
331
380
 
332
- // (4) checkpoint — periodic, lowest urgency
381
+ // (4) checkpoint — periodic, lowest urgency. Fires ONLY when the agent may be
382
+ // spinning in search: suppressed whenever a write happened in the window, so it
383
+ // never nags mid-implementation (where "stop searching and answer" is nonsense).
384
+ // git is authoritative when present (ground-truth over the whole window); the
385
+ // fast per-call signal covers the first window and non-git repos.
333
386
  if (st.total - st.last_checkpoint >= CHECKPOINT_EVERY) {
334
387
  st.last_checkpoint = st.total;
335
- msgs.push([
336
- 5,
337
- `Checkpoint (${st.total} tool calls). Stop and think before the next call answer these in order:\n` +
338
- "1. Restate the task in one sentence what is the ONE thing it asks for?\n" +
339
- "2. List the files you have already surfaced or read that bear on it.\n" +
340
- "3. Can you answer (1) from (2)? If YES write the answer now and stop searching; you almost " +
341
- "certainly have enough.\n" +
342
- "4. Only if NO name the single specific fact still missing, and make the next call target ONLY " +
343
- "that. Do not re-run a search whose results are already in your context.",
344
- ]);
388
+ const headNow = gitHead(input.cwd);
389
+ const fpNow = gitFingerprint(input.cwd);
390
+ // Trust the git working-tree diff only with a prior baseline AND an unmoved
391
+ // HEAD a branch switch / commit / rebase shifts the whole tree and would
392
+ // read as a spurious write. Otherwise (first window, no git, or HEAD moved)
393
+ // the fast per-call signal governs: it tracks the agent's own writes and is
394
+ // immune to branch movement.
395
+ const gitUsable = !!fpNow && !!st.git_fp && !!headNow && headNow === st.git_head;
396
+ const wrote = gitUsable ? fpNow !== st.git_fp : st.wrote_since_ckpt;
397
+ if (fpNow) st.git_fp = fpNow;
398
+ if (headNow) st.git_head = headNow;
399
+ st.wrote_since_ckpt = false;
400
+ if (!wrote) {
401
+ msgs.push([
402
+ 5,
403
+ `Checkpoint (${st.total} tool calls). If you are searching or investigating, stop and think ` +
404
+ "before the next call (if you are mid-implementation, disregard this):\n" +
405
+ "1. Restate the task in one sentence — what is the ONE thing it asks for?\n" +
406
+ "2. List the files you have already surfaced or read that bear on it.\n" +
407
+ "3. Can you answer (1) from (2)? If YES — write the answer now and stop searching; you almost " +
408
+ "certainly have enough.\n" +
409
+ "4. Only if NO — name the single specific fact still missing, and make the next call target ONLY " +
410
+ "that. Do not re-run a search whose results are already in your context.",
411
+ ]);
412
+ }
345
413
  }
346
414
 
347
415
  // merge THIS call's evidence into the store (after novelty was computed above)
@@ -181,9 +181,17 @@ have firsthand knowledge of — never enumerate the rest.
181
181
  The best facet/summary says: what it does that the name doesn't tell you, what to watch out \
182
182
  for when changing it, and which tests or checks matter.
183
183
 
184
- 2. FLOW notes — when your task traced how something works ACROSS files: the ordered story. Each \
185
- step points at a file (path + symbols) with its role in the story. A step never restates what a \
186
- file note already says the detail lives in the file's facet; the flow links to it.
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 knowledgesuch 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.
187
195
 
188
196
  3. LESSON notes — rare. Only one thing qualifies:
189
197
  - a confirmed ABSENCE ("there is no X in this repo"), with the search terms that proved it.
@@ -202,6 +210,10 @@ RULES:
202
210
  - Codebase knowledge only — never the interaction, the user, or your own process.
203
211
  - Firsthand only: if it arrived secondhand (e.g. a subagent's report) and you did not verify it \
204
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.
205
217
  - If a future agent would not act differently for knowing it, do not store it.
206
218
  - SEARCH BEFORE YOU WRITE a flow or lesson: run \`node ${CLI} kb search "<your task words>" \
207
219
  --root ${root}\` once. If an existing flow already tells this mechanism's story, UPDATE it \
@@ -300,12 +312,16 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
300
312
  const sid = String(input.session_id || "").replace(/[^A-Za-z0-9_-]/g, "");
301
313
  if (!sid) { log("SKIP no-session-id"); process.exit(0); }
302
314
 
303
- // Guard 2: one elicitation per (session, agent) subagents share the
304
- // parent session_id, so the marker is scoped by agent too.
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.
305
321
  const aid = String(input.agent_id || "main").replace(/[^A-Za-z0-9_-]/g, "") || "main";
306
- const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.done`);
307
- if (existsSync(marker)) { log(`SKIP already-elicited session=${sid} agent=${aid}`); process.exit(0); }
308
- try { writeFileSync(marker, String(Date.now())); } catch { /* best effort */ }
322
+ const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
323
+ let offered = new Set();
324
+ try { offered = new Set(JSON.parse(readFileSync(marker, "utf8")).files || []); } catch { /* first Stop of this session */ }
309
325
 
310
326
  // On SubagentStop, transcript_path is the PARENT's transcript (confirmed:
311
327
  // claude-code#11396) — scanning it would elicit the sub off the parent's
@@ -326,18 +342,27 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
326
342
  transcriptPath = own;
327
343
  }
328
344
  const files = transcriptPath ? touchedFiles(transcriptPath, root) : [];
329
-
330
- // FAST-EXIT only when the agent touched NO repo file at all (pure
331
- // orchestration / Q&A). Anything touched → the agent judges what's worth
332
- // capturing; the hook never guesses from read modality.
333
- if (!files.length) {
334
- log(`FAST-EXIT zero touched files session=${sid} agent=${aid} event=${input.hook_event_name || "?"}`);
345
+ // Delta: only files not already offered on an earlier Stop this session.
346
+ const newFiles = files.filter((f) => !offered.has(f));
347
+
348
+ // FAST-EXIT when there is nothing NEW to capture — either no repo file was
349
+ // touched at all (pure orchestration / Q&A) or every touched file was already
350
+ // offered on a previous Stop. No record is written on this path, so an early
351
+ // no-op Stop can never burn the session's capture.
352
+ if (!newFiles.length) {
353
+ log(`FAST-EXIT no-new-files session=${sid} agent=${aid} touched=${files.length} event=${input.hook_event_name || "?"}`);
335
354
  process.exit(0);
336
355
  }
337
356
 
338
- const prompt = buildCapturePrompt(root, filesBlock(root, files), sid, input.hook_event_name === "SubagentStop");
339
- logCaptureEvent(root, { event: "elicit", session: sid, agent: aid, touched: files.length, hook: input.hook_event_name });
340
- log(`ELICIT session=${sid} agent=${aid} touched=${files.length} promptBytes=${prompt.length} event=${input.hook_event_name || "?"}`);
357
+ // Record everything now offered BEFORE returning the block, so the post-write
358
+ // re-Stop and every later Stop skip these files. stop_hook_active (Guard 1)
359
+ // still short-circuits the immediate re-Stop; this record survives across the
360
+ // natural turns of a long session.
361
+ try { writeFileSync(marker, JSON.stringify({ files: [...offered, ...newFiles], ts: Date.now() })); } catch { /* best effort */ }
362
+
363
+ const prompt = buildCapturePrompt(root, filesBlock(root, newFiles), sid, input.hook_event_name === "SubagentStop");
364
+ logCaptureEvent(root, { event: "elicit", session: sid, agent: aid, touched: files.length, new: newFiles.length, hook: input.hook_event_name });
365
+ log(`ELICIT session=${sid} agent=${aid} new=${newFiles.length} touched=${files.length} promptBytes=${prompt.length} event=${input.hook_event_name || "?"}`);
341
366
  process.stdout.write(JSON.stringify({ decision: "block", reason: prompt }));
342
367
  } catch (e) {
343
368
  log(`handler ${e?.stack || e}`); // fail-open: no stdout → stop allowed
@@ -37,6 +37,7 @@
37
37
 
38
38
  import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
39
39
  import { execFileSync } from "node:child_process";
40
+ import { createHash } from "node:crypto";
40
41
  import { join } from "node:path";
41
42
  import { fileURLToPath } from "node:url";
42
43
  import { canonicalFindKey } from "./canonical-find-key.mjs";
@@ -67,6 +68,48 @@ const SEARCH_RE =
67
68
  /(^|[;&|]|\s)(grep|egrep|fgrep|rg)\b|git\s+grep|git\s+log|(^|[;&|]|\s)find\s|(^|[;&|]|\s)ls\b|(^|[;&|]|\s)cat\b/;
68
69
  const GS_FILE_RE = /(?:coldstart|index\.js)\s+gs\s+(\S+)/;
69
70
 
71
+ // The checkpoint (detector 4) only makes sense when the agent may be SPINNING IN
72
+ // SEARCH — never mid-implementation. A write is the signal that it is producing,
73
+ // not spinning. Fast per-call detection: structured write tools + write-shaped
74
+ // shell (sed -i, redirections, cp/mv/rm, git apply/checkout…). This is the
75
+ // fallback; git ground-truth (below) is authoritative when the repo is a git repo.
76
+ const WRITE_TOOLS = new Set([
77
+ "Edit", "Write", "MultiEdit", "NotebookEdit", // Claude
78
+ "apply_patch", "edit_file", "write_file", "create_file", // Codex / Cursor / MCP writers
79
+ ]);
80
+ const WRITE_CMD_RE =
81
+ /(^|[\s;&|(])(sed\s+-i|tee|dd|truncate|patch|apply_patch|cp|mv|rm|mkdir|touch|install)\b|>>?(?!\s*\/dev\/null)|git\s+(apply|checkout|restore|stash|reset|revert|commit|mv|rm)\b/;
82
+
83
+ // Genuine, tool-agnostic write detection: hash the working-tree state so ANY
84
+ // modification (Edit, sed -i, >>, an MCP writer, a new or deleted file) changes
85
+ // it, while pure reads leave it untouched. Empty string when git is unavailable
86
+ // (no repo / no commits / no git) — callers fall back to the fast signal above.
87
+ // porcelain catches added/removed/untracked files; shortstat catches further
88
+ // edits to an already-dirty file. Verified across write modalities 2026-07-10.
89
+ function gitFingerprint(root) {
90
+ try {
91
+ const opts = { cwd: root, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
92
+ const status = execFileSync("git", ["status", "--porcelain=v1"], opts);
93
+ const stat = execFileSync("git", ["diff", "HEAD", "--shortstat"], opts);
94
+ return createHash("sha1").update(status + " " + stat).digest("hex").slice(0, 16);
95
+ } catch {
96
+ return "";
97
+ }
98
+ }
99
+
100
+ // Current HEAD (short sha), or "" when unavailable. The fingerprint above reads
101
+ // the working-tree DIFF against HEAD, so a HEAD move (branch switch / commit /
102
+ // rebase) can shift it without the agent writing anything — we compare HEAD
103
+ // across checkpoints and distrust the fingerprint whenever it moved.
104
+ function gitHead(root) {
105
+ try {
106
+ const opts = { cwd: root, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
107
+ return execFileSync("git", ["rev-parse", "HEAD"], opts).trim().slice(0, 12);
108
+ } catch {
109
+ return "";
110
+ }
111
+ }
112
+
70
113
  // Evidence store regexes — paths printed in ANY output, and files named as ARGS.
71
114
  const PATH_RE = /[\w./-]+\.(?:py|js|jsx|ts|tsx|htm|html|vue|json|scss|css|rb|java)/g;
72
115
  const FILE_ARG_RE =
@@ -161,6 +204,9 @@ export default function handle(input) {
161
204
  held_files: [],
162
205
  redundant_fired: 0,
163
206
  seen_find_queries: [],
207
+ wrote_since_ckpt: false, // (4) any write since the last checkpoint window
208
+ git_fp: "", // (4) working-tree fingerprint at the last checkpoint
209
+ git_head: "", // (4) HEAD at the last checkpoint — a move invalidates the fp diff
164
210
  };
165
211
  try {
166
212
  Object.assign(st, JSON.parse(readFileSync(stateFile, "utf8")));
@@ -185,6 +231,8 @@ export default function handle(input) {
185
231
  tool === "Glob" ||
186
232
  (tool === "Bash" && !isFind && !isGs && !isKb && SEARCH_RE.test(cmd));
187
233
  const isNonfindShell = isSearch;
234
+ // Producing, not spinning — used only to gate the checkpoint (detector 4).
235
+ const isWrite = WRITE_TOOLS.has(tool) || (tool === "Bash" && WRITE_CMD_RE.test(cmd));
188
236
 
189
237
  const _ob = out !== null && out !== undefined ? out.trim().toLowerCase() : null;
190
238
  const outEmpty =
@@ -195,6 +243,7 @@ export default function handle(input) {
195
243
  _ob === "no files found");
196
244
 
197
245
  st.total += 1;
246
+ if (isWrite) st.wrote_since_ckpt = true;
198
247
  // seen_find gates the spiral detectors (3/3b): they stay silent for sessions that
199
248
  // never touch coldstart at all (deliberate — this hook must not nag non-users).
200
249
  // Any coldstart surface proves awareness: find (set below on the find branch),
@@ -329,19 +378,38 @@ export default function handle(input) {
329
378
  st.last_gs_fallback = out && GS_FALLBACK_RE.test(out) ? gsFile : "";
330
379
  }
331
380
 
332
- // (4) checkpoint — periodic, lowest urgency
381
+ // (4) checkpoint — periodic, lowest urgency. Fires ONLY when the agent may be
382
+ // spinning in search: suppressed whenever a write happened in the window, so it
383
+ // never nags mid-implementation (where "stop searching and answer" is nonsense).
384
+ // git is authoritative when present (ground-truth over the whole window); the
385
+ // fast per-call signal covers the first window and non-git repos.
333
386
  if (st.total - st.last_checkpoint >= CHECKPOINT_EVERY) {
334
387
  st.last_checkpoint = st.total;
335
- msgs.push([
336
- 5,
337
- `Checkpoint (${st.total} tool calls). Stop and think before the next call answer these in order:\n` +
338
- "1. Restate the task in one sentence what is the ONE thing it asks for?\n" +
339
- "2. List the files you have already surfaced or read that bear on it.\n" +
340
- "3. Can you answer (1) from (2)? If YES write the answer now and stop searching; you almost " +
341
- "certainly have enough.\n" +
342
- "4. Only if NO name the single specific fact still missing, and make the next call target ONLY " +
343
- "that. Do not re-run a search whose results are already in your context.",
344
- ]);
388
+ const headNow = gitHead(input.cwd);
389
+ const fpNow = gitFingerprint(input.cwd);
390
+ // Trust the git working-tree diff only with a prior baseline AND an unmoved
391
+ // HEAD a branch switch / commit / rebase shifts the whole tree and would
392
+ // read as a spurious write. Otherwise (first window, no git, or HEAD moved)
393
+ // the fast per-call signal governs: it tracks the agent's own writes and is
394
+ // immune to branch movement.
395
+ const gitUsable = !!fpNow && !!st.git_fp && !!headNow && headNow === st.git_head;
396
+ const wrote = gitUsable ? fpNow !== st.git_fp : st.wrote_since_ckpt;
397
+ if (fpNow) st.git_fp = fpNow;
398
+ if (headNow) st.git_head = headNow;
399
+ st.wrote_since_ckpt = false;
400
+ if (!wrote) {
401
+ msgs.push([
402
+ 5,
403
+ `Checkpoint (${st.total} tool calls). If you are searching or investigating, stop and think ` +
404
+ "before the next call (if you are mid-implementation, disregard this):\n" +
405
+ "1. Restate the task in one sentence — what is the ONE thing it asks for?\n" +
406
+ "2. List the files you have already surfaced or read that bear on it.\n" +
407
+ "3. Can you answer (1) from (2)? If YES — write the answer now and stop searching; you almost " +
408
+ "certainly have enough.\n" +
409
+ "4. Only if NO — name the single specific fact still missing, and make the next call target ONLY " +
410
+ "that. Do not re-run a search whose results are already in your context.",
411
+ ]);
412
+ }
345
413
  }
346
414
 
347
415
  // merge THIS call's evidence into the store (after novelty was computed above)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cstart/coldstart",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,7 +14,8 @@
14
14
  "files": [
15
15
  "dist",
16
16
  "hooks",
17
- "templates"
17
+ "templates",
18
+ "vendor/wasm"
18
19
  ],
19
20
  "keywords": [
20
21
  "cli",
@@ -45,22 +46,8 @@
45
46
  },
46
47
  "dependencies": {
47
48
  "@modelcontextprotocol/sdk": "latest",
48
- "@tree-sitter-grammars/tree-sitter-toml": "^0.7.0",
49
- "@tree-sitter-grammars/tree-sitter-xml": "^0.7.0",
50
- "@tree-sitter-grammars/tree-sitter-yaml": "^0.7.1",
51
49
  "@vscode/ripgrep": "^1.18.0",
52
- "tree-sitter": "^0.22.4",
53
- "tree-sitter-c-sharp": "^0.21.3",
54
- "tree-sitter-cpp": "^0.23.4",
55
- "tree-sitter-go": "^0.23.4",
56
- "tree-sitter-groovy": "^0.1.2",
57
- "tree-sitter-java": "^0.23.5",
58
- "tree-sitter-kotlin": "^0.3.8",
59
- "tree-sitter-php": "^0.23.12",
60
- "tree-sitter-python": "^0.23.4",
61
- "tree-sitter-ruby": "^0.23.1",
62
- "tree-sitter-rust": "^0.23.1",
63
- "tree-sitter-typescript": "^0.23.2"
50
+ "web-tree-sitter": "^0.26.10"
64
51
  },
65
52
  "devDependencies": {
66
53
  "@types/node": "^22",
Binary file
Binary file
Binary file
Binary file
Binary file