portawhip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- package/surface-matrix.yaml +93 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { actionDirective, resolveId } from "./universal-hook.mjs";
|
|
8
|
+
|
|
9
|
+
// Integration-level: runs the real hook as a subprocess against this
|
|
10
|
+
// repo's real recipe.yaml (same style as core/router.test.mjs's
|
|
11
|
+
// discovery tests, which already exercise the live machine state).
|
|
12
|
+
// Feedback events land in this repo's real .hp-state/feedback, so every
|
|
13
|
+
// test needs a clean slate to assert against - but a plain rm here was
|
|
14
|
+
// found live (2026-07-06) to permanently delete real dogfood events
|
|
15
|
+
// (this repo's own push-hook firing during actual use), the first time
|
|
16
|
+
// this suite happened to run right after a real hook fire. Snapshot and
|
|
17
|
+
// restore instead of deleting, so running tests never costs real history.
|
|
18
|
+
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
19
|
+
const HOOK = join(ROOT, "adapters", "hooks", "universal-hook.mjs");
|
|
20
|
+
const FEEDBACK_PATH = join(ROOT, ".hp-state", "feedback", "events.jsonl");
|
|
21
|
+
|
|
22
|
+
let feedbackBackup;
|
|
23
|
+
|
|
24
|
+
function clearFeedback() {
|
|
25
|
+
feedbackBackup = existsSync(FEEDBACK_PATH) ? readFileSync(FEEDBACK_PATH, "utf8") : null;
|
|
26
|
+
if (existsSync(FEEDBACK_PATH)) rmSync(FEEDBACK_PATH);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function restoreFeedback() {
|
|
30
|
+
if (existsSync(FEEDBACK_PATH)) rmSync(FEEDBACK_PATH);
|
|
31
|
+
if (feedbackBackup != null) {
|
|
32
|
+
mkdirSync(dirname(FEEDBACK_PATH), { recursive: true });
|
|
33
|
+
writeFileSync(FEEDBACK_PATH, feedbackBackup);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const LEGACY_PUSH_ENV = { PORTAWHIP_PUSH_MODE: "legacy" };
|
|
38
|
+
|
|
39
|
+
function runHook(args, payload, { env = {} } = {}) {
|
|
40
|
+
const result = spawnSync(process.execPath, [HOOK, ...args], {
|
|
41
|
+
input: JSON.stringify(payload ?? {}),
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
cwd: ROOT,
|
|
44
|
+
env: { ...process.env, ...env },
|
|
45
|
+
});
|
|
46
|
+
return result.stdout.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("universal-hook: user_prompt is silent by default even on a confident match", () => {
|
|
50
|
+
clearFeedback();
|
|
51
|
+
try {
|
|
52
|
+
const stdout = runHook(
|
|
53
|
+
["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"],
|
|
54
|
+
{ prompt: "search codebase for the word foo" },
|
|
55
|
+
);
|
|
56
|
+
assert.equal(stdout, "");
|
|
57
|
+
assert.ok(!existsSync(FEEDBACK_PATH), "silent push must not log a suggested event");
|
|
58
|
+
} finally {
|
|
59
|
+
restoreFeedback();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("universal-hook: raw meta-prompts stay silent and create no suggestion events", () => {
|
|
64
|
+
clearFeedback();
|
|
65
|
+
try {
|
|
66
|
+
const args = ["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"];
|
|
67
|
+
const prompts = [
|
|
68
|
+
"what are the tradeoffs of cross-host agent capability loading",
|
|
69
|
+
"how should a router avoid token bloat when many tools are installed",
|
|
70
|
+
];
|
|
71
|
+
for (const prompt of prompts) assert.equal(runHook(args, { prompt }), "", prompt);
|
|
72
|
+
assert.ok(!existsSync(FEEDBACK_PATH), "silent meta-prompts must not log suggested events");
|
|
73
|
+
} finally {
|
|
74
|
+
restoreFeedback();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("universal-hook: legacy push is an explicit opt-in for rollback", () => {
|
|
79
|
+
clearFeedback();
|
|
80
|
+
try {
|
|
81
|
+
const stdout = runHook(
|
|
82
|
+
["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"],
|
|
83
|
+
{ prompt: "search codebase for the word foo" },
|
|
84
|
+
{ env: LEGACY_PUSH_ENV },
|
|
85
|
+
);
|
|
86
|
+
assert.ok(stdout, "expected non-empty stdout for a matching prompt");
|
|
87
|
+
const parsed = JSON.parse(stdout);
|
|
88
|
+
assert.equal(parsed.hookSpecificOutput.hookEventName, "UserPromptSubmit");
|
|
89
|
+
assert.ok(parsed.hookSpecificOutput.additionalContext.includes("ripgrep"));
|
|
90
|
+
} finally {
|
|
91
|
+
restoreFeedback();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("universal-hook: user_prompt renders tersely once a capability was already suggested this session", () => {
|
|
96
|
+
clearFeedback();
|
|
97
|
+
try {
|
|
98
|
+
const args = ["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"];
|
|
99
|
+
const payload = { prompt: "search codebase for the word foo", session_id: "test-session-repeat" };
|
|
100
|
+
|
|
101
|
+
const first = JSON.parse(runHook(args, payload, { env: LEGACY_PUSH_ENV }));
|
|
102
|
+
assert.match(first.hookSpecificOutput.additionalContext, /ripgrep/);
|
|
103
|
+
assert.match(first.hookSpecificOutput.additionalContext, /run it directly now/);
|
|
104
|
+
|
|
105
|
+
const second = JSON.parse(runHook(args, payload, { env: LEGACY_PUSH_ENV }));
|
|
106
|
+
assert.match(second.hookSpecificOutput.additionalContext, /- ripgrep - still relevant, use it again/);
|
|
107
|
+
assert.doesNotMatch(second.hookSpecificOutput.additionalContext, /run it directly now/);
|
|
108
|
+
} finally {
|
|
109
|
+
restoreFeedback();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("universal-hook: user_prompt stays silent on a short/unrelated prompt", () => {
|
|
114
|
+
clearFeedback();
|
|
115
|
+
try {
|
|
116
|
+
const stdout = runHook(
|
|
117
|
+
["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"],
|
|
118
|
+
{ prompt: "hi" },
|
|
119
|
+
);
|
|
120
|
+
assert.equal(stdout, "");
|
|
121
|
+
} finally {
|
|
122
|
+
restoreFeedback();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("universal-hook: gemini-cli defaults hookEventName to BeforeAgent when no nativeEvent is passed", () => {
|
|
127
|
+
clearFeedback();
|
|
128
|
+
try {
|
|
129
|
+
const stdout = runHook(
|
|
130
|
+
["--host", "gemini-cli", "--event", "user_prompt"],
|
|
131
|
+
{ prompt: "search codebase for the word foo" },
|
|
132
|
+
{ env: LEGACY_PUSH_ENV },
|
|
133
|
+
);
|
|
134
|
+
assert.ok(stdout);
|
|
135
|
+
const parsed = JSON.parse(stdout);
|
|
136
|
+
assert.equal(parsed.hookSpecificOutput.hookEventName, "BeforeAgent");
|
|
137
|
+
} finally {
|
|
138
|
+
restoreFeedback();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("actionDirective: skill on claude-code names the Skill tool by id", () => {
|
|
143
|
+
const directive = actionDirective({ kind: "skill", id: "workspace-surface-audit", pointer: "/some/path" }, "claude-code");
|
|
144
|
+
assert.match(directive, /Skill tool/);
|
|
145
|
+
assert.match(directive, /"workspace-surface-audit"/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("actionDirective: skill on a non-claude-code host falls back to reading the pointer", () => {
|
|
149
|
+
const directive = actionDirective({ kind: "skill", id: "workspace-surface-audit", pointer: "/some/path" }, "gemini-cli");
|
|
150
|
+
assert.doesNotMatch(directive, /Skill tool/);
|
|
151
|
+
assert.match(directive, /\/some\/path/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("actionDirective: mcp on claude-code gives the mcp__<id>__ prefix and a ToolSearch fallback", () => {
|
|
155
|
+
const directive = actionDirective({ kind: "tool", type: "mcp", id: "playwright", pointer: "@playwright/mcp@latest" }, "claude-code");
|
|
156
|
+
assert.match(directive, /mcp__playwright__/);
|
|
157
|
+
assert.match(directive, /ToolSearch/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("actionDirective: mcp on a non-claude-code host stays generic, no invented syntax", () => {
|
|
161
|
+
const directive = actionDirective({ kind: "tool", type: "mcp", id: "playwright", pointer: "@playwright/mcp@latest" }, "codex");
|
|
162
|
+
assert.doesNotMatch(directive, /mcp__/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("actionDirective: agent on claude-code names the Agent tool by subagent_type", () => {
|
|
166
|
+
const directive = actionDirective({ kind: "agent", id: "e2e-runner", pointer: "/agents/e2e-runner.md" }, "claude-code");
|
|
167
|
+
assert.match(directive, /Agent tool/);
|
|
168
|
+
assert.match(directive, /"e2e-runner"/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("actionDirective: cli surfaces the pointer as a runnable command", () => {
|
|
172
|
+
const directive = actionDirective({ kind: "tool", type: "cli", id: "ripgrep", pointer: "mise exec -- ripgrep" }, "claude-code");
|
|
173
|
+
assert.match(directive, /mise exec -- ripgrep/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("resolveId: a CLI binary name with a regex metacharacter still matches its own command", () => {
|
|
177
|
+
// "." is unescaped-regex "any character" - a dot-containing name sitting
|
|
178
|
+
// between word characters (unlike a trailing "g++") still hits \b on
|
|
179
|
+
// both sides, so this exercises the escaping fix without the separate,
|
|
180
|
+
// pre-existing \b-at-a-symbol-edge limitation (word boundaries don't
|
|
181
|
+
// fire between two non-word characters, e.g. "+" then a space - a
|
|
182
|
+
// different, lower-priority gap left as-is since no registered CLI name
|
|
183
|
+
// ends in a symbol today).
|
|
184
|
+
const index = {
|
|
185
|
+
entries: [{ id: "foo-dot-bar", type: "cli", source: "foo.bar", route: {} }],
|
|
186
|
+
};
|
|
187
|
+
assert.doesNotThrow(() => {
|
|
188
|
+
const id = resolveId(index, "Bash", { command: "foo.bar --version" });
|
|
189
|
+
assert.equal(id, "foo-dot-bar");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("resolveId: an unescaped '.' would false-match a lookalike command; escaped it does not", () => {
|
|
194
|
+
const index = {
|
|
195
|
+
entries: [{ id: "foo-dot-bar", type: "cli", source: "foo.bar", route: {} }],
|
|
196
|
+
};
|
|
197
|
+
// Unescaped, "." in the regex means "any character" - "fooXbar" would
|
|
198
|
+
// wrongly match a binary named "foo.bar". Escaped, it correctly doesn't.
|
|
199
|
+
const id = resolveId(index, "Bash", { command: "run fooXbar now" });
|
|
200
|
+
assert.equal(id, null);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("universal-hook: third mention of the same id in one session is silent (interrupt budget)", () => {
|
|
204
|
+
clearFeedback();
|
|
205
|
+
try {
|
|
206
|
+
const args = ["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"];
|
|
207
|
+
const payload = { prompt: "search codebase for the word foo", session_id: "test-session-budget" };
|
|
208
|
+
|
|
209
|
+
const first = JSON.parse(runHook(args, payload, { env: LEGACY_PUSH_ENV }));
|
|
210
|
+
assert.match(first.hookSpecificOutput.additionalContext, /run it directly now/);
|
|
211
|
+
const second = JSON.parse(runHook(args, payload, { env: LEGACY_PUSH_ENV }));
|
|
212
|
+
assert.match(second.hookSpecificOutput.additionalContext, /still relevant/);
|
|
213
|
+
// Budget (pushMaxMentionsPerSession=2) spent: full once, terse once, then silence.
|
|
214
|
+
const third = runHook(args, payload, { env: LEGACY_PUSH_ENV });
|
|
215
|
+
assert.equal(third, "");
|
|
216
|
+
} finally {
|
|
217
|
+
restoreFeedback();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("universal-hook: user_prompt stays silent on a harness-generated task-notification payload", () => {
|
|
222
|
+
clearFeedback();
|
|
223
|
+
try {
|
|
224
|
+
// Real shape from a live session: a background-task completion notice
|
|
225
|
+
// that arrives through UserPromptSubmit. Long enough and vocabulary-rich
|
|
226
|
+
// enough that it WOULD route (21/26 historical suggested events were
|
|
227
|
+
// exactly this) - only the synthetic-prompt gate keeps it silent.
|
|
228
|
+
const stdout = runHook(
|
|
229
|
+
["--host", "claude-code", "--event", "user_prompt", "--nativeEvent", "UserPromptSubmit"],
|
|
230
|
+
{
|
|
231
|
+
prompt:
|
|
232
|
+
"<task-notification>\n<task-id>b9ugezqg8</task-id>\n<summary>Background command \"search codebase for the word foo\" completed (exit code 0)</summary>\n</task-notification>",
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
assert.equal(stdout, "");
|
|
236
|
+
assert.ok(!existsSync(FEEDBACK_PATH), "no suggested events should be logged for synthetic prompts");
|
|
237
|
+
} finally {
|
|
238
|
+
restoreFeedback();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("universal-hook: post_tool with the Skill tool logs a used event for the skill id", () => {
|
|
243
|
+
clearFeedback();
|
|
244
|
+
try {
|
|
245
|
+
runHook(["--host", "claude-code", "--event", "post_tool"], {
|
|
246
|
+
tool_name: "Skill",
|
|
247
|
+
tool_input: { skill: "workspace-surface-audit" },
|
|
248
|
+
});
|
|
249
|
+
assert.ok(existsSync(FEEDBACK_PATH));
|
|
250
|
+
const contents = readFileSync(FEEDBACK_PATH, "utf8");
|
|
251
|
+
assert.ok(contents.includes('"type":"used"'));
|
|
252
|
+
assert.ok(contents.includes('"id":"workspace-surface-audit"'));
|
|
253
|
+
} finally {
|
|
254
|
+
restoreFeedback();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("resolveId: a plugin-namespaced Skill invocation matches the plain-slug registry id", () => {
|
|
259
|
+
const index = {
|
|
260
|
+
entries: [{ id: "code-review", type: "skill", route: {} }],
|
|
261
|
+
};
|
|
262
|
+
assert.equal(resolveId(index, "Skill", { skill: "ecc:code-review" }), "code-review");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("resolveId: an Agent invocation matches an agent entry by subagent_type, not a same-named skill", () => {
|
|
266
|
+
const index = {
|
|
267
|
+
entries: [
|
|
268
|
+
{ id: "e2e-runner", type: "skill", route: {} },
|
|
269
|
+
{ id: "e2e-runner", type: "agent", route: {} },
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
// Both exist; the Agent tool must attribute to the agent-type entry.
|
|
273
|
+
const id = resolveId(index, "Agent", { subagent_type: "ecc:e2e-runner" });
|
|
274
|
+
assert.equal(id, "e2e-runner");
|
|
275
|
+
// And an id that only exists as a skill must NOT match via the Agent tool.
|
|
276
|
+
const skillOnly = { entries: [{ id: "pdf", type: "skill", route: {} }] };
|
|
277
|
+
assert.equal(resolveId(skillOnly, "Agent", { subagent_type: "pdf" }), null);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("universal-hook: post_tool with an mcp__<id>__ tool name logs a used event for that id", () => {
|
|
281
|
+
clearFeedback();
|
|
282
|
+
try {
|
|
283
|
+
runHook(["--host", "claude-code", "--event", "post_tool"], {
|
|
284
|
+
tool_name: "mcp__context7__resolve-library-id",
|
|
285
|
+
tool_input: {},
|
|
286
|
+
});
|
|
287
|
+
assert.ok(existsSync(FEEDBACK_PATH));
|
|
288
|
+
const contents = readFileSync(FEEDBACK_PATH, "utf8");
|
|
289
|
+
assert.ok(contents.includes('"type":"used"'));
|
|
290
|
+
assert.ok(contents.includes('"id":"context7"'));
|
|
291
|
+
} finally {
|
|
292
|
+
restoreFeedback();
|
|
293
|
+
}
|
|
294
|
+
});
|