@hasna/hooks 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -12
- package/bin/index.js +2089 -470
- package/bin/serve.js +5248 -0
- package/dist/cf/provision.d.ts +24 -0
- package/dist/config.d.ts +18 -0
- package/dist/db/legacy-import.d.ts +1 -1
- package/dist/db/migrations/004_hooks_table.d.ts +9 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/storage-sync.d.ts +26 -6
- package/dist/index.d.ts +18 -2
- package/dist/index.js +5341 -286
- package/dist/lib/custom-install.d.ts +19 -0
- package/dist/lib/manifest.d.ts +70 -0
- package/dist/lib/resolve.d.ts +19 -0
- package/dist/lib/run.d.ts +37 -0
- package/dist/lib/store.d.ts +69 -0
- package/dist/lib/sync.d.ts +35 -0
- package/dist/serve.d.ts +36 -0
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +133 -42
- package/hooks/codewith-native-common.test.ts +1521 -3
- package/hooks/codewith-native-common.ts +1627 -37
- package/hooks/hook-scanoutput/README.md +151 -0
- package/hooks/hook-scanoutput/package.json +12 -0
- package/hooks/hook-scanoutput/src/hook.test.ts +217 -0
- package/hooks/hook-scanoutput/src/hook.ts +319 -0
- package/hooks/mention-context/README.md +109 -0
- package/hooks/mention-context/package.json +9 -0
- package/hooks/mention-context/src/hasna-mention-context.py +1218 -0
- package/hooks/mention-context/src/hasna-mention-warm.py +521 -0
- package/hooks/mention-context/src/hook.test.ts +68 -0
- package/hooks/mention-context/src/test_run_capture.py +345 -0
- package/hooks/pre-bash/README.md +72 -2
- package/hooks/worktree-guard/README.md +9 -2
- package/package.json +9 -5
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: scanoutput
|
|
5
|
+
*
|
|
6
|
+
* PostToolUse hook that scans tool output for credential shapes and WARNS.
|
|
7
|
+
*
|
|
8
|
+
* THIS IS DETECTION, NOT PREVENTION, AND THE DISTINCTION IS THE WHOLE DESIGN.
|
|
9
|
+
*
|
|
10
|
+
* PostToolUse fires after the tool has run and after its result exists. Measured
|
|
11
|
+
* across the 48-hook catalog at @hasna/hooks 0.5.0: no output-rewriting field
|
|
12
|
+
* exists on any event — `continue`, `decision`, `hookSpecificOutput`,
|
|
13
|
+
* `additionalContext`, `suppressOutput` and `permissionDecision` are the whole
|
|
14
|
+
* surface, and `suppressOutput` hides THE HOOK'S OWN stdout rather than the
|
|
15
|
+
* tool's. So by the time this hook can see a credential, that credential is
|
|
16
|
+
* already in the transcript. This hook cannot and does not stop that.
|
|
17
|
+
*
|
|
18
|
+
* It is worth running anyway because today nothing notices at all: a credential
|
|
19
|
+
* emitted by a third-party tool into ordinary output is indistinguishable from
|
|
20
|
+
* ordinary output until a human happens to look. This converts a silent
|
|
21
|
+
* exposure into a recorded one with a named blast radius, which is what the
|
|
22
|
+
* incident duty needs and currently cannot get.
|
|
23
|
+
*
|
|
24
|
+
* Do not describe it as prevention anywhere — in docs, help text, commit
|
|
25
|
+
* messages or channel posts. A guard advertised as preventing a leak it only
|
|
26
|
+
* reports afterwards retires the worry without retiring the exposure, and that
|
|
27
|
+
* is worse than no guard.
|
|
28
|
+
*
|
|
29
|
+
* It never blocks. Every path answers `{ continue: true }`, including its own
|
|
30
|
+
* failures, following the fail-open idiom the catalog already uses.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync } from "fs";
|
|
34
|
+
|
|
35
|
+
/** The scanner surface this hook uses: `scanInputExposures` from @hasna/secrets. */
|
|
36
|
+
export type ScanFn = (options: { text?: string; maxBytes?: number; timeoutMs?: number }) => {
|
|
37
|
+
schema: string;
|
|
38
|
+
version: number;
|
|
39
|
+
source: string;
|
|
40
|
+
root: string;
|
|
41
|
+
redacted: true;
|
|
42
|
+
limits: { findings: number; maxFileBytes?: number; timeoutMs?: number };
|
|
43
|
+
stats: {
|
|
44
|
+
filesScanned: number;
|
|
45
|
+
filesSkipped: number;
|
|
46
|
+
bytesScanned: number;
|
|
47
|
+
errors: string[];
|
|
48
|
+
skipped?: { path: string; reason: string; bytes?: number }[];
|
|
49
|
+
};
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
truncatedReason?: string;
|
|
52
|
+
findings: {
|
|
53
|
+
id: string;
|
|
54
|
+
detector: string;
|
|
55
|
+
severity: string;
|
|
56
|
+
path: string;
|
|
57
|
+
line: number;
|
|
58
|
+
column: number;
|
|
59
|
+
/** Already redacted by the scanner — the value never appears here. */
|
|
60
|
+
preview: string;
|
|
61
|
+
evidencePath: string;
|
|
62
|
+
}[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface HookInput {
|
|
66
|
+
session_id?: string;
|
|
67
|
+
cwd?: string;
|
|
68
|
+
hook_event_name?: string;
|
|
69
|
+
tool_name?: string;
|
|
70
|
+
tool_input?: Record<string, unknown>;
|
|
71
|
+
/**
|
|
72
|
+
* THE FIELD CLAUDE CODE ACTUALLY SENDS. Verified against the Claude Code
|
|
73
|
+
* 2.1.226 binary on 2026-08-10, in both its embedded documentation and its
|
|
74
|
+
* implementation:
|
|
75
|
+
*
|
|
76
|
+
* "tool_input": { ... },
|
|
77
|
+
* "tool_response": { "success": true } // PostToolUse only
|
|
78
|
+
*
|
|
79
|
+
* hook_event_name:"PostToolUse", tool_name:e, tool_input:r,
|
|
80
|
+
* tool_response:n, tool_use_id:t, duration_ms:l
|
|
81
|
+
*
|
|
82
|
+
* Counted in the same binary: `tool_response` 21 occurrences,
|
|
83
|
+
* `tool_output` 2 — and both of those are the telemetry event name
|
|
84
|
+
* `tengu_dead_probe_hook_updated_mcp_tool_output`, neither adjacent to
|
|
85
|
+
* `PostToolUse`. Control: a deliberately absent string returns 0.
|
|
86
|
+
*
|
|
87
|
+
* Read this before "simplifying" the two fields below into one. Every other
|
|
88
|
+
* hook in this catalog reads `tool_output` only, so on Claude Code they
|
|
89
|
+
* receive `undefined` and silently observe nothing — which for a scanner
|
|
90
|
+
* means a clean verdict over an empty string. That is the vacuous-gate
|
|
91
|
+
* defect this hook exists to notice, and it nearly shipped inside it.
|
|
92
|
+
*/
|
|
93
|
+
tool_response?: Record<string, unknown> | string;
|
|
94
|
+
/** Compatibility only: the pre-existing catalog convention, and some runtimes may use it. */
|
|
95
|
+
tool_output?: Record<string, unknown> | string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface HookOutput {
|
|
99
|
+
continue: true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ScanOutcome {
|
|
103
|
+
/**
|
|
104
|
+
* `unscanned` exists so that "I looked at all of it and it was clean" and "I
|
|
105
|
+
* could not look" do not share an answer. A guard that reports those
|
|
106
|
+
* identically is the defect this hook was built to notice, committed inside
|
|
107
|
+
* the hook itself.
|
|
108
|
+
*/
|
|
109
|
+
status: "clean" | "found" | "unscanned" | "empty" | "error";
|
|
110
|
+
findingCount: number;
|
|
111
|
+
detectors: string[];
|
|
112
|
+
previews: string[];
|
|
113
|
+
bytesScanned?: number;
|
|
114
|
+
reason?: string;
|
|
115
|
+
error?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Ceiling on what we hand the scanner. Above it the outcome is `unscanned`, never `clean`. */
|
|
119
|
+
export const MAX_SCAN_BYTES = 4_000_000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wall-clock bound on the scan, well under the scanner's own 10s default.
|
|
123
|
+
*
|
|
124
|
+
* This sits on a tool call's critical path, and the Claude installer target
|
|
125
|
+
* writes no `timeout` into settings.json (only the Codewith target does), so
|
|
126
|
+
* the wiring supplies no outer bound. Measured cost is 1.4-7.3ms for 11 KB and
|
|
127
|
+
* 64-90ms for 2.2 MB, so 2s is generous by more than an order of magnitude and
|
|
128
|
+
* still bounds a pathological input. Exceeding it marks the scan truncated,
|
|
129
|
+
* which this hook reports as `unscanned` rather than clean.
|
|
130
|
+
*/
|
|
131
|
+
export const SCAN_TIMEOUT_MS = 2_000;
|
|
132
|
+
|
|
133
|
+
const OUTPUT_FIELDS = ["stdout", "stderr", "output", "content", "text", "error", "result"] as const;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pull the text out of a tool result, whether it arrived as a string or a record.
|
|
137
|
+
*
|
|
138
|
+
* `tool_response` is what Claude Code sends (see HookInput above); `tool_output`
|
|
139
|
+
* is accepted as a fallback so this also works on any runtime using the older
|
|
140
|
+
* catalog convention. Reading only one of them is how a scanner ends up
|
|
141
|
+
* reporting a confident clean over an empty string.
|
|
142
|
+
*/
|
|
143
|
+
export function extractToolOutputText(input: HookInput): string {
|
|
144
|
+
const output = input.tool_response ?? input.tool_output;
|
|
145
|
+
if (output === undefined || output === null) return "";
|
|
146
|
+
if (typeof output === "string") return output;
|
|
147
|
+
if (typeof output !== "object") return String(output);
|
|
148
|
+
|
|
149
|
+
const parts: string[] = [];
|
|
150
|
+
for (const field of OUTPUT_FIELDS) {
|
|
151
|
+
const value = (output as Record<string, unknown>)[field];
|
|
152
|
+
if (typeof value === "string" && value.length > 0) parts.push(value);
|
|
153
|
+
}
|
|
154
|
+
// Nothing recognised but the record is non-empty: scan its serialised form rather
|
|
155
|
+
// than silently skipping an output shape this list does not know about.
|
|
156
|
+
if (parts.length === 0) {
|
|
157
|
+
try {
|
|
158
|
+
const serialised = JSON.stringify(output);
|
|
159
|
+
if (serialised && serialised !== "{}") return serialised;
|
|
160
|
+
} catch {
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
return parts.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Scan the text and classify the result.
|
|
170
|
+
*
|
|
171
|
+
* `scan` is injected so the hook's own behaviour is testable without the
|
|
172
|
+
* scanner, and so a missing or broken @hasna/secrets degrades to `error` and a
|
|
173
|
+
* warning rather than taking the session down with it.
|
|
174
|
+
*/
|
|
175
|
+
export function analyzeToolOutput(text: string, scan: ScanFn): ScanOutcome {
|
|
176
|
+
if (!text || text.length === 0) {
|
|
177
|
+
return { status: "empty", findingCount: 0, detectors: [], previews: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let result: ReturnType<ScanFn>;
|
|
181
|
+
try {
|
|
182
|
+
result = scan({ text, maxBytes: MAX_SCAN_BYTES, timeoutMs: SCAN_TIMEOUT_MS });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return {
|
|
185
|
+
status: "error",
|
|
186
|
+
findingCount: 0,
|
|
187
|
+
detectors: [],
|
|
188
|
+
previews: [],
|
|
189
|
+
error: err instanceof Error ? err.message : String(err),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const findings = result?.findings ?? [];
|
|
194
|
+
if (findings.length > 0) {
|
|
195
|
+
return {
|
|
196
|
+
status: "found",
|
|
197
|
+
findingCount: findings.length,
|
|
198
|
+
detectors: [...new Set(findings.map((f) => f.detector))],
|
|
199
|
+
previews: findings.slice(0, 5).map((f) => `${f.evidencePath} ${f.detector}/${f.severity} ${f.preview}`),
|
|
200
|
+
bytesScanned: result.stats?.bytesScanned,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// No findings is only good news if the whole input was actually read.
|
|
205
|
+
const stats = result?.stats;
|
|
206
|
+
const skipped = stats?.skipped?.length ?? 0;
|
|
207
|
+
const errors = stats?.errors?.length ?? 0;
|
|
208
|
+
const bytes = stats?.bytesScanned ?? 0;
|
|
209
|
+
if (result?.truncated || skipped > 0 || errors > 0 || bytes <= 0) {
|
|
210
|
+
const reason =
|
|
211
|
+
result?.truncatedReason ??
|
|
212
|
+
stats?.skipped?.[0]?.reason ??
|
|
213
|
+
stats?.errors?.[0] ??
|
|
214
|
+
(bytes <= 0 ? "zero bytes scanned" : "incomplete scan");
|
|
215
|
+
return { status: "unscanned", findingCount: 0, detectors: [], previews: [], bytesScanned: bytes, reason };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { status: "clean", findingCount: 0, detectors: [], previews: [], bytesScanned: bytes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The stderr line(s) a human or agent actually reads. Empty string means stay silent. */
|
|
222
|
+
export function formatWarning(outcome: ScanOutcome): string {
|
|
223
|
+
if (outcome.status === "clean" || outcome.status === "empty") return "";
|
|
224
|
+
|
|
225
|
+
if (outcome.status === "error") {
|
|
226
|
+
return `[scanoutput] could not scan this tool output: ${outcome.error ?? "unknown error"} — treat it as UNSCANNED, not clean.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (outcome.status === "unscanned") {
|
|
230
|
+
return `[scanoutput] UNSCANNED tool output (${outcome.reason ?? "incomplete"}) — no finding here is not evidence of no credential.`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const lines = [
|
|
234
|
+
`[scanoutput] ${outcome.findingCount} credential shape(s) in tool output ` +
|
|
235
|
+
`[${outcome.detectors.join(", ")}] — this output is ALREADY in the transcript; ` +
|
|
236
|
+
`this hook reports it and cannot remove it.`,
|
|
237
|
+
...outcome.previews.map((p) => ` ${p}`),
|
|
238
|
+
` Record it to the incidents channel by NAME and SCOPE only, never the value, then continue.`,
|
|
239
|
+
];
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function buildHookOutput(_outcome: ScanOutcome): HookOutput {
|
|
244
|
+
// Unconditional. A guard that fails closed on its own bug gets uninstalled.
|
|
245
|
+
return { continue: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readStdinJson(): HookInput | null {
|
|
249
|
+
try {
|
|
250
|
+
const raw = readFileSync(0, "utf-8").trim();
|
|
251
|
+
if (!raw) return null;
|
|
252
|
+
return JSON.parse(raw) as HookInput;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve the real scanner. Imported lazily and in-process: a CLI spawn costs a
|
|
260
|
+
* fixed ~1.3-1.8s on this fleet regardless of payload, while the in-process call
|
|
261
|
+
* is ~25ms of import plus ~2ms for 11 KB and ~80ms for 2.2 MB (measured
|
|
262
|
+
* 2026-08-10, station01). The spawn is the dominant term and is avoidable.
|
|
263
|
+
*/
|
|
264
|
+
async function loadScanner(): Promise<ScanFn> {
|
|
265
|
+
const mod = (await import("@hasna/secrets/scanner")) as unknown as { scanInputExposures: ScanFn };
|
|
266
|
+
if (typeof mod?.scanInputExposures !== "function") {
|
|
267
|
+
throw new Error("@hasna/secrets/scanner does not export scanInputExposures");
|
|
268
|
+
}
|
|
269
|
+
return mod.scanInputExposures;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function respond(output: HookOutput): void {
|
|
273
|
+
console.log(JSON.stringify(output));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function warn(message: string): void {
|
|
277
|
+
if (message) console.error(message);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function run(): Promise<void> {
|
|
281
|
+
try {
|
|
282
|
+
const input = readStdinJson();
|
|
283
|
+
if (!input) {
|
|
284
|
+
respond({ continue: true });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const text = extractToolOutputText(input);
|
|
289
|
+
if (!text) {
|
|
290
|
+
respond({ continue: true });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let scan: ScanFn;
|
|
295
|
+
try {
|
|
296
|
+
scan = await loadScanner();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
warn(
|
|
299
|
+
`[scanoutput] could not scan this tool output: ${
|
|
300
|
+
err instanceof Error ? err.message : String(err)
|
|
301
|
+
} — treat it as UNSCANNED, not clean.`,
|
|
302
|
+
);
|
|
303
|
+
respond({ continue: true });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const outcome = analyzeToolOutput(text, scan);
|
|
308
|
+
warn(formatWarning(outcome));
|
|
309
|
+
respond(buildHookOutput(outcome));
|
|
310
|
+
} catch (err) {
|
|
311
|
+
// Nothing this hook does may end a session.
|
|
312
|
+
warn(`[scanoutput] hook failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
313
|
+
respond({ continue: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (import.meta.main) {
|
|
318
|
+
void run();
|
|
319
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# mention-context
|
|
2
|
+
|
|
3
|
+
A `UserPromptSubmit` hook. When a prompt mentions `hasna/<repo>` or `hasnaxyz/<repo>`,
|
|
4
|
+
it injects a short context block for that repository — local checkout HEAD, installed
|
|
5
|
+
version, worktrees, npm version, GitHub default branch and open PRs.
|
|
6
|
+
|
|
7
|
+
This is the first Python hook in this repository. Everything else under `hooks/` is
|
|
8
|
+
TypeScript. The language is not a preference: this hook runs on **every prompt
|
|
9
|
+
submission** under a sub-second budget, and starting a Python interpreter that exits
|
|
10
|
+
immediately when no token matches is measurably cheaper here than the alternative that
|
|
11
|
+
was available when it was written. A TypeScript port is a reasonable future change; it
|
|
12
|
+
is a rewrite, not a move, and it is out of scope for the defect this directory was
|
|
13
|
+
created to fix.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/hasna-mention-context.py the hook
|
|
19
|
+
src/hasna-mention-warm.py the out-of-band cache warmer — see "The two files are a pair"
|
|
20
|
+
src/hook.test.ts bun-test wrapper — runs the Python suite under `bun test`
|
|
21
|
+
src/test_run_capture.py the Python regression suite
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Running the tests
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun test hooks/mention-context # via the wrapper, as CI runs it
|
|
28
|
+
python3 hooks/mention-context/src/test_run_capture.py -v # directly
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The wrapper exists so the Python suite runs under the repository's existing `bun test`
|
|
32
|
+
step with no CI workflow change. It **fails** rather than skips when `python3` is
|
|
33
|
+
absent: a skip is indistinguishable from a pass in the summary line, and a regression
|
|
34
|
+
test that can silently not run is not a regression test.
|
|
35
|
+
|
|
36
|
+
## The two files are a pair, and each resolves the other by directory
|
|
37
|
+
|
|
38
|
+
`hasna-mention-context.py` and `hasna-mention-warm.py` must be installed **into the same
|
|
39
|
+
directory**. Neither takes a configured path for the other; each derives the other's
|
|
40
|
+
location from its own `__file__`. The coupling runs in both directions and the two
|
|
41
|
+
failures look nothing alike.
|
|
42
|
+
|
|
43
|
+
**Warmer missing, hook present — silent.** The hook computes
|
|
44
|
+
`WARM_BIN = <its own dir>/hasna-mention-warm.py` and guards the call with
|
|
45
|
+
`os.path.isfile`, so `request_warm()` simply returns. Nothing raises, nothing is logged,
|
|
46
|
+
and the prompt block still renders. What stops is the cold-cache self-heal: the hook
|
|
47
|
+
hands every degraded or GitHub-less token to the warmer precisely so the *next* prompt
|
|
48
|
+
is clean, and that hand-off never happens. A repository mentioned for the first time is
|
|
49
|
+
degraded once and then stays degraded on every later prompt, instead of being clean
|
|
50
|
+
thereafter. It does not recover on its own, because the hook's own live GitHub probe
|
|
51
|
+
cannot close the gap — by the measurement recorded in the hook's header, `gh api graphql`
|
|
52
|
+
takes 1.02–1.18 s against a whole-hook deadline of 0.900 s, so the probe is killed before
|
|
53
|
+
it can write the cache. The warmer is the only thing that reliably fills it.
|
|
54
|
+
|
|
55
|
+
**Hook missing, warmer present — loud.** The warmer computes
|
|
56
|
+
`HOOK_PATH = <its own dir>/hasna-mention-context.py` and imports it at *module scope*
|
|
57
|
+
(`H = load_hook()`), with no guard, to keep one definition of the cache paths, the entry
|
|
58
|
+
shapes and the sanitizer. An absent hook is an immediate `FileNotFoundError` and the
|
|
59
|
+
warmer does not start at all.
|
|
60
|
+
|
|
61
|
+
Because the warmer imports the hook, the hook's `CACHE_DIR`, cache entry shape and
|
|
62
|
+
sanitizer are a **contract with a second program in this directory**, not private
|
|
63
|
+
details. Changing them means changing both files together.
|
|
64
|
+
|
|
65
|
+
Note that the scheduled warming path does not depend on co-location: the cron entry
|
|
66
|
+
invokes the warmer by absolute path. Co-location is what the hook's self-heal path needs.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
This directory is the source. The hook is installed by copying
|
|
71
|
+
`src/hasna-mention-context.py` to the path registered in the agent's settings
|
|
72
|
+
(`~/.hasna/hooks/bin/hasna-mention-context.py` on the current fleet) and is registered
|
|
73
|
+
there by absolute path under `UserPromptSubmit`.
|
|
74
|
+
|
|
75
|
+
Copy `src/hasna-mention-warm.py` to that **same directory** in the same step, and
|
|
76
|
+
schedule it. On the current fleet it runs from cron at minutes 1, 11, 21, 31, 41 and 51
|
|
77
|
+
under `flock`, and it holds its own lock, so overlapping runs are harmless. Installing
|
|
78
|
+
the hook alone is a supported thing to do — the hook works without the warmer — but it
|
|
79
|
+
costs the self-heal described above, silently, so it should be a decision rather than an
|
|
80
|
+
oversight.
|
|
81
|
+
|
|
82
|
+
Installation is deliberately **not** performed by merging this directory. The hook
|
|
83
|
+
renders into every agent's prompt on every firing, so landing the source and updating
|
|
84
|
+
the live path are two separately verified steps.
|
|
85
|
+
|
|
86
|
+
## The defect this directory was created to fix
|
|
87
|
+
|
|
88
|
+
`run_capture` derived its temp-file path from its `tag` argument alone, and
|
|
89
|
+
`probe_local_head` passed a constant `tag="gitlog"`. Repository probes run concurrently
|
|
90
|
+
against one shared temp directory, so every mentioned repository's `git log` wrote to
|
|
91
|
+
and read back the same `gitlog.out`, and the reader got whatever the last writer left.
|
|
92
|
+
|
|
93
|
+
The emitted value was always a **real sha from a real repository — just the wrong
|
|
94
|
+
one**, which is why it read as correct. Four consecutive firings, each a different wrong
|
|
95
|
+
pairing:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
17:44 loops=82a3acf (right) accounts=27cffd7 (right) logs=absent
|
|
99
|
+
17:48 loops=27cffd7 (WRONG) logs=82a3acf (WRONG) <- clean swap
|
|
100
|
+
18:11 loops=27cffd7 (WRONG) logs=146a70e (right)
|
|
101
|
+
18:17 loops=82a3acf (right) logs=82a3acf (WRONG) <- duplicate sha
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The race needs two or three mentions in one prompt. `MAX_TOKENS = 3`, so a single-repo
|
|
105
|
+
mention produces one probe, no concurrency, and always the correct answer.
|
|
106
|
+
|
|
107
|
+
The fix is in `run_capture`, which owns path construction, rather than at the call site:
|
|
108
|
+
threading `org`/`word` into `probe_local_head` would have matched its siblings but left
|
|
109
|
+
the invariant unenforced, so a fifth call site added later would inherit the bug.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mention-context",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "UserPromptSubmit hook that injects repository context for @hasna/<repo> mentions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hasna-mention-context.py",
|
|
7
|
+
"author": "Hasna",
|
|
8
|
+
"license": "Apache-2.0"
|
|
9
|
+
}
|