portawhip 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/adapters/hooks/universal-hook.mjs +348 -348
  4. package/adapters/instructions/generate.mjs +114 -114
  5. package/core/registry/capability-docs.mjs +145 -145
  6. package/core/registry/capability-graph-compiler.mjs +90 -90
  7. package/core/registry/capability-graph.mjs +53 -53
  8. package/core/registry/capability-kind.mjs +9 -9
  9. package/core/registry/cli-enrich.mjs +316 -316
  10. package/core/registry/cli-enrich.test.mjs +119 -119
  11. package/core/registry/discover.mjs +379 -379
  12. package/core/registry/enrich.mjs +213 -213
  13. package/core/registry/enrich.test.mjs +53 -53
  14. package/core/registry/eval-harvest.mjs +93 -93
  15. package/core/registry/eval-harvest.test.mjs +64 -64
  16. package/core/registry/registry.mjs +197 -197
  17. package/core/router/concept-vector.mjs +91 -91
  18. package/core/router/concept-vector.test.mjs +36 -36
  19. package/core/router/dense-embedder.mjs +242 -242
  20. package/core/router/fusion.mjs +18 -18
  21. package/core/router/hybrid-router.mjs +323 -323
  22. package/core/router/intent-evidence.mjs +60 -60
  23. package/core/router/prompt-hygiene.mjs +31 -31
  24. package/core/router/route-entry.mjs +74 -74
  25. package/core/router/router-cli.mjs +199 -199
  26. package/core/router/router-eval.mjs +166 -166
  27. package/core/router/router-live.test.mjs +69 -69
  28. package/core/router/router.test.mjs +838 -838
  29. package/core/router/scorer.mjs +72 -72
  30. package/core/router/sparse-retriever.mjs +79 -79
  31. package/core/router/tokenize.mjs +42 -42
  32. package/core/state/bundle-state.mjs +117 -117
  33. package/core/state/bundle-state.test.mjs +175 -175
  34. package/core/state/config.mjs +115 -115
  35. package/core/state/feedback.mjs +129 -129
  36. package/core/state/feedback.test.mjs +181 -181
  37. package/core/state/stack-detect.mjs +102 -102
  38. package/core/state/stack-detect.test.mjs +64 -64
  39. package/core/surface/config-sync-backends.mjs +224 -224
  40. package/core/surface/connector-targets.mjs +205 -205
  41. package/core/surface/discover-hooks.mjs +151 -151
  42. package/core/surface/discover-hooks.test.mjs +63 -63
  43. package/core/surface/discover-surface.test.mjs +49 -49
  44. package/core/surface/extra-hosts.mjs +48 -48
  45. package/core/surface/extra-hosts.test.mjs +24 -24
  46. package/core/surface/hook-targets.mjs +102 -102
  47. package/core/surface/surface-copy-targets.mjs +37 -37
  48. package/core/surface/surface-inventory.mjs +98 -98
  49. package/core/surface/surface-matrix.mjs +133 -133
  50. package/core/surface/surface-matrix.test.mjs +90 -90
  51. package/docs/router-eval-set.jsonl +15 -15
  52. package/hooks.manifest.yaml +16 -16
  53. package/package.json +49 -49
  54. package/recipe.yaml +247 -247
  55. package/router.config.yaml +120 -120
  56. package/scripts/bundles.mjs +131 -131
  57. package/scripts/doctor.mjs +117 -117
  58. package/scripts/embedded-hooks.mjs +27 -27
  59. package/scripts/hosts.mjs +60 -60
  60. package/scripts/link/link-connectors.mjs +190 -190
  61. package/scripts/link/link-connectors.test.mjs +111 -111
  62. package/scripts/link/link-hooks.mjs +259 -259
  63. package/scripts/link/link-hooks.test.mjs +112 -112
  64. package/scripts/link/link-surfaces.mjs +188 -188
  65. package/scripts/link/link-surfaces.test.mjs +76 -76
  66. package/scripts/load.mjs +143 -143
  67. package/scripts/surface-inventory.mjs +6 -6
  68. package/scripts/sync/agents-surface.mjs +55 -55
  69. package/scripts/sync/agents-surface.test.mjs +18 -18
  70. package/scripts/sync/auto-sync.mjs +135 -135
  71. package/scripts/sync/auto-sync.test.mjs +41 -41
  72. package/scripts/sync/import-surfaces.mjs +331 -331
  73. package/scripts/sync/import-surfaces.test.mjs +106 -106
  74. package/scripts/sync/sync-config.mjs +230 -230
  75. package/scripts/sync/sync-config.test.mjs +141 -141
  76. package/scripts/sync/sync-surfaces.mjs +229 -229
  77. package/scripts/sync/sync-surfaces.test.mjs +66 -66
  78. package/scripts/tui.mjs +613 -613
  79. package/scripts/uninstall-all.mjs +39 -39
  80. package/server/mcp-server.mjs +129 -129
  81. package/skills/portawhip/SKILL.md +80 -80
  82. package/surface-matrix.yaml +93 -93
