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.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. 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
+ });