@kintsugi-ai/hook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.kintsugi/logs/hook.log +11 -0
  2. package/dist/agent-detect.d.ts +59 -0
  3. package/dist/agent-detect.d.ts.map +1 -0
  4. package/dist/agent-detect.js +123 -0
  5. package/dist/agent-detect.js.map +1 -0
  6. package/dist/agent-detect.test.d.ts +2 -0
  7. package/dist/agent-detect.test.d.ts.map +1 -0
  8. package/dist/agent-detect.test.js +42 -0
  9. package/dist/agent-detect.test.js.map +1 -0
  10. package/dist/context.d.ts +10 -0
  11. package/dist/context.d.ts.map +1 -0
  12. package/dist/context.js +81 -0
  13. package/dist/context.js.map +1 -0
  14. package/dist/escalation.d.ts +28 -0
  15. package/dist/escalation.d.ts.map +1 -0
  16. package/dist/escalation.js +48 -0
  17. package/dist/escalation.js.map +1 -0
  18. package/dist/flow-matcher.d.ts +9 -0
  19. package/dist/flow-matcher.d.ts.map +1 -0
  20. package/dist/flow-matcher.js +12 -0
  21. package/dist/flow-matcher.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +209 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/logger.d.ts +7 -0
  27. package/dist/logger.d.ts.map +1 -0
  28. package/dist/logger.js +58 -0
  29. package/dist/logger.js.map +1 -0
  30. package/dist/ui-file-detector.d.ts +9 -0
  31. package/dist/ui-file-detector.d.ts.map +1 -0
  32. package/dist/ui-file-detector.js +34 -0
  33. package/dist/ui-file-detector.js.map +1 -0
  34. package/package.json +29 -0
  35. package/src/agent-detect.test.ts +53 -0
  36. package/src/agent-detect.ts +167 -0
  37. package/src/context.ts +80 -0
  38. package/src/escalation.ts +60 -0
  39. package/src/flow-matcher.ts +13 -0
  40. package/src/index.ts +269 -0
  41. package/src/logger.ts +62 -0
  42. package/src/ui-file-detector.ts +40 -0
  43. package/tsconfig.json +8 -0
