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.
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/adapters/hooks/universal-hook.mjs +348 -348
- package/adapters/instructions/generate.mjs +114 -114
- package/core/registry/capability-docs.mjs +145 -145
- package/core/registry/capability-graph-compiler.mjs +90 -90
- package/core/registry/capability-graph.mjs +53 -53
- package/core/registry/capability-kind.mjs +9 -9
- package/core/registry/cli-enrich.mjs +316 -316
- package/core/registry/cli-enrich.test.mjs +119 -119
- package/core/registry/discover.mjs +379 -379
- package/core/registry/enrich.mjs +213 -213
- package/core/registry/enrich.test.mjs +53 -53
- package/core/registry/eval-harvest.mjs +93 -93
- package/core/registry/eval-harvest.test.mjs +64 -64
- package/core/registry/registry.mjs +197 -197
- package/core/router/concept-vector.mjs +91 -91
- package/core/router/concept-vector.test.mjs +36 -36
- package/core/router/dense-embedder.mjs +242 -242
- package/core/router/fusion.mjs +18 -18
- package/core/router/hybrid-router.mjs +323 -323
- package/core/router/intent-evidence.mjs +60 -60
- package/core/router/prompt-hygiene.mjs +31 -31
- package/core/router/route-entry.mjs +74 -74
- package/core/router/router-cli.mjs +199 -199
- package/core/router/router-eval.mjs +166 -166
- package/core/router/router-live.test.mjs +69 -69
- package/core/router/router.test.mjs +838 -838
- package/core/router/scorer.mjs +72 -72
- package/core/router/sparse-retriever.mjs +79 -79
- package/core/router/tokenize.mjs +42 -42
- package/core/state/bundle-state.mjs +117 -117
- package/core/state/bundle-state.test.mjs +175 -175
- package/core/state/config.mjs +115 -115
- package/core/state/feedback.mjs +129 -129
- package/core/state/feedback.test.mjs +181 -181
- package/core/state/stack-detect.mjs +102 -102
- package/core/state/stack-detect.test.mjs +64 -64
- package/core/surface/config-sync-backends.mjs +224 -224
- package/core/surface/connector-targets.mjs +205 -205
- package/core/surface/discover-hooks.mjs +151 -151
- package/core/surface/discover-hooks.test.mjs +63 -63
- package/core/surface/discover-surface.test.mjs +49 -49
- package/core/surface/extra-hosts.mjs +48 -48
- package/core/surface/extra-hosts.test.mjs +24 -24
- package/core/surface/hook-targets.mjs +102 -102
- package/core/surface/surface-copy-targets.mjs +37 -37
- package/core/surface/surface-inventory.mjs +98 -98
- package/core/surface/surface-matrix.mjs +133 -133
- package/core/surface/surface-matrix.test.mjs +90 -90
- package/docs/router-eval-set.jsonl +15 -15
- package/hooks.manifest.yaml +16 -16
- package/package.json +49 -49
- package/recipe.yaml +247 -247
- package/router.config.yaml +120 -120
- package/scripts/bundles.mjs +131 -131
- package/scripts/doctor.mjs +117 -117
- package/scripts/embedded-hooks.mjs +27 -27
- package/scripts/hosts.mjs +60 -60
- package/scripts/link/link-connectors.mjs +190 -190
- package/scripts/link/link-connectors.test.mjs +111 -111
- package/scripts/link/link-hooks.mjs +259 -259
- package/scripts/link/link-hooks.test.mjs +112 -112
- package/scripts/link/link-surfaces.mjs +188 -188
- package/scripts/link/link-surfaces.test.mjs +76 -76
- package/scripts/load.mjs +143 -143
- package/scripts/surface-inventory.mjs +6 -6
- package/scripts/sync/agents-surface.mjs +55 -55
- package/scripts/sync/agents-surface.test.mjs +18 -18
- package/scripts/sync/auto-sync.mjs +135 -135
- package/scripts/sync/auto-sync.test.mjs +41 -41
- package/scripts/sync/import-surfaces.mjs +331 -331
- package/scripts/sync/import-surfaces.test.mjs +106 -106
- package/scripts/sync/sync-config.mjs +230 -230
- package/scripts/sync/sync-config.test.mjs +141 -141
- package/scripts/sync/sync-surfaces.mjs +229 -229
- package/scripts/sync/sync-surfaces.test.mjs +66 -66
- package/scripts/tui.mjs +613 -613
- package/scripts/uninstall-all.mjs +39 -39
- package/server/mcp-server.mjs +129 -129
- package/skills/portawhip/SKILL.md +80 -80
- 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
|
+
}
|