@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/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
- }
@@ -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
- }
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src/**/*"]
8
- }