package/dist/index.js ADDED
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { loadConfig, listFlows, loadFlow, captureCurrentState, compareFlow, formatAgentFeedback, buildClassifierContext, loadKintsugiEnv, DiffResult } from '@kintsugi-ai/core';
5
+ import { detectAgent, outputFeedback, exitWithResult } from './agent-detect.js';
6
+ import { extractTurnContext } from './context.js';
7
+ import { writeEscalation, readEscalation, clearEscalation } from './escalation.js';
8
+ import { createLogger } from './logger.js';
9
+ async function main() {
10
+ const earlyWarnings = [];
11
+ // Check command line arguments for explicit event flags (e.g. --event stop)
12
+ const isExplicitStopEvent = process.argv.includes('stop') ||
13
+ process.argv.some(arg => arg.toLowerCase().includes('stop'));
14
+ let stdinData = '';
15
+ try {
16
+ stdinData = fs.readFileSync(0, 'utf-8');
17
+ }
18
+ catch {
19
+ // stdin may not be available
20
+ earlyWarnings.push('could not read stdin (no piped hook payload?)');
21
+ }
22
+ let input = {};
23
+ if (stdinData.trim()) {
24
+ try {
25
+ input = JSON.parse(stdinData);
26
+ }
27
+ catch (e) {
28
+ earlyWarnings.push(`stdin is not valid JSON: ${e.message} preview=${stdinData.slice(0, 500)}`);
29
+ }
30
+ }
31
+ else {
32
+ earlyWarnings.push('stdin was empty — agent sent no hook payload');
33
+ }
34
+ const agent = detectAgent(input);
35
+ // Stop events arrive as terminationReason (agy), hook_event_name: "Stop"
36
+ // (Claude Code), or the explicit --event stop flag in registered configs.
37
+ const eventName = typeof input.hook_event_name === 'string'
38
+ ? input.hook_event_name.toLowerCase()
39
+ : undefined;
40
+ const isStopEvent = isExplicitStopEvent || Boolean(input.terminationReason) || eventName === 'stop';
41
+ // Some agents (Antigravity/agy) run hooks with cwd set to their config
42
+ // dir (e.g. <workspace>/.agents) rather than the workspace root. Resolve
43
+ // the real project dir from the payload's workspacePaths when available.
44
+ const projectDir = input.workspacePaths?.[0] ?? process.cwd();
45
+ const log = createLogger(projectDir);
46
+ log.info('hook invoked', {
47
+ argv: process.argv.slice(2),
48
+ cwd: process.cwd(),
49
+ projectDir,
50
+ stdinBytes: stdinLength(),
51
+ });
52
+ for (const w of earlyWarnings)
53
+ log.warn(w);
54
+ log.info('agent detected', { agent, isStopEvent, isExplicitStopEvent, inputKeys: Object.keys(input) });
55
+ if (!isStopEvent) {
56
+ // Kintsugi checks once per turn (Stop event), not on every file edit —
57
+ // per-edit checks would replay flows dozens of times per turn.
58
+ log.info('non-stop event — deferring visual check to turn end');
59
+ outputFeedback(agent, '', { isStopEvent: false, hasRegression: false });
60
+ process.exit(0);
61
+ }
62
+ await runTurnEndCheck(agent, input, projectDir, log);
63
+ }
64
+ /**
65
+ * Turn-end visual check: verifies every recorded flow in parallel (each in
66
+ * its own browser context), classifies oversized diffs in parallel, and on a
67
+ * regression blocks the agent's stop with fix-it feedback until it passes or
68
+ * maxAttempts is reached.
69
+ */
70
+ async function runTurnEndCheck(agent, input, projectDir, log) {
71
+ // Pick up gitignored <project>/.kintsugi/.env before resolving the token
72
+ loadKintsugiEnv(projectDir);
73
+ const config = await loadConfig(projectDir);
74
+ log.info('config loaded', { devServerUrl: config.devServerUrl, classifierModel: config.classifier.model });
75
+ // Shared builder (also used by `kintsugi check` and MCP) — bootstraps an
76
+ // anonymous hosted key on first use so the classifier is never silently dark.
77
+ const classifierContext = await buildClassifierContext(config, {
78
+ context: extractTurnContext(input),
79
+ logger: log,
80
+ bootstrapKey: true,
81
+ projectDir,
82
+ });
83
+ const flows = await listFlows(projectDir);
84
+ log.info('flows discovered', {
85
+ flowsDir: path.join(projectDir, '.kintsugi', 'flows'),
86
+ count: flows.length,
87
+ names: flows.map(f => f.name),
88
+ });
89
+ if (flows.length === 0) {
90
+ log.info('no recorded flows — nothing to check');
91
+ outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
92
+ process.exit(0);
93
+ }
94
+ const maxAttempts = config.agent.maxRetries;
95
+ const outcomes = await Promise.all(flows.map(async (flow) => {
96
+ try {
97
+ return await checkFlow(flow.name, config, classifierContext, projectDir, log);
98
+ }
99
+ catch (err) {
100
+ log.error('flow check crashed', { flowName: flow.name, error: err.message });
101
+ return { flowName: flow.name, isRegression: false };
102
+ }
103
+ }));
104
+ const broken = outcomes.filter(o => o.isRegression);
105
+ if (broken.length === 0) {
106
+ // Clean pass — either nothing broke or the agent just fixed the
107
+ // previous regression. Either way the agent may stop.
108
+ log.info('turn-end check passed — clearing any previous escalation');
109
+ await clearEscalation(projectDir);
110
+ outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
111
+ process.exit(0);
112
+ }
113
+ // The fresh check above is the source of truth; only treat a pending
114
+ // escalation as "still unfixed" now that the current state is known broken.
115
+ const pendingEscalation = await readEscalation(projectDir);
116
+ if (pendingEscalation && pendingEscalation.attempt >= maxAttempts) {
117
+ log.info('max fix attempts reached — allowing agent to stop', {
118
+ flowName: pendingEscalation.flowName,
119
+ attempt: pendingEscalation.attempt,
120
+ maxAttempts,
121
+ });
122
+ await clearEscalation(projectDir);
123
+ outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
124
+ process.exit(0);
125
+ }
126
+ const first = broken[0];
127
+ const attempt = (pendingEscalation?.attempt || 0) + 1;
128
+ await writeEscalation(projectDir, {
129
+ flowName: first.flowName,
130
+ result: first.comparisonResult,
131
+ oldVideoPath: first.oldPath ?? '',
132
+ newVideoPath: first.newPath ?? '',
133
+ timestamp: new Date().toISOString(),
134
+ attempt,
135
+ maxAttempts,
136
+ });
137
+ // Fix-it feedback covers every broken flow: aria diffs, image pairs, per-pair fixes
138
+ const combinedFeedback = broken
139
+ .map(o => formatAgentFeedback(o.comparisonResult, o.flowName))
140
+ .join('\n\n');
141
+ log.warn('turn-end regression — blocking agent stop with feedback', {
142
+ flows: broken.map(b => b.flowName),
143
+ attempt,
144
+ maxAttempts,
145
+ });
146
+ outputFeedback(agent, combinedFeedback, { isStopEvent: true, hasRegression: true });
147
+ exitWithResult(agent, true);
148
+ }
149
+ /** Replays one flow and compares it against its baseline. */
150
+ async function checkFlow(flowName, config, classifierContext, projectDir, log) {
151
+ const flowRecord = await loadFlow(projectDir, flowName);
152
+ if (!flowRecord) {
153
+ log.warn('flow metadata listed but could not be loaded', { flowName });
154
+ return { flowName, isRegression: false };
155
+ }
156
+ log.info('checking flow', { flowName, stepCount: flowRecord.steps.length, baselineScreenshots: flowRecord.screenshotPaths.length });
157
+ const outputDir = path.join(projectDir, '.kintsugi', '.tmp', flowName);
158
+ fs.mkdirSync(outputDir, { recursive: true });
159
+ const currentState = await captureCurrentState({
160
+ url: config.devServerUrl,
161
+ flow: flowRecord,
162
+ outputDir,
163
+ viewport: config.viewport,
164
+ });
165
+ log.info('current state captured', { flowName, url: config.devServerUrl, screenshots: currentState.screenshots.length, errors: currentState.errors.length });
166
+ const comparisonResult = await compareFlow(flowRecord, currentState, { outputDir, classifier: classifierContext });
167
+ log.info('comparison finished', { flowName, result: comparisonResult.result });
168
+ // Any definite visual change (CHANGED) or broken capture is a regression —
169
+ // the baseline flow no longer matches the live page. INTENTIONAL means the
170
+ // classifier accepted the change.
171
+ const isRegression = comparisonResult.result !== DiffResult.IDENTICAL &&
172
+ comparisonResult.result !== DiffResult.MINOR &&
173
+ comparisonResult.result !== DiffResult.INTENTIONAL;
174
+ if (!isRegression) {
175
+ if (comparisonResult.result === DiffResult.INTENTIONAL) {
176
+ log.info('large visual change accepted as intentional by classifier', { flowName });
177
+ }
178
+ return { flowName, isRegression: false, comparisonResult };
179
+ }
180
+ log.warn('REGRESSION detected', { flowName, result: comparisonResult.result });
181
+ const failedIndex = comparisonResult.failedStep ?? 0;
182
+ return {
183
+ flowName,
184
+ isRegression: true,
185
+ comparisonResult,
186
+ oldPath: flowRecord.screenshotPaths[failedIndex] ?? flowRecord.screenshotPaths[0] ?? '',
187
+ newPath: currentState.screenshotPaths[failedIndex] ?? currentState.screenshotPaths[0] ?? '',
188
+ };
189
+ }
190
+ function stdinLength() {
191
+ try {
192
+ return fs.fstatSync(0).size;
193
+ }
194
+ catch {
195
+ return -1;
196
+ }
197
+ }
198
+ main().catch((e) => {
199
+ try {
200
+ const log = createLogger(process.cwd());
201
+ log.error('hook crashed', e);
202
+ }
203
+ catch { /* ignore */ }
204
+ // Exit non-zero so the agent knows something went wrong.
205
+ // Write a diagnostic to stderr for agents that capture it.
206
+ process.stderr.write(`kintsugi hook crashed: ${e instanceof Error ? e.message : String(e)}\n`);
207
+ process.exit(1);
208
+ });
209
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ export declare function createLogger(projectDir: string): {
2
+ info: (message: string, data?: unknown) => void;
3
+ warn: (message: string, data?: unknown) => void;
4
+ error: (message: string, error?: unknown) => void;
5
+ logPath: string;
6
+ };
7
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAiBA,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM;oBAkBvB,MAAM,SAAS,OAAO;oBAGtB,MAAM,SAAS,OAAO;qBAGrB,MAAM,UAAU,OAAO;;EAK/C"}
package/dist/logger.js ADDED
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Debug logging for the kintsugi hook.
5
+ *
6
+ * The #1 failure mode for agent hooks is silent early-exit: the hook runs,
7
+ * decides "nothing to do", and exits 0 without any trace. To debug why a
8
+ * regression was NOT caught, we log every decision point to
9
+ * `<projectDir>/.kintsugi/logs/hook.log` (always) and to stderr (when
10
+ * KINTSUGI_DEBUG=1, since agent CLIs may otherwise swallow or misrender
11
+ * unexpected stderr).
12
+ */
13
+ const LOG_DIRNAME = path.join('.kintsugi', 'logs');
14
+ const LOG_FILENAME = 'hook.log';
15
+ export function createLogger(projectDir) {
16
+ const debugToStderr = process.env.KINTSUGI_DEBUG === '1';
17
+ const logPath = path.join(projectDir, LOG_DIRNAME, LOG_FILENAME);
18
+ function write(line) {
19
+ const entry = `[${new Date().toISOString()}] ${line}\n`;
20
+ try {
21
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
22
+ fs.appendFileSync(logPath, entry);
23
+ }
24
+ catch {
25
+ // Never let logging break the hook
26
+ }
27
+ if (debugToStderr) {
28
+ process.stderr.write(`[kintsugi-hook] ${entry}`);
29
+ }
30
+ }
31
+ return {
32
+ info: (message, data) => {
33
+ write(data === undefined ? message : `${message} ${safeStringify(data)}`);
34
+ },
35
+ warn: (message, data) => {
36
+ write(`WARN: ${data === undefined ? message : `${message} ${safeStringify(data)}`}`);
37
+ },
38
+ error: (message, error) => {
39
+ write(`ERROR: ${message}${error ? ` ${formatError(error)}` : ''}`);
40
+ },
41
+ logPath,
42
+ };
43
+ }
44
+ function safeStringify(data) {
45
+ try {
46
+ return JSON.stringify(data);
47
+ }
48
+ catch {
49
+ return String(data);
50
+ }
51
+ }
52
+ function formatError(error) {
53
+ if (error instanceof Error) {
54
+ return `${error.message}\n${error.stack ?? ''}`;
55
+ }
56
+ return safeStringify(error);
57
+ }
58
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B;;;;;;;;;GASG;AAEH,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;AACnD,MAAM,YAAY,GAAG,UAAU,CAAC;AAEhC,MAAM,UAAU,YAAY,CAAC,UAAkB;IAC3C,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,KAAK,GAAG,CAAC;IACzD,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;IAEjE,SAAS,KAAK,CAAC,IAAY;QACvB,MAAM,KAAK,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,IAAI,IAAI,CAAC;QACxD,IAAI,CAAC;YACD,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YACzD,EAAE,CAAC,cAAc,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACtC,CAAC;QAAC,MAAM,CAAC;YACL,mCAAmC;QACvC,CAAC;QACD,IAAI,aAAa,EAAE,CAAC;YAChB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,mBAAmB,KAAK,EAAE,CAAC,CAAC;QACrD,CAAC;IACL,CAAC;IAED,OAAO;QACH,IAAI,EAAE,CAAC,OAAe,EAAE,IAAc,EAAE,EAAE;YACtC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,IAAI,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC9E,CAAC;QACD,IAAI,EAAE,CAAC,OAAe,EAAE,IAAc,EAAE,EAAE;YACtC,KAAK,CAAC,SAAS,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,IAAI,aAAa,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;QACzF,CAAC;QACD,KAAK,EAAE,CAAC,OAAe,EAAE,KAAe,EAAE,EAAE;YACxC,KAAK,CAAC,UAAU,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACvE,CAAC;QACD,OAAO;KACV,CAAC;AACN,CAAC;AAED,SAAS,aAAa,CAAC,IAAa;IAChC,IAAI,CAAC;QACD,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAChC,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC;IACxB,CAAC;AACL,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IAC/B,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;QACzB,OAAO,GAAG,KAAK,CAAC,OAAO,KAAK,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC;IACpD,CAAC;IACD,OAAO,aAAa,CAAC,KAAK,CAAC,CAAC;AAChC,CAAC"}
@@ -0,0 +1,9 @@
1
+ export declare const DEFAULT_UI_PATTERNS: string[];
2
+ /**
3
+ * Detects if a file is a UI file that might require visual regression testing.
4
+ * @param filePath The path to the modified file.
5
+ * @param patterns Optional array of glob patterns to match against.
6
+ * @returns True if the file is considered a UI file.
7
+ */
8
+ export declare function isUIFile(filePath: string, patterns?: string[]): boolean;
9
+ //# sourceMappingURL=ui-file-detector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-file-detector.d.ts","sourceRoot":"","sources":["../src/ui-file-detector.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,mBAAmB,UAG/B,CAAC;AAKF;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAM,EAAwB,GAAG,OAAO,CAuB5F"}
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ export const DEFAULT_UI_PATTERNS = [
3
+ '**/*.tsx', '**/*.jsx', '**/*.vue', '**/*.svelte',
4
+ '**/*.html', '**/*.css', '**/*.scss'
5
+ ];
6
+ const UI_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte', '.html', '.css', '.scss']);
7
+ const NON_UI_SUBSTRINGS = ['.test.', '.spec.', '.config.', '/test/', '/tests/'];
8
+ /**
9
+ * Detects if a file is a UI file that might require visual regression testing.
10
+ * @param filePath The path to the modified file.
11
+ * @param patterns Optional array of glob patterns to match against.
12
+ * @returns True if the file is considered a UI file.
13
+ */
14
+ export function isUIFile(filePath, patterns = DEFAULT_UI_PATTERNS) {
15
+ const ext = path.extname(filePath).toLowerCase();
16
+ for (const sub of NON_UI_SUBSTRINGS) {
17
+ if (filePath.includes(sub)) {
18
+ return false;
19
+ }
20
+ }
21
+ if (UI_EXTENSIONS.has(ext)) {
22
+ return true;
23
+ }
24
+ // Simplified pattern matching if not matched by extension
25
+ for (const pattern of patterns) {
26
+ // Extract extension from glob patterns like '**/*.tsx' or '*.css'
27
+ const extMatch = pattern.match(/\*\.([a-zA-Z0-9]+)$/);
28
+ if (extMatch && ext === '.' + extMatch[1]) {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+ //# sourceMappingURL=ui-file-detector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui-file-detector.js","sourceRoot":"","sources":["../src/ui-file-detector.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,MAAM,CAAC,MAAM,mBAAmB,GAAG;IAC/B,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,aAAa;IACjD,WAAW,EAAE,UAAU,EAAE,WAAW;CACvC,CAAC;AAEF,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAC7F,MAAM,iBAAiB,GAAG,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;AAEhF;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CAAC,QAAgB,EAAE,WAAqB,mBAAmB;IAC/E,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAC;IAEjD,KAAK,MAAM,GAAG,IAAI,iBAAiB,EAAE,CAAC;QAClC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,OAAO,KAAK,CAAC;QACjB,CAAC;IACL,CAAC;IAED,IAAI,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QACzB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,0DAA0D;IAC1D,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC7B,kEAAkE;QAClE,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;QACtD,IAAI,QAAQ,IAAI,GAAG,KAAK,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YACxC,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;IAED,OAAO,KAAK,CAAC;AACjB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@kintsugi-ai/hook",
3
+ "version": "0.1.0",
4
+ "description": "Agent hook script for Kintsugi visual regression checks",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "kintsugi-hook": "./dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "@kintsugi-ai/core": "0.1.0"
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "^22.0.0",
15
+ "typescript": "^5.7.0"
16
+ },
17
+ "engines": {
18
+ "node": ">=20.0.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "clean": "rm -rf dist",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "node --test dist/**/*.test.js"
28
+ }
29
+ }
@@ -0,0 +1,53 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ detectAgent,
5
+ getModifiedFilePath,
6
+ type HookInput
7
+ } from './agent-detect.js';
8
+
9
+ describe('Agent Detection & Protocol Adaptation', () => {
10
+ test('detects Antigravity (agy) from stdin payload markers', () => {
11
+ const agyPayload: HookInput = {
12
+ conversationId: '550e8400-e29b-41d4-a716-446655440000',
13
+ workspacePaths: ['/workspace/kintsugi'],
14
+ toolCall: {
15
+ name: 'write_to_file',
16
+ args: {
17
+ TargetFile: 'src/components/Checkout.tsx',
18
+ }
19
+ }
20
+ };
21
+
22
+ const detected = detectAgent(agyPayload);
23
+ assert.equal(detected, 'agy');
24
+
25
+ const filePath = getModifiedFilePath(detected, agyPayload);
26
+ assert.equal(filePath, 'src/components/Checkout.tsx');
27
+ });
28
+
29
+ test('detects Antigravity from Stop event payload', () => {
30
+ const stopPayload: HookInput = {
31
+ conversationId: '550e8400-e29b-41d4-a716-446655440000',
32
+ terminationReason: 'model_stop',
33
+ fullyIdle: true
34
+ };
35
+
36
+ const detected = detectAgent(stopPayload);
37
+ assert.equal(detected, 'agy');
38
+ });
39
+
40
+ test('extracts modified file path from Codex tool input', () => {
41
+ const codexPayload: HookInput = {
42
+ tool_input: {
43
+ file_path: 'src/Navbar.tsx'
44
+ }
45
+ };
46
+
47
+ const detected = detectAgent(codexPayload);
48
+ assert.equal(detected, 'codex');
49
+
50
+ const filePath = getModifiedFilePath(detected, codexPayload);
51
+ assert.equal(filePath, 'src/Navbar.tsx');
52
+ });
53
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Agent type detection and protocol helpers for Claude Code, Codex CLI, and Antigravity (agy).
3
+ */
4
+
5
+ export type AgentType = 'claude-code' | 'codex' | 'agy' | 'unknown';
6
+
7
+ export interface HookInput {
8
+ // Antigravity (agy) protocol fields
9
+ conversationId?: string;
10
+ workspacePaths?: string[];
11
+ transcriptPath?: string;
12
+ artifactDirectoryPath?: string;
13
+ terminationReason?: string;
14
+ toolCall?: {
15
+ name: string;
16
+ args?: {
17
+ TargetFile?: string;
18
+ CommandLine?: string;
19
+ [key: string]: unknown;
20
+ };
21
+ };
22
+
23
+ // Claude / Codex protocol fields
24
+ hook_event_name?: string;
25
+ tool_input?: {
26
+ file_path?: string;
27
+ path?: string;
28
+ TargetFile?: string;
29
+ [key: string]: unknown;
30
+ };
31
+ tool_name?: string;
32
+ session_id?: string;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ /**
37
+ * Detects the type of agent invoking the hook.
38
+ * @param stdin Optional parsed stdin payload to inspect markers.
39
+ * @returns The detected agent type.
40
+ */
41
+ export function detectAgent(stdin?: HookInput): AgentType {
42
+ // 1. Explicit Antigravity (agy) payload markers in stdin
43
+ if (
44
+ stdin?.conversationId ||
45
+ stdin?.transcriptPath ||
46
+ stdin?.toolCall
47
+ ) {
48
+ return 'agy';
49
+ }
50
+
51
+ // 2. Explicit Claude Code markers
52
+ if (process.env.CLAUDE_SESSION_ID || process.env.CLAUDE_FILE_PATH) {
53
+ return 'claude-code';
54
+ }
55
+
56
+ // 3. Explicit Codex payload marker (tool_input without agy headers)
57
+ if (stdin?.tool_input && !stdin?.conversationId) {
58
+ return 'codex';
59
+ }
60
+
61
+ // 4. Ambient environment markers
62
+ if (process.env.ANTIGRAVITY_AGENT || process.env.GEMINI_CLI) {
63
+ return 'agy';
64
+ }
65
+
66
+ // 5. Default fallback
67
+ return 'codex';
68
+ }
69
+
70
+ /**
71
+ * Extracts the modified file path from the agent context.
72
+ * @param agent The type of agent.
73
+ * @param stdin The input from standard input.
74
+ * @returns The modified file path, if found.
75
+ */
76
+ export function getModifiedFilePath(agent: AgentType, stdin: HookInput): string | undefined {
77
+ if (agent === 'agy') {
78
+ return (
79
+ stdin?.toolCall?.args?.TargetFile ||
80
+ (stdin?.toolCall?.args?.['target_file'] as string | undefined) ||
81
+ stdin?.tool_input?.TargetFile ||
82
+ stdin?.tool_input?.file_path
83
+ );
84
+ }
85
+
86
+ if (agent === 'claude-code') {
87
+ return (
88
+ process.env.CLAUDE_FILE_PATH ||
89
+ stdin?.tool_input?.file_path ||
90
+ (stdin?.tool_input?.['filePath'] as string | undefined)
91
+ );
92
+ }
93
+
94
+ return (
95
+ stdin?.tool_input?.file_path ||
96
+ stdin?.tool_input?.path ||
97
+ stdin?.tool_input?.TargetFile
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Outputs feedback based on the agent type protocols.
103
+ * @param agent The type of agent.
104
+ * @param feedback The formatted feedback string.
105
+ * @param options Additional metadata such as whether this is a Stop event.
106
+ */
107
+ export function outputFeedback(
108
+ agent: AgentType,
109
+ feedback: string,
110
+ options?: { isStopEvent?: boolean; hasRegression?: boolean }
111
+ ): void {
112
+ if (agent === 'agy') {
113
+ if (options?.isStopEvent) {
114
+ if (options.hasRegression) {
115
+ // Antigravity Stop Hook protocol: continue execution loop with reason
116
+ const response = {
117
+ decision: 'continue',
118
+ reason: feedback
119
+ };
120
+ process.stdout.write(JSON.stringify(response, null, 2) + '\n');
121
+ } else {
122
+ process.stdout.write('{}\n');
123
+ }
124
+ } else {
125
+ // Antigravity PostToolUse contract expects empty JSON {}
126
+ process.stdout.write('{}\n');
127
+ }
128
+ } else if (agent === 'claude-code') {
129
+ if (options?.isStopEvent) {
130
+ // Claude Code Stop-hook contract: {"decision":"block"} keeps the
131
+ // agent working with `reason` as the feedback. Plain stdout text
132
+ // would not reach the model on a Stop event.
133
+ if (options.hasRegression) {
134
+ const response = {
135
+ decision: 'block',
136
+ reason: feedback
137
+ };
138
+ process.stdout.write(JSON.stringify(response, null, 2) + '\n');
139
+ } else {
140
+ process.stdout.write('{}\n');
141
+ }
142
+ } else {
143
+ process.stdout.write(feedback + '\n');
144
+ }
145
+ } else {
146
+ process.stderr.write(feedback + '\n');
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Exits the process with the appropriate code for the agent.
152
+ * @param agent The type of agent.
153
+ * @param hasRegression Whether a regression was detected.
154
+ */
155
+ export function exitWithResult(agent: AgentType, hasRegression: boolean): never {
156
+ if (agent === 'agy') {
157
+ // Antigravity communicates decisions via JSON on stdout, with exit code 0
158
+ process.exit(0);
159
+ } else if (agent === 'claude-code') {
160
+ // Claude always expects exit 0; feedback is passed via stdout
161
+ process.exit(0);
162
+ } else if (agent === 'codex') {
163
+ // Codex expects exit 2 for failure (reject action)
164
+ process.exit(hasRegression ? 2 : 0);
165
+ }
166
+ process.exit(0);
167
+ }