@kintsugi-ai/hook 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/coverage-nudge.d.ts +14 -0
- package/dist/coverage-nudge.js +46 -0
- package/dist/escalation.d.ts +4 -25
- package/dist/escalation.js +3 -45
- package/dist/flow-matcher.d.ts +9 -5
- package/dist/flow-matcher.js +44 -8
- package/dist/index.js +149 -30
- package/package.json +17 -2
- package/.kintsugi/logs/hook.log +0 -11
- package/dist/agent-detect.d.ts.map +0 -1
- package/dist/agent-detect.js.map +0 -1
- package/dist/agent-detect.test.d.ts +0 -2
- package/dist/agent-detect.test.d.ts.map +0 -1
- package/dist/agent-detect.test.js +0 -42
- package/dist/agent-detect.test.js.map +0 -1
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js.map +0 -1
- package/dist/escalation.d.ts.map +0 -1
- package/dist/escalation.js.map +0 -1
- package/dist/flow-matcher.d.ts.map +0 -1
- package/dist/flow-matcher.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js.map +0 -1
- package/dist/ui-file-detector.d.ts.map +0 -1
- package/dist/ui-file-detector.js.map +0 -1
- package/src/agent-detect.test.ts +0 -53
- package/src/agent-detect.ts +0 -167
- package/src/context.ts +0 -80
- package/src/escalation.ts +0 -60
- package/src/flow-matcher.ts +0 -13
- package/src/index.ts +0 -269
- package/src/logger.ts +0 -62
- package/src/ui-file-detector.ts +0 -40
- package/tsconfig.json +0 -8
package/src/index.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
loadConfig,
|
|
7
|
-
listFlows,
|
|
8
|
-
loadFlow,
|
|
9
|
-
captureCurrentState,
|
|
10
|
-
compareFlow,
|
|
11
|
-
formatAgentFeedback,
|
|
12
|
-
buildClassifierContext,
|
|
13
|
-
loadKintsugiEnv,
|
|
14
|
-
DiffResult,
|
|
15
|
-
type ComparisonResult,
|
|
16
|
-
type KintsugiConfig
|
|
17
|
-
} from '@kintsugi-ai/core';
|
|
18
|
-
import {
|
|
19
|
-
detectAgent,
|
|
20
|
-
outputFeedback,
|
|
21
|
-
exitWithResult,
|
|
22
|
-
type AgentType,
|
|
23
|
-
type HookInput
|
|
24
|
-
} from './agent-detect.js';
|
|
25
|
-
import { extractTurnContext } from './context.js';
|
|
26
|
-
import { writeEscalation, readEscalation, clearEscalation } from './escalation.js';
|
|
27
|
-
import { createLogger } from './logger.js';
|
|
28
|
-
|
|
29
|
-
async function main() {
|
|
30
|
-
const earlyWarnings: string[] = [];
|
|
31
|
-
|
|
32
|
-
// Check command line arguments for explicit event flags (e.g. --event stop)
|
|
33
|
-
const isExplicitStopEvent = process.argv.includes('stop') ||
|
|
34
|
-
process.argv.some(arg => arg.toLowerCase().includes('stop'));
|
|
35
|
-
|
|
36
|
-
let stdinData = '';
|
|
37
|
-
try {
|
|
38
|
-
stdinData = fs.readFileSync(0, 'utf-8');
|
|
39
|
-
} catch {
|
|
40
|
-
// stdin may not be available
|
|
41
|
-
earlyWarnings.push('could not read stdin (no piped hook payload?)');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let input: HookInput = {};
|
|
45
|
-
if (stdinData.trim()) {
|
|
46
|
-
try {
|
|
47
|
-
input = JSON.parse(stdinData);
|
|
48
|
-
} catch (e) {
|
|
49
|
-
earlyWarnings.push(`stdin is not valid JSON: ${(e as Error).message} preview=${stdinData.slice(0, 500)}`);
|
|
50
|
-
}
|
|
51
|
-
} else {
|
|
52
|
-
earlyWarnings.push('stdin was empty — agent sent no hook payload');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const agent = detectAgent(input);
|
|
56
|
-
// Stop events arrive as terminationReason (agy), hook_event_name: "Stop"
|
|
57
|
-
// (Claude Code), or the explicit --event stop flag in registered configs.
|
|
58
|
-
const eventName = typeof input.hook_event_name === 'string'
|
|
59
|
-
? input.hook_event_name.toLowerCase()
|
|
60
|
-
: undefined;
|
|
61
|
-
const isStopEvent = isExplicitStopEvent || Boolean(input.terminationReason) || eventName === 'stop';
|
|
62
|
-
|
|
63
|
-
// Some agents (Antigravity/agy) run hooks with cwd set to their config
|
|
64
|
-
// dir (e.g. <workspace>/.agents) rather than the workspace root. Resolve
|
|
65
|
-
// the real project dir from the payload's workspacePaths when available.
|
|
66
|
-
const projectDir = input.workspacePaths?.[0] ?? process.cwd();
|
|
67
|
-
const log = createLogger(projectDir);
|
|
68
|
-
log.info('hook invoked', {
|
|
69
|
-
argv: process.argv.slice(2),
|
|
70
|
-
cwd: process.cwd(),
|
|
71
|
-
projectDir,
|
|
72
|
-
stdinBytes: stdinLength(),
|
|
73
|
-
});
|
|
74
|
-
for (const w of earlyWarnings) log.warn(w);
|
|
75
|
-
log.info('agent detected', { agent, isStopEvent, isExplicitStopEvent, inputKeys: Object.keys(input) });
|
|
76
|
-
|
|
77
|
-
if (!isStopEvent) {
|
|
78
|
-
// Kintsugi checks once per turn (Stop event), not on every file edit —
|
|
79
|
-
// per-edit checks would replay flows dozens of times per turn.
|
|
80
|
-
log.info('non-stop event — deferring visual check to turn end');
|
|
81
|
-
outputFeedback(agent, '', { isStopEvent: false, hasRegression: false });
|
|
82
|
-
process.exit(0);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
await runTurnEndCheck(agent, input, projectDir, log);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface FlowCheckOutcome {
|
|
89
|
-
flowName: string;
|
|
90
|
-
isRegression: boolean;
|
|
91
|
-
comparisonResult?: ComparisonResult;
|
|
92
|
-
oldPath?: string;
|
|
93
|
-
newPath?: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Turn-end visual check: verifies every recorded flow in parallel (each in
|
|
98
|
-
* its own browser context), classifies oversized diffs in parallel, and on a
|
|
99
|
-
* regression blocks the agent's stop with fix-it feedback until it passes or
|
|
100
|
-
* maxAttempts is reached.
|
|
101
|
-
*/
|
|
102
|
-
async function runTurnEndCheck(
|
|
103
|
-
agent: AgentType,
|
|
104
|
-
input: HookInput,
|
|
105
|
-
projectDir: string,
|
|
106
|
-
log: ReturnType<typeof createLogger>
|
|
107
|
-
): Promise<void> {
|
|
108
|
-
// Pick up gitignored <project>/.kintsugi/.env before resolving the token
|
|
109
|
-
loadKintsugiEnv(projectDir);
|
|
110
|
-
const config: KintsugiConfig = await loadConfig(projectDir);
|
|
111
|
-
log.info('config loaded', { devServerUrl: config.devServerUrl, classifierModel: config.classifier.model });
|
|
112
|
-
|
|
113
|
-
// Shared builder (also used by `kintsugi check` and MCP) — bootstraps an
|
|
114
|
-
// anonymous hosted key on first use so the classifier is never silently dark.
|
|
115
|
-
const classifierContext = await buildClassifierContext(config, {
|
|
116
|
-
context: extractTurnContext(input),
|
|
117
|
-
logger: log,
|
|
118
|
-
bootstrapKey: true,
|
|
119
|
-
projectDir,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const flows = await listFlows(projectDir);
|
|
123
|
-
log.info('flows discovered', {
|
|
124
|
-
flowsDir: path.join(projectDir, '.kintsugi', 'flows'),
|
|
125
|
-
count: flows.length,
|
|
126
|
-
names: flows.map(f => f.name),
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
if (flows.length === 0) {
|
|
130
|
-
log.info('no recorded flows — nothing to check');
|
|
131
|
-
outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
|
|
132
|
-
process.exit(0);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const maxAttempts = config.agent.maxRetries;
|
|
136
|
-
const outcomes = await Promise.all(
|
|
137
|
-
flows.map(async flow => {
|
|
138
|
-
try {
|
|
139
|
-
return await checkFlow(flow.name, config, classifierContext, projectDir, log);
|
|
140
|
-
} catch (err) {
|
|
141
|
-
log.error('flow check crashed', { flowName: flow.name, error: (err as Error).message });
|
|
142
|
-
return { flowName: flow.name, isRegression: false } as FlowCheckOutcome;
|
|
143
|
-
}
|
|
144
|
-
})
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
const broken = outcomes.filter(o => o.isRegression);
|
|
148
|
-
|
|
149
|
-
if (broken.length === 0) {
|
|
150
|
-
// Clean pass — either nothing broke or the agent just fixed the
|
|
151
|
-
// previous regression. Either way the agent may stop.
|
|
152
|
-
log.info('turn-end check passed — clearing any previous escalation');
|
|
153
|
-
await clearEscalation(projectDir);
|
|
154
|
-
outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
|
|
155
|
-
process.exit(0);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// The fresh check above is the source of truth; only treat a pending
|
|
159
|
-
// escalation as "still unfixed" now that the current state is known broken.
|
|
160
|
-
const pendingEscalation = await readEscalation(projectDir);
|
|
161
|
-
if (pendingEscalation && pendingEscalation.attempt >= maxAttempts) {
|
|
162
|
-
log.info('max fix attempts reached — allowing agent to stop', {
|
|
163
|
-
flowName: pendingEscalation.flowName,
|
|
164
|
-
attempt: pendingEscalation.attempt,
|
|
165
|
-
maxAttempts,
|
|
166
|
-
});
|
|
167
|
-
await clearEscalation(projectDir);
|
|
168
|
-
outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
|
|
169
|
-
process.exit(0);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const first = broken[0];
|
|
173
|
-
const attempt = (pendingEscalation?.attempt || 0) + 1;
|
|
174
|
-
await writeEscalation(projectDir, {
|
|
175
|
-
flowName: first.flowName,
|
|
176
|
-
result: first.comparisonResult!,
|
|
177
|
-
oldVideoPath: first.oldPath ?? '',
|
|
178
|
-
newVideoPath: first.newPath ?? '',
|
|
179
|
-
timestamp: new Date().toISOString(),
|
|
180
|
-
attempt,
|
|
181
|
-
maxAttempts,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// Fix-it feedback covers every broken flow: aria diffs, image pairs, per-pair fixes
|
|
185
|
-
const combinedFeedback = broken
|
|
186
|
-
.map(o => formatAgentFeedback(o.comparisonResult!, o.flowName))
|
|
187
|
-
.join('\n\n');
|
|
188
|
-
|
|
189
|
-
log.warn('turn-end regression — blocking agent stop with feedback', {
|
|
190
|
-
flows: broken.map(b => b.flowName),
|
|
191
|
-
attempt,
|
|
192
|
-
maxAttempts,
|
|
193
|
-
});
|
|
194
|
-
outputFeedback(agent, combinedFeedback, { isStopEvent: true, hasRegression: true });
|
|
195
|
-
exitWithResult(agent, true);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Replays one flow and compares it against its baseline. */
|
|
199
|
-
async function checkFlow(
|
|
200
|
-
flowName: string,
|
|
201
|
-
config: KintsugiConfig,
|
|
202
|
-
classifierContext: Awaited<ReturnType<typeof buildClassifierContext>>,
|
|
203
|
-
projectDir: string,
|
|
204
|
-
log: ReturnType<typeof createLogger>
|
|
205
|
-
): Promise<FlowCheckOutcome> {
|
|
206
|
-
const flowRecord = await loadFlow(projectDir, flowName);
|
|
207
|
-
if (!flowRecord) {
|
|
208
|
-
log.warn('flow metadata listed but could not be loaded', { flowName });
|
|
209
|
-
return { flowName, isRegression: false };
|
|
210
|
-
}
|
|
211
|
-
log.info('checking flow', { flowName, stepCount: flowRecord.steps.length, baselineScreenshots: flowRecord.screenshotPaths.length });
|
|
212
|
-
|
|
213
|
-
const outputDir = path.join(projectDir, '.kintsugi', '.tmp', flowName);
|
|
214
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
215
|
-
|
|
216
|
-
const currentState = await captureCurrentState({
|
|
217
|
-
url: config.devServerUrl,
|
|
218
|
-
flow: flowRecord,
|
|
219
|
-
outputDir,
|
|
220
|
-
viewport: config.viewport,
|
|
221
|
-
});
|
|
222
|
-
log.info('current state captured', { flowName, url: config.devServerUrl, screenshots: currentState.screenshots.length, errors: currentState.errors.length });
|
|
223
|
-
|
|
224
|
-
const comparisonResult = await compareFlow(flowRecord, currentState, { outputDir, classifier: classifierContext });
|
|
225
|
-
log.info('comparison finished', { flowName, result: comparisonResult.result });
|
|
226
|
-
|
|
227
|
-
// Any definite visual change (CHANGED) or broken capture is a regression —
|
|
228
|
-
// the baseline flow no longer matches the live page. INTENTIONAL means the
|
|
229
|
-
// classifier accepted the change.
|
|
230
|
-
const isRegression = comparisonResult.result !== DiffResult.IDENTICAL &&
|
|
231
|
-
comparisonResult.result !== DiffResult.MINOR &&
|
|
232
|
-
comparisonResult.result !== DiffResult.INTENTIONAL;
|
|
233
|
-
|
|
234
|
-
if (!isRegression) {
|
|
235
|
-
if (comparisonResult.result === DiffResult.INTENTIONAL) {
|
|
236
|
-
log.info('large visual change accepted as intentional by classifier', { flowName });
|
|
237
|
-
}
|
|
238
|
-
return { flowName, isRegression: false, comparisonResult };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
log.warn('REGRESSION detected', { flowName, result: comparisonResult.result });
|
|
242
|
-
const failedIndex = comparisonResult.failedStep ?? 0;
|
|
243
|
-
return {
|
|
244
|
-
flowName,
|
|
245
|
-
isRegression: true,
|
|
246
|
-
comparisonResult,
|
|
247
|
-
oldPath: flowRecord.screenshotPaths[failedIndex] ?? flowRecord.screenshotPaths[0] ?? '',
|
|
248
|
-
newPath: currentState.screenshotPaths[failedIndex] ?? currentState.screenshotPaths[0] ?? '',
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function stdinLength(): number {
|
|
253
|
-
try {
|
|
254
|
-
return fs.fstatSync(0).size;
|
|
255
|
-
} catch {
|
|
256
|
-
return -1;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
main().catch((e) => {
|
|
261
|
-
try {
|
|
262
|
-
const log = createLogger(process.cwd());
|
|
263
|
-
log.error('hook crashed', e);
|
|
264
|
-
} catch { /* ignore */ }
|
|
265
|
-
// Exit non-zero so the agent knows something went wrong.
|
|
266
|
-
// Write a diagnostic to stderr for agents that capture it.
|
|
267
|
-
process.stderr.write(`kintsugi hook crashed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
268
|
-
process.exit(1);
|
|
269
|
-
});
|
package/src/logger.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Debug logging for the kintsugi hook.
|
|
6
|
-
*
|
|
7
|
-
* The #1 failure mode for agent hooks is silent early-exit: the hook runs,
|
|
8
|
-
* decides "nothing to do", and exits 0 without any trace. To debug why a
|
|
9
|
-
* regression was NOT caught, we log every decision point to
|
|
10
|
-
* `<projectDir>/.kintsugi/logs/hook.log` (always) and to stderr (when
|
|
11
|
-
* KINTSUGI_DEBUG=1, since agent CLIs may otherwise swallow or misrender
|
|
12
|
-
* unexpected stderr).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const LOG_DIRNAME = path.join('.kintsugi', 'logs');
|
|
16
|
-
const LOG_FILENAME = 'hook.log';
|
|
17
|
-
|
|
18
|
-
export function createLogger(projectDir: string) {
|
|
19
|
-
const debugToStderr = process.env.KINTSUGI_DEBUG === '1';
|
|
20
|
-
const logPath = path.join(projectDir, LOG_DIRNAME, LOG_FILENAME);
|
|
21
|
-
|
|
22
|
-
function write(line: string): void {
|
|
23
|
-
const entry = `[${new Date().toISOString()}] ${line}\n`;
|
|
24
|
-
try {
|
|
25
|
-
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
26
|
-
fs.appendFileSync(logPath, entry);
|
|
27
|
-
} catch {
|
|
28
|
-
// Never let logging break the hook
|
|
29
|
-
}
|
|
30
|
-
if (debugToStderr) {
|
|
31
|
-
process.stderr.write(`[kintsugi-hook] ${entry}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
info: (message: string, data?: unknown) => {
|
|
37
|
-
write(data === undefined ? message : `${message} ${safeStringify(data)}`);
|
|
38
|
-
},
|
|
39
|
-
warn: (message: string, data?: unknown) => {
|
|
40
|
-
write(`WARN: ${data === undefined ? message : `${message} ${safeStringify(data)}`}`);
|
|
41
|
-
},
|
|
42
|
-
error: (message: string, error?: unknown) => {
|
|
43
|
-
write(`ERROR: ${message}${error ? ` ${formatError(error)}` : ''}`);
|
|
44
|
-
},
|
|
45
|
-
logPath,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function safeStringify(data: unknown): string {
|
|
50
|
-
try {
|
|
51
|
-
return JSON.stringify(data);
|
|
52
|
-
} catch {
|
|
53
|
-
return String(data);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function formatError(error: unknown): string {
|
|
58
|
-
if (error instanceof Error) {
|
|
59
|
-
return `${error.message}\n${error.stack ?? ''}`;
|
|
60
|
-
}
|
|
61
|
-
return safeStringify(error);
|
|
62
|
-
}
|
package/src/ui-file-detector.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
|
|
3
|
-
export const DEFAULT_UI_PATTERNS = [
|
|
4
|
-
'**/*.tsx', '**/*.jsx', '**/*.vue', '**/*.svelte',
|
|
5
|
-
'**/*.html', '**/*.css', '**/*.scss'
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
const UI_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte', '.html', '.css', '.scss']);
|
|
9
|
-
const NON_UI_SUBSTRINGS = ['.test.', '.spec.', '.config.', '/test/', '/tests/'];
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Detects if a file is a UI file that might require visual regression testing.
|
|
13
|
-
* @param filePath The path to the modified file.
|
|
14
|
-
* @param patterns Optional array of glob patterns to match against.
|
|
15
|
-
* @returns True if the file is considered a UI file.
|
|
16
|
-
*/
|
|
17
|
-
export function isUIFile(filePath: string, patterns: string[] = DEFAULT_UI_PATTERNS): boolean {
|
|
18
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
19
|
-
|
|
20
|
-
for (const sub of NON_UI_SUBSTRINGS) {
|
|
21
|
-
if (filePath.includes(sub)) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (UI_EXTENSIONS.has(ext)) {
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Simplified pattern matching if not matched by extension
|
|
31
|
-
for (const pattern of patterns) {
|
|
32
|
-
// Extract extension from glob patterns like '**/*.tsx' or '*.css'
|
|
33
|
-
const extMatch = pattern.match(/\*\.([a-zA-Z0-9]+)$/);
|
|
34
|
-
if (extMatch && ext === '.' + extMatch[1]) {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return false;
|
|
40
|
-
}
|