@hasna/hooks 0.2.20 → 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.
Files changed (69) hide show
  1. package/README.md +46 -2
  2. package/bin/index.js +1205 -93
  3. package/dist/db/migrations/002_session_events.d.ts +10 -0
  4. package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
  5. package/dist/db/pg-migrations.d.ts +1 -1
  6. package/dist/db/remote-storage.d.ts +9 -0
  7. package/dist/db/schema.d.ts +4 -3
  8. package/dist/db/storage-sync.d.ts +57 -0
  9. package/dist/index.d.ts +16 -3
  10. package/dist/index.js +1092 -67
  11. package/dist/lib/installer.d.ts +26 -5
  12. package/dist/lib/registry.d.ts +5 -1
  13. package/dist/storage.d.ts +4 -0
  14. package/dist/storage.js +696 -0
  15. package/hooks/codewith-native-common.test.ts +61 -0
  16. package/hooks/codewith-native-common.ts +478 -0
  17. package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
  18. package/hooks/hook-agent-rules-version-check/README.md +50 -0
  19. package/hooks/hook-agent-rules-version-check/package.json +53 -0
  20. package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
  21. package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
  22. package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
  23. package/hooks/hook-announce-start/README.md +7 -5
  24. package/hooks/hook-announce-start/package.json +2 -2
  25. package/hooks/hook-announce-start/src/hook.ts +33 -14
  26. package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
  27. package/hooks/hook-fleet-blockers-gate/README.md +39 -0
  28. package/hooks/hook-fleet-blockers-gate/package.json +52 -0
  29. package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
  30. package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
  31. package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
  32. package/hooks/hook-fleet-catchup/LICENSE +191 -0
  33. package/hooks/hook-fleet-catchup/README.md +40 -0
  34. package/hooks/hook-fleet-catchup/package.json +52 -0
  35. package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
  36. package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
  37. package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
  38. package/hooks/hook-knowledge-context/README.md +53 -0
  39. package/hooks/hook-knowledge-context/package.json +52 -0
  40. package/hooks/hook-knowledge-context/src/hook.test.ts +168 -0
  41. package/hooks/hook-knowledge-context/src/hook.ts +355 -0
  42. package/hooks/hook-knowledge-context/tsconfig.json +25 -0
  43. package/hooks/pre-bash/README.md +17 -0
  44. package/hooks/pre-bash/package.json +12 -0
  45. package/hooks/pre-bash/src/hook.ts +103 -0
  46. package/hooks/prompt-guard/README.md +17 -0
  47. package/hooks/prompt-guard/package.json +12 -0
  48. package/hooks/prompt-guard/src/hook.ts +76 -0
  49. package/hooks/session-start/README.md +17 -0
  50. package/hooks/session-start/package.json +12 -0
  51. package/hooks/session-start/src/hook.ts +150 -0
  52. package/hooks/stop-sync/README.md +17 -0
  53. package/hooks/stop-sync/package.json +12 -0
  54. package/hooks/stop-sync/src/hook.ts +69 -0
  55. package/hooks/worktree-guard/README.md +17 -0
  56. package/hooks/worktree-guard/package.json +12 -0
  57. package/hooks/worktree-guard/src/hook.ts +88 -0
  58. package/package.json +14 -6
  59. package/dist/cli/components/App.d.ts +0 -7
  60. package/dist/cli/components/CategorySelect.d.ts +0 -7
  61. package/dist/cli/components/DataTable.d.ts +0 -11
  62. package/dist/cli/components/Header.d.ts +0 -7
  63. package/dist/cli/components/HookSelect.d.ts +0 -11
  64. package/dist/cli/components/InstallProgress.d.ts +0 -9
  65. package/dist/cli/components/SearchView.d.ts +0 -9
  66. package/dist/cli/index.d.ts +0 -2
  67. package/dist/lib/db-writer.d.ts +0 -9
  68. package/dist/mcp/http.d.ts +0 -16
  69. package/dist/mcp/server.d.ts +0 -17
@@ -0,0 +1,156 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ parseJsonList,
4
+ hasJsonList,
5
+ resolveSince,
6
+ formatCatchup,
7
+ truncate,
8
+ safeIdentifier,
9
+ safeSince,
10
+ } from "./hook";
11
+
12
+ describe("hook-fleet-catchup", () => {
13
+ describe("parseJsonList", () => {
14
+ test("parses a bare array", () => {
15
+ expect(parseJsonList('[1,2]')).toEqual([1, 2]);
16
+ });
17
+
18
+ test("unwraps named keys in order", () => {
19
+ expect(parseJsonList('{"blockers":[1]}', "blockers", "messages")).toEqual([1]);
20
+ expect(parseJsonList('{"messages":[2]}', "blockers", "messages")).toEqual([2]);
21
+ });
22
+
23
+ test("returns empty for garbage and null", () => {
24
+ expect(parseJsonList("nope")).toEqual([]);
25
+ expect(parseJsonList(null)).toEqual([]);
26
+ expect(parseJsonList('{"other":true}')).toEqual([]);
27
+ });
28
+ });
29
+
30
+ describe("hasJsonList", () => {
31
+ test("accepts bare arrays and configured array envelopes", () => {
32
+ expect(hasJsonList("[]", "notifications", "messages")).toBe(true);
33
+ expect(hasJsonList('{"notifications":[]}', "notifications", "messages")).toBe(true);
34
+ expect(hasJsonList('{"messages":[1]}', "notifications", "messages")).toBe(true);
35
+ });
36
+
37
+ test("rejects invalid JSON and unexpected envelopes", () => {
38
+ expect(hasJsonList(null, "notifications", "messages")).toBe(false);
39
+ expect(hasJsonList("not json", "notifications", "messages")).toBe(false);
40
+ expect(hasJsonList('{"other":[]}', "notifications", "messages")).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("resolveSince", () => {
45
+ const now = new Date("2026-07-06T12:00:00.000Z");
46
+
47
+ test("uses the stored timestamp when valid and in the past", () => {
48
+ expect(resolveSince(now, "2026-07-06T09:30:00.000Z")).toBe("2026-07-06T09:30:00.000Z");
49
+ });
50
+
51
+ test("falls back to 24h lookback when state is missing", () => {
52
+ expect(resolveSince(now, null)).toBe("2026-07-05T12:00:00.000Z");
53
+ });
54
+
55
+ test("falls back when state is garbage", () => {
56
+ expect(resolveSince(now, "not-a-date")).toBe("2026-07-05T12:00:00.000Z");
57
+ });
58
+
59
+ test("falls back when state is in the future (clock skew)", () => {
60
+ expect(resolveSince(now, "2027-01-01T00:00:00.000Z")).toBe("2026-07-05T12:00:00.000Z");
61
+ });
62
+ });
63
+
64
+ describe("formatCatchup", () => {
65
+ test("returns null when everything is empty", () => {
66
+ expect(formatCatchup([], [], null)).toBeNull();
67
+ expect(formatCatchup([], [], '{"messages":[]}')).toBeNull();
68
+ });
69
+
70
+ test("includes blockers section with read-duty reminder", () => {
71
+ const out = formatCatchup(
72
+ [{ from: "chief", content: "[FREEZE] hold publishes", created_at: "2026-07-06T10:00:00Z" }],
73
+ [],
74
+ null
75
+ );
76
+ expect(out).toContain("UNREAD BLOCKING MESSAGES (1)");
77
+ expect(out).toContain("[FREEZE] hold publishes");
78
+ expect(out).toContain("stop and escalate");
79
+ });
80
+
81
+ test("includes notifications and digest sections", () => {
82
+ const out = formatCatchup(
83
+ [],
84
+ [{ from: "bot", channel: "git-releases", preview: "released x@1.0.0" }],
85
+ JSON.stringify({ messages: [{ from: "chief", content: "[POLICY] HACP v1.1" }] })
86
+ );
87
+ expect(out).toContain("CHANNEL NOTIFICATIONS since last catchup (1)");
88
+ expect(out).toContain("#git-releases");
89
+ expect(out).toContain("ANNOUNCEMENTS digest");
90
+ expect(out).toContain("[POLICY] HACP v1.1");
91
+ });
92
+
93
+ test("skips digest section when digest JSON is invalid", () => {
94
+ const out = formatCatchup([{ content: "x" }], [], "garbage");
95
+ expect(out).toContain("UNREAD BLOCKING MESSAGES");
96
+ expect(out).not.toContain("ANNOUNCEMENTS digest");
97
+ });
98
+
99
+ test("caps listed blockers at 10", () => {
100
+ const blockers = Array.from({ length: 25 }, (_, i) => ({ content: `blocker-${i}` }));
101
+ const out = formatCatchup(blockers, [], null)!;
102
+ expect(out).toContain("UNREAD BLOCKING MESSAGES (25)");
103
+ expect(out).toContain("blocker-9");
104
+ expect(out).not.toContain("blocker-10");
105
+ expect(out.split("\n").filter((l) => l.startsWith("- ")).length).toBe(10);
106
+ });
107
+ });
108
+
109
+ describe("truncate", () => {
110
+ test("passes short strings through", () => {
111
+ expect(truncate("abc", 10)).toBe("abc");
112
+ });
113
+
114
+ test("truncates long strings with ellipsis", () => {
115
+ const out = truncate("a".repeat(50), 10);
116
+ expect(out.length).toBe(10);
117
+ expect(out.endsWith("…")).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("safeIdentifier", () => {
122
+ test("accepts normal agent names", () => {
123
+ expect(safeIdentifier("chief")).toBe("chief");
124
+ expect(safeIdentifier("build-hooks_01.a")).toBe("build-hooks_01.a");
125
+ });
126
+
127
+ test("rejects shell metacharacters", () => {
128
+ expect(safeIdentifier("$(rm -rf /)")).toBeNull();
129
+ expect(safeIdentifier("a;b")).toBeNull();
130
+ expect(safeIdentifier("a b")).toBeNull();
131
+ expect(safeIdentifier("`x`")).toBeNull();
132
+ });
133
+
134
+ test("rejects empty/undefined", () => {
135
+ expect(safeIdentifier(undefined)).toBeNull();
136
+ expect(safeIdentifier("")).toBeNull();
137
+ });
138
+ });
139
+
140
+ describe("safeSince", () => {
141
+ test("accepts relative durations", () => {
142
+ expect(safeSince("7d", "1d")).toBe("7d");
143
+ expect(safeSince("24h", "1d")).toBe("24h");
144
+ });
145
+
146
+ test("accepts ISO timestamps", () => {
147
+ expect(safeSince("2026-07-06T00:00:00Z", "1d")).toBe("2026-07-06T00:00:00Z");
148
+ });
149
+
150
+ test("falls back on anything else", () => {
151
+ expect(safeSince("; rm -rf /", "7d")).toBe("7d");
152
+ expect(safeSince("$(x)", "7d")).toBe("7d");
153
+ expect(safeSince(undefined, "7d")).toBe("7d");
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: fleet-catchup
5
+ *
6
+ * SessionStart hook that catches an agent up on fleet communications:
7
+ * - `conversations blockers -j` → unread blocking messages
8
+ * - `conversations notifications --since <last>` → channel notifications since last catchup
9
+ * - `conversations digest announcements --unread --since 7d` → bounded announcements digest
10
+ *
11
+ * All reads are deterministic CLI calls against the local conversations
12
+ * service. Every step is fail-open: a missing CLI, timeout, or parse error
13
+ * skips that section and never blocks the session. Output is injected via
14
+ * SessionStart's `hookSpecificOutput.additionalContext`.
15
+ *
16
+ * Environment:
17
+ * - HOOKS_FLEET_CATCHUP_DISABLE=1 → skip entirely
18
+ * - HOOKS_FLEET_AGENT=<name> → identity passed as --from
19
+ * - HOOKS_FLEET_TIMEOUT_MS=<n> → per-command exec timeout (default 1500)
20
+ * - HOOKS_FLEET_SINCE=<duration> → announcements digest window (default 7d)
21
+ */
22
+
23
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
24
+ import { join } from "path";
25
+ import { homedir } from "os";
26
+ import { exec } from "child_process";
27
+
28
+ interface HookInput {
29
+ session_id: string;
30
+ cwd: string;
31
+ hook_event_name: string;
32
+ source?: string;
33
+ }
34
+
35
+ interface HookOutput {
36
+ continue: boolean;
37
+ hookSpecificOutput?: {
38
+ hookEventName: "SessionStart";
39
+ additionalContext: string;
40
+ };
41
+ }
42
+
43
+ const DEFAULT_TIMEOUT_MS = 1500;
44
+ const DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1000; // 24h when no last-seen state
45
+ const STATE_DIR = join(homedir(), ".hasna", "hooks", "state");
46
+ const LAST_SEEN_FILE = join(STATE_DIR, "fleet-catchup.last_seen");
47
+
48
+ function readStdinJson(): HookInput | null {
49
+ try {
50
+ const input = readFileSync(0, "utf-8").trim();
51
+ if (!input) return null;
52
+ return JSON.parse(input);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function respond(output: HookOutput): void {
59
+ console.log(JSON.stringify(output));
60
+ }
61
+
62
+ function timeoutMs(): number {
63
+ const raw = Number(process.env.HOOKS_FLEET_TIMEOUT_MS);
64
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
65
+ }
66
+
67
+ /** Only pass through shell-safe identifier values (defense against env-borne injection). */
68
+ export function safeIdentifier(value: string | undefined): string | null {
69
+ if (!value) return null;
70
+ return /^[A-Za-z0-9._-]{1,64}$/.test(value) ? value : null;
71
+ }
72
+
73
+ /** Only accept relative durations (7d, 24h) or ISO-ish timestamps for --since. */
74
+ export function safeSince(value: string | undefined, fallback: string): string {
75
+ if (!value) return fallback;
76
+ if (/^[0-9]{1,4}[smhdw]$/.test(value)) return value;
77
+ if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}[T0-9:.Z+-]*$/.test(value)) return value;
78
+ return fallback;
79
+ }
80
+
81
+ function fromFlag(): string {
82
+ const agent = safeIdentifier(process.env.HOOKS_FLEET_AGENT);
83
+ return agent ? ` --from ${agent}` : "";
84
+ }
85
+
86
+ /**
87
+ * Run a CLI command, resolving stdout or null on any failure (fail-open).
88
+ * Async so the three catchup reads run in parallel — worst-case session-start
89
+ * cost is one timeout, not the sum of three.
90
+ */
91
+ function tryExec(command: string): Promise<string | null> {
92
+ return new Promise((resolve) => {
93
+ try {
94
+ exec(command, { encoding: "utf-8", timeout: timeoutMs() }, (err, stdout) => {
95
+ resolve(err ? null : (stdout ?? "").trim() || null);
96
+ });
97
+ } catch {
98
+ resolve(null);
99
+ }
100
+ });
101
+ }
102
+
103
+ /** Parse CLI JSON that may be an array or an object wrapping an array. */
104
+ export function parseJsonList(raw: string | null, ...wrapperKeys: string[]): unknown[] {
105
+ if (!raw) return [];
106
+ try {
107
+ const data = JSON.parse(raw);
108
+ if (Array.isArray(data)) return data;
109
+ if (data && typeof data === "object") {
110
+ for (const key of wrapperKeys) {
111
+ const inner = (data as Record<string, unknown>)[key];
112
+ if (Array.isArray(inner)) return inner;
113
+ }
114
+ }
115
+ return [];
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /** True when raw CLI output was parseable as the expected list shape. */
122
+ export function hasJsonList(raw: string | null, ...wrapperKeys: string[]): boolean {
123
+ if (!raw) return false;
124
+ try {
125
+ const data = JSON.parse(raw);
126
+ if (Array.isArray(data)) return true;
127
+ if (data && typeof data === "object") {
128
+ return wrapperKeys.some((key) => Array.isArray((data as Record<string, unknown>)[key]));
129
+ }
130
+ return false;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** Read the last catchup timestamp; fall back to a bounded 24h lookback. */
137
+ export function resolveSince(now: Date, stored: string | null): string {
138
+ if (stored) {
139
+ const parsed = new Date(stored.trim());
140
+ if (!Number.isNaN(parsed.getTime()) && parsed.getTime() <= now.getTime()) {
141
+ return parsed.toISOString();
142
+ }
143
+ }
144
+ return new Date(now.getTime() - DEFAULT_LOOKBACK_MS).toISOString();
145
+ }
146
+
147
+ function readLastSeen(): string | null {
148
+ try {
149
+ if (!existsSync(LAST_SEEN_FILE)) return null;
150
+ return readFileSync(LAST_SEEN_FILE, "utf-8");
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function writeLastSeen(now: Date): void {
157
+ try {
158
+ mkdirSync(STATE_DIR, { recursive: true });
159
+ writeFileSync(LAST_SEEN_FILE, now.toISOString());
160
+ } catch {
161
+ // fail-open: state is an optimization, not a requirement
162
+ }
163
+ }
164
+
165
+ function describeItem(item: unknown): string {
166
+ if (!item || typeof item !== "object") return String(item);
167
+ const m = item as Record<string, unknown>;
168
+ const from = typeof m.from === "string" ? m.from : typeof m.sender === "string" ? m.sender : "unknown";
169
+ const channel = typeof m.channel === "string" ? m.channel : typeof m.channel_name === "string" ? m.channel_name : null;
170
+ const body =
171
+ typeof m.preview === "string" ? m.preview :
172
+ typeof m.content === "string" ? m.content :
173
+ typeof m.message === "string" ? m.message : JSON.stringify(m).slice(0, 200);
174
+ const time = typeof m.created_at === "string" ? m.created_at : typeof m.timestamp === "string" ? m.timestamp : "";
175
+ const where = channel ? ` #${channel}` : "";
176
+ return `- [${time}]${where} ${from}: ${truncate(body, 300)}`;
177
+ }
178
+
179
+ export function truncate(text: string, max: number): string {
180
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
181
+ }
182
+
183
+ /** Build the injected context from the three catchup sections. */
184
+ export function formatCatchup(
185
+ blockers: unknown[],
186
+ notifications: unknown[],
187
+ digestRaw: string | null
188
+ ): string | null {
189
+ const sections: string[] = [];
190
+
191
+ if (blockers.length > 0) {
192
+ sections.push(
193
+ `UNREAD BLOCKING MESSAGES (${blockers.length}) — resolve before starting work:\n` +
194
+ blockers.slice(0, 10).map(describeItem).join("\n")
195
+ );
196
+ }
197
+
198
+ if (notifications.length > 0) {
199
+ sections.push(
200
+ `CHANNEL NOTIFICATIONS since last catchup (${notifications.length}):\n` +
201
+ notifications.slice(0, 15).map(describeItem).join("\n")
202
+ );
203
+ }
204
+
205
+ if (digestRaw) {
206
+ try {
207
+ const digest = JSON.parse(digestRaw) as Record<string, unknown>;
208
+ const messages = Array.isArray(digest.messages) ? digest.messages : [];
209
+ if (messages.length > 0) {
210
+ sections.push(
211
+ `ANNOUNCEMENTS digest (unread, last 7d, ${messages.length} shown):\n` +
212
+ messages.slice(0, 15).map(describeItem).join("\n")
213
+ );
214
+ }
215
+ } catch {
216
+ // unparseable digest → skip section
217
+ }
218
+ }
219
+
220
+ if (sections.length === 0) return null;
221
+
222
+ return (
223
+ `[hook-fleet-catchup] Fleet communications catchup:\n\n` +
224
+ sections.join("\n\n") +
225
+ `\n\nRead-duty: handle blockers before claiming work; an unread [FREEZE] means stop and escalate to #help.`
226
+ );
227
+ }
228
+
229
+ export async function run(): Promise<void> {
230
+ if (process.env.HOOKS_FLEET_CATCHUP_DISABLE === "1") {
231
+ respond({ continue: true });
232
+ return;
233
+ }
234
+
235
+ const input = readStdinJson();
236
+ if (!input) {
237
+ respond({ continue: true });
238
+ return;
239
+ }
240
+
241
+ const now = new Date();
242
+ const from = fromFlag();
243
+ const since = resolveSince(now, readLastSeen());
244
+ const window = safeSince(process.env.HOOKS_FLEET_SINCE, "7d");
245
+
246
+ // The three catchup reads run in parallel — all fail-open independently:
247
+ // 1. unread blocking messages
248
+ // 2. channel notifications since last catchup (bounded lookback)
249
+ // 3. bounded announcements digest (never hands a session full history)
250
+ const [blockersRaw, notificationsRaw, digestRaw] = await Promise.all([
251
+ tryExec(`conversations blockers -j${from}`),
252
+ tryExec(`conversations notifications --since ${since} -j${from}`),
253
+ tryExec(`conversations digest announcements --unread --since ${window} -j${from}`),
254
+ ]);
255
+
256
+ const blockers = parseJsonList(blockersRaw, "blockers", "messages");
257
+ const notifications = parseJsonList(notificationsRaw, "notifications", "messages");
258
+
259
+ // Advance the last-seen cursor ONLY when the notifications read succeeded
260
+ // and returned the expected JSON shape. Otherwise bad output at session
261
+ // start would silently swallow the window.
262
+ if (hasJsonList(notificationsRaw, "notifications", "messages")) {
263
+ writeLastSeen(now);
264
+ }
265
+
266
+ const context = formatCatchup(blockers, notifications, digestRaw);
267
+ if (context) {
268
+ respond({
269
+ continue: true,
270
+ hookSpecificOutput: {
271
+ hookEventName: "SessionStart",
272
+ additionalContext: context,
273
+ },
274
+ });
275
+ return;
276
+ }
277
+
278
+ respond({ continue: true });
279
+ }
280
+
281
+ if (import.meta.main) {
282
+ void run();
283
+ }
@@ -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"]
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist"]
25
+ }
@@ -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
+ }