toolfunnel 0.5.0 → 0.6.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 (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,134 +1,134 @@
1
- 'use strict';
2
-
3
- /**
4
- * config-home.js — WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
5
- *
6
- * The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
7
- * directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
8
- * hooks, curation, and identity. Config must be able to live in a home of its own:
9
- *
10
- * resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
11
- *
12
- * The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
13
- * (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
14
- * empty directory makes the gateway SEED it on first use — the shipped register + its scripts, an
15
- * empty expose.json, the default hooks manifest — and every mutable path (tools/ mcp/ hooks/
16
- * auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
17
- * existing file, so a home survives package updates by construction.
18
- *
19
- * Two env vars are the process-tree contract (children inherit them via defaultRunScript's
20
- * spawn env):
21
- * TOOLFUNNEL_HOME — the resolved ABSOLUTE config home (normalised at init so a relative value
22
- * can't skew between processes with different cwds).
23
- * TOOLFUNNEL_PKG — the package root of the gateway that spawned the tool, so a management
24
- * script SEEDED INTO an external home can still require the engine code
25
- * (src/…) it was copied away from. A script always prefers its own
26
- * __dirname-derived root when that root contains the engine (the git-clone
27
- * layout), so a stale inherited value can never shadow a real local install.
28
- *
29
- * Zero new dependencies. Node built-ins only.
30
- */
31
-
32
- const fs = require('node:fs');
33
- const path = require('node:path');
34
-
35
- /** The package root: this file lives at <pkg>/src/core/config-home.js. */
36
- const PKG_ROOT = path.resolve(__dirname, '..', '..');
37
-
38
- /**
39
- * Resolve the config home. Pure — no filesystem writes, no env mutation.
40
- * @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
41
- * @returns {string} absolute config-home path
42
- */
43
- function resolveConfigHome(opts) {
44
- const o = opts || {};
45
- const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
46
- const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
47
- ? process.env.TOOLFUNNEL_HOME.trim()
48
- : null;
49
- const chosen = fromFlag || fromEnv;
50
- return chosen ? path.resolve(chosen) : PKG_ROOT;
51
- }
52
-
53
- /** Recursively copy a directory, skipping any destination file that already exists. */
54
- function copyTreeIfAbsent(srcDir, destDir, seeded) {
55
- let entries;
56
- try {
57
- entries = fs.readdirSync(srcDir, { withFileTypes: true });
58
- } catch (_e) {
59
- return; // a missing source dir seeds nothing (defensive)
60
- }
61
- fs.mkdirSync(destDir, { recursive: true });
62
- for (const e of entries) {
63
- const from = path.join(srcDir, e.name);
64
- const to = path.join(destDir, e.name);
65
- if (e.isDirectory()) {
66
- copyTreeIfAbsent(from, to, seeded);
67
- } else if (e.isFile() && !fs.existsSync(to)) {
68
- fs.copyFileSync(from, to);
69
- seeded.push(to);
70
- }
71
- }
72
- }
73
-
74
- /**
75
- * Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
76
- * Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
77
- * package updates — that is the whole point). home === package root → a no-op (the git-clone
78
- * layout is already complete). Throws only if the home cannot be created at all.
79
- *
80
- * Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
81
- * off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity — an
82
- * npm-wrap ships its OWN copy inside its bundled home).
83
- *
84
- * @param {string} home absolute config-home path (from resolveConfigHome)
85
- * @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
86
- */
87
- function ensureConfigHome(home) {
88
- const seeded = [];
89
- if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
90
- fs.mkdirSync(home, { recursive: true });
91
-
92
- // The register + every shipped script (management tf_* + demo tools). The management scripts
93
- // are config-tree citizens (the register's invoke paths point at them), so they travel.
94
- copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
95
- // The state overlay is user-state, not a shipped default — drop a copied one if the package
96
- // tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
97
- const strayState = path.join(home, 'tools', 'tools.state.json');
98
- if (seeded.includes(strayState)) {
99
- try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
100
- seeded.splice(seeded.indexOf(strayState), 1);
101
- }
102
-
103
- // The upstream/curation store and the hooks tree. NOTE the npm tarball ships
104
- // mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
105
- // tree copy the live store is synthesised EMPTY when absent. Never seed it from the example —
106
- // the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
107
- // connected to nothing, exactly like a fresh clone.
108
- copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
109
- const exposePath = path.join(home, 'mcp', 'expose.json');
110
- if (!fs.existsSync(exposePath)) {
111
- fs.mkdirSync(path.dirname(exposePath), { recursive: true });
112
- fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
113
- seeded.push(exposePath);
114
- }
115
- copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
116
- return { home, seeded };
117
- }
118
-
119
- /**
120
- * The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
121
- * process-tree contract back into the env (see the header) so every module loaded AFTER this —
122
- * and every child tool spawned later — reads the same resolved home.
123
- * @param {{dir?: string}} [opts]
124
- * @returns {{home: string, seeded: string[]}}
125
- */
126
- function initConfigHome(opts) {
127
- const home = resolveConfigHome(opts);
128
- const res = ensureConfigHome(home);
129
- process.env.TOOLFUNNEL_HOME = res.home;
130
- process.env.TOOLFUNNEL_PKG = PKG_ROOT;
131
- return res;
132
- }
133
-
134
- module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
1
+ 'use strict';
2
+
3
+ /**
4
+ * config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
5
+ *
6
+ * The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
7
+ * directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
8
+ * hooks, curation, and identity. Config must be able to live in a home of its own:
9
+ *
10
+ * resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
11
+ *
12
+ * The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
13
+ * (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
14
+ * empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
15
+ * empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
16
+ * auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
17
+ * existing file, so a home survives package updates by construction.
18
+ *
19
+ * Two env vars are the process-tree contract (children inherit them via defaultRunScript's
20
+ * spawn env):
21
+ * TOOLFUNNEL_HOME - the resolved ABSOLUTE config home (normalised at init so a relative value
22
+ * can't skew between processes with different cwds).
23
+ * TOOLFUNNEL_PKG - the package root of the gateway that spawned the tool, so a management
24
+ * script SEEDED INTO an external home can still require the engine code
25
+ * (src/...) it was copied away from. A script always prefers its own
26
+ * __dirname-derived root when that root contains the engine (the git-clone
27
+ * layout), so a stale inherited value can never shadow a real local install.
28
+ *
29
+ * Zero new dependencies. Node built-ins only.
30
+ */
31
+
32
+ const fs = require('node:fs');
33
+ const path = require('node:path');
34
+
35
+ /** The package root: this file lives at <pkg>/src/core/config-home.js. */
36
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
37
+
38
+ /**
39
+ * Resolve the config home. Pure - no filesystem writes, no env mutation.
40
+ * @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
41
+ * @returns {string} absolute config-home path
42
+ */
43
+ function resolveConfigHome(opts) {
44
+ const o = opts || {};
45
+ const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
46
+ const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
47
+ ? process.env.TOOLFUNNEL_HOME.trim()
48
+ : null;
49
+ const chosen = fromFlag || fromEnv;
50
+ return chosen ? path.resolve(chosen) : PKG_ROOT;
51
+ }
52
+
53
+ /** Recursively copy a directory, skipping any destination file that already exists. */
54
+ function copyTreeIfAbsent(srcDir, destDir, seeded) {
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(srcDir, { withFileTypes: true });
58
+ } catch (_e) {
59
+ return; // a missing source dir seeds nothing (defensive)
60
+ }
61
+ fs.mkdirSync(destDir, { recursive: true });
62
+ for (const e of entries) {
63
+ const from = path.join(srcDir, e.name);
64
+ const to = path.join(destDir, e.name);
65
+ if (e.isDirectory()) {
66
+ copyTreeIfAbsent(from, to, seeded);
67
+ } else if (e.isFile() && !fs.existsSync(to)) {
68
+ fs.copyFileSync(from, to);
69
+ seeded.push(to);
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
76
+ * Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
77
+ * package updates - that is the whole point). home === package root -> a no-op (the git-clone
78
+ * layout is already complete). Throws only if the home cannot be created at all.
79
+ *
80
+ * Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
81
+ * off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity - an
82
+ * npm-wrap ships its OWN copy inside its bundled home).
83
+ *
84
+ * @param {string} home absolute config-home path (from resolveConfigHome)
85
+ * @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
86
+ */
87
+ function ensureConfigHome(home) {
88
+ const seeded = [];
89
+ if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
90
+ fs.mkdirSync(home, { recursive: true });
91
+
92
+ // The register + every shipped script (management tf_* + demo tools). The management scripts
93
+ // are config-tree citizens (the register's invoke paths point at them), so they travel.
94
+ copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
95
+ // The state overlay is user-state, not a shipped default - drop a copied one if the package
96
+ // tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
97
+ const strayState = path.join(home, 'tools', 'tools.state.json');
98
+ if (seeded.includes(strayState)) {
99
+ try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
100
+ seeded.splice(seeded.indexOf(strayState), 1);
101
+ }
102
+
103
+ // The upstream/curation store and the hooks tree. NOTE the npm tarball ships
104
+ // mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
105
+ // tree copy the live store is synthesised EMPTY when absent. Never seed it from the example -
106
+ // the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
107
+ // connected to nothing, exactly like a fresh clone.
108
+ copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
109
+ const exposePath = path.join(home, 'mcp', 'expose.json');
110
+ if (!fs.existsSync(exposePath)) {
111
+ fs.mkdirSync(path.dirname(exposePath), { recursive: true });
112
+ fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
113
+ seeded.push(exposePath);
114
+ }
115
+ copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
116
+ return { home, seeded };
117
+ }
118
+
119
+ /**
120
+ * The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
121
+ * process-tree contract back into the env (see the header) so every module loaded AFTER this -
122
+ * and every child tool spawned later - reads the same resolved home.
123
+ * @param {{dir?: string}} [opts]
124
+ * @returns {{home: string, seeded: string[]}}
125
+ */
126
+ function initConfigHome(opts) {
127
+ const home = resolveConfigHome(opts);
128
+ const res = ensureConfigHome(home);
129
+ process.env.TOOLFUNNEL_HOME = res.home;
130
+ process.env.TOOLFUNNEL_PKG = PKG_ROOT;
131
+ return res;
132
+ }
133
+
134
+ module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
@@ -1,105 +1,105 @@
1
- 'use strict';
2
-
3
- /**
4
- * events.js — lifecycle event names + per-event stdin payload builder.
5
- *
6
- * This is the contract surface from HOOK_ENGINE.md §1 (the six events) and §2
7
- * (the exact stdin JSON shape). Field names are FROZEN by §2's table — they must
8
- * match Claude Code's documented hook input byte-for-byte so the real hook
9
- * scripts run unchanged. Do not rename or add fields.
10
- *
11
- * CommonJS only. Zero host imports (core/ must run headless under node --test).
12
- */
13
-
14
- /**
15
- * The six lifecycle events v3 supports (HOOK_ENGINE.md §1).
16
- * Values equal the `hook_event_name` written into the stdin payload.
17
- * Frozen so callers can rely on identity comparison and can't mutate the set.
18
- */
19
- const EVENTS = Object.freeze({
20
- SessionStart: 'SessionStart',
21
- UserPromptSubmit: 'UserPromptSubmit',
22
- PreToolUse: 'PreToolUse',
23
- PostToolUse: 'PostToolUse',
24
- Stop: 'Stop',
25
- PreCompact: 'PreCompact',
26
- });
27
-
28
- // Set of valid event names for fast membership checks / validation.
29
- const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
30
-
31
- /**
32
- * Build the stdin JSON object the runner pipes to a hook command.
33
- *
34
- * Every event carries the four common fields (HOOK_ENGINE.md §2):
35
- * session_id, transcript_path, cwd, hook_event_name
36
- * supplied by `ctx`. Each event then adds its own fields from `extra`:
37
- *
38
- * SessionStart → source : "startup" | "resume" | "compact"
39
- * UserPromptSubmit → prompt : "<user text>"
40
- * PreToolUse → tool_name, tool_input
41
- * PostToolUse → tool_name, tool_input, tool_response
42
- * Stop → stop_hook_active : true | false
43
- * PreCompact → trigger ("manual"|"auto"), custom_instructions
44
- *
45
- * @param {string} event one of EVENTS (the hook_event_name).
46
- * @param {object} ctx common fields: { session_id, transcript_path, cwd }.
47
- * @param {object} [extra] event-specific fields (see table above).
48
- * @returns {object} the stdin payload, with frozen field names per §2.
49
- * @throws {Error} if `event` is not one of the six known events.
50
- */
51
- function buildPayload(event, ctx, extra) {
52
- if (!EVENT_NAMES.includes(event)) {
53
- throw new Error(`buildPayload: unknown event "${event}"`);
54
- }
55
-
56
- const c = ctx || {};
57
- const e = extra || {};
58
-
59
- // Common fields — present on EVERY event, in the order §2 lists them.
60
- // hook_event_name is always the canonical event string (not whatever ctx held).
61
- const payload = {
62
- session_id: c.session_id,
63
- transcript_path: c.transcript_path,
64
- cwd: c.cwd,
65
- hook_event_name: event,
66
- };
67
-
68
- // Event-specific additions. Each branch sets EXACTLY the fields §2 names —
69
- // no more, no fewer — so the payload matches Claude Code's shape precisely.
70
- switch (event) {
71
- case EVENTS.SessionStart:
72
- payload.source = e.source;
73
- break;
74
-
75
- case EVENTS.UserPromptSubmit:
76
- payload.prompt = e.prompt;
77
- break;
78
-
79
- case EVENTS.PreToolUse:
80
- payload.tool_name = e.tool_name;
81
- payload.tool_input = e.tool_input;
82
- break;
83
-
84
- case EVENTS.PostToolUse:
85
- payload.tool_name = e.tool_name;
86
- payload.tool_input = e.tool_input;
87
- payload.tool_response = e.tool_response;
88
- break;
89
-
90
- case EVENTS.Stop:
91
- payload.stop_hook_active = e.stop_hook_active;
92
- break;
93
-
94
- case EVENTS.PreCompact:
95
- payload.trigger = e.trigger;
96
- payload.custom_instructions = e.custom_instructions;
97
- break;
98
-
99
- // No default: the membership check above already rejected unknown events.
100
- }
101
-
102
- return payload;
103
- }
104
-
105
- module.exports = { EVENTS, EVENT_NAMES, buildPayload };
1
+ 'use strict';
2
+
3
+ /**
4
+ * events.js - lifecycle event names + per-event stdin payload builder.
5
+ *
6
+ * This is the contract surface from HOOK_ENGINE.md §1 (the six events) and §2
7
+ * (the exact stdin JSON shape). Field names are FROZEN by §2's table - they must
8
+ * match Claude Code's documented hook input byte-for-byte so the real hook
9
+ * scripts run unchanged. Do not rename or add fields.
10
+ *
11
+ * CommonJS only. Zero host imports (core/ must run headless under node --test).
12
+ */
13
+
14
+ /**
15
+ * The six lifecycle events v3 supports (HOOK_ENGINE.md §1).
16
+ * Values equal the `hook_event_name` written into the stdin payload.
17
+ * Frozen so callers can rely on identity comparison and can't mutate the set.
18
+ */
19
+ const EVENTS = Object.freeze({
20
+ SessionStart: 'SessionStart',
21
+ UserPromptSubmit: 'UserPromptSubmit',
22
+ PreToolUse: 'PreToolUse',
23
+ PostToolUse: 'PostToolUse',
24
+ Stop: 'Stop',
25
+ PreCompact: 'PreCompact',
26
+ });
27
+
28
+ // Set of valid event names for fast membership checks / validation.
29
+ const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
30
+
31
+ /**
32
+ * Build the stdin JSON object the runner pipes to a hook command.
33
+ *
34
+ * Every event carries the four common fields (HOOK_ENGINE.md §2):
35
+ * session_id, transcript_path, cwd, hook_event_name
36
+ * supplied by `ctx`. Each event then adds its own fields from `extra`:
37
+ *
38
+ * SessionStart -> source : "startup" | "resume" | "compact"
39
+ * UserPromptSubmit -> prompt : "<user text>"
40
+ * PreToolUse -> tool_name, tool_input
41
+ * PostToolUse -> tool_name, tool_input, tool_response
42
+ * Stop -> stop_hook_active : true | false
43
+ * PreCompact -> trigger ("manual"|"auto"), custom_instructions
44
+ *
45
+ * @param {string} event one of EVENTS (the hook_event_name).
46
+ * @param {object} ctx common fields: { session_id, transcript_path, cwd }.
47
+ * @param {object} [extra] event-specific fields (see table above).
48
+ * @returns {object} the stdin payload, with frozen field names per §2.
49
+ * @throws {Error} if `event` is not one of the six known events.
50
+ */
51
+ function buildPayload(event, ctx, extra) {
52
+ if (!EVENT_NAMES.includes(event)) {
53
+ throw new Error(`buildPayload: unknown event "${event}"`);
54
+ }
55
+
56
+ const c = ctx || {};
57
+ const e = extra || {};
58
+
59
+ // Common fields - present on EVERY event, in the order §2 lists them.
60
+ // hook_event_name is always the canonical event string (not whatever ctx held).
61
+ const payload = {
62
+ session_id: c.session_id,
63
+ transcript_path: c.transcript_path,
64
+ cwd: c.cwd,
65
+ hook_event_name: event,
66
+ };
67
+
68
+ // Event-specific additions. Each branch sets EXACTLY the fields §2 names -
69
+ // no more, no fewer - so the payload matches Claude Code's shape precisely.
70
+ switch (event) {
71
+ case EVENTS.SessionStart:
72
+ payload.source = e.source;
73
+ break;
74
+
75
+ case EVENTS.UserPromptSubmit:
76
+ payload.prompt = e.prompt;
77
+ break;
78
+
79
+ case EVENTS.PreToolUse:
80
+ payload.tool_name = e.tool_name;
81
+ payload.tool_input = e.tool_input;
82
+ break;
83
+
84
+ case EVENTS.PostToolUse:
85
+ payload.tool_name = e.tool_name;
86
+ payload.tool_input = e.tool_input;
87
+ payload.tool_response = e.tool_response;
88
+ break;
89
+
90
+ case EVENTS.Stop:
91
+ payload.stop_hook_active = e.stop_hook_active;
92
+ break;
93
+
94
+ case EVENTS.PreCompact:
95
+ payload.trigger = e.trigger;
96
+ payload.custom_instructions = e.custom_instructions;
97
+ break;
98
+
99
+ // No default: the membership check above already rejected unknown events.
100
+ }
101
+
102
+ return payload;
103
+ }
104
+
105
+ module.exports = { EVENTS, EVENT_NAMES, buildPayload };