@hasna/hooks 0.3.0 → 0.3.1
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/README.md +4 -3
- package/bin/index.js +67 -28
- package/dist/index.d.ts +3 -3
- package/dist/index.js +70 -29
- package/dist/lib/installer.d.ts +2 -1
- package/dist/lib/registry.d.ts +3 -1
- package/hooks/codewith-native-common.ts +1 -1
- package/hooks/hook-knowledge-context/README.md +53 -0
- package/hooks/hook-knowledge-context/package.json +52 -0
- package/hooks/hook-knowledge-context/src/hook.test.ts +168 -0
- package/hooks/hook-knowledge-context/src/hook.ts +355 -0
- package/hooks/hook-knowledge-context/tsconfig.json +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,26 +28,27 @@ hooks --help
|
|
|
28
28
|
|
|
29
29
|
## Codewith-native hooks
|
|
30
30
|
|
|
31
|
-
@hasna/hooks includes
|
|
31
|
+
@hasna/hooks includes unprefixed Codewith-native hook names:
|
|
32
32
|
|
|
33
33
|
- `session-start` — `SessionStart` digest and additional context.
|
|
34
34
|
- `prompt-guard` — `UserPromptSubmit` guard for pasted fake policy/freeze/run-this-now content.
|
|
35
35
|
- `pre-bash` — `PreToolUse` Bash gate for staged secrets scans and risky-op comms checks.
|
|
36
36
|
- `worktree-guard` — `PreToolUse` guard for managed repos worktree boundaries.
|
|
37
37
|
- `stop-sync` — `Stop` turn-end heartbeat/evidence best effort.
|
|
38
|
+
- `knowledge-context` — deterministic Knowledge context packs for `SessionStart`, `UserPromptSubmit`, and `SubagentStart`.
|
|
38
39
|
|
|
39
40
|
For Codewith, the installer is renderer-safe by default: it emits a TOML
|
|
40
41
|
fragment instead of mutating managed `~/.codewith/config.toml`.
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
hooks install session-start prompt-guard pre-bash worktree-guard stop-sync --target codewith
|
|
44
|
+
hooks install session-start prompt-guard pre-bash worktree-guard stop-sync knowledge-context --target codewith
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
Apply that fragment through `open-configs` or the managed config renderer. A
|
|
47
48
|
direct write path exists only for explicit local/test use:
|
|
48
49
|
|
|
49
50
|
```bash
|
|
50
|
-
hooks install
|
|
51
|
+
hooks install knowledge-context --target codewith --apply-codewith --codewith-config /tmp/codewith-config.toml
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
## Storage
|
package/bin/index.js
CHANGED
|
@@ -1890,6 +1890,9 @@ var require_commander = __commonJS((exports) => {
|
|
|
1890
1890
|
function getHooksByCategory(category) {
|
|
1891
1891
|
return HOOKS.filter((h) => h.category === category);
|
|
1892
1892
|
}
|
|
1893
|
+
function getHookEvents(hook) {
|
|
1894
|
+
return hook.events && hook.events.length > 0 ? hook.events : [hook.event];
|
|
1895
|
+
}
|
|
1893
1896
|
function searchHooks(query) {
|
|
1894
1897
|
const q = query.toLowerCase();
|
|
1895
1898
|
return HOOKS.filter((h) => h.name.toLowerCase().includes(q) || h.displayName.toLowerCase().includes(q) || h.description.toLowerCase().includes(q) || h.tags.some((t) => t.includes(q)));
|
|
@@ -2082,6 +2085,17 @@ var init_registry = __esm(() => {
|
|
|
2082
2085
|
matcher: "",
|
|
2083
2086
|
tags: ["context", "compaction", "state", "backup"]
|
|
2084
2087
|
},
|
|
2088
|
+
{
|
|
2089
|
+
name: "knowledge-context",
|
|
2090
|
+
displayName: "Knowledge Context",
|
|
2091
|
+
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
2092
|
+
version: "0.1.0",
|
|
2093
|
+
category: "Context Management",
|
|
2094
|
+
event: "SessionStart",
|
|
2095
|
+
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
2096
|
+
matcher: "",
|
|
2097
|
+
tags: ["knowledge", "context", "codewith", "deterministic", "session-start", "prompt", "subagent"]
|
|
2098
|
+
},
|
|
2085
2099
|
{
|
|
2086
2100
|
name: "autoformat",
|
|
2087
2101
|
displayName: "Auto Format",
|
|
@@ -4111,6 +4125,8 @@ function codewithMatcher(matcher) {
|
|
|
4111
4125
|
}
|
|
4112
4126
|
function codewithTimeout(name) {
|
|
4113
4127
|
switch (name) {
|
|
4128
|
+
case "knowledge-context":
|
|
4129
|
+
return 2;
|
|
4114
4130
|
case "session-start":
|
|
4115
4131
|
return 8;
|
|
4116
4132
|
case "pre-bash":
|
|
@@ -4126,6 +4142,8 @@ function codewithTimeout(name) {
|
|
|
4126
4142
|
}
|
|
4127
4143
|
function codewithStatusMessage(name) {
|
|
4128
4144
|
switch (name) {
|
|
4145
|
+
case "knowledge-context":
|
|
4146
|
+
return "Loading Knowledge context";
|
|
4129
4147
|
case "session-start":
|
|
4130
4148
|
return "Checking Hasna session context";
|
|
4131
4149
|
case "pre-bash":
|
|
@@ -4145,19 +4163,25 @@ function buildCodewithTomlFragment(name, profile) {
|
|
|
4145
4163
|
const meta = getHook(shortName);
|
|
4146
4164
|
if (!meta)
|
|
4147
4165
|
throw new Error(`Hook '${shortName}' not found`);
|
|
4148
|
-
const eventKey = getTargetEventName(meta.event, "codewith");
|
|
4149
|
-
if (!eventKey) {
|
|
4150
|
-
throw new Error(`Hook '${shortName}' uses event '${meta.event}', which is not supported by the Codewith target`);
|
|
4151
|
-
}
|
|
4152
4166
|
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
4153
4167
|
const matcher = codewithMatcher(meta.matcher);
|
|
4154
|
-
const
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4168
|
+
const fragments = [];
|
|
4169
|
+
for (const event of getHookEvents(meta)) {
|
|
4170
|
+
const eventKey = getTargetEventName(event, "codewith");
|
|
4171
|
+
if (!eventKey) {
|
|
4172
|
+
throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
|
|
4173
|
+
}
|
|
4174
|
+
const lines = [
|
|
4175
|
+
`[[hooks.${eventKey}]]`
|
|
4176
|
+
];
|
|
4177
|
+
if (matcher)
|
|
4178
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
4179
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
4180
|
+
fragments.push(lines.join(`
|
|
4181
|
+
`));
|
|
4182
|
+
}
|
|
4183
|
+
return `${fragments.join(`
|
|
4184
|
+
|
|
4161
4185
|
`)}
|
|
4162
4186
|
`;
|
|
4163
4187
|
}
|
|
@@ -4185,12 +4209,15 @@ function detectConflict(name, scope, target) {
|
|
|
4185
4209
|
const meta = getHook(name);
|
|
4186
4210
|
if (!meta || !meta.matcher)
|
|
4187
4211
|
return;
|
|
4212
|
+
const events = new Set(getHookEvents(meta));
|
|
4188
4213
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
4189
4214
|
for (const existingName of registered) {
|
|
4190
4215
|
if (existingName === name)
|
|
4191
4216
|
continue;
|
|
4192
4217
|
const existing = getHook(existingName);
|
|
4193
|
-
if (!existing ||
|
|
4218
|
+
if (!existing || !existing.matcher)
|
|
4219
|
+
continue;
|
|
4220
|
+
if (!getHookEvents(existing).some((event) => events.has(event)))
|
|
4194
4221
|
continue;
|
|
4195
4222
|
const a = meta.matcher.toLowerCase();
|
|
4196
4223
|
const b = existing.matcher.toLowerCase();
|
|
@@ -4277,12 +4304,12 @@ function installHook(name, options = {}) {
|
|
|
4277
4304
|
const shortName = shortHookName(name);
|
|
4278
4305
|
const meta = getHook(shortName);
|
|
4279
4306
|
if (meta) {
|
|
4280
|
-
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) =>
|
|
4307
|
+
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
|
|
4281
4308
|
if (unsupportedTargets.length > 0) {
|
|
4282
4309
|
return {
|
|
4283
4310
|
hook: shortName,
|
|
4284
4311
|
success: false,
|
|
4285
|
-
error: `Event '${meta.
|
|
4312
|
+
error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
4286
4313
|
target: "all"
|
|
4287
4314
|
};
|
|
4288
4315
|
}
|
|
@@ -4319,24 +4346,33 @@ function registerHook(name, scope = "global", target = "claude", profile) {
|
|
|
4319
4346
|
const meta = getHook(name);
|
|
4320
4347
|
if (!meta)
|
|
4321
4348
|
return;
|
|
4322
|
-
const
|
|
4323
|
-
|
|
4324
|
-
|
|
4349
|
+
const eventKeys = getHookEvents(meta).map((event) => {
|
|
4350
|
+
const eventKey = getTargetEventName(event, target);
|
|
4351
|
+
if (eventKey === null) {
|
|
4352
|
+
throw new Error(`Event '${event}' is not supported by target '${target}'`);
|
|
4353
|
+
}
|
|
4354
|
+
return eventKey;
|
|
4355
|
+
});
|
|
4356
|
+
const uniqueEventKeys = [...new Set(eventKeys)];
|
|
4357
|
+
if (uniqueEventKeys.length === 0) {
|
|
4358
|
+
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
4325
4359
|
}
|
|
4326
4360
|
const settings = readSettings(scope, target);
|
|
4327
4361
|
if (!settings.hooks)
|
|
4328
4362
|
settings.hooks = {};
|
|
4329
4363
|
removeHookFromAllEvents(settings, name);
|
|
4330
|
-
if (!settings.hooks[eventKey])
|
|
4331
|
-
settings.hooks[eventKey] = [];
|
|
4332
4364
|
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
4333
|
-
const
|
|
4334
|
-
hooks
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4365
|
+
for (const eventKey of uniqueEventKeys) {
|
|
4366
|
+
if (!settings.hooks[eventKey])
|
|
4367
|
+
settings.hooks[eventKey] = [];
|
|
4368
|
+
const entry = {
|
|
4369
|
+
hooks: [{ type: "command", command: hookCommand }]
|
|
4370
|
+
};
|
|
4371
|
+
if (meta.matcher) {
|
|
4372
|
+
entry.matcher = meta.matcher;
|
|
4373
|
+
}
|
|
4374
|
+
settings.hooks[eventKey].push(entry);
|
|
4338
4375
|
}
|
|
4339
|
-
settings.hooks[eventKey].push(entry);
|
|
4340
4376
|
writeSettings(settings, scope, target);
|
|
4341
4377
|
}
|
|
4342
4378
|
function removeHookFromAllEvents(settings, name) {
|
|
@@ -4427,7 +4463,8 @@ var init_installer = __esm(() => {
|
|
|
4427
4463
|
Notification: "Notification",
|
|
4428
4464
|
SessionStart: "SessionStart",
|
|
4429
4465
|
SessionEnd: "SessionEnd",
|
|
4430
|
-
UserPromptSubmit: null
|
|
4466
|
+
UserPromptSubmit: null,
|
|
4467
|
+
SubagentStart: null
|
|
4431
4468
|
},
|
|
4432
4469
|
gemini: {
|
|
4433
4470
|
PreToolUse: "BeforeTool",
|
|
@@ -4436,7 +4473,8 @@ var init_installer = __esm(() => {
|
|
|
4436
4473
|
Notification: "Notification",
|
|
4437
4474
|
SessionStart: null,
|
|
4438
4475
|
SessionEnd: null,
|
|
4439
|
-
UserPromptSubmit: null
|
|
4476
|
+
UserPromptSubmit: null,
|
|
4477
|
+
SubagentStart: null
|
|
4440
4478
|
},
|
|
4441
4479
|
codewith: {
|
|
4442
4480
|
PreToolUse: "PreToolUse",
|
|
@@ -4445,7 +4483,8 @@ var init_installer = __esm(() => {
|
|
|
4445
4483
|
Notification: null,
|
|
4446
4484
|
SessionStart: "SessionStart",
|
|
4447
4485
|
SessionEnd: null,
|
|
4448
|
-
UserPromptSubmit: "UserPromptSubmit"
|
|
4486
|
+
UserPromptSubmit: "UserPromptSubmit",
|
|
4487
|
+
SubagentStart: "SubagentStart"
|
|
4449
4488
|
}
|
|
4450
4489
|
};
|
|
4451
4490
|
getInstalledHooks = getRegisteredHooks;
|
package/dist/index.d.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* Or use the interactive CLI:
|
|
8
8
|
* npx @hasna/hooks
|
|
9
9
|
*/
|
|
10
|
-
export { HOOKS, HOOK_EVENTS, CATEGORIES, getHook, getHooksByCategory, searchHooks, type HookMeta, type HookEvent, type Category, } from "./lib/registry.js";
|
|
10
|
+
export { HOOKS, HOOK_EVENTS, CATEGORIES, getHook, getHookEvents, getHooksByCategory, searchHooks, type HookMeta, type HookEvent, type Category, } from "./lib/registry.js";
|
|
11
11
|
import type { HookEvent as HookEventType } from "./lib/registry.js";
|
|
12
|
-
export { installHook, installHooks, getInstalledHooks, getRegisteredHooks, getRegisteredHooksForTarget, removeHook, hookExists, buildCodewithTomlFragment, getHookPath, getSettingsPath, isEventSupported, type InstallResult, type InstallOptions, type Scope, type Target, type CodewithInstallMode, } from "./lib/installer.js";
|
|
12
|
+
export { installHook, installHooks, getInstalledHooks, getRegisteredHooks, getRegisteredHooksForTarget, removeHook, hookExists, buildCodewithTomlFragment, getHookPath, getSettingsPath, isEventSupported, type InstallResult, type InstallOptions, type Scope, type ConcreteTarget, type Target, type CodewithInstallMode, } from "./lib/installer.js";
|
|
13
13
|
export interface HookAgentInfo {
|
|
14
14
|
agent_id: string;
|
|
15
15
|
agent_type: "claude" | "gemini" | "codewith" | "custom";
|
|
@@ -33,7 +33,7 @@ export interface HookOutput {
|
|
|
33
33
|
reason?: string;
|
|
34
34
|
continue?: boolean;
|
|
35
35
|
hookSpecificOutput?: {
|
|
36
|
-
hookEventName: "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "Stop" | "Notification" | "SessionEnd";
|
|
36
|
+
hookEventName: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | "PreToolUse" | "PostToolUse" | "Stop" | "Notification" | "SessionEnd";
|
|
37
37
|
additionalContext?: string;
|
|
38
38
|
permissionDecision?: "allow" | "deny" | "ask";
|
|
39
39
|
permissionDecisionReason?: string;
|
package/dist/index.js
CHANGED
|
@@ -820,7 +820,8 @@ var HOOK_EVENTS = [
|
|
|
820
820
|
"Notification",
|
|
821
821
|
"SessionStart",
|
|
822
822
|
"SessionEnd",
|
|
823
|
-
"UserPromptSubmit"
|
|
823
|
+
"UserPromptSubmit",
|
|
824
|
+
"SubagentStart"
|
|
824
825
|
];
|
|
825
826
|
var CATEGORIES = [
|
|
826
827
|
"Git Safety",
|
|
@@ -1005,6 +1006,17 @@ var HOOKS = [
|
|
|
1005
1006
|
matcher: "",
|
|
1006
1007
|
tags: ["context", "compaction", "state", "backup"]
|
|
1007
1008
|
},
|
|
1009
|
+
{
|
|
1010
|
+
name: "knowledge-context",
|
|
1011
|
+
displayName: "Knowledge Context",
|
|
1012
|
+
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
1013
|
+
version: "0.1.0",
|
|
1014
|
+
category: "Context Management",
|
|
1015
|
+
event: "SessionStart",
|
|
1016
|
+
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
1017
|
+
matcher: "",
|
|
1018
|
+
tags: ["knowledge", "context", "codewith", "deterministic", "session-start", "prompt", "subagent"]
|
|
1019
|
+
},
|
|
1008
1020
|
{
|
|
1009
1021
|
name: "autoformat",
|
|
1010
1022
|
displayName: "Auto Format",
|
|
@@ -1309,6 +1321,9 @@ var HOOKS = [
|
|
|
1309
1321
|
function getHooksByCategory(category) {
|
|
1310
1322
|
return HOOKS.filter((h) => h.category === category);
|
|
1311
1323
|
}
|
|
1324
|
+
function getHookEvents(hook) {
|
|
1325
|
+
return hook.events && hook.events.length > 0 ? hook.events : [hook.event];
|
|
1326
|
+
}
|
|
1312
1327
|
function searchHooks(query) {
|
|
1313
1328
|
const q = query.toLowerCase();
|
|
1314
1329
|
return HOOKS.filter((h) => h.name.toLowerCase().includes(q) || h.displayName.toLowerCase().includes(q) || h.description.toLowerCase().includes(q) || h.tags.some((t) => t.includes(q)));
|
|
@@ -1343,7 +1358,8 @@ var EVENT_MAP = {
|
|
|
1343
1358
|
Notification: "Notification",
|
|
1344
1359
|
SessionStart: "SessionStart",
|
|
1345
1360
|
SessionEnd: "SessionEnd",
|
|
1346
|
-
UserPromptSubmit: null
|
|
1361
|
+
UserPromptSubmit: null,
|
|
1362
|
+
SubagentStart: null
|
|
1347
1363
|
},
|
|
1348
1364
|
gemini: {
|
|
1349
1365
|
PreToolUse: "BeforeTool",
|
|
@@ -1352,7 +1368,8 @@ var EVENT_MAP = {
|
|
|
1352
1368
|
Notification: "Notification",
|
|
1353
1369
|
SessionStart: null,
|
|
1354
1370
|
SessionEnd: null,
|
|
1355
|
-
UserPromptSubmit: null
|
|
1371
|
+
UserPromptSubmit: null,
|
|
1372
|
+
SubagentStart: null
|
|
1356
1373
|
},
|
|
1357
1374
|
codewith: {
|
|
1358
1375
|
PreToolUse: "PreToolUse",
|
|
@@ -1361,7 +1378,8 @@ var EVENT_MAP = {
|
|
|
1361
1378
|
Notification: null,
|
|
1362
1379
|
SessionStart: "SessionStart",
|
|
1363
1380
|
SessionEnd: null,
|
|
1364
|
-
UserPromptSubmit: "UserPromptSubmit"
|
|
1381
|
+
UserPromptSubmit: "UserPromptSubmit",
|
|
1382
|
+
SubagentStart: "SubagentStart"
|
|
1365
1383
|
}
|
|
1366
1384
|
};
|
|
1367
1385
|
function getTargetSettingsDir(target) {
|
|
@@ -1433,6 +1451,8 @@ function codewithMatcher(matcher) {
|
|
|
1433
1451
|
}
|
|
1434
1452
|
function codewithTimeout(name) {
|
|
1435
1453
|
switch (name) {
|
|
1454
|
+
case "knowledge-context":
|
|
1455
|
+
return 2;
|
|
1436
1456
|
case "session-start":
|
|
1437
1457
|
return 8;
|
|
1438
1458
|
case "pre-bash":
|
|
@@ -1448,6 +1468,8 @@ function codewithTimeout(name) {
|
|
|
1448
1468
|
}
|
|
1449
1469
|
function codewithStatusMessage(name) {
|
|
1450
1470
|
switch (name) {
|
|
1471
|
+
case "knowledge-context":
|
|
1472
|
+
return "Loading Knowledge context";
|
|
1451
1473
|
case "session-start":
|
|
1452
1474
|
return "Checking Hasna session context";
|
|
1453
1475
|
case "pre-bash":
|
|
@@ -1467,19 +1489,25 @@ function buildCodewithTomlFragment(name, profile) {
|
|
|
1467
1489
|
const meta = getHook(shortName);
|
|
1468
1490
|
if (!meta)
|
|
1469
1491
|
throw new Error(`Hook '${shortName}' not found`);
|
|
1470
|
-
const eventKey = getTargetEventName(meta.event, "codewith");
|
|
1471
|
-
if (!eventKey) {
|
|
1472
|
-
throw new Error(`Hook '${shortName}' uses event '${meta.event}', which is not supported by the Codewith target`);
|
|
1473
|
-
}
|
|
1474
1492
|
const command = profile ? `hooks run ${shortName} --profile ${profile}` : `hooks run ${shortName}`;
|
|
1475
1493
|
const matcher = codewithMatcher(meta.matcher);
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1494
|
+
const fragments = [];
|
|
1495
|
+
for (const event of getHookEvents(meta)) {
|
|
1496
|
+
const eventKey = getTargetEventName(event, "codewith");
|
|
1497
|
+
if (!eventKey) {
|
|
1498
|
+
throw new Error(`Hook '${shortName}' uses event '${event}', which is not supported by the Codewith target`);
|
|
1499
|
+
}
|
|
1500
|
+
const lines = [
|
|
1501
|
+
`[[hooks.${eventKey}]]`
|
|
1502
|
+
];
|
|
1503
|
+
if (matcher)
|
|
1504
|
+
lines.push(`matcher = ${tomlString(matcher)}`);
|
|
1505
|
+
lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
|
|
1506
|
+
fragments.push(lines.join(`
|
|
1507
|
+
`));
|
|
1508
|
+
}
|
|
1509
|
+
return `${fragments.join(`
|
|
1510
|
+
|
|
1483
1511
|
`)}
|
|
1484
1512
|
`;
|
|
1485
1513
|
}
|
|
@@ -1507,12 +1535,15 @@ function detectConflict(name, scope, target) {
|
|
|
1507
1535
|
const meta = getHook(name);
|
|
1508
1536
|
if (!meta || !meta.matcher)
|
|
1509
1537
|
return;
|
|
1538
|
+
const events = new Set(getHookEvents(meta));
|
|
1510
1539
|
const registered = getRegisteredHooksForTarget(scope, target);
|
|
1511
1540
|
for (const existingName of registered) {
|
|
1512
1541
|
if (existingName === name)
|
|
1513
1542
|
continue;
|
|
1514
1543
|
const existing = getHook(existingName);
|
|
1515
|
-
if (!existing ||
|
|
1544
|
+
if (!existing || !existing.matcher)
|
|
1545
|
+
continue;
|
|
1546
|
+
if (!getHookEvents(existing).some((event) => events.has(event)))
|
|
1516
1547
|
continue;
|
|
1517
1548
|
const a = meta.matcher.toLowerCase();
|
|
1518
1549
|
const b = existing.matcher.toLowerCase();
|
|
@@ -1599,12 +1630,12 @@ function installHook(name, options = {}) {
|
|
|
1599
1630
|
const shortName = shortHookName(name);
|
|
1600
1631
|
const meta = getHook(shortName);
|
|
1601
1632
|
if (meta) {
|
|
1602
|
-
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) =>
|
|
1633
|
+
const unsupportedTargets = ["claude", "gemini", "codewith"].filter((agentTarget) => getHookEvents(meta).some((event) => !isEventSupported(event, agentTarget)));
|
|
1603
1634
|
if (unsupportedTargets.length > 0) {
|
|
1604
1635
|
return {
|
|
1605
1636
|
hook: shortName,
|
|
1606
1637
|
success: false,
|
|
1607
|
-
error: `Event '${meta.
|
|
1638
|
+
error: `Event(s) '${getHookEvents(meta).join(", ")}' are not supported by target(s): ${unsupportedTargets.join(", ")}`,
|
|
1608
1639
|
target: "all"
|
|
1609
1640
|
};
|
|
1610
1641
|
}
|
|
@@ -1641,24 +1672,33 @@ function registerHook(name, scope = "global", target = "claude", profile) {
|
|
|
1641
1672
|
const meta = getHook(name);
|
|
1642
1673
|
if (!meta)
|
|
1643
1674
|
return;
|
|
1644
|
-
const
|
|
1645
|
-
|
|
1646
|
-
|
|
1675
|
+
const eventKeys = getHookEvents(meta).map((event) => {
|
|
1676
|
+
const eventKey = getTargetEventName(event, target);
|
|
1677
|
+
if (eventKey === null) {
|
|
1678
|
+
throw new Error(`Event '${event}' is not supported by target '${target}'`);
|
|
1679
|
+
}
|
|
1680
|
+
return eventKey;
|
|
1681
|
+
});
|
|
1682
|
+
const uniqueEventKeys = [...new Set(eventKeys)];
|
|
1683
|
+
if (uniqueEventKeys.length === 0) {
|
|
1684
|
+
throw new Error(`Hook '${name}' has no installable events for target '${target}'`);
|
|
1647
1685
|
}
|
|
1648
1686
|
const settings = readSettings(scope, target);
|
|
1649
1687
|
if (!settings.hooks)
|
|
1650
1688
|
settings.hooks = {};
|
|
1651
1689
|
removeHookFromAllEvents(settings, name);
|
|
1652
|
-
if (!settings.hooks[eventKey])
|
|
1653
|
-
settings.hooks[eventKey] = [];
|
|
1654
1690
|
const hookCommand = profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`;
|
|
1655
|
-
const
|
|
1656
|
-
hooks
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1691
|
+
for (const eventKey of uniqueEventKeys) {
|
|
1692
|
+
if (!settings.hooks[eventKey])
|
|
1693
|
+
settings.hooks[eventKey] = [];
|
|
1694
|
+
const entry = {
|
|
1695
|
+
hooks: [{ type: "command", command: hookCommand }]
|
|
1696
|
+
};
|
|
1697
|
+
if (meta.matcher) {
|
|
1698
|
+
entry.matcher = meta.matcher;
|
|
1699
|
+
}
|
|
1700
|
+
settings.hooks[eventKey].push(entry);
|
|
1660
1701
|
}
|
|
1661
|
-
settings.hooks[eventKey].push(entry);
|
|
1662
1702
|
writeSettings(settings, scope, target);
|
|
1663
1703
|
}
|
|
1664
1704
|
function removeHookFromAllEvents(settings, name) {
|
|
@@ -1833,6 +1873,7 @@ export {
|
|
|
1833
1873
|
getInstalledHooks,
|
|
1834
1874
|
getHooksByCategory,
|
|
1835
1875
|
getHookPath,
|
|
1876
|
+
getHookEvents,
|
|
1836
1877
|
getHook,
|
|
1837
1878
|
exportProfiles,
|
|
1838
1879
|
deleteProfile,
|
package/dist/lib/installer.d.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* Hooks run directly from the globally installed @hasna/hooks package.
|
|
10
10
|
* No files are copied. The settings entry points to `hooks run <name>`.
|
|
11
11
|
*/
|
|
12
|
-
import type
|
|
12
|
+
import { type HookEvent } from "./registry.js";
|
|
13
13
|
export type Scope = "global" | "project";
|
|
14
14
|
export type Target = "claude" | "gemini" | "codewith" | "all";
|
|
15
15
|
type SingleTarget = Exclude<Target, "all">;
|
|
16
|
+
export type ConcreteTarget = SingleTarget;
|
|
16
17
|
export type CodewithInstallMode = "fragment" | "write";
|
|
17
18
|
export interface InstallResult {
|
|
18
19
|
hook: string;
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook registry - metadata about all available hooks
|
|
3
3
|
*/
|
|
4
|
-
export type HookEvent = "PreToolUse" | "PostToolUse" | "Stop" | "Notification" | "SessionStart" | "SessionEnd" | "UserPromptSubmit";
|
|
4
|
+
export type HookEvent = "PreToolUse" | "PostToolUse" | "Stop" | "Notification" | "SessionStart" | "SessionEnd" | "UserPromptSubmit" | "SubagentStart";
|
|
5
5
|
export declare const HOOK_EVENTS: HookEvent[];
|
|
6
6
|
export interface HookMeta {
|
|
7
7
|
name: string;
|
|
@@ -10,6 +10,7 @@ export interface HookMeta {
|
|
|
10
10
|
version: string;
|
|
11
11
|
category: string;
|
|
12
12
|
event: HookEvent;
|
|
13
|
+
events?: HookEvent[];
|
|
13
14
|
matcher: string;
|
|
14
15
|
tags: string[];
|
|
15
16
|
}
|
|
@@ -17,5 +18,6 @@ export declare const CATEGORIES: readonly ["Git Safety", "Code Quality", "Securi
|
|
|
17
18
|
export type Category = (typeof CATEGORIES)[number];
|
|
18
19
|
export declare const HOOKS: HookMeta[];
|
|
19
20
|
export declare function getHooksByCategory(category: Category): HookMeta[];
|
|
21
|
+
export declare function getHookEvents(hook: HookMeta): HookEvent[];
|
|
20
22
|
export declare function searchHooks(query: string): HookMeta[];
|
|
21
23
|
export declare function getHook(name: string): HookMeta | undefined;
|
|
@@ -31,7 +31,7 @@ export interface CodewithHookOutput {
|
|
|
31
31
|
suppressOutput?: boolean;
|
|
32
32
|
systemMessage?: string;
|
|
33
33
|
hookSpecificOutput?: {
|
|
34
|
-
hookEventName: "SessionStart" | "UserPromptSubmit" | "PreToolUse";
|
|
34
|
+
hookEventName: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | "PreToolUse";
|
|
35
35
|
additionalContext?: string;
|
|
36
36
|
permissionDecision?: "allow" | "deny" | "ask";
|
|
37
37
|
permissionDecisionReason?: string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# hook-knowledge-context
|
|
2
|
+
|
|
3
|
+
Codewith lifecycle hook that injects deterministic Knowledge context packs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
hooks install knowledge-context --target codewith
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
By default this prints a Codewith TOML fragment and does not mutate managed
|
|
12
|
+
Codewith config. Apply that fragment through `open-configs` or the managed
|
|
13
|
+
config renderer. For explicit local testing only:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
hooks install knowledge-context --target codewith --apply-codewith --codewith-config /tmp/codewith-config.toml
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
On `SessionStart`, `UserPromptSubmit`, and `SubagentStart`, the hook builds a
|
|
22
|
+
small redacted query from the hook input and runs:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
knowledge context pack <query> --from search --max-items <n> --max-tokens <n> --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The hook intentionally does not pass `--semantic`, does not call web search,
|
|
29
|
+
does not call ask/build/generate flows, and does not crawl raw stores directly.
|
|
30
|
+
When Knowledge returns a nonempty context pack, the hook redacts credential-like
|
|
31
|
+
text from that pack and emits Codewith-native
|
|
32
|
+
`hookSpecificOutput.additionalContext` with the same event name.
|
|
33
|
+
|
|
34
|
+
All failures are fail-open: missing CLI, timeout, nonzero exit, malformed JSON,
|
|
35
|
+
bad stdin, or empty packs return `{"continue":true}` without context.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export HOOKS_KNOWLEDGE_CONTEXT_DISABLE=1 # Kill switch
|
|
41
|
+
export HOOKS_KNOWLEDGE_COMMAND=knowledge # CLI command/path
|
|
42
|
+
export HOOKS_KNOWLEDGE_TIMEOUT_MS=1500 # Per-call timeout
|
|
43
|
+
export HOOKS_KNOWLEDGE_MAX_ITEMS=6 # Context pack item budget
|
|
44
|
+
export HOOKS_KNOWLEDGE_MAX_TOKENS=1200 # Context pack token budget
|
|
45
|
+
export HOOKS_KNOWLEDGE_MAX_QUERY_CHARS=1200 # Redacted query bound
|
|
46
|
+
export HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS=8000
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Events
|
|
50
|
+
|
|
51
|
+
- `SessionStart`
|
|
52
|
+
- `UserPromptSubmit`
|
|
53
|
+
- `SubagentStart`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasna/hook-knowledge-context",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codewith hook that injects deterministic Knowledge context packs into lifecycle events",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/hook.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/hook.js",
|
|
10
|
+
"types": "./dist/hook.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/hook.js",
|
|
15
|
+
"dist/hook.d.ts",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "rm -rf dist && bun build ./src/hook.ts --outdir ./dist --target node && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false --noEmit false",
|
|
20
|
+
"prepublishOnly": "bun run build",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"codewith",
|
|
25
|
+
"hook",
|
|
26
|
+
"knowledge",
|
|
27
|
+
"context",
|
|
28
|
+
"deterministic",
|
|
29
|
+
"session-start",
|
|
30
|
+
"user-prompt-submit",
|
|
31
|
+
"subagent-start"
|
|
32
|
+
],
|
|
33
|
+
"author": "Hasna",
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/hasna/hooks.git"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18",
|
|
45
|
+
"bun": ">=1.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "^1.3.8",
|
|
49
|
+
"@types/node": "^20",
|
|
50
|
+
"typescript": "^5.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildHookOutput,
|
|
4
|
+
buildKnowledgeArgs,
|
|
5
|
+
buildQuery,
|
|
6
|
+
extractContextText,
|
|
7
|
+
getConfig,
|
|
8
|
+
redactSecrets,
|
|
9
|
+
sanitizeQuery,
|
|
10
|
+
type KnowledgeContextConfig,
|
|
11
|
+
type KnowledgeExecutor,
|
|
12
|
+
} from "./hook";
|
|
13
|
+
|
|
14
|
+
const baseConfig: KnowledgeContextConfig = {
|
|
15
|
+
command: "knowledge",
|
|
16
|
+
timeoutMs: 1500,
|
|
17
|
+
maxItems: 6,
|
|
18
|
+
maxTokens: 1200,
|
|
19
|
+
maxQueryChars: 1200,
|
|
20
|
+
maxOutputChars: 8000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("hook-knowledge-context", () => {
|
|
24
|
+
test("builds deterministic knowledge context pack args without semantic, web, or generation flags", () => {
|
|
25
|
+
const args = buildKnowledgeArgs("repo hook context", baseConfig);
|
|
26
|
+
|
|
27
|
+
expect(args).toEqual([
|
|
28
|
+
"context",
|
|
29
|
+
"pack",
|
|
30
|
+
"repo hook context",
|
|
31
|
+
"--from",
|
|
32
|
+
"search",
|
|
33
|
+
"--max-items",
|
|
34
|
+
"6",
|
|
35
|
+
"--max-tokens",
|
|
36
|
+
"1200",
|
|
37
|
+
"--json",
|
|
38
|
+
]);
|
|
39
|
+
expect(args).not.toContain("--semantic");
|
|
40
|
+
expect(args).not.toContain("--generate");
|
|
41
|
+
expect(args).not.toContain("web");
|
|
42
|
+
expect(args).not.toContain("ask");
|
|
43
|
+
expect(args).not.toContain("build");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("emits Codewith additionalContext for SessionStart", async () => {
|
|
47
|
+
const executor: KnowledgeExecutor = async () => ({
|
|
48
|
+
ok: true,
|
|
49
|
+
stdout: JSON.stringify({ context: "Use hooks.json for Codewith lifecycle hooks." }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const output = await buildHookOutput(
|
|
53
|
+
{
|
|
54
|
+
hook_event_name: "SessionStart",
|
|
55
|
+
cwd: "/repo",
|
|
56
|
+
source: "startup",
|
|
57
|
+
model: "gpt-test",
|
|
58
|
+
},
|
|
59
|
+
executor
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(output.continue).toBe(true);
|
|
63
|
+
expect(output.hookSpecificOutput?.hookEventName).toBe("SessionStart");
|
|
64
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("knowledge context pack --from search");
|
|
65
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Use hooks.json");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("emits event-matched additionalContext for UserPromptSubmit and redacts query secrets", async () => {
|
|
69
|
+
let capturedQuery = "";
|
|
70
|
+
const executor: KnowledgeExecutor = async (query) => {
|
|
71
|
+
capturedQuery = query;
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
stdout: JSON.stringify({
|
|
75
|
+
items: [{ title: "Decision", text: "Prefer deterministic context packs. secret=demo" }],
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const output = await buildHookOutput(
|
|
81
|
+
{
|
|
82
|
+
hook_event_name: "UserPromptSubmit",
|
|
83
|
+
cwd: "/repo",
|
|
84
|
+
prompt: "implement this with token=demo",
|
|
85
|
+
},
|
|
86
|
+
executor
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(output.hookSpecificOutput?.hookEventName).toBe("UserPromptSubmit");
|
|
90
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Prefer deterministic context packs");
|
|
91
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("secret=<redacted>");
|
|
92
|
+
expect(output.hookSpecificOutput?.additionalContext).not.toContain("secret=demo");
|
|
93
|
+
expect(capturedQuery).toContain("token=<redacted>");
|
|
94
|
+
expect(capturedQuery).not.toContain("token=demo");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("emits event-matched additionalContext for SubagentStart", async () => {
|
|
98
|
+
const executor: KnowledgeExecutor = async () => ({
|
|
99
|
+
ok: true,
|
|
100
|
+
stdout: JSON.stringify({ data: { markdown: "Subagents inherit parent scope and should stay bounded." } }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const output = await buildHookOutput(
|
|
104
|
+
{
|
|
105
|
+
hook_event_name: "SubagentStart",
|
|
106
|
+
cwd: "/repo",
|
|
107
|
+
agent_type: "verifier",
|
|
108
|
+
turn_id: "turn-1",
|
|
109
|
+
},
|
|
110
|
+
executor
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(output.hookSpecificOutput?.hookEventName).toBe("SubagentStart");
|
|
114
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Subagents inherit parent scope");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("fails open without context on executor error, timeout, or invalid JSON", async () => {
|
|
118
|
+
const failed = await buildHookOutput({ hook_event_name: "SessionStart", cwd: "/repo" }, async () => ({ ok: false }));
|
|
119
|
+
const timedOut = await buildHookOutput(
|
|
120
|
+
{ hook_event_name: "SessionStart", cwd: "/repo" },
|
|
121
|
+
async () => ({ ok: false, timedOut: true })
|
|
122
|
+
);
|
|
123
|
+
const invalidJson = await buildHookOutput(
|
|
124
|
+
{ hook_event_name: "SessionStart", cwd: "/repo" },
|
|
125
|
+
async () => ({ ok: true, stdout: "not json" })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(failed).toEqual({ continue: true });
|
|
129
|
+
expect(timedOut).toEqual({ continue: true });
|
|
130
|
+
expect(invalidJson).toEqual({ continue: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("fails open for disabled or unsupported events", async () => {
|
|
134
|
+
const executor: KnowledgeExecutor = async () => {
|
|
135
|
+
throw new Error("executor should not run");
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
expect(
|
|
139
|
+
await buildHookOutput({ hook_event_name: "PostToolUse", cwd: "/repo" }, executor)
|
|
140
|
+
).toEqual({ continue: true });
|
|
141
|
+
expect(
|
|
142
|
+
await buildHookOutput({ hook_event_name: "SessionStart", cwd: "/repo" }, executor, {
|
|
143
|
+
HOOKS_KNOWLEDGE_CONTEXT_DISABLE: "1",
|
|
144
|
+
})
|
|
145
|
+
).toEqual({ continue: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("sanitizes and bounds queries", () => {
|
|
149
|
+
expect(redactSecrets("password=hunter2 token=demo")).toBe(
|
|
150
|
+
"password=<redacted> token=<redacted>"
|
|
151
|
+
);
|
|
152
|
+
expect(sanitizeQuery("a\nb\tc", 100)).toBe("a b c");
|
|
153
|
+
expect(sanitizeQuery("word ".repeat(200), 20).length).toBeLessThanOrEqual(20);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("builds event-specific queries and extracts common context pack shapes", () => {
|
|
157
|
+
const config = getConfig({});
|
|
158
|
+
|
|
159
|
+
expect(buildQuery({ hook_event_name: "SessionStart", cwd: "/repo", source: "resume" }, config)).toContain(
|
|
160
|
+
"session start"
|
|
161
|
+
);
|
|
162
|
+
expect(buildQuery({ hook_event_name: "SubagentStart", cwd: "/repo", agent_type: "reviewer" }, config)).toContain(
|
|
163
|
+
"subagent start"
|
|
164
|
+
);
|
|
165
|
+
expect(extractContextText({ pack: { context: "direct context" } })).toBe("direct context");
|
|
166
|
+
expect(extractContextText([{ title: "One", text: "Body", uri: "knowledge://item/one" }])).toContain("Body");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Codewith Hook: knowledge-context
|
|
5
|
+
*
|
|
6
|
+
* Deterministically injects bounded Knowledge context into Codewith lifecycle
|
|
7
|
+
* events. This hook never calls LLM providers, semantic search, web search, or
|
|
8
|
+
* raw stores directly; it delegates to `knowledge context pack --from search`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
|
|
14
|
+
export type KnowledgeContextEvent = "SessionStart" | "UserPromptSubmit" | "SubagentStart";
|
|
15
|
+
|
|
16
|
+
interface HookInput {
|
|
17
|
+
session_id?: string;
|
|
18
|
+
cwd?: string;
|
|
19
|
+
hook_event_name?: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
permission_mode?: string;
|
|
22
|
+
source?: string;
|
|
23
|
+
prompt?: string;
|
|
24
|
+
user_prompt?: string;
|
|
25
|
+
agent_id?: string;
|
|
26
|
+
agent_type?: string;
|
|
27
|
+
turn_id?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface HookOutput {
|
|
31
|
+
continue: boolean;
|
|
32
|
+
hookSpecificOutput?: {
|
|
33
|
+
hookEventName: KnowledgeContextEvent;
|
|
34
|
+
additionalContext: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface KnowledgeContextConfig {
|
|
39
|
+
command: string;
|
|
40
|
+
timeoutMs: number;
|
|
41
|
+
maxItems: number;
|
|
42
|
+
maxTokens: number;
|
|
43
|
+
maxQueryChars: number;
|
|
44
|
+
maxOutputChars: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface KnowledgeExecResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
stdout?: string;
|
|
50
|
+
timedOut?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type KnowledgeExecutor = (
|
|
54
|
+
query: string,
|
|
55
|
+
config: KnowledgeContextConfig
|
|
56
|
+
) => Promise<KnowledgeExecResult>;
|
|
57
|
+
|
|
58
|
+
const DEFAULT_TIMEOUT_MS = 1500;
|
|
59
|
+
const DEFAULT_MAX_ITEMS = 6;
|
|
60
|
+
const DEFAULT_MAX_TOKENS = 1200;
|
|
61
|
+
const DEFAULT_MAX_QUERY_CHARS = 1200;
|
|
62
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 8000;
|
|
63
|
+
const SUPPORTED_EVENTS = new Set<KnowledgeContextEvent>([
|
|
64
|
+
"SessionStart",
|
|
65
|
+
"UserPromptSubmit",
|
|
66
|
+
"SubagentStart",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function readStdinJson(): HookInput | null {
|
|
70
|
+
try {
|
|
71
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
72
|
+
if (!input) return null;
|
|
73
|
+
return JSON.parse(input);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function respond(output: HookOutput): void {
|
|
80
|
+
console.log(JSON.stringify(output));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function boundedNumber(raw: string | undefined, fallback: number, min: number, max: number): number {
|
|
84
|
+
const value = Number(raw);
|
|
85
|
+
if (!Number.isFinite(value)) return fallback;
|
|
86
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function commandFromEnv(raw: string | undefined): string {
|
|
90
|
+
if (raw && /^[^\s\0]+$/.test(raw)) return raw;
|
|
91
|
+
return "knowledge";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getConfig(env: NodeJS.ProcessEnv = process.env): KnowledgeContextConfig {
|
|
95
|
+
return {
|
|
96
|
+
command: commandFromEnv(env.HOOKS_KNOWLEDGE_COMMAND),
|
|
97
|
+
timeoutMs: boundedNumber(env.HOOKS_KNOWLEDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100, 10_000),
|
|
98
|
+
maxItems: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20),
|
|
99
|
+
maxTokens: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_TOKENS, DEFAULT_MAX_TOKENS, 100, 8_000),
|
|
100
|
+
maxQueryChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_QUERY_CHARS, DEFAULT_MAX_QUERY_CHARS, 80, 4_000),
|
|
101
|
+
maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20_000),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isKnowledgeContextEvent(value: string | undefined): value is KnowledgeContextEvent {
|
|
106
|
+
return SUPPORTED_EVENTS.has(value as KnowledgeContextEvent);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stringField(input: HookInput, key: keyof HookInput): string {
|
|
110
|
+
const value = input[key];
|
|
111
|
+
return typeof value === "string" ? value : "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function redactSecrets(text: string): string {
|
|
115
|
+
let redacted = text;
|
|
116
|
+
redacted = redacted.replace(
|
|
117
|
+
/\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*[^,\s]+/gi,
|
|
118
|
+
(_match, key: string) => `${key}=<redacted>`
|
|
119
|
+
);
|
|
120
|
+
redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "<redacted>");
|
|
121
|
+
redacted = redacted.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "<redacted>");
|
|
122
|
+
redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, "<redacted>");
|
|
123
|
+
redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "<redacted>");
|
|
124
|
+
redacted = redacted.replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, "<redacted>");
|
|
125
|
+
redacted = redacted.replace(/\b[A-Za-z0-9+/]{48,}={0,2}\b/g, "<redacted>");
|
|
126
|
+
return redacted;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function sanitizeQuery(text: string, maxChars: number): string {
|
|
130
|
+
const normalized = text
|
|
131
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
132
|
+
.replace(/\s+/g, " ")
|
|
133
|
+
.trim();
|
|
134
|
+
const redacted = redactSecrets(normalized);
|
|
135
|
+
return redacted.length > maxChars ? redacted.slice(0, maxChars).trim() : redacted;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildQuery(input: HookInput, config: KnowledgeContextConfig): string | null {
|
|
139
|
+
const cwd = stringField(input, "cwd");
|
|
140
|
+
const event = stringField(input, "hook_event_name");
|
|
141
|
+
|
|
142
|
+
if (event === "UserPromptSubmit") {
|
|
143
|
+
const prompt = stringField(input, "prompt") || stringField(input, "user_prompt");
|
|
144
|
+
return sanitizeQuery(`Codewith user prompt context. cwd: ${cwd}. prompt: ${prompt}`, config.maxQueryChars);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (event === "SubagentStart") {
|
|
148
|
+
const agentType = stringField(input, "agent_type") || "subagent";
|
|
149
|
+
const turnId = stringField(input, "turn_id");
|
|
150
|
+
return sanitizeQuery(
|
|
151
|
+
`Codewith subagent start context. cwd: ${cwd}. agent type: ${agentType}. turn: ${turnId}`,
|
|
152
|
+
config.maxQueryChars
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (event === "SessionStart") {
|
|
157
|
+
const source = stringField(input, "source") || "startup";
|
|
158
|
+
const model = stringField(input, "model");
|
|
159
|
+
return sanitizeQuery(`Codewith session start context. cwd: ${cwd}. source: ${source}. model: ${model}`, config.maxQueryChars);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function buildKnowledgeArgs(query: string, config: KnowledgeContextConfig): string[] {
|
|
166
|
+
return [
|
|
167
|
+
"context",
|
|
168
|
+
"pack",
|
|
169
|
+
query,
|
|
170
|
+
"--from",
|
|
171
|
+
"search",
|
|
172
|
+
"--max-items",
|
|
173
|
+
String(config.maxItems),
|
|
174
|
+
"--max-tokens",
|
|
175
|
+
String(config.maxTokens),
|
|
176
|
+
"--json",
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function spawnKnowledgeContextPack(query: string, config: KnowledgeContextConfig): Promise<KnowledgeExecResult> {
|
|
181
|
+
const args = buildKnowledgeArgs(query, config);
|
|
182
|
+
|
|
183
|
+
return new Promise((resolve) => {
|
|
184
|
+
let stdout = "";
|
|
185
|
+
let settled = false;
|
|
186
|
+
let timedOut = false;
|
|
187
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
188
|
+
|
|
189
|
+
const settle = (result: KnowledgeExecResult): void => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
settled = true;
|
|
192
|
+
if (timer) clearTimeout(timer);
|
|
193
|
+
resolve(result);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const child = spawn(config.command, args, {
|
|
197
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
198
|
+
env: process.env,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
timer = setTimeout(() => {
|
|
202
|
+
timedOut = true;
|
|
203
|
+
try {
|
|
204
|
+
child.kill("SIGTERM");
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore kill errors; close/error will settle
|
|
207
|
+
}
|
|
208
|
+
}, config.timeoutMs);
|
|
209
|
+
|
|
210
|
+
child.stdout.setEncoding("utf-8");
|
|
211
|
+
child.stdout.on("data", (chunk: string) => {
|
|
212
|
+
stdout += chunk;
|
|
213
|
+
if (stdout.length > config.maxOutputChars * 2) {
|
|
214
|
+
stdout = stdout.slice(0, config.maxOutputChars * 2);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
child.on("error", () => settle({ ok: false, timedOut }));
|
|
218
|
+
child.on("close", (code) => settle({ ok: code === 0 && !timedOut, stdout, timedOut }));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function firstString(obj: Record<string, unknown>, keys: string[]): string | null {
|
|
223
|
+
for (const key of keys) {
|
|
224
|
+
const value = obj[key];
|
|
225
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function firstArray(obj: Record<string, unknown>, keys: string[]): unknown[] | null {
|
|
231
|
+
for (const key of keys) {
|
|
232
|
+
const value = obj[key];
|
|
233
|
+
if (Array.isArray(value)) return value;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function truncate(text: string, max: number): string {
|
|
239
|
+
if (text.length <= max) return text;
|
|
240
|
+
if (max <= 3) return text.slice(0, max);
|
|
241
|
+
return `${text.slice(0, max - 3)}...`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatPackItems(items: unknown[]): string | null {
|
|
245
|
+
const lines: string[] = [];
|
|
246
|
+
|
|
247
|
+
for (const [index, item] of items.entries()) {
|
|
248
|
+
if (typeof item === "string" && item.trim()) {
|
|
249
|
+
lines.push(`- ${truncate(item.trim(), 900)}`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!item || typeof item !== "object") continue;
|
|
253
|
+
|
|
254
|
+
const obj = item as Record<string, unknown>;
|
|
255
|
+
const title = firstString(obj, ["title", "name", "id", "ref", "uri"]) || `item ${index + 1}`;
|
|
256
|
+
const body = firstString(obj, ["summary", "text", "content", "snippet", "preview", "body"]);
|
|
257
|
+
const source = firstString(obj, ["source", "uri", "ref", "url", "citation"]);
|
|
258
|
+
if (!body && !source) continue;
|
|
259
|
+
const suffix = source ? ` (source: ${truncate(source, 180)})` : "";
|
|
260
|
+
lines.push(`- ${truncate(title, 160)}${suffix}${body ? `: ${truncate(body, 900)}` : ""}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function extractContextText(pack: unknown): string | null {
|
|
267
|
+
if (typeof pack === "string") {
|
|
268
|
+
return pack.trim() || null;
|
|
269
|
+
}
|
|
270
|
+
if (Array.isArray(pack)) {
|
|
271
|
+
return formatPackItems(pack);
|
|
272
|
+
}
|
|
273
|
+
if (!pack || typeof pack !== "object") {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const obj = pack as Record<string, unknown>;
|
|
278
|
+
const direct = firstString(obj, ["additionalContext", "context", "markdown", "content", "text", "summary"]);
|
|
279
|
+
if (direct) return direct;
|
|
280
|
+
|
|
281
|
+
for (const key of ["pack", "contextPack", "context_pack", "data", "result"]) {
|
|
282
|
+
const nested = obj[key];
|
|
283
|
+
const text = extractContextText(nested);
|
|
284
|
+
if (text) return text;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const items = firstArray(obj, ["items", "results", "sources", "citations"]);
|
|
288
|
+
return items ? formatPackItems(items) : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function formatAdditionalContext(
|
|
292
|
+
event: KnowledgeContextEvent,
|
|
293
|
+
context: string,
|
|
294
|
+
config: KnowledgeContextConfig
|
|
295
|
+
): string {
|
|
296
|
+
const header = `[hook-knowledge-context] Deterministic Knowledge context (${event}; knowledge context pack --from search):`;
|
|
297
|
+
const safeContext = redactSecrets(context.trim());
|
|
298
|
+
return `${header}\n\n${truncate(safeContext, Math.max(0, config.maxOutputChars - header.length - 2))}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function buildHookOutput(
|
|
302
|
+
input: HookInput | null,
|
|
303
|
+
executor: KnowledgeExecutor = spawnKnowledgeContextPack,
|
|
304
|
+
env: NodeJS.ProcessEnv = process.env
|
|
305
|
+
): Promise<HookOutput> {
|
|
306
|
+
if (env.HOOKS_KNOWLEDGE_CONTEXT_DISABLE === "1") {
|
|
307
|
+
return { continue: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!input) {
|
|
311
|
+
return { continue: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const event = input.hook_event_name;
|
|
315
|
+
if (!isKnowledgeContextEvent(event)) {
|
|
316
|
+
return { continue: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const config = getConfig(env);
|
|
320
|
+
const query = buildQuery(input, config);
|
|
321
|
+
if (!query) {
|
|
322
|
+
return { continue: true };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const result = await executor(query, config);
|
|
327
|
+
if (!result.ok || !result.stdout) {
|
|
328
|
+
return { continue: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const parsed = JSON.parse(result.stdout);
|
|
332
|
+
const context = extractContextText(parsed);
|
|
333
|
+
if (!context) {
|
|
334
|
+
return { continue: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
continue: true,
|
|
339
|
+
hookSpecificOutput: {
|
|
340
|
+
hookEventName: event,
|
|
341
|
+
additionalContext: formatAdditionalContext(event, context, config),
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
} catch {
|
|
345
|
+
return { continue: true };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function run(): Promise<void> {
|
|
350
|
+
respond(await buildHookOutput(readStdinJson()));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (import.meta.main) {
|
|
354
|
+
void run();
|
|
355
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"outDir": "./dist",
|
|
20
|
+
"rootDir": "./src",
|
|
21
|
+
"types": ["bun-types", "node"]
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*"],
|
|
24
|
+
"exclude": ["node_modules", "dist"]
|
|
25
|
+
}
|
package/package.json
CHANGED