@cstart/coldstart 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -0
- package/dist/cache/disk-cache.d.ts +14 -0
- package/dist/cache/disk-cache.d.ts.map +1 -0
- package/dist/cache/disk-cache.js +371 -0
- package/dist/cache/disk-cache.js.map +1 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +206 -0
- package/dist/cli.js.map +1 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +111 -0
- package/dist/constants.js.map +1 -0
- package/dist/daemon-lock.d.ts +63 -0
- package/dist/daemon-lock.d.ts.map +1 -0
- package/dist/daemon-lock.js +218 -0
- package/dist/daemon-lock.js.map +1 -0
- package/dist/daemon-log.d.ts +26 -0
- package/dist/daemon-log.d.ts.map +1 -0
- package/dist/daemon-log.js +110 -0
- package/dist/daemon-log.js.map +1 -0
- package/dist/index-manager.d.ts +103 -0
- package/dist/index-manager.d.ts.map +1 -0
- package/dist/index-manager.js +361 -0
- package/dist/index-manager.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +724 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer/content-tokens.d.ts +74 -0
- package/dist/indexer/content-tokens.d.ts.map +1 -0
- package/dist/indexer/content-tokens.js +406 -0
- package/dist/indexer/content-tokens.js.map +1 -0
- package/dist/indexer/cpp-include-roots.d.ts +7 -0
- package/dist/indexer/cpp-include-roots.d.ts.map +1 -0
- package/dist/indexer/cpp-include-roots.js +166 -0
- package/dist/indexer/cpp-include-roots.js.map +1 -0
- package/dist/indexer/csharp-synthetic.d.ts +9 -0
- package/dist/indexer/csharp-synthetic.d.ts.map +1 -0
- package/dist/indexer/csharp-synthetic.js +51 -0
- package/dist/indexer/csharp-synthetic.js.map +1 -0
- package/dist/indexer/django-synthetic.d.ts +12 -0
- package/dist/indexer/django-synthetic.d.ts.map +1 -0
- package/dist/indexer/django-synthetic.js +49 -0
- package/dist/indexer/django-synthetic.js.map +1 -0
- package/dist/indexer/extractors/angularjs.d.ts +16 -0
- package/dist/indexer/extractors/angularjs.d.ts.map +1 -0
- package/dist/indexer/extractors/angularjs.js +49 -0
- package/dist/indexer/extractors/angularjs.js.map +1 -0
- package/dist/indexer/extractors/cpp.d.ts +9 -0
- package/dist/indexer/extractors/cpp.d.ts.map +1 -0
- package/dist/indexer/extractors/cpp.js +254 -0
- package/dist/indexer/extractors/cpp.js.map +1 -0
- package/dist/indexer/extractors/csharp.d.ts +19 -0
- package/dist/indexer/extractors/csharp.d.ts.map +1 -0
- package/dist/indexer/extractors/csharp.js +327 -0
- package/dist/indexer/extractors/csharp.js.map +1 -0
- package/dist/indexer/extractors/env.d.ts +9 -0
- package/dist/indexer/extractors/env.d.ts.map +1 -0
- package/dist/indexer/extractors/env.js +43 -0
- package/dist/indexer/extractors/env.js.map +1 -0
- package/dist/indexer/extractors/go.d.ts +9 -0
- package/dist/indexer/extractors/go.d.ts.map +1 -0
- package/dist/indexer/extractors/go.js +227 -0
- package/dist/indexer/extractors/go.js.map +1 -0
- package/dist/indexer/extractors/graphql.d.ts +22 -0
- package/dist/indexer/extractors/graphql.d.ts.map +1 -0
- package/dist/indexer/extractors/graphql.js +88 -0
- package/dist/indexer/extractors/graphql.js.map +1 -0
- package/dist/indexer/extractors/groovy.d.ts +9 -0
- package/dist/indexer/extractors/groovy.d.ts.map +1 -0
- package/dist/indexer/extractors/groovy.js +356 -0
- package/dist/indexer/extractors/groovy.js.map +1 -0
- package/dist/indexer/extractors/java.d.ts +10 -0
- package/dist/indexer/extractors/java.d.ts.map +1 -0
- package/dist/indexer/extractors/java.js +586 -0
- package/dist/indexer/extractors/java.js.map +1 -0
- package/dist/indexer/extractors/kotlin.d.ts +10 -0
- package/dist/indexer/extractors/kotlin.d.ts.map +1 -0
- package/dist/indexer/extractors/kotlin.js +398 -0
- package/dist/indexer/extractors/kotlin.js.map +1 -0
- package/dist/indexer/extractors/node-helpers.d.ts +13 -0
- package/dist/indexer/extractors/node-helpers.d.ts.map +1 -0
- package/dist/indexer/extractors/node-helpers.js +17 -0
- package/dist/indexer/extractors/node-helpers.js.map +1 -0
- package/dist/indexer/extractors/parser-factory.d.ts +4 -0
- package/dist/indexer/extractors/parser-factory.d.ts.map +1 -0
- package/dist/indexer/extractors/parser-factory.js +28 -0
- package/dist/indexer/extractors/parser-factory.js.map +1 -0
- package/dist/indexer/extractors/php.d.ts +17 -0
- package/dist/indexer/extractors/php.d.ts.map +1 -0
- package/dist/indexer/extractors/php.js +363 -0
- package/dist/indexer/extractors/php.js.map +1 -0
- package/dist/indexer/extractors/python.d.ts +14 -0
- package/dist/indexer/extractors/python.d.ts.map +1 -0
- package/dist/indexer/extractors/python.js +449 -0
- package/dist/indexer/extractors/python.js.map +1 -0
- package/dist/indexer/extractors/ruby.d.ts +10 -0
- package/dist/indexer/extractors/ruby.d.ts.map +1 -0
- package/dist/indexer/extractors/ruby.js +996 -0
- package/dist/indexer/extractors/ruby.js.map +1 -0
- package/dist/indexer/extractors/rust.d.ts +9 -0
- package/dist/indexer/extractors/rust.d.ts.map +1 -0
- package/dist/indexer/extractors/rust.js +322 -0
- package/dist/indexer/extractors/rust.js.map +1 -0
- package/dist/indexer/extractors/toml.d.ts +9 -0
- package/dist/indexer/extractors/toml.d.ts.map +1 -0
- package/dist/indexer/extractors/toml.js +84 -0
- package/dist/indexer/extractors/toml.js.map +1 -0
- package/dist/indexer/extractors/xml.d.ts +9 -0
- package/dist/indexer/extractors/xml.d.ts.map +1 -0
- package/dist/indexer/extractors/xml.js +163 -0
- package/dist/indexer/extractors/xml.js.map +1 -0
- package/dist/indexer/extractors/yaml.d.ts +9 -0
- package/dist/indexer/extractors/yaml.d.ts.map +1 -0
- package/dist/indexer/extractors/yaml.js +117 -0
- package/dist/indexer/extractors/yaml.js.map +1 -0
- package/dist/indexer/git.d.ts +19 -0
- package/dist/indexer/git.d.ts.map +1 -0
- package/dist/indexer/git.js +55 -0
- package/dist/indexer/git.js.map +1 -0
- package/dist/indexer/graph.d.ts +7 -0
- package/dist/indexer/graph.d.ts.map +1 -0
- package/dist/indexer/graph.js +20 -0
- package/dist/indexer/graph.js.map +1 -0
- package/dist/indexer/indexed-file.d.ts +15 -0
- package/dist/indexer/indexed-file.d.ts.map +1 -0
- package/dist/indexer/indexed-file.js +50 -0
- package/dist/indexer/indexed-file.js.map +1 -0
- package/dist/indexer/invariants.d.ts +15 -0
- package/dist/indexer/invariants.d.ts.map +1 -0
- package/dist/indexer/invariants.js +96 -0
- package/dist/indexer/invariants.js.map +1 -0
- package/dist/indexer/laravel-synthetic.d.ts +10 -0
- package/dist/indexer/laravel-synthetic.d.ts.map +1 -0
- package/dist/indexer/laravel-synthetic.js +78 -0
- package/dist/indexer/laravel-synthetic.js.map +1 -0
- package/dist/indexer/parser.d.ts +4 -0
- package/dist/indexer/parser.d.ts.map +1 -0
- package/dist/indexer/parser.js +499 -0
- package/dist/indexer/parser.js.map +1 -0
- package/dist/indexer/patch.d.ts +3 -0
- package/dist/indexer/patch.d.ts.map +1 -0
- package/dist/indexer/patch.js +291 -0
- package/dist/indexer/patch.js.map +1 -0
- package/dist/indexer/rails-synthetic.d.ts +11 -0
- package/dist/indexer/rails-synthetic.d.ts.map +1 -0
- package/dist/indexer/rails-synthetic.js +80 -0
- package/dist/indexer/rails-synthetic.js.map +1 -0
- package/dist/indexer/reconcile.d.ts +9 -0
- package/dist/indexer/reconcile.d.ts.map +1 -0
- package/dist/indexer/reconcile.js +96 -0
- package/dist/indexer/reconcile.js.map +1 -0
- package/dist/indexer/resolvers/cpp.d.ts +15 -0
- package/dist/indexer/resolvers/cpp.d.ts.map +1 -0
- package/dist/indexer/resolvers/cpp.js +35 -0
- package/dist/indexer/resolvers/cpp.js.map +1 -0
- package/dist/indexer/resolvers/csharp.d.ts +2 -0
- package/dist/indexer/resolvers/csharp.d.ts.map +1 -0
- package/dist/indexer/resolvers/csharp.js +89 -0
- package/dist/indexer/resolvers/csharp.js.map +1 -0
- package/dist/indexer/resolvers/generic.d.ts +10 -0
- package/dist/indexer/resolvers/generic.d.ts.map +1 -0
- package/dist/indexer/resolvers/generic.js +35 -0
- package/dist/indexer/resolvers/generic.js.map +1 -0
- package/dist/indexer/resolvers/go.d.ts +2 -0
- package/dist/indexer/resolvers/go.d.ts.map +1 -0
- package/dist/indexer/resolvers/go.js +171 -0
- package/dist/indexer/resolvers/go.js.map +1 -0
- package/dist/indexer/resolvers/index.d.ts +26 -0
- package/dist/indexer/resolvers/index.d.ts.map +1 -0
- package/dist/indexer/resolvers/index.js +301 -0
- package/dist/indexer/resolvers/index.js.map +1 -0
- package/dist/indexer/resolvers/java.d.ts +17 -0
- package/dist/indexer/resolvers/java.d.ts.map +1 -0
- package/dist/indexer/resolvers/java.js +104 -0
- package/dist/indexer/resolvers/java.js.map +1 -0
- package/dist/indexer/resolvers/php.d.ts +2 -0
- package/dist/indexer/resolvers/php.d.ts.map +1 -0
- package/dist/indexer/resolvers/php.js +108 -0
- package/dist/indexer/resolvers/php.js.map +1 -0
- package/dist/indexer/resolvers/python.d.ts +17 -0
- package/dist/indexer/resolvers/python.d.ts.map +1 -0
- package/dist/indexer/resolvers/python.js +63 -0
- package/dist/indexer/resolvers/python.js.map +1 -0
- package/dist/indexer/resolvers/ruby.d.ts +13 -0
- package/dist/indexer/resolvers/ruby.d.ts.map +1 -0
- package/dist/indexer/resolvers/ruby.js +199 -0
- package/dist/indexer/resolvers/ruby.js.map +1 -0
- package/dist/indexer/resolvers/rust.d.ts +12 -0
- package/dist/indexer/resolvers/rust.d.ts.map +1 -0
- package/dist/indexer/resolvers/rust.js +61 -0
- package/dist/indexer/resolvers/rust.js.map +1 -0
- package/dist/indexer/resolvers/shared.d.ts +18 -0
- package/dist/indexer/resolvers/shared.d.ts.map +1 -0
- package/dist/indexer/resolvers/shared.js +71 -0
- package/dist/indexer/resolvers/shared.js.map +1 -0
- package/dist/indexer/rust-workspace.d.ts +25 -0
- package/dist/indexer/rust-workspace.d.ts.map +1 -0
- package/dist/indexer/rust-workspace.js +155 -0
- package/dist/indexer/rust-workspace.js.map +1 -0
- package/dist/indexer/symbol-edges.d.ts +19 -0
- package/dist/indexer/symbol-edges.d.ts.map +1 -0
- package/dist/indexer/symbol-edges.js +117 -0
- package/dist/indexer/symbol-edges.js.map +1 -0
- package/dist/indexer/tokenize.d.ts +26 -0
- package/dist/indexer/tokenize.d.ts.map +1 -0
- package/dist/indexer/tokenize.js +200 -0
- package/dist/indexer/tokenize.js.map +1 -0
- package/dist/indexer/ts-parser.d.ts +10 -0
- package/dist/indexer/ts-parser.d.ts.map +1 -0
- package/dist/indexer/ts-parser.js +605 -0
- package/dist/indexer/ts-parser.js.map +1 -0
- package/dist/indexer/walker.d.ts +9 -0
- package/dist/indexer/walker.d.ts.map +1 -0
- package/dist/indexer/walker.js +91 -0
- package/dist/indexer/walker.js.map +1 -0
- package/dist/init.d.ts +111 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +787 -0
- package/dist/init.js.map +1 -0
- package/dist/kb/cli.d.ts +2 -0
- package/dist/kb/cli.d.ts.map +1 -0
- package/dist/kb/cli.js +377 -0
- package/dist/kb/cli.js.map +1 -0
- package/dist/kb/commit.d.ts +6 -0
- package/dist/kb/commit.d.ts.map +1 -0
- package/dist/kb/commit.js +84 -0
- package/dist/kb/commit.js.map +1 -0
- package/dist/kb/fold.d.ts +42 -0
- package/dist/kb/fold.d.ts.map +1 -0
- package/dist/kb/fold.js +285 -0
- package/dist/kb/fold.js.map +1 -0
- package/dist/kb/freshness.d.ts +9 -0
- package/dist/kb/freshness.d.ts.map +1 -0
- package/dist/kb/freshness.js +52 -0
- package/dist/kb/freshness.js.map +1 -0
- package/dist/kb/git.d.ts +4 -0
- package/dist/kb/git.d.ts.map +1 -0
- package/dist/kb/git.js +28 -0
- package/dist/kb/git.js.map +1 -0
- package/dist/kb/ids.d.ts +8 -0
- package/dist/kb/ids.d.ts.map +1 -0
- package/dist/kb/ids.js +42 -0
- package/dist/kb/ids.js.map +1 -0
- package/dist/kb/lint.d.ts +23 -0
- package/dist/kb/lint.d.ts.map +1 -0
- package/dist/kb/lint.js +102 -0
- package/dist/kb/lint.js.map +1 -0
- package/dist/kb/lookup.d.ts +55 -0
- package/dist/kb/lookup.d.ts.map +1 -0
- package/dist/kb/lookup.js +92 -0
- package/dist/kb/lookup.js.map +1 -0
- package/dist/kb/notes-index.d.ts +31 -0
- package/dist/kb/notes-index.d.ts.map +1 -0
- package/dist/kb/notes-index.js +0 -0
- package/dist/kb/notes-index.js.map +1 -0
- package/dist/kb/raw-log.d.ts +34 -0
- package/dist/kb/raw-log.d.ts.map +1 -0
- package/dist/kb/raw-log.js +106 -0
- package/dist/kb/raw-log.js.map +1 -0
- package/dist/kb/render.d.ts +17 -0
- package/dist/kb/render.d.ts.map +1 -0
- package/dist/kb/render.js +101 -0
- package/dist/kb/render.js.map +1 -0
- package/dist/kb/search.d.ts +110 -0
- package/dist/kb/search.d.ts.map +1 -0
- package/dist/kb/search.js +453 -0
- package/dist/kb/search.js.map +1 -0
- package/dist/kb/store.d.ts +35 -0
- package/dist/kb/store.d.ts.map +1 -0
- package/dist/kb/store.js +160 -0
- package/dist/kb/store.js.map +1 -0
- package/dist/kb/types.d.ts +142 -0
- package/dist/kb/types.d.ts.map +1 -0
- package/dist/kb/types.js +13 -0
- package/dist/kb/types.js.map +1 -0
- package/dist/kb/view-template.d.ts +8 -0
- package/dist/kb/view-template.d.ts.map +1 -0
- package/dist/kb/view-template.js +8 -0
- package/dist/kb/view-template.js.map +1 -0
- package/dist/kb/view.d.ts +42 -0
- package/dist/kb/view.d.ts.map +1 -0
- package/dist/kb/view.js +118 -0
- package/dist/kb/view.js.map +1 -0
- package/dist/kb/write.d.ts +72 -0
- package/dist/kb/write.d.ts.map +1 -0
- package/dist/kb/write.js +345 -0
- package/dist/kb/write.js.map +1 -0
- package/dist/keeper-state.d.ts +29 -0
- package/dist/keeper-state.d.ts.map +1 -0
- package/dist/keeper-state.js +89 -0
- package/dist/keeper-state.js.map +1 -0
- package/dist/keeper.d.ts +23 -0
- package/dist/keeper.d.ts.map +1 -0
- package/dist/keeper.js +105 -0
- package/dist/keeper.js.map +1 -0
- package/dist/migrate.d.ts +9 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +102 -0
- package/dist/migrate.js.map +1 -0
- package/dist/restart.d.ts +15 -0
- package/dist/restart.d.ts.map +1 -0
- package/dist/restart.js +48 -0
- package/dist/restart.js.map +1 -0
- package/dist/server/find.d.ts +39 -0
- package/dist/server/find.d.ts.map +1 -0
- package/dist/server/find.js +979 -0
- package/dist/server/find.js.map +1 -0
- package/dist/server/mcp.d.ts +117 -0
- package/dist/server/mcp.d.ts.map +1 -0
- package/dist/server/mcp.js +301 -0
- package/dist/server/mcp.js.map +1 -0
- package/dist/server/searcher.d.ts +14 -0
- package/dist/server/searcher.d.ts.map +1 -0
- package/dist/server/searcher.js +146 -0
- package/dist/server/searcher.js.map +1 -0
- package/dist/server/tools.d.ts +17 -0
- package/dist/server/tools.d.ts.map +1 -0
- package/dist/server/tools.js +875 -0
- package/dist/server/tools.js.map +1 -0
- package/dist/status.d.ts +12 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +157 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/watcher.d.ts +11 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +67 -0
- package/dist/watcher.js.map +1 -0
- package/hooks/canonical-find-key.mjs +68 -0
- package/hooks/codex-find-nudge.mjs +15 -0
- package/hooks/codex-find-preguard.mjs +14 -0
- package/hooks/codex-kb-elicit.mjs +329 -0
- package/hooks/codex-kb-recall.mjs +151 -0
- package/hooks/codex-nudge-handler.mjs +370 -0
- package/hooks/codex-preguard-handler.mjs +75 -0
- package/hooks/codex-run-hook.mjs +132 -0
- package/hooks/coldstart-call.mjs +58 -0
- package/hooks/cursor-find-nudge.mjs +15 -0
- package/hooks/cursor-find-preguard.mjs +14 -0
- package/hooks/cursor-input.mjs +72 -0
- package/hooks/cursor-kb-elicit.mjs +335 -0
- package/hooks/cursor-kb-recall.mjs +138 -0
- package/hooks/cursor-nudge-handler.mjs +33 -0
- package/hooks/cursor-preguard-handler.mjs +39 -0
- package/hooks/cursor-run-hook.mjs +129 -0
- package/hooks/find-nudge.mjs +15 -0
- package/hooks/find-preguard.mjs +14 -0
- package/hooks/kb-elicit.mjs +334 -0
- package/hooks/kb-recall.mjs +151 -0
- package/hooks/nudge-handler.mjs +370 -0
- package/hooks/preguard-handler.mjs +75 -0
- package/hooks/run-hook.mjs +132 -0
- package/package.json +69 -0
- package/templates/coldstart.cli.md +60 -0
- package/templates/coldstart.mcp.md +59 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-nudge-handler.mjs — Codex PostToolUse nudge for `find`/`gs`, CLI and MCP.
|
|
3
|
+
*
|
|
4
|
+
* The agent may reach coldstart via the CLI (Bash `coldstart find/gs`) or the MCP
|
|
5
|
+
* tools (`mcp__coldstart__find/gs`). normalizeColdstartCall rewrites an MCP call
|
|
6
|
+
* into the equivalent CLI command string so every detector below runs unchanged on
|
|
7
|
+
* either surface — same logic, only the entry tool name differs.
|
|
8
|
+
*
|
|
9
|
+
* Fires advisory nudges (additionalContext) at the moments the agent's search
|
|
10
|
+
* behaviour goes wrong. Detectors, each fires sparingly:
|
|
11
|
+
*
|
|
12
|
+
* 1. READ-AFTER-2-FINDS — ran `coldstart find` twice with no Read/gs in between
|
|
13
|
+
* => stop searching, open a candidate.
|
|
14
|
+
* 2. EMPTY-SEARCH — a grep/glob/shell-find returned nothing
|
|
15
|
+
* => empty != absent; refine find or read what you have.
|
|
16
|
+
* 3. NONFIND-SHELL-3 — 3 non-find search/shell calls since last find/Read/gs
|
|
17
|
+
* => the spiral; go back to find or read.
|
|
18
|
+
* 3b. NO-NEW-EVIDENCE — a grep/find CONFINED to files already in context
|
|
19
|
+
* => re-checks surfaced data, can't reveal a new file.
|
|
20
|
+
* Recall-safe: any call surfacing a new file is silent.
|
|
21
|
+
* 4. CHECKPOINT — every CHECKPOINT_EVERY tool calls
|
|
22
|
+
* => can you answer from what you've read? name the gap.
|
|
23
|
+
* 5. GS-OVER-SLICE — sliced the SAME file with `gs` >= GS_SLICE_CAP times
|
|
24
|
+
* => you have its bodies + pointers; answer or move on.
|
|
25
|
+
* 6. GS-REGUESS — re-called `gs --symbol` on a file that just returned
|
|
26
|
+
* the method-menu fallback => pick from the menu.
|
|
27
|
+
*
|
|
28
|
+
* Also REGISTERS the canonical key of every SUCCESSFUL (non-empty) find into
|
|
29
|
+
* `seen_find_queries` — the Codex PreToolUse guard denies a re-run of
|
|
30
|
+
* a registered key. The key fn is shared (canonical-find-key.mjs); it MUST match
|
|
31
|
+
* the guard exactly or the deny-key and registration-key drift.
|
|
32
|
+
*
|
|
33
|
+
* State per (session,agent) in /tmp. Fail-open: any error → null (runHook swallows).
|
|
34
|
+
*
|
|
35
|
+
* Ported 1:1 from find-nudge.py. Thresholds below are the same defaults.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
|
|
39
|
+
import { execFileSync } from "node:child_process";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import { canonicalFindKey } from "./canonical-find-key.mjs";
|
|
43
|
+
import { normalizeColdstartCall } from "./coldstart-call.mjs";
|
|
44
|
+
|
|
45
|
+
// hooks/ sits beside dist/ in both the repo and the published package.
|
|
46
|
+
const KB_CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
|
|
47
|
+
|
|
48
|
+
// ---- detector thresholds (tune freely) ----
|
|
49
|
+
const FINDS_BEFORE_READ = 2; // (1) nudge to read after this many finds w/o a Read/gs
|
|
50
|
+
const NONFIND_SHELL_STREAK = 3; // (3) nudge after this many non-find search/shell calls
|
|
51
|
+
const CHECKPOINT_EVERY = 12; // (4) checkpoint nudge cadence, in tool calls
|
|
52
|
+
const GS_SLICE_CAP = 3; // (5) nudge once gs has sliced the SAME file this many times
|
|
53
|
+
const EMPTY_NUDGE_CAP = 2; // (2) max empty-search nudges per session
|
|
54
|
+
const REDUNDANT_CAP = 6; // (3b) max no-new-evidence nudges per session
|
|
55
|
+
|
|
56
|
+
// coldstart find (the GOOD locator)
|
|
57
|
+
const FIND_RE = /coldstart\s+find\b|index\.js\s+find\b/;
|
|
58
|
+
// coldstart gs (the GOOD reader — slices symbol bodies): read-equivalent for the
|
|
59
|
+
// spiral, but with its own abuse modes (over-slice, re-guess after a menu fallback).
|
|
60
|
+
const GS_RE = /coldstart\s+gs\b|index\.js\s+gs\b/;
|
|
61
|
+
// coldstart kb (notebook search/write) — proves the session is coldstart-aware.
|
|
62
|
+
const KB_RE = /coldstart\s+kb\b|index\.js\s+kb\b/;
|
|
63
|
+
// the gs menu-fallback marker (printed when --symbol isn't a declared symbol)
|
|
64
|
+
const GS_FALLBACK_RE = /NOT a declared symbol here|no declared symbol matches/;
|
|
65
|
+
// search/shell that ISN'T coldstart find/gs — the spiral surface
|
|
66
|
+
const SEARCH_RE =
|
|
67
|
+
/(^|[;&|]|\s)(grep|egrep|fgrep|rg)\b|git\s+grep|git\s+log|(^|[;&|]|\s)find\s|(^|[;&|]|\s)ls\b|(^|[;&|]|\s)cat\b/;
|
|
68
|
+
const GS_FILE_RE = /(?:coldstart|index\.js)\s+gs\s+(\S+)/;
|
|
69
|
+
|
|
70
|
+
// Evidence store regexes — paths printed in ANY output, and files named as ARGS.
|
|
71
|
+
const PATH_RE = /[\w./-]+\.(?:py|js|jsx|ts|tsx|htm|html|vue|json|scss|css|rb|java)/g;
|
|
72
|
+
const FILE_ARG_RE =
|
|
73
|
+
/(?<![\w/])([\w][\w./-]*\.(?:py|js|jsx|ts|tsx|htm|html|vue|json|scss|css|rb|java))\b/g;
|
|
74
|
+
|
|
75
|
+
/** Mirror of find-nudge.py result_text: extract the raw text payload of a tool
|
|
76
|
+
* response, collapsing a present-but-empty stdout to "" (NOT to the JSON of the
|
|
77
|
+
* whole envelope, which would hide emptiness). */
|
|
78
|
+
function resultText(input) {
|
|
79
|
+
let r = input.tool_response;
|
|
80
|
+
if (r === undefined) r = input.tool_output;
|
|
81
|
+
if (r === undefined || r === null) return null;
|
|
82
|
+
if (typeof r === "string") return r;
|
|
83
|
+
if (Array.isArray(r)) {
|
|
84
|
+
return r
|
|
85
|
+
.map((x) => (x && typeof x === "object" ? x.text || "" : String(x)))
|
|
86
|
+
.join(" ");
|
|
87
|
+
}
|
|
88
|
+
if (typeof r === "object") {
|
|
89
|
+
const KEYS = ["stdout", "stderr", "content", "output", "result"];
|
|
90
|
+
if (KEYS.some((k) => k in r)) {
|
|
91
|
+
return KEYS.map((k) => String(r[k] ?? "")).join("");
|
|
92
|
+
}
|
|
93
|
+
return JSON.stringify(r);
|
|
94
|
+
}
|
|
95
|
+
return String(r);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function matchAllGroup(re, s, group) {
|
|
99
|
+
const found = new Set();
|
|
100
|
+
if (!s) return found;
|
|
101
|
+
for (const m of s.matchAll(re)) found.add(group ? m[group] : m[0]);
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The pattern the agent is grepping for — first quoted string or bare arg
|
|
106
|
+
* after the grep/rg invocation. Empty when nothing sensible is extractable. */
|
|
107
|
+
function grepPattern(cmd) {
|
|
108
|
+
const m = String(cmd || "").match(
|
|
109
|
+
/(?:^|[;&|]\s*|\s)(?:grep|egrep|fgrep|rg|git\s+grep)\s+(?:-{1,2}[\w=-]+\s+)*(?:"([^"]+)"|'([^']+)'|([^\s'"|;&-]\S*))/,
|
|
110
|
+
);
|
|
111
|
+
return m ? (m[1] || m[2] || m[3] || "").replace(/[\\^$.*+?()[\]{}]/g, " ").trim() : "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Hit-gated notebook probe for the spiral nudge: ONE hook-mode kb search on
|
|
115
|
+
* what the agent is grepping for. Returns the top note's title, or null —
|
|
116
|
+
* and the nudge only mentions the notebook when a note actually exists
|
|
117
|
+
* (a generic "try the notebook" would be boilerplate steering; a hit is a
|
|
118
|
+
* report of fact). Fail-open and skipped entirely when there's no notebook. */
|
|
119
|
+
function kbSpiralHit(root, pattern) {
|
|
120
|
+
try {
|
|
121
|
+
if (!pattern || !root || !existsSync(join(root, ".coldstart", "notebook", ".raw"))) return null;
|
|
122
|
+
const page = execFileSync(
|
|
123
|
+
"node", [KB_CLI, "kb", "search", "--hook", "--max", "1", "--root", root, pattern],
|
|
124
|
+
{ encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] },
|
|
125
|
+
);
|
|
126
|
+
const m = page.match(/^- \*\*(.+?)\*\*/m);
|
|
127
|
+
return m ? m[1] : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {any} input parsed PostToolUse stdin payload
|
|
135
|
+
* @returns {object|null} an additionalContext envelope, or null for no nudge
|
|
136
|
+
*/
|
|
137
|
+
export default function handle(input) {
|
|
138
|
+
const sid = input.session_id || "default";
|
|
139
|
+
const aid = input.agent_id || ""; // set when inside a subagent
|
|
140
|
+
const tin = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
141
|
+
// Collapse CLI and MCP coldstart calls to one shape: an MCP find/gs becomes a
|
|
142
|
+
// synthetic `coldstart find/gs ...` Bash command so the detectors run unchanged.
|
|
143
|
+
const { tool, cmd } = normalizeColdstartCall(input.tool_name || "", tin);
|
|
144
|
+
const out = resultText(input);
|
|
145
|
+
|
|
146
|
+
const key = aid ? `${sid}_${aid}` : sid;
|
|
147
|
+
const stateFile = `/tmp/find_nudge_${key}.json`;
|
|
148
|
+
const st = {
|
|
149
|
+
seen_find: false,
|
|
150
|
+
total: 0,
|
|
151
|
+
finds_since_read: 0,
|
|
152
|
+
nonfind_streak: 0,
|
|
153
|
+
empty_fired: 0,
|
|
154
|
+
last_checkpoint: 0,
|
|
155
|
+
read_fired: false,
|
|
156
|
+
shell_fired: false,
|
|
157
|
+
gs_counts: {},
|
|
158
|
+
gs_slice_fired: [],
|
|
159
|
+
last_gs_fallback: "",
|
|
160
|
+
gs_reguess_fired: false,
|
|
161
|
+
held_files: [],
|
|
162
|
+
redundant_fired: 0,
|
|
163
|
+
seen_find_queries: [],
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
Object.assign(st, JSON.parse(readFileSync(stateFile, "utf8")));
|
|
167
|
+
} catch {
|
|
168
|
+
/* defaults */
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// classify this call
|
|
172
|
+
const isFind = tool === "Bash" && FIND_RE.test(cmd);
|
|
173
|
+
const isGs = tool === "Bash" && GS_RE.test(cmd);
|
|
174
|
+
const isKb = tool === "Bash" && KB_RE.test(cmd);
|
|
175
|
+
let gsFile = "";
|
|
176
|
+
if (isGs) {
|
|
177
|
+
const gm = GS_FILE_RE.exec(cmd);
|
|
178
|
+
gsFile = gm ? gm[1] : "";
|
|
179
|
+
}
|
|
180
|
+
const prevFallback = st.last_gs_fallback || ""; // set by the PREVIOUS gs call's output
|
|
181
|
+
const isRead = tool === "Read";
|
|
182
|
+
// gs/find/kb are tools, not the grep-spiral, even when piped to head/grep
|
|
183
|
+
const isSearch =
|
|
184
|
+
tool === "Grep" ||
|
|
185
|
+
tool === "Glob" ||
|
|
186
|
+
(tool === "Bash" && !isFind && !isGs && !isKb && SEARCH_RE.test(cmd));
|
|
187
|
+
const isNonfindShell = isSearch;
|
|
188
|
+
|
|
189
|
+
const _ob = out !== null && out !== undefined ? out.trim().toLowerCase() : null;
|
|
190
|
+
const outEmpty =
|
|
191
|
+
_ob !== null &&
|
|
192
|
+
(_ob === "" ||
|
|
193
|
+
_ob.includes("completed with no output") ||
|
|
194
|
+
_ob === "no matches found" ||
|
|
195
|
+
_ob === "no files found");
|
|
196
|
+
|
|
197
|
+
st.total += 1;
|
|
198
|
+
// seen_find gates the spiral detectors (3/3b): they stay silent for sessions that
|
|
199
|
+
// never touch coldstart at all (deliberate — this hook must not nag non-users).
|
|
200
|
+
// Any coldstart surface proves awareness: find (set below on the find branch),
|
|
201
|
+
// gs, a kb call, or a kb-recall injection (kb-recall.mjs pre-seeds this flag) —
|
|
202
|
+
// a note-implanted session may legitimately skip `find` because the note handed
|
|
203
|
+
// it the files, and it still needs the anti-grep-spiral coverage (the q23 case).
|
|
204
|
+
if (isGs || isKb) st.seen_find = true;
|
|
205
|
+
const msgs = []; // [priority, text]; lower priority number = more urgent, wins
|
|
206
|
+
|
|
207
|
+
// --- evidence: what files THIS call touched, and which are genuinely NEW ---
|
|
208
|
+
const held = new Set(st.held_files || []);
|
|
209
|
+
const outFiles = matchAllGroup(PATH_RE, out, 0);
|
|
210
|
+
const cmdFiles = matchAllGroup(FILE_ARG_RE, cmd, 1);
|
|
211
|
+
if (isRead) {
|
|
212
|
+
const rp = typeof tin.file_path === "string" ? tin.file_path : "";
|
|
213
|
+
if (rp) cmdFiles.add(rp);
|
|
214
|
+
}
|
|
215
|
+
const scope = new Set([...outFiles, ...cmdFiles]);
|
|
216
|
+
const novel = [...scope].filter((f) => !held.has(f)); // files newly in context
|
|
217
|
+
|
|
218
|
+
if (isFind) {
|
|
219
|
+
st.seen_find = true;
|
|
220
|
+
st.finds_since_read += 1;
|
|
221
|
+
st.nonfind_streak = 0;
|
|
222
|
+
st.shell_fired = false;
|
|
223
|
+
// register the canonical key ONLY on a successful (non-empty) result, so the
|
|
224
|
+
// PreToolUse guard never blocks a retry of a find that errored/returned nothing.
|
|
225
|
+
if (out && !outEmpty) {
|
|
226
|
+
const ck = canonicalFindKey(cmd);
|
|
227
|
+
if (ck && !st.seen_find_queries.includes(ck)) {
|
|
228
|
+
st.seen_find_queries = st.seen_find_queries.concat([ck]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (st.finds_since_read >= FINDS_BEFORE_READ && !st.read_fired) {
|
|
232
|
+
st.read_fired = true;
|
|
233
|
+
msgs.push([
|
|
234
|
+
2,
|
|
235
|
+
`You've run \`coldstart find\` ${st.finds_since_read}× without opening a file. ` +
|
|
236
|
+
"The page already ranks candidates and shows symbol + body lines WITH line numbers — " +
|
|
237
|
+
"stop searching and READ the 1–2 most promising now. Refine the query only if a file's " +
|
|
238
|
+
"actual contents send you elsewhere.",
|
|
239
|
+
]);
|
|
240
|
+
}
|
|
241
|
+
} else if (isRead) {
|
|
242
|
+
st.finds_since_read = 0;
|
|
243
|
+
st.nonfind_streak = 0;
|
|
244
|
+
st.read_fired = false;
|
|
245
|
+
st.shell_fired = false;
|
|
246
|
+
} else if (isGs) {
|
|
247
|
+
// gs opens file content — credit it like a Read for the spiral/find counters.
|
|
248
|
+
st.finds_since_read = 0;
|
|
249
|
+
st.nonfind_streak = 0;
|
|
250
|
+
st.read_fired = false;
|
|
251
|
+
st.shell_fired = false;
|
|
252
|
+
const counts = st.gs_counts || {};
|
|
253
|
+
counts[gsFile] = (counts[gsFile] || 0) + 1;
|
|
254
|
+
st.gs_counts = counts;
|
|
255
|
+
const fired = st.gs_slice_fired || [];
|
|
256
|
+
// (6) GS-REGUESS — the previous gs on THIS file returned the method menu, and
|
|
257
|
+
// the agent is slicing it again instead of picking a name from that menu.
|
|
258
|
+
if (gsFile && prevFallback === gsFile && !st.gs_reguess_fired) {
|
|
259
|
+
st.gs_reguess_fired = true;
|
|
260
|
+
msgs.push([
|
|
261
|
+
2,
|
|
262
|
+
`\`${gsFile}\` just returned its method MENU — the \`--symbol\` name you passed isn't declared ` +
|
|
263
|
+
"there. Pick a name from that menu, or `Read` the file. Re-calling `gs --symbol` with another " +
|
|
264
|
+
"guessed name on the same file is the single most common wasted call — you already have the menu.",
|
|
265
|
+
]);
|
|
266
|
+
} else if (gsFile && counts[gsFile] >= GS_SLICE_CAP && !fired.includes(gsFile)) {
|
|
267
|
+
// (5) GS-OVER-SLICE — sliced one file enough times; you have it.
|
|
268
|
+
fired.push(gsFile);
|
|
269
|
+
st.gs_slice_fired = fired;
|
|
270
|
+
msgs.push([
|
|
271
|
+
2,
|
|
272
|
+
`You've sliced \`${gsFile}\` ${counts[gsFile]}× with \`gs\` — you have its bodies plus the ` +
|
|
273
|
+
"`calls:`/`callers:` pointers, so you almost certainly have enough of THIS file. Answer from " +
|
|
274
|
+
"what you have, `Read` it whole if you need lines BETWEEN symbols, or follow a pointer to a " +
|
|
275
|
+
"DIFFERENT file. Stop re-slicing this one.",
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
} else if (isNonfindShell && st.seen_find) {
|
|
279
|
+
st.nonfind_streak += 1;
|
|
280
|
+
// (3b) NO-NEW-EVIDENCE — per-call: this grep/find is confined to files the
|
|
281
|
+
// agent ALREADY has in context, so it surfaced nothing new.
|
|
282
|
+
const confined = scope.size > 0 && novel.length === 0 && !outEmpty;
|
|
283
|
+
if (confined && (st.redundant_fired || 0) < REDUNDANT_CAP) {
|
|
284
|
+
st.redundant_fired = (st.redundant_fired || 0) + 1;
|
|
285
|
+
const heldHits = [...scope].sort().slice(0, 3);
|
|
286
|
+
const names = heldHits.map((f) => "`" + f + "`").join(", ");
|
|
287
|
+
msgs.push([
|
|
288
|
+
1,
|
|
289
|
+
`This search only touched files you ALREADY have in context — ${names}. You surfaced or read ` +
|
|
290
|
+
"them earlier, so re-grepping them returns lines you already hold; it cannot reveal a new file. " +
|
|
291
|
+
"The answer is in the results already in your context — RE-READ those (the find/gs page, the " +
|
|
292
|
+
"files you opened) and answer the task now. Only search if you can name a genuinely NEW file, " +
|
|
293
|
+
"identifier, or directory you have not yet covered.",
|
|
294
|
+
]);
|
|
295
|
+
} else if (st.nonfind_streak >= NONFIND_SHELL_STREAK && !st.shell_fired) {
|
|
296
|
+
// (3) generic spiral fallback — non-find streak, but nothing was re-checked
|
|
297
|
+
st.shell_fired = true;
|
|
298
|
+
// Hit-gated notebook pointer: if the notebook already has a note on what
|
|
299
|
+
// the agent is hunting, say so — that note may END the hunt.
|
|
300
|
+
const noteTitle = kbSpiralHit(input.cwd, grepPattern(cmd));
|
|
301
|
+
msgs.push([
|
|
302
|
+
3,
|
|
303
|
+
`${st.nonfind_streak} non-find search/shell calls since your last \`coldstart find\`/Read — ` +
|
|
304
|
+
"this is the spiral. `coldstart find` already body-scans the top files and ranks on filenames " +
|
|
305
|
+
"and symbols, not just body text. Add the missing term and re-run it, or Read a candidate you " +
|
|
306
|
+
"already have. Reserve grep for a literal body string you KNOW exists." +
|
|
307
|
+
(noteTitle
|
|
308
|
+
? ` Also: the repo notebook has a note matching what you're grepping for — "${noteTitle}". ` +
|
|
309
|
+
`Fetch it first: \`coldstart kb search ${noteTitle.split(/\s+/).slice(0, 5).join(" ")}\`.`
|
|
310
|
+
: ""),
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// (2) empty search result — independent, capped
|
|
316
|
+
if (isSearch && outEmpty && st.empty_fired < EMPTY_NUDGE_CAP) {
|
|
317
|
+
st.empty_fired += 1;
|
|
318
|
+
msgs.push([
|
|
319
|
+
4,
|
|
320
|
+
"That search returned nothing. An empty result only tells you the term doesn't appear here — it is " +
|
|
321
|
+
"not evidence the thing is absent. If you're grepping a specific FILE for an identifier, run " +
|
|
322
|
+
"`coldstart gs <file> --symbol <token>` instead: it returns the body lines where the token appears " +
|
|
323
|
+
"(it greps in-tool), so you don't have to guess at spelling.",
|
|
324
|
+
]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// record whether THIS gs call hit the menu fallback, so the NEXT call can detect a re-guess
|
|
328
|
+
if (isGs) {
|
|
329
|
+
st.last_gs_fallback = out && GS_FALLBACK_RE.test(out) ? gsFile : "";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// (4) checkpoint — periodic, lowest urgency
|
|
333
|
+
if (st.total - st.last_checkpoint >= CHECKPOINT_EVERY) {
|
|
334
|
+
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
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// merge THIS call's evidence into the store (after novelty was computed above)
|
|
348
|
+
if (scope.size > 0) {
|
|
349
|
+
st.held_files = [...new Set([...held, ...scope])];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const tmp = stateFile + ".tmp";
|
|
354
|
+
writeFileSync(tmp, JSON.stringify(st));
|
|
355
|
+
renameSync(tmp, stateFile);
|
|
356
|
+
} catch {
|
|
357
|
+
/* never fail on state write */
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (msgs.length) {
|
|
361
|
+
msgs.sort((a, b) => a[0] - b[0]);
|
|
362
|
+
return {
|
|
363
|
+
hookSpecificOutput: {
|
|
364
|
+
hookEventName: "PostToolUse",
|
|
365
|
+
additionalContext: msgs[0][1],
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-preguard-handler.mjs — Codex PreToolUse guard for `coldstart find`.
|
|
3
|
+
*
|
|
4
|
+
* `coldstart find` is a PURE function of its term-SET: reordering terms, changing
|
|
5
|
+
* case, or repeating a term yields byte-identical output (every ranking stage is
|
|
6
|
+
* set/sum based, sort is deterministic by score then unique path). So an exact
|
|
7
|
+
* re-query is PROVABLY redundant on a static index: its result is already in the
|
|
8
|
+
* agent's context. This handler canonicalizes a PROPOSED find (lowercase → dedup
|
|
9
|
+
* → SORT terms, + significant flags) and, if that canonical key was already run
|
|
10
|
+
* SUCCESSFULLY this session, DENIES the call before it costs a generation.
|
|
11
|
+
*
|
|
12
|
+
* Safety:
|
|
13
|
+
* - Only EXACT term-set+flag matches are blocked. Add/drop a term, or change
|
|
14
|
+
* --path/--tests, and it's a different query that legitimately differs → allowed.
|
|
15
|
+
* - Registration happens in the PostToolUse hook (find-nudge) and ONLY on a
|
|
16
|
+
* successful, non-empty result — so retrying a FAILED/empty find is never blocked.
|
|
17
|
+
* - Flags are folded into the key, so a scoped re-query (`--path ...`) never
|
|
18
|
+
* collides with the unscoped one. Bias is toward specificity: a missed dup just
|
|
19
|
+
* falls back to the PostToolUse late-catch; a false block is the error we avoid.
|
|
20
|
+
* - Fail-open: any error → return null and the call proceeds (runHook swallows).
|
|
21
|
+
*
|
|
22
|
+
* State is shared with find-nudge: /tmp/find_nudge_{session}_{agent}.json,
|
|
23
|
+
* list key `seen_find_queries`.
|
|
24
|
+
*
|
|
25
|
+
* Ported 1:1 from find-preguard.py.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import { canonicalFindKey } from "./canonical-find-key.mjs";
|
|
30
|
+
import { normalizeColdstartCall } from "./coldstart-call.mjs";
|
|
31
|
+
|
|
32
|
+
const REASON =
|
|
33
|
+
"You have ALREADY run this exact `coldstart find` earlier in this session — same " +
|
|
34
|
+
"terms (order/case/repeats don't change the result), same scope. `find` is deterministic " +
|
|
35
|
+
"on a static index, so re-running it returns the IDENTICAL ranked page you already have in " +
|
|
36
|
+
"context. Re-read that earlier find result and answer, or search a GENUINELY different " +
|
|
37
|
+
"term-set (add/drop a salient identifier, or add `--path` to scope it). Do not re-run the " +
|
|
38
|
+
"same query.";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {any} input parsed PreToolUse stdin payload
|
|
42
|
+
* @returns {object|null} a permissionDecision:"deny" envelope, or null to allow
|
|
43
|
+
*/
|
|
44
|
+
export default function handle(input) {
|
|
45
|
+
const tin = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
|
|
46
|
+
// Normalize so a `mcp__coldstart__find` call is deduped against a CLI `coldstart
|
|
47
|
+
// find` (and vice-versa) using the same canonical key. Non-coldstart Bash falls
|
|
48
|
+
// through to canonicalFindKey, which returns null for anything that isn't a find.
|
|
49
|
+
const { tool, cmd } = normalizeColdstartCall(input.tool_name || "", tin);
|
|
50
|
+
if (tool !== "Bash") return null;
|
|
51
|
+
const key = canonicalFindKey(cmd);
|
|
52
|
+
if (!key) return null;
|
|
53
|
+
|
|
54
|
+
const sid = input.session_id || "default";
|
|
55
|
+
const aid = input.agent_id || "";
|
|
56
|
+
const skey = aid ? `${sid}_${aid}` : sid;
|
|
57
|
+
const stateFile = `/tmp/find_nudge_${skey}.json`;
|
|
58
|
+
|
|
59
|
+
let st = {};
|
|
60
|
+
try {
|
|
61
|
+
st = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
st = {};
|
|
64
|
+
}
|
|
65
|
+
const seen = Array.isArray(st.seen_find_queries) ? st.seen_find_queries : [];
|
|
66
|
+
if (!seen.includes(key)) return null; // never run before → allow
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
hookSpecificOutput: {
|
|
70
|
+
hookEventName: "PreToolUse",
|
|
71
|
+
permissionDecision: "deny",
|
|
72
|
+
permissionDecisionReason: REASON,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* codex-run-hook.mjs — crash-resilient wrapper for coldstart Codex hook entries.
|
|
4
|
+
*
|
|
5
|
+
* Adapted from context-mode's run-hook.mjs (MIT). The whole point: a coldstart
|
|
6
|
+
* hook must NEVER block a tool call or spam the user, no matter what breaks —
|
|
7
|
+
* a parse error, a missing dep, a malformed stdin payload, a thrown handler.
|
|
8
|
+
* Codex surfaces a non-zero hook exit as a hook error. A single bad deploy can
|
|
9
|
+
* therefore nag the user on every matching tool call.
|
|
10
|
+
* This wrapper guarantees exit 0 and fail-open (no output = no decision = the
|
|
11
|
+
* tool proceeds normally) on any failure.
|
|
12
|
+
*
|
|
13
|
+
* Three layers of protection:
|
|
14
|
+
* 1. This file imports ONLY node: built-ins, so it cannot fail at parse time.
|
|
15
|
+
* 2. process-level uncaughtException / unhandledRejection nets, installed
|
|
16
|
+
* before any handler runs, catch async throws the try/catch would miss.
|
|
17
|
+
* 3. The handler is invoked inside a try; any throw is logged and we exit 0.
|
|
18
|
+
* Load your real handler via dynamic import INSIDE the handler thunk so its
|
|
19
|
+
* own parse-time import errors are caught here too (see usage below).
|
|
20
|
+
*
|
|
21
|
+
* Failures log to <configDir>/coldstart/hook-errors.log, where configDir honors
|
|
22
|
+
* $CODEX_HOME (incl. a leading ~) and falls back to ~/.codex.
|
|
23
|
+
*
|
|
24
|
+
* Usage — keep the entry file's top-level imports to node: built-ins only:
|
|
25
|
+
* #!/usr/bin/env node
|
|
26
|
+
* import { runHook } from "./codex-run-hook.mjs";
|
|
27
|
+
* await runHook(async (input) => {
|
|
28
|
+
* const { default: handle } = await import("./codex-preguard-handler.mjs");
|
|
29
|
+
* return handle(input); // return an object → printed as JSON to stdout
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* The handler receives the parsed stdin payload (or {} if stdin was empty/bad)
|
|
33
|
+
* and may return:
|
|
34
|
+
* - undefined / null → no stdout (fail-open: tool proceeds)
|
|
35
|
+
* - a string → written to stdout verbatim
|
|
36
|
+
* - an object → JSON.stringify'd to stdout (e.g. a PreToolUse
|
|
37
|
+
* permissionDecision, or PostToolUse additionalContext)
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { resolve, join } from "node:path";
|
|
42
|
+
import { existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
43
|
+
|
|
44
|
+
// Inlined so this wrapper stays dependency-free (parse-time-proof).
|
|
45
|
+
function resolveCodexConfigDir() {
|
|
46
|
+
const envVal = process.env.CODEX_HOME;
|
|
47
|
+
if (envVal) {
|
|
48
|
+
if (envVal.startsWith("~")) return join(homedir(), envVal.replace(/^~[/\\]?/, ""));
|
|
49
|
+
return envVal;
|
|
50
|
+
}
|
|
51
|
+
return resolve(homedir(), ".codex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function logError(err) {
|
|
55
|
+
try {
|
|
56
|
+
const dir = resolve(resolveCodexConfigDir(), "coldstart");
|
|
57
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
58
|
+
const line = `[${new Date().toISOString()}] pid=${process.pid} ${err?.stack || err?.message || String(err)}\n`;
|
|
59
|
+
appendFileSync(resolve(dir, "hook-errors.log"), line);
|
|
60
|
+
} catch {
|
|
61
|
+
/* never fail logging */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Safety nets BEFORE any handler code runs. Static top-level imports in the
|
|
66
|
+
// ENTRY file would bypass these, which is why the entry must dynamic-import its
|
|
67
|
+
// real handler from inside the thunk.
|
|
68
|
+
process.on("uncaughtException", (err) => {
|
|
69
|
+
logError(err);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
});
|
|
72
|
+
process.on("unhandledRejection", (err) => {
|
|
73
|
+
logError(err);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Read all of stdin. Resolves to "" if stdin is closed/empty. */
|
|
78
|
+
function readStdin() {
|
|
79
|
+
return new Promise((res) => {
|
|
80
|
+
let data = "";
|
|
81
|
+
let settled = false;
|
|
82
|
+
const done = () => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
res(data);
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
if (process.stdin.isTTY) return done();
|
|
89
|
+
process.stdin.setEncoding("utf8");
|
|
90
|
+
process.stdin.on("data", (c) => (data += c));
|
|
91
|
+
process.stdin.on("end", done);
|
|
92
|
+
process.stdin.on("error", done);
|
|
93
|
+
// Hard ceiling so a wedged pipe can never hang the tool call.
|
|
94
|
+
setTimeout(done, 2000).unref?.();
|
|
95
|
+
} catch {
|
|
96
|
+
done();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run a hook handler with full crash-resilience. Reads + parses stdin, invokes
|
|
103
|
+
* the handler, prints its return value, and ALWAYS exits 0.
|
|
104
|
+
*
|
|
105
|
+
* @param {(input: any) => Promise<unknown> | unknown} handler
|
|
106
|
+
*/
|
|
107
|
+
export async function runHook(handler) {
|
|
108
|
+
let input = {};
|
|
109
|
+
try {
|
|
110
|
+
const raw = await readStdin();
|
|
111
|
+
if (raw && raw.trim()) input = JSON.parse(raw);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
// Malformed/absent payload is non-fatal — hand the handler {} and let it
|
|
114
|
+
// decide. Most handlers no-op without the fields they need.
|
|
115
|
+
logError(e);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const out = await handler(input);
|
|
120
|
+
if (out == null) {
|
|
121
|
+
// fail-open / no-op: emit nothing, tool proceeds normally.
|
|
122
|
+
} else if (typeof out === "string") {
|
|
123
|
+
process.stdout.write(out);
|
|
124
|
+
} else {
|
|
125
|
+
process.stdout.write(JSON.stringify(out));
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
logError(e);
|
|
129
|
+
// Swallow: no stdout means no hook decision → the tool is not blocked.
|
|
130
|
+
}
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coldstart-call.mjs — normalize a tool call into the shape the detectors expect.
|
|
3
|
+
*
|
|
4
|
+
* coldstart is reachable two ways with IDENTICAL semantics:
|
|
5
|
+
* - CLI : Bash `coldstart find <terms>` / `coldstart gs <file> --symbol <s>`
|
|
6
|
+
* - MCP : tools `mcp__coldstart__find {query}` / `mcp__coldstart__gs {file_path,...}`
|
|
7
|
+
*
|
|
8
|
+
* The nudge/preguard detectors were written against the CLI surface: they key on
|
|
9
|
+
* `tool === "Bash"` and run regexes over the command STRING. Rather than fork the
|
|
10
|
+
* detector logic per surface, this normalizer rewrites an MCP coldstart call into
|
|
11
|
+
* the equivalent CLI command string and reports it as a Bash call. Every downstream
|
|
12
|
+
* regex (FIND_RE/GS_RE/GS_FILE_RE/SEARCH_RE) and the canonical-find-key then run
|
|
13
|
+
* UNCHANGED — same logic, regardless of which surface the agent used.
|
|
14
|
+
*
|
|
15
|
+
* Non-coldstart calls pass through untouched (tool name + `tool_input.command`).
|
|
16
|
+
*
|
|
17
|
+
* The MCP tool name is `mcp__<serverKey>__<tool>`; the benchmark wires coldstart
|
|
18
|
+
* under the server key `coldstart` (see the arm's `.mcp.json`). If you key it
|
|
19
|
+
* differently, change MCP_SERVER_KEY below — that is the only surface-specific knob.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MCP_SERVER_KEY = "coldstart";
|
|
23
|
+
const MCP_FIND = `mcp__${MCP_SERVER_KEY}__find`;
|
|
24
|
+
const MCP_GS = `mcp__${MCP_SERVER_KEY}__gs`;
|
|
25
|
+
|
|
26
|
+
function str(v) {
|
|
27
|
+
return typeof v === "string" ? v : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} toolName input.tool_name
|
|
32
|
+
* @param {any} toolInput input.tool_input (already coerced to {} if missing)
|
|
33
|
+
* @returns {{ tool: string, cmd: string }}
|
|
34
|
+
* For an MCP coldstart find/gs call: { tool: "Bash", cmd: "<synthesized CLI command>" }.
|
|
35
|
+
* For anything else: { tool: <toolName>, cmd: <tool_input.command or ""> }.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeColdstartCall(toolName, toolInput) {
|
|
38
|
+
const tin = toolInput && typeof toolInput === "object" ? toolInput : {};
|
|
39
|
+
|
|
40
|
+
if (toolName === MCP_FIND) {
|
|
41
|
+
// find accepts `query` (alias `domain_filter`); `path` scopes it (folded into the dedup key).
|
|
42
|
+
const query = str(tin.query) || str(tin.domain_filter);
|
|
43
|
+
const path = str(tin.path) ? ` --path ${str(tin.path)}` : "";
|
|
44
|
+
return { tool: "Bash", cmd: `coldstart find ${query}${path}`.trim() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (toolName === MCP_GS) {
|
|
48
|
+
// gs needs the file as the first token after `gs` (GS_FILE_RE), plus the flags
|
|
49
|
+
// that the detectors look at (--symbol drives the slice/re-guess detectors).
|
|
50
|
+
const file = str(tin.file_path) || str(tin.file) || str(tin.file_name);
|
|
51
|
+
const symbol = str(tin.symbol) ? ` --symbol ${str(tin.symbol)}` : "";
|
|
52
|
+
const match = str(tin.match) ? ` --match ${str(tin.match)}` : "";
|
|
53
|
+
const view = str(tin.view) ? ` --view ${str(tin.view)}` : "";
|
|
54
|
+
return { tool: "Bash", cmd: `coldstart gs ${file}${symbol}${match}${view}`.trim() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { tool: toolName || "", cmd: str(tin.command) };
|
|
58
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cursor-find-nudge.mjs — Cursor postToolUse entry. Emits search nudges + registers
|
|
4
|
+
* successful find keys for the preToolUse guard.
|
|
5
|
+
* Wire as: node <abs>/cursor-find-nudge.mjs (hook: postToolUse)
|
|
6
|
+
*
|
|
7
|
+
* Top-level imports stay node:-builtin-only (via run-hook); the real handler is
|
|
8
|
+
* dynamic-imported inside the thunk so its parse-time errors are caught + fail-open.
|
|
9
|
+
*/
|
|
10
|
+
import { runHook } from "./cursor-run-hook.mjs";
|
|
11
|
+
|
|
12
|
+
await runHook(async (input) => {
|
|
13
|
+
const { default: handle } = await import("./cursor-nudge-handler.mjs");
|
|
14
|
+
return handle(input);
|
|
15
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cursor-find-preguard.mjs — Cursor preToolUse entry for exact find re-runs.
|
|
4
|
+
* Wire as: node <abs>/cursor-find-preguard.mjs (hook: preToolUse)
|
|
5
|
+
*
|
|
6
|
+
* Top-level imports stay node:-builtin-only (via run-hook); the real handler is
|
|
7
|
+
* dynamic-imported inside the thunk so its parse-time errors are caught + fail-open.
|
|
8
|
+
*/
|
|
9
|
+
import { runHook } from "./cursor-run-hook.mjs";
|
|
10
|
+
|
|
11
|
+
await runHook(async (input) => {
|
|
12
|
+
const { default: handle } = await import("./cursor-preguard-handler.mjs");
|
|
13
|
+
return handle(input);
|
|
14
|
+
});
|