@@ -1,348 +1,348 @@
1
- #!/usr/bin/env node
2
- // One hook body, many host adapters.
3
- //
4
- // Host config files should call this script with:
5
- // node adapters/hooks/universal-hook.mjs --host <host-id> --event <logical-event>
6
- //
7
- // Logical events:
8
- // user_prompt -> route capabilities and inject additionalContext
9
- // post_tool -> mark suggested capabilities as used
10
-
11
- import { existsSync } from "node:fs";
12
- import spawn from "cross-spawn";
13
- import { fileURLToPath, pathToFileURL } from "node:url";
14
- import { dirname, join, isAbsolute } from "node:path";
15
- import { loadIndex, readCachedIndex } from "../../core/registry/registry.mjs";
16
- import { runRoute } from "../../core/router/route-entry.mjs";
17
- import { pointerFor } from "../../core/registry/capability-docs.mjs";
18
- import { loadConfig } from "../../core/state/config.mjs";
19
- import { computeFactors, logEvent, readEvents } from "../../core/state/feedback.mjs";
20
- import { stackFactors, combineFactors } from "../../core/state/stack-detect.mjs";
21
- import { readActiveSelection, resolveRecipePaths } from "../../core/state/bundle-state.mjs";
22
- import { isSyntheticPrompt } from "../../core/router/prompt-hygiene.mjs";
23
-
24
- const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
25
- // Whatever bundles were opted into via `scripts/bundles.mjs select` (foundry
26
- // + roles), resolved in front of this repo's own recipe.yaml — defaults to
27
- // just recipe.yaml when nothing has been selected (today's behavior).
28
- const RECIPE_PATHS = resolveRecipePaths(ROOT, readActiveSelection(ROOT));
29
- const CONFIG_PATH = join(ROOT, "router.config.yaml");
30
- const MIN_PROMPT_LEN = 8;
31
-
32
- function parseArgs(argv) {
33
- const args = {};
34
- for (let i = 2; i < argv.length; i += 1) {
35
- if (argv[i].startsWith("--")) {
36
- args[argv[i].slice(2)] = argv[i + 1];
37
- i += 1;
38
- }
39
- }
40
- return args;
41
- }
42
-
43
- async function readStdinJson() {
44
- const raw = await new Promise((res, rej) => {
45
- let data = "";
46
- process.stdin.setEncoding("utf8");
47
- process.stdin.on("data", (chunk) => (data += chunk));
48
- process.stdin.on("end", () => res(data));
49
- process.stdin.on("error", rej);
50
- });
51
- const text = raw.replace(/^\uFEFF/, "").trim();
52
- return text ? JSON.parse(text) : {};
53
- }
54
-
55
- function payloadCwd(payload) {
56
- return payload.cwd || process.cwd();
57
- }
58
-
59
- function promptFromPayload(payload) {
60
- return (payload.prompt || payload.user_prompt || payload.input?.prompt || "").trim();
61
- }
62
-
63
- function readinessNote(hit, cwd) {
64
- if (!hit.readyMarker) return "";
65
- const ready = existsSync(join(cwd, hit.readyMarker));
66
- return ready ? "" : ` (not set up here - run \`${hit.readyHint ?? "see docs"}\`)`;
67
- }
68
-
69
- // A bare pointer alone doesn't get acted on. Verified live (2026-07-06):
70
- // this repo's own push hook correctly suggested workspace-surface-audit and
71
- // configure-ecc on every turn of a session about diagnosing this project's
72
- // own hooks — genuinely relevant — and neither was ever invoked. The
73
- // rendered line gave a bare path/name with no invocation syntax, so it read
74
- // as background info, not a directive. adapters/instructions/generate.mjs
75
- // already proved the fix for the sibling problem (route() itself never
76
- // being called): be maximally explicit and host-aware, not more
77
- // descriptive. Same principle here, generic over kind/host — never a
78
- // specific tool name hardcoded, so this scales to whatever the caller has
79
- // installed.
80
- export function actionDirective(hit, host) {
81
- if (hit.kind === "skill") {
82
- return host === "claude-code"
83
- ? `invoke now via the Skill tool (skill: "${hit.id}")`
84
- : `read and follow this skill's instructions now: ${hit.pointer}`;
85
- }
86
- if (hit.kind === "agent") {
87
- return host === "claude-code"
88
- ? `delegate now via the Agent tool (subagent_type: "${hit.id}")`
89
- : `use this agent capability now if relevant: ${hit.pointer}`;
90
- }
91
- if (hit.type === "mcp") {
92
- return host === "claude-code"
93
- ? `call its MCP tool directly now (tool names start with "mcp__${hit.id}__"; if not shown as callable yet, call ToolSearch with query "${hit.id}" first)`
94
- : `call this MCP tool directly now if relevant`;
95
- }
96
- // cli (and any other pointer-bearing type): the pointer is already a
97
- // runnable shell command - see capability-docs.mjs's pointerFor.
98
- return `run it directly now: ${hit.pointer}`;
99
- }
100
-
101
- // Every "suggested" event is already logged per-sessionId (see the bottom
102
- // of userPrompt()) purely for computeFactors' boost/decay signal - never
103
- // read back to affect what gets rendered. Found live (2026-07-06): the
104
- // mcp directive's ToolSearch-fallback clause alone costs ~150 chars, and
105
- // full lines repeat every single turn a capability keeps matching, even
106
- // within the same session where the agent was already told this once.
107
- // Reusing that existing log to render tersely on repeat costs nothing new
108
- // (no state to add). Extended 2026-07-09 from a Set to per-id counts:
109
- // mention 1 renders full, mention 2 renders terse, then the id goes SILENT
110
- // for the session (interrupt budget - an assistant that reminds twice and
111
- // then shuts up, instead of nagging every turn a capability keeps matching).
112
- function sessionSuggestedCounts(root, sessionId) {
113
- const counts = new Map();
114
- if (!sessionId) return counts;
115
- for (const e of readEvents(root)) {
116
- if (e.type !== "suggested" || e.sessionId !== sessionId) continue;
117
- counts.set(e.id, (counts.get(e.id) ?? 0) + 1);
118
- }
119
- return counts;
120
- }
121
-
122
- // Returns {block, renderedIds}: only ids actually shown to the model get
123
- // logged as "suggested" (previously every routed hit was logged even when
124
- // the char budget dropped its line - phantom suggestions the model never
125
- // saw, each counting as an "ignored" outcome in computeFactors).
126
- function formatBlock(result, budgetChars, cwd, host, mentionCounts, maxMentions) {
127
- const lines = [];
128
- const renderedIds = [];
129
- let used = 0;
130
- for (const hit of result) {
131
- const mentions = mentionCounts.get(hit.id) ?? 0;
132
- if (mentions >= maxMentions) continue; // interrupt budget spent for this session
133
- const line =
134
- mentions > 0
135
- ? `- ${hit.id} - still relevant, use it again if applicable`
136
- : `- ${hit.id} - ${hit.how_to_use}${readinessNote(hit, cwd)} - ${actionDirective(hit, host)}`;
137
- if (used + line.length > budgetChars && lines.length > 0) break;
138
- lines.push(line);
139
- renderedIds.push(hit.id);
140
- used += line.length;
141
- }
142
- return { block: lines.join("\n"), renderedIds };
143
- }
144
-
145
- function outputAdditionalContext(host, nativeEvent, additionalContext) {
146
- const hookEventName = nativeEvent || (host === "gemini-cli" ? "BeforeAgent" : "UserPromptSubmit");
147
- return {
148
- hookSpecificOutput: {
149
- hookEventName,
150
- additionalContext,
151
- },
152
- };
153
- }
154
-
155
- function escapeRegExp(text) {
156
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
157
- }
158
-
159
- export function resolveId(index, toolName, toolInput) {
160
- const mcpMatch = /^mcp[_]{1,2}([^_]+)[_]{1,2}/.exec(toolName || "");
161
- if (mcpMatch) {
162
- const id = mcpMatch[1];
163
- return index.entries.some((e) => e.id === id && e.type === "mcp") ? id : null;
164
- }
165
-
166
- // Skill/Agent invocations are how MOST suggestions actually get acted on
167
- // (352 skills + 90 agents vs 11 mcp/11 cli in today's index), yet neither
168
- // had a branch here - so a suggested skill being used never logged a
169
- // "used" event, boost never fired, and the 2026-07-09 hit-rate audit
170
- // (4/26) was measuring with one eye shut. Host invocations may be
171
- // plugin-namespaced ("ecc:code-review") while registry ids are plain
172
- // slugs - match both the raw arg and the part after the last colon.
173
- if (toolName === "Skill" || toolName === "Agent") {
174
- const wantType = toolName === "Skill" ? "skill" : "agent";
175
- const arg = String(
176
- (toolName === "Skill" ? toolInput?.skill : toolInput?.subagent_type) ?? "",
177
- );
178
- if (!arg) return null;
179
- const bare = arg.includes(":") ? arg.slice(arg.lastIndexOf(":") + 1) : arg;
180
- const hit = index.entries.find((e) => e.type === wantType && (e.id === arg || e.id === bare));
181
- return hit?.id ?? null;
182
- }
183
-
184
- if (["Read", "read_file"].includes(toolName)) {
185
- const filePath = (toolInput?.file_path || toolInput?.path || "").replace(/\\/g, "/");
186
- if (!filePath) return null;
187
- const hit = index.entries.find(
188
- (e) => e.type === "skill" && e.path && filePath.includes(String(e.path).replace(/\\/g, "/")),
189
- );
190
- return hit?.id ?? null;
191
- }
192
-
193
- if (["Bash", "run_shell_command", "bash"].includes(toolName)) {
194
- const command = toolInput?.command || toolInput?.cmd || "";
195
- // name comes from the registry (route.binary/source), not the tool call
196
- // itself, but a CLI binary name can still contain regex metacharacters
197
- // (e.g. "g++") - unescaped, that either throws (an uncaught exception
198
- // here propagates out of postTool(), silently disabling pull-mode
199
- // matching for every entry, not just this one - main() fails open with
200
- // an empty catch) or matches something nonsensical.
201
- const hit = index.entries.find((e) => {
202
- if (e.type !== "cli") return false;
203
- const name = e.route?.binary ?? e.source;
204
- return name && new RegExp(`\\b${escapeRegExp(name)}\\b`, "i").test(command);
205
- });
206
- return hit?.id ?? null;
207
- }
208
-
209
- return null;
210
- }
211
-
212
- function toolFields(payload) {
213
- return {
214
- toolName: payload.tool_name || payload.toolName || payload.tool || payload.input?.tool || "",
215
- toolInput: payload.tool_input || payload.toolInput || payload.args || payload.input?.tool_input || {},
216
- };
217
- }
218
-
219
- async function userPrompt(payload, args) {
220
- const prompt = promptFromPayload(payload);
221
- if (prompt.length < MIN_PROMPT_LEN || prompt.startsWith("/")) return;
222
- // Harness-generated payloads (task notifications, system reminders) arrive
223
- // through the same UserPromptSubmit channel as real typing - routing them
224
- // was 81% of all suggested events and pure decay-noise for computeFactors
225
- // (see core/prompt-hygiene.mjs for the live numbers).
226
- if (isSyntheticPrompt(prompt)) return;
227
-
228
- const config = loadConfig(CONFIG_PATH);
229
- // Workstream A: a push hook sees only the raw prompt, not the agent's
230
- // reasoned task summary. The characterization spikes proved no lexical or
231
- // embedding threshold can reliably separate meta-discussion from a real
232
- // capability request, so unsolicited push stays silent by default. The env
233
- // override is a scoped rollback switch; it never changes retrieval itself.
234
- const pushMode = process.env.PORTAWHIP_PUSH_MODE === "legacy" ? "legacy" : config.pushMode;
235
- if (pushMode === "silent") return;
236
- const index = await loadIndex(RECIPE_PATHS);
237
- const graphPath =
238
- config.graphPath && !isAbsolute(config.graphPath) ? join(ROOT, config.graphPath) : config.graphPath;
239
- const factors = combineFactors(computeFactors(ROOT), stackFactors(index, payloadCwd(payload)));
240
- // Dense retrieval (core/dense-embedder.mjs) loads a 500MB+ model on first
241
- // use - fine for the long-lived MCP server/CLI, but this hook is a fresh
242
- // subprocess per prompt (see hook-stub.mjs), so it would pay that cold
243
- // load on every keystroke. Push mode stays sparse+peakedness-gate only;
244
- // dense is opt-in for callers that can amortize the load across calls.
245
- const routed = await runRoute(index, prompt, {
246
- ...config,
247
- graphPath,
248
- factors,
249
- denseEnabled: false,
250
- mode: "push",
251
- pushMode,
252
- });
253
- if (!routed || routed.length === 0) return;
254
-
255
- // Push precision gate (2026-07-09): an unsolicited interruption needs a
256
- // much higher bar than a solicited MCP route() lookup - see
257
- // router.config.yaml's pushMinConfidence comment (alert fatigue). Curated
258
- // required-tier entries keep their deliberately-authored pass.
259
- const result = routed.filter(
260
- (hit) => hit.tier === "required" || hit.confidence >= config.pushMinConfidence,
261
- );
262
- if (result.length === 0) return;
263
-
264
- const mentionCounts = sessionSuggestedCounts(ROOT, payload.session_id ?? null);
265
- const { block, renderedIds } = formatBlock(
266
- result,
267
- config.pushBudgetChars,
268
- payloadCwd(payload),
269
- args.host,
270
- mentionCounts,
271
- config.pushMaxMentionsPerSession,
272
- );
273
- if (!block) return;
274
-
275
- for (const id of renderedIds) {
276
- logEvent(ROOT, { type: "suggested", id, prompt, sessionId: payload.session_id ?? null });
277
- }
278
-
279
- process.stdout.write(JSON.stringify(outputAdditionalContext(args.host, args.nativeEvent, block)));
280
- }
281
-
282
- // Soft nudge only — hooks must stay fail-open (see main()'s catch below), so
283
- // this never blocks the tool call that already ran. It just tells the model
284
- // a harness capability covers what it just did by hand, for next time.
285
- function neverSuggested(root, id) {
286
- return !readEvents(root).some((e) => e.type === "suggested" && e.id === id);
287
- }
288
-
289
- async function postTool(payload, args) {
290
- const index = readCachedIndex(RECIPE_PATHS);
291
- if (!index) return;
292
- const { toolName, toolInput } = toolFields(payload);
293
- const id = resolveId(index, toolName, toolInput);
294
- if (!id) return;
295
-
296
- const wasIgnored = neverSuggested(ROOT, id);
297
- logEvent(ROOT, { type: "used", id, tool: toolName, sessionId: payload.session_id ?? null });
298
- if (!wasIgnored) return;
299
-
300
- const entry = index.entries.find((e) => e.id === id);
301
- if (!entry) return;
302
- // Raw index entries only carry route.description/path/source — how_to_use
303
- // and pointer are fields scorer.mjs constructs for the FORMATTED route()
304
- // output, not present here. Reading them off the raw entry silently
305
- // produced "undefined - undefined" (found live 2026-07-05, first time a
306
- // curated CLI entry with a `binary` field actually matched a Bash command).
307
- const description = entry.route?.description ?? "";
308
- const pointer = pointerFor(entry) ?? "";
309
- const nudge = `Note: "${toolName}" just did something \`${id}\` already covers - ${description} - ${pointer}. Prefer it next time.`;
310
- process.stdout.write(JSON.stringify(outputAdditionalContext(args.host, args.nativeEvent, nudge)));
311
- }
312
-
313
- // Session start: fire-and-forget the auto-sync worker (decision D). This hook
314
- // must return instantly, so it detaches a fully independent process and
315
- // unrefs it — the worker throttles/locks and fans already-canonical
316
- // capabilities out to all hosts on its own, logging to
317
- // .hp-state/auto-sync.log. Import stays manual; this only propagates what is
318
- // already canonical. Any failure stays inside the worker; the session is
319
- // never blocked or delayed.
320
- function sessionStart() {
321
- try {
322
- const child = spawn.spawn(process.execPath, [join(ROOT, "scripts", "sync", "auto-sync.mjs")], {
323
- cwd: ROOT,
324
- detached: true,
325
- stdio: "ignore",
326
- });
327
- child.unref();
328
- } catch {
329
- // fail-open: never block session start on a spawn error
330
- }
331
- }
332
-
333
- async function main() {
334
- const args = parseArgs(process.argv);
335
- if (args.event === "session_start") {
336
- sessionStart();
337
- return;
338
- }
339
- const payload = await readStdinJson();
340
- if (args.event === "user_prompt") await userPrompt(payload, args);
341
- if (args.event === "post_tool") await postTool(payload, args);
342
- }
343
-
344
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
345
- main().catch(() => {
346
- // Hooks must fail open; a sync-layer bug should never block the user.
347
- });
348
- }
1
+ #!/usr/bin/env node
2
+ // One hook body, many host adapters.
3
+ //
4
+ // Host config files should call this script with:
5
+ // node adapters/hooks/universal-hook.mjs --host <host-id> --event <logical-event>
6
+ //
7
+ // Logical events:
8
+ // user_prompt -> route capabilities and inject additionalContext
9
+ // post_tool -> mark suggested capabilities as used
10
+
11
+ import { existsSync } from "node:fs";
12
+ import spawn from "cross-spawn";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import { dirname, join, isAbsolute } from "node:path";
15
+ import { loadIndex, readCachedIndex } from "../../core/registry/registry.mjs";
16
+ import { runRoute } from "../../core/router/route-entry.mjs";
17
+ import { pointerFor } from "../../core/registry/capability-docs.mjs";
18
+ import { loadConfig } from "../../core/state/config.mjs";
19
+ import { computeFactors, logEvent, readEvents } from "../../core/state/feedback.mjs";
20
+ import { stackFactors, combineFactors } from "../../core/state/stack-detect.mjs";
21
+ import { readActiveSelection, resolveRecipePaths } from "../../core/state/bundle-state.mjs";
22
+ import { isSyntheticPrompt } from "../../core/router/prompt-hygiene.mjs";
23
+
24
+ const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
25
+ // Whatever bundles were opted into via `scripts/bundles.mjs select` (foundry
26
+ // + roles), resolved in front of this repo's own recipe.yaml — defaults to
27
+ // just recipe.yaml when nothing has been selected (today's behavior).
28
+ const RECIPE_PATHS = resolveRecipePaths(ROOT, readActiveSelection(ROOT));
29
+ const CONFIG_PATH = join(ROOT, "router.config.yaml");
30
+ const MIN_PROMPT_LEN = 8;
31
+
32
+ function parseArgs(argv) {
33
+ const args = {};
34
+ for (let i = 2; i < argv.length; i += 1) {
35
+ if (argv[i].startsWith("--")) {
36
+ args[argv[i].slice(2)] = argv[i + 1];
37
+ i += 1;
38
+ }
39
+ }
40
+ return args;
41
+ }
42
+
43
+ async function readStdinJson() {
44
+ const raw = await new Promise((res, rej) => {
45
+ let data = "";
46
+ process.stdin.setEncoding("utf8");
47
+ process.stdin.on("data", (chunk) => (data += chunk));
48
+ process.stdin.on("end", () => res(data));
49
+ process.stdin.on("error", rej);
50
+ });
51
+ const text = raw.replace(/^\uFEFF/, "").trim();
52
+ return text ? JSON.parse(text) : {};
53
+ }
54
+
55
+ function payloadCwd(payload) {
56
+ return payload.cwd || process.cwd();
57
+ }
58
+
59
+ function promptFromPayload(payload) {
60
+ return (payload.prompt || payload.user_prompt || payload.input?.prompt || "").trim();
61
+ }
62
+
63
+ function readinessNote(hit, cwd) {
64
+ if (!hit.readyMarker) return "";
65
+ const ready = existsSync(join(cwd, hit.readyMarker));
66
+ return ready ? "" : ` (not set up here - run \`${hit.readyHint ?? "see docs"}\`)`;
67
+ }
68
+
69
+ // A bare pointer alone doesn't get acted on. Verified live (2026-07-06):
70
+ // this repo's own push hook correctly suggested workspace-surface-audit and
71
+ // configure-ecc on every turn of a session about diagnosing this project's
72
+ // own hooks — genuinely relevant — and neither was ever invoked. The
73
+ // rendered line gave a bare path/name with no invocation syntax, so it read
74
+ // as background info, not a directive. adapters/instructions/generate.mjs
75
+ // already proved the fix for the sibling problem (route() itself never
76
+ // being called): be maximally explicit and host-aware, not more
77
+ // descriptive. Same principle here, generic over kind/host — never a
78
+ // specific tool name hardcoded, so this scales to whatever the caller has
79
+ // installed.
80
+ export function actionDirective(hit, host) {
81
+ if (hit.kind === "skill") {
82
+ return host === "claude-code"
83
+ ? `invoke now via the Skill tool (skill: "${hit.id}")`
84
+ : `read and follow this skill's instructions now: ${hit.pointer}`;
85
+ }
86
+ if (hit.kind === "agent") {
87
+ return host === "claude-code"
88
+ ? `delegate now via the Agent tool (subagent_type: "${hit.id}")`
89
+ : `use this agent capability now if relevant: ${hit.pointer}`;
90
+ }
91
+ if (hit.type === "mcp") {
92
+ return host === "claude-code"
93
+ ? `call its MCP tool directly now (tool names start with "mcp__${hit.id}__"; if not shown as callable yet, call ToolSearch with query "${hit.id}" first)`
94
+ : `call this MCP tool directly now if relevant`;
95
+ }
96
+ // cli (and any other pointer-bearing type): the pointer is already a
97
+ // runnable shell command - see capability-docs.mjs's pointerFor.
98
+ return `run it directly now: ${hit.pointer}`;
99
+ }
100
+
101
+ // Every "suggested" event is already logged per-sessionId (see the bottom
102
+ // of userPrompt()) purely for computeFactors' boost/decay signal - never
103
+ // read back to affect what gets rendered. Found live (2026-07-06): the
104
+ // mcp directive's ToolSearch-fallback clause alone costs ~150 chars, and
105
+ // full lines repeat every single turn a capability keeps matching, even
106
+ // within the same session where the agent was already told this once.
107
+ // Reusing that existing log to render tersely on repeat costs nothing new
108
+ // (no state to add). Extended 2026-07-09 from a Set to per-id counts:
109
+ // mention 1 renders full, mention 2 renders terse, then the id goes SILENT
110
+ // for the session (interrupt budget - an assistant that reminds twice and
111
+ // then shuts up, instead of nagging every turn a capability keeps matching).
112
+ function sessionSuggestedCounts(root, sessionId) {
113
+ const counts = new Map();
114
+ if (!sessionId) return counts;
115
+ for (const e of readEvents(root)) {
116
+ if (e.type !== "suggested" || e.sessionId !== sessionId) continue;
117
+ counts.set(e.id, (counts.get(e.id) ?? 0) + 1);
118
+ }
119
+ return counts;
120
+ }
121
+
122
+ // Returns {block, renderedIds}: only ids actually shown to the model get
123
+ // logged as "suggested" (previously every routed hit was logged even when
124
+ // the char budget dropped its line - phantom suggestions the model never
125
+ // saw, each counting as an "ignored" outcome in computeFactors).
126
+ function formatBlock(result, budgetChars, cwd, host, mentionCounts, maxMentions) {
127
+ const lines = [];
128
+ const renderedIds = [];
129
+ let used = 0;
130
+ for (const hit of result) {
131
+ const mentions = mentionCounts.get(hit.id) ?? 0;
132
+ if (mentions >= maxMentions) continue; // interrupt budget spent for this session
133
+ const line =
134
+ mentions > 0
135
+ ? `- ${hit.id} - still relevant, use it again if applicable`
136
+ : `- ${hit.id} - ${hit.how_to_use}${readinessNote(hit, cwd)} - ${actionDirective(hit, host)}`;
137
+ if (used + line.length > budgetChars && lines.length > 0) break;
138
+ lines.push(line);
139
+ renderedIds.push(hit.id);
140
+ used += line.length;
141
+ }
142
+ return { block: lines.join("\n"), renderedIds };
143
+ }
144
+
145
+ function outputAdditionalContext(host, nativeEvent, additionalContext) {
146
+ const hookEventName = nativeEvent || (host === "gemini-cli" ? "BeforeAgent" : "UserPromptSubmit");
147
+ return {
148
+ hookSpecificOutput: {
149
+ hookEventName,
150
+ additionalContext,
151
+ },
152
+ };
153
+ }
154
+
155
+ function escapeRegExp(text) {
156
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
157
+ }
158
+
159
+ export function resolveId(index, toolName, toolInput) {
160
+ const mcpMatch = /^mcp[_]{1,2}([^_]+)[_]{1,2}/.exec(toolName || "");
161
+ if (mcpMatch) {
162
+ const id = mcpMatch[1];
163
+ return index.entries.some((e) => e.id === id && e.type === "mcp") ? id : null;
164
+ }
165
+
166
+ // Skill/Agent invocations are how MOST suggestions actually get acted on
167
+ // (352 skills + 90 agents vs 11 mcp/11 cli in today's index), yet neither
168
+ // had a branch here - so a suggested skill being used never logged a
169
+ // "used" event, boost never fired, and the 2026-07-09 hit-rate audit
170
+ // (4/26) was measuring with one eye shut. Host invocations may be
171
+ // plugin-namespaced ("ecc:code-review") while registry ids are plain
172
+ // slugs - match both the raw arg and the part after the last colon.
173
+ if (toolName === "Skill" || toolName === "Agent") {
174
+ const wantType = toolName === "Skill" ? "skill" : "agent";
175
+ const arg = String(
176
+ (toolName === "Skill" ? toolInput?.skill : toolInput?.subagent_type) ?? "",
177
+ );
178
+ if (!arg) return null;
179
+ const bare = arg.includes(":") ? arg.slice(arg.lastIndexOf(":") + 1) : arg;
180
+ const hit = index.entries.find((e) => e.type === wantType && (e.id === arg || e.id === bare));
181
+ return hit?.id ?? null;
182
+ }
183
+
184
+ if (["Read", "read_file"].includes(toolName)) {
185
+ const filePath = (toolInput?.file_path || toolInput?.path || "").replace(/\\/g, "/");
186
+ if (!filePath) return null;
187
+ const hit = index.entries.find(
188
+ (e) => e.type === "skill" && e.path && filePath.includes(String(e.path).replace(/\\/g, "/")),
189
+ );
190
+ return hit?.id ?? null;
191
+ }
192
+
193
+ if (["Bash", "run_shell_command", "bash"].includes(toolName)) {
194
+ const command = toolInput?.command || toolInput?.cmd || "";
195
+ // name comes from the registry (route.binary/source), not the tool call
196
+ // itself, but a CLI binary name can still contain regex metacharacters
197
+ // (e.g. "g++") - unescaped, that either throws (an uncaught exception
198
+ // here propagates out of postTool(), silently disabling pull-mode
199
+ // matching for every entry, not just this one - main() fails open with
200
+ // an empty catch) or matches something nonsensical.
201
+ const hit = index.entries.find((e) => {
202
+ if (e.type !== "cli") return false;
203
+ const name = e.route?.binary ?? e.source;
204
+ return name && new RegExp(`\\b${escapeRegExp(name)}\\b`, "i").test(command);
205
+ });
206
+ return hit?.id ?? null;
207
+ }
208
+
209
+ return null;
210
+ }
211
+
212
+ function toolFields(payload) {
213
+ return {
214
+ toolName: payload.tool_name || payload.toolName || payload.tool || payload.input?.tool || "",
215
+ toolInput: payload.tool_input || payload.toolInput || payload.args || payload.input?.tool_input || {},
216
+ };
217
+ }
218
+
219
+ async function userPrompt(payload, args) {
220
+ const prompt = promptFromPayload(payload);
221
+ if (prompt.length < MIN_PROMPT_LEN || prompt.startsWith("/")) return;
222
+ // Harness-generated payloads (task notifications, system reminders) arrive
223
+ // through the same UserPromptSubmit channel as real typing - routing them
224
+ // was 81% of all suggested events and pure decay-noise for computeFactors
225
+ // (see core/prompt-hygiene.mjs for the live numbers).
226
+ if (isSyntheticPrompt(prompt)) return;
227
+
228
+ const config = loadConfig(CONFIG_PATH);
229
+ // Workstream A: a push hook sees only the raw prompt, not the agent's
230
+ // reasoned task summary. The characterization spikes proved no lexical or
231
+ // embedding threshold can reliably separate meta-discussion from a real
232
+ // capability request, so unsolicited push stays silent by default. The env
233
+ // override is a scoped rollback switch; it never changes retrieval itself.
234
+ const pushMode = process.env.PORTAWHIP_PUSH_MODE === "legacy" ? "legacy" : config.pushMode;
235
+ if (pushMode === "silent") return;
236
+ const index = await loadIndex(RECIPE_PATHS);
237
+ const graphPath =
238
+ config.graphPath && !isAbsolute(config.graphPath) ? join(ROOT, config.graphPath) : config.graphPath;
239
+ const factors = combineFactors(computeFactors(ROOT), stackFactors(index, payloadCwd(payload)));
240
+ // Dense retrieval (core/dense-embedder.mjs) loads a 500MB+ model on first
241
+ // use - fine for the long-lived MCP server/CLI, but this hook is a fresh
242
+ // subprocess per prompt (see hook-stub.mjs), so it would pay that cold
243
+ // load on every keystroke. Push mode stays sparse+peakedness-gate only;
244
+ // dense is opt-in for callers that can amortize the load across calls.
245
+ const routed = await runRoute(index, prompt, {
246
+ ...config,
247
+ graphPath,
248
+ factors,
249
+ denseEnabled: false,
250
+ mode: "push",
251
+ pushMode,
252
+ });
253
+ if (!routed || routed.length === 0) return;
254
+
255
+ // Push precision gate (2026-07-09): an unsolicited interruption needs a
256
+ // much higher bar than a solicited MCP route() lookup - see
257
+ // router.config.yaml's pushMinConfidence comment (alert fatigue). Curated
258
+ // required-tier entries keep their deliberately-authored pass.
259
+ const result = routed.filter(
260
+ (hit) => hit.tier === "required" || hit.confidence >= config.pushMinConfidence,
261
+ );
262
+ if (result.length === 0) return;
263
+
264
+ const mentionCounts = sessionSuggestedCounts(ROOT, payload.session_id ?? null);
265
+ const { block, renderedIds } = formatBlock(
266
+ result,
267
+ config.pushBudgetChars,
268
+ payloadCwd(payload),
269
+ args.host,
270
+ mentionCounts,
271
+ config.pushMaxMentionsPerSession,
272
+ );
273
+ if (!block) return;
274
+
275
+ for (const id of renderedIds) {
276
+ logEvent(ROOT, { type: "suggested", id, prompt, sessionId: payload.session_id ?? null });
277
+ }
278
+
279
+ process.stdout.write(JSON.stringify(outputAdditionalContext(args.host, args.nativeEvent, block)));
280
+ }
281
+
282
+ // Soft nudge only — hooks must stay fail-open (see main()'s catch below), so
283
+ // this never blocks the tool call that already ran. It just tells the model
284
+ // a harness capability covers what it just did by hand, for next time.
285
+ function neverSuggested(root, id) {
286
+ return !readEvents(root).some((e) => e.type === "suggested" && e.id === id);
287
+ }
288
+
289
+ async function postTool(payload, args) {
290
+ const index = readCachedIndex(RECIPE_PATHS);
291
+ if (!index) return;
292
+ const { toolName, toolInput } = toolFields(payload);
293
+ const id = resolveId(index, toolName, toolInput);
294
+ if (!id) return;
295
+
296
+ const wasIgnored = neverSuggested(ROOT, id);
297
+ logEvent(ROOT, { type: "used", id, tool: toolName, sessionId: payload.session_id ?? null });
298
+ if (!wasIgnored) return;
299
+
300
+ const entry = index.entries.find((e) => e.id === id);
301
+ if (!entry) return;
302
+ // Raw index entries only carry route.description/path/source — how_to_use
303
+ // and pointer are fields scorer.mjs constructs for the FORMATTED route()
304
+ // output, not present here. Reading them off the raw entry silently
305
+ // produced "undefined - undefined" (found live 2026-07-05, first time a
306
+ // curated CLI entry with a `binary` field actually matched a Bash command).
307
+ const description = entry.route?.description ?? "";
308
+ const pointer = pointerFor(entry) ?? "";
309
+ const nudge = `Note: "${toolName}" just did something \`${id}\` already covers - ${description} - ${pointer}. Prefer it next time.`;
310
+ process.stdout.write(JSON.stringify(outputAdditionalContext(args.host, args.nativeEvent, nudge)));
311
+ }
312
+
313
+ // Session start: fire-and-forget the auto-sync worker (decision D). This hook
314
+ // must return instantly, so it detaches a fully independent process and
315
+ // unrefs it — the worker throttles/locks and fans already-canonical
316
+ // capabilities out to all hosts on its own, logging to
317
+ // .hp-state/auto-sync.log. Import stays manual; this only propagates what is
318
+ // already canonical. Any failure stays inside the worker; the session is
319
+ // never blocked or delayed.
320
+ function sessionStart() {
321
+ try {
322
+ const child = spawn.spawn(process.execPath, [join(ROOT, "scripts", "sync", "auto-sync.mjs")], {
323
+ cwd: ROOT,
324
+ detached: true,
325
+ stdio: "ignore",
326
+ });
327
+ child.unref();
328
+ } catch {
329
+ // fail-open: never block session start on a spawn error
330
+ }
331
+ }
332
+
333
+ async function main() {
334
+ const args = parseArgs(process.argv);
335
+ if (args.event === "session_start") {
336
+ sessionStart();
337
+ return;
338
+ }
339
+ const payload = await readStdinJson();
340
+ if (args.event === "user_prompt") await userPrompt(payload, args);
341
+ if (args.event === "post_tool") await postTool(payload, args);
342
+ }
343
+
344
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
345
+ main().catch(() => {
346
+ // Hooks must fail open; a sync-layer bug should never block the user.
347
+ });
348
+ }