@hasna/hooks 0.3.0 → 0.3.2

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 CHANGED
@@ -28,26 +28,27 @@ hooks --help
28
28
 
29
29
  ## Codewith-native hooks
30
30
 
31
- @hasna/hooks includes five unprefixed Codewith-native hook names:
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 session-start --target codewith --apply-codewith --codewith-config /tmp/codewith-config.toml
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.1",
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 6;
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 lines = [
4155
- `[[hooks.${eventKey}]]`
4156
- ];
4157
- if (matcher)
4158
- lines.push(`matcher = ${tomlString(matcher)}`);
4159
- lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
4160
- return `${lines.join(`
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 || existing.event !== meta.event || !existing.matcher)
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) => !isEventSupported(meta.event, 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.event}' is not supported by target(s): ${unsupportedTargets.join(", ")}`,
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 eventKey = getTargetEventName(meta.event, target);
4323
- if (eventKey === null) {
4324
- throw new Error(`Event '${meta.event}' is not supported by target '${target}'`);
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 entry = {
4334
- hooks: [{ type: "command", command: hookCommand }]
4335
- };
4336
- if (meta.matcher) {
4337
- entry.matcher = meta.matcher;
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.1",
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 6;
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 lines = [
1477
- `[[hooks.${eventKey}]]`
1478
- ];
1479
- if (matcher)
1480
- lines.push(`matcher = ${tomlString(matcher)}`);
1481
- lines.push("", `[[hooks.${eventKey}.hooks]]`, `type = "command"`, `command = ${tomlString(command)}`, `timeout = ${codewithTimeout(shortName)}`, `statusMessage = ${tomlString(codewithStatusMessage(shortName))}`);
1482
- return `${lines.join(`
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 || existing.event !== meta.event || !existing.matcher)
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) => !isEventSupported(meta.event, 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.event}' is not supported by target(s): ${unsupportedTargets.join(", ")}`,
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 eventKey = getTargetEventName(meta.event, target);
1645
- if (eventKey === null) {
1646
- throw new Error(`Event '${meta.event}' is not supported by target '${target}'`);
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 entry = {
1656
- hooks: [{ type: "command", command: hookCommand }]
1657
- };
1658
- if (meta.matcher) {
1659
- entry.matcher = meta.matcher;
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,
@@ -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 { HookEvent } from "./registry.js";
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;
@@ -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=5000 # 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.1",
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,173 @@
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: 5000,
17
+ maxItems: 6,
18
+ maxTokens: 1200,
19
+ maxQueryChars: 1200,
20
+ maxOutputChars: 8000,
21
+ };
22
+
23
+ describe("hook-knowledge-context", () => {
24
+ test("defaults Knowledge CLI timeout to 5000ms and keeps env override", () => {
25
+ expect(getConfig({}).timeoutMs).toBe(5000);
26
+ expect(getConfig({ HOOKS_KNOWLEDGE_TIMEOUT_MS: "2345" }).timeoutMs).toBe(2345);
27
+ });
28
+
29
+ test("builds deterministic knowledge context pack args without semantic, web, or generation flags", () => {
30
+ const args = buildKnowledgeArgs("repo hook context", baseConfig);
31
+
32
+ expect(args).toEqual([
33
+ "context",
34
+ "pack",
35
+ "repo hook context",
36
+ "--from",
37
+ "search",
38
+ "--max-items",
39
+ "6",
40
+ "--max-tokens",
41
+ "1200",
42
+ "--json",
43
+ ]);
44
+ expect(args).not.toContain("--semantic");
45
+ expect(args).not.toContain("--generate");
46
+ expect(args).not.toContain("web");
47
+ expect(args).not.toContain("ask");
48
+ expect(args).not.toContain("build");
49
+ });
50
+
51
+ test("emits Codewith additionalContext for SessionStart", async () => {
52
+ const executor: KnowledgeExecutor = async () => ({
53
+ ok: true,
54
+ stdout: JSON.stringify({ context: "Use hooks.json for Codewith lifecycle hooks." }),
55
+ });
56
+
57
+ const output = await buildHookOutput(
58
+ {
59
+ hook_event_name: "SessionStart",
60
+ cwd: "/repo",
61
+ source: "startup",
62
+ model: "gpt-test",
63
+ },
64
+ executor
65
+ );
66
+
67
+ expect(output.continue).toBe(true);
68
+ expect(output.hookSpecificOutput?.hookEventName).toBe("SessionStart");
69
+ expect(output.hookSpecificOutput?.additionalContext).toContain("knowledge context pack --from search");
70
+ expect(output.hookSpecificOutput?.additionalContext).toContain("Use hooks.json");
71
+ });
72
+
73
+ test("emits event-matched additionalContext for UserPromptSubmit and redacts query secrets", async () => {
74
+ let capturedQuery = "";
75
+ const executor: KnowledgeExecutor = async (query) => {
76
+ capturedQuery = query;
77
+ return {
78
+ ok: true,
79
+ stdout: JSON.stringify({
80
+ items: [{ title: "Decision", text: "Prefer deterministic context packs. secret=demo" }],
81
+ }),
82
+ };
83
+ };
84
+
85
+ const output = await buildHookOutput(
86
+ {
87
+ hook_event_name: "UserPromptSubmit",
88
+ cwd: "/repo",
89
+ prompt: "implement this with token=demo",
90
+ },
91
+ executor
92
+ );
93
+
94
+ expect(output.hookSpecificOutput?.hookEventName).toBe("UserPromptSubmit");
95
+ expect(output.hookSpecificOutput?.additionalContext).toContain("Prefer deterministic context packs");
96
+ expect(output.hookSpecificOutput?.additionalContext).toContain("secret=<redacted>");
97
+ expect(output.hookSpecificOutput?.additionalContext).not.toContain("secret=demo");
98
+ expect(capturedQuery).toContain("token=<redacted>");
99
+ expect(capturedQuery).not.toContain("token=demo");
100
+ });
101
+
102
+ test("emits event-matched additionalContext for SubagentStart", async () => {
103
+ const executor: KnowledgeExecutor = async () => ({
104
+ ok: true,
105
+ stdout: JSON.stringify({ data: { markdown: "Subagents inherit parent scope and should stay bounded." } }),
106
+ });
107
+
108
+ const output = await buildHookOutput(
109
+ {
110
+ hook_event_name: "SubagentStart",
111
+ cwd: "/repo",
112
+ agent_type: "verifier",
113
+ turn_id: "turn-1",
114
+ },
115
+ executor
116
+ );
117
+
118
+ expect(output.hookSpecificOutput?.hookEventName).toBe("SubagentStart");
119
+ expect(output.hookSpecificOutput?.additionalContext).toContain("Subagents inherit parent scope");
120
+ });
121
+
122
+ test("fails open without context on executor error, timeout, or invalid JSON", async () => {
123
+ const failed = await buildHookOutput({ hook_event_name: "SessionStart", cwd: "/repo" }, async () => ({ ok: false }));
124
+ const timedOut = await buildHookOutput(
125
+ { hook_event_name: "SessionStart", cwd: "/repo" },
126
+ async () => ({ ok: false, timedOut: true })
127
+ );
128
+ const invalidJson = await buildHookOutput(
129
+ { hook_event_name: "SessionStart", cwd: "/repo" },
130
+ async () => ({ ok: true, stdout: "not json" })
131
+ );
132
+
133
+ expect(failed).toEqual({ continue: true });
134
+ expect(timedOut).toEqual({ continue: true });
135
+ expect(invalidJson).toEqual({ continue: true });
136
+ });
137
+
138
+ test("fails open for disabled or unsupported events", async () => {
139
+ const executor: KnowledgeExecutor = async () => {
140
+ throw new Error("executor should not run");
141
+ };
142
+
143
+ expect(
144
+ await buildHookOutput({ hook_event_name: "PostToolUse", cwd: "/repo" }, executor)
145
+ ).toEqual({ continue: true });
146
+ expect(
147
+ await buildHookOutput({ hook_event_name: "SessionStart", cwd: "/repo" }, executor, {
148
+ HOOKS_KNOWLEDGE_CONTEXT_DISABLE: "1",
149
+ })
150
+ ).toEqual({ continue: true });
151
+ });
152
+
153
+ test("sanitizes and bounds queries", () => {
154
+ expect(redactSecrets("password=hunter2 token=demo")).toBe(
155
+ "password=<redacted> token=<redacted>"
156
+ );
157
+ expect(sanitizeQuery("a\nb\tc", 100)).toBe("a b c");
158
+ expect(sanitizeQuery("word ".repeat(200), 20).length).toBeLessThanOrEqual(20);
159
+ });
160
+
161
+ test("builds event-specific queries and extracts common context pack shapes", () => {
162
+ const config = getConfig({});
163
+
164
+ expect(buildQuery({ hook_event_name: "SessionStart", cwd: "/repo", source: "resume" }, config)).toContain(
165
+ "session start"
166
+ );
167
+ expect(buildQuery({ hook_event_name: "SubagentStart", cwd: "/repo", agent_type: "reviewer" }, config)).toContain(
168
+ "subagent start"
169
+ );
170
+ expect(extractContextText({ pack: { context: "direct context" } })).toBe("direct context");
171
+ expect(extractContextText([{ title: "One", text: "Body", uri: "knowledge://item/one" }])).toContain("Body");
172
+ });
173
+ });
@@ -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 = 5000;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {