@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.
- package/.kintsugi/logs/hook.log +11 -0
- package/dist/agent-detect.d.ts +59 -0
- package/dist/agent-detect.d.ts.map +1 -0
- package/dist/agent-detect.js +123 -0
- package/dist/agent-detect.js.map +1 -0
- package/dist/agent-detect.test.d.ts +2 -0
- package/dist/agent-detect.test.d.ts.map +1 -0
- package/dist/agent-detect.test.js +42 -0
- package/dist/agent-detect.test.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +81 -0
- package/dist/context.js.map +1 -0
- package/dist/escalation.d.ts +28 -0
- package/dist/escalation.d.ts.map +1 -0
- package/dist/escalation.js +48 -0
- package/dist/escalation.js.map +1 -0
- package/dist/flow-matcher.d.ts +9 -0
- package/dist/flow-matcher.d.ts.map +1 -0
- package/dist/flow-matcher.js +12 -0
- package/dist/flow-matcher.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +209 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +58 -0
- package/dist/logger.js.map +1 -0
- package/dist/ui-file-detector.d.ts +9 -0
- package/dist/ui-file-detector.d.ts.map +1 -0
- package/dist/ui-file-detector.js +34 -0
- package/dist/ui-file-detector.js.map +1 -0
- package/package.json +29 -0
- package/src/agent-detect.test.ts +53 -0
- package/src/agent-detect.ts +167 -0
- package/src/context.ts +80 -0
- package/src/escalation.ts +60 -0
- package/src/flow-matcher.ts +13 -0
- package/src/index.ts +269 -0
- package/src/logger.ts +62 -0
- package/src/ui-file-detector.ts +40 -0
- 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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"}
|
package/dist/logger.d.ts
ADDED
|
@@ -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
|
+
}
|