@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Munkhin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @kintsugi-ai/hook
2
+
3
+ Turn-end Stop hook for [Kintsugi](https://github.com/Munkhin/kintsugi), the visual
4
+ UX regression guard for AI coding agents.
5
+
6
+ Registered by `npx @kintsugi-ai/cli init` into Claude Code (`.claude/settings.json`),
7
+ Codex CLI (`.codex/hooks.json`), and Antigravity (`.agents/hooks.json`) as:
8
+
9
+ ```
10
+ npx -y @kintsugi-ai/hook --event stop
11
+ ```
12
+
13
+ At the end of every agent turn it replays all recorded flows, compares them against
14
+ baselines, and — when a flow is broken — blocks the stop with actionable fix feedback
15
+ in each agent's native protocol (`decision: block/continue` JSON on stdout, or
16
+ exit 2 + stderr for Codex). Escalations feed the VS Code split view.
17
+
18
+ You normally never install this package directly; the CLI registers it for you.
@@ -0,0 +1,14 @@
1
+ import type { FlowMetadata } from '@kintsugi-ai/core';
2
+ /** Changed files in the working tree (staged, unstaged and untracked). */
3
+ export declare function getChangedFiles(projectDir: string): Promise<string[]>;
4
+ /**
5
+ * Non-blocking coverage suggestion: when UI files changed this turn but no
6
+ * recorded flow covers them, build the "record a flow" message. Emitted every
7
+ * qualifying turn (no rate limit) — appended to the block reason when a
8
+ * regression is already blocking, otherwise written to stderr. Returns an
9
+ * empty string when everything changed is covered.
10
+ */
11
+ export declare function maybeBuildCoverageNudge(projectDir: string, flows: FlowMetadata[], logger?: {
12
+ info: (msg: string, data?: unknown) => void;
13
+ }, uiFilePatterns?: string[]): Promise<string>;
14
+ //# sourceMappingURL=coverage-nudge.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { isUIFile, DEFAULT_UI_PATTERNS } from './ui-file-detector.js';
3
+ import { anyFlowCovers } from './flow-matcher.js';
4
+ /** Changed files in the working tree (staged, unstaged and untracked). */
5
+ export async function getChangedFiles(projectDir) {
6
+ const run = (args) => new Promise((resolve) => {
7
+ execFile('git', args, { cwd: projectDir }, (err, stdout) => resolve(err ? '' : stdout));
8
+ });
9
+ // -uall lists untracked files individually (porcelain collapses new dirs).
10
+ const out = await run(['status', '--porcelain', '-uall']);
11
+ if (!out)
12
+ return [];
13
+ return out
14
+ .split('\n')
15
+ .map(line => line.slice(3).trim())
16
+ .filter(f => f !== '' && !f.includes(' -> ')) // skip renames' old side
17
+ .map(f => f.replace(/^"|"$/g, '').replace(/ -> .+$/, ''));
18
+ }
19
+ /**
20
+ * Non-blocking coverage suggestion: when UI files changed this turn but no
21
+ * recorded flow covers them, build the "record a flow" message. Emitted every
22
+ * qualifying turn (no rate limit) — appended to the block reason when a
23
+ * regression is already blocking, otherwise written to stderr. Returns an
24
+ * empty string when everything changed is covered.
25
+ */
26
+ export async function maybeBuildCoverageNudge(projectDir, flows, logger, uiFilePatterns) {
27
+ let changed = [];
28
+ try {
29
+ changed = await getChangedFiles(projectDir);
30
+ }
31
+ catch {
32
+ return '';
33
+ }
34
+ const uiChanged = changed.filter(f => isUIFile(f, uiFilePatterns ?? DEFAULT_UI_PATTERNS));
35
+ if (uiChanged.length === 0)
36
+ return '';
37
+ const uncovered = uiChanged.filter(f => !anyFlowCovers([f], flows));
38
+ if (uncovered.length === 0)
39
+ return '';
40
+ const fileList = uncovered.slice(0, 10).map(f => ` - ${f}`).join('\n');
41
+ logger?.info('coverage nudge emitted', { uncoveredCount: uncovered.length });
42
+ return (`Kintsugi coverage gap: UI files changed with no flow covering them:\n${fileList}\n` +
43
+ `Record a flow for this UI (save_flow with source_files, then capture_baseline) so future regressions are caught automatically. ` +
44
+ `If the page was removed, delete_flow the flows that guarded it.`);
45
+ }
46
+ //# sourceMappingURL=coverage-nudge.js.map
@@ -1,28 +1,7 @@
1
- import type { ComparisonResult } from '@kintsugi-ai/core';
2
- export interface EscalationSignal {
3
- flowName: string;
4
- result: ComparisonResult;
5
- oldVideoPath?: string;
6
- newVideoPath?: string;
7
- timestamp: string;
8
- attempt: number;
9
- maxAttempts: number;
10
- }
11
1
  /**
12
- * Writes an escalation signal for the VS Code extension.
13
- * @param projectDir The project directory.
14
- * @param signal The escalation signal data.
2
+ * Escalation signals live in @kintsugi-ai/core (shared with the CLI and the
3
+ * VS Code extension). Re-exported here for the hook's own imports.
15
4
  */
16
- export declare function writeEscalation(projectDir: string, signal: EscalationSignal): Promise<void>;
17
- /**
18
- * Reads the current escalation signal.
19
- * @param projectDir The project directory.
20
- * @returns The current escalation signal or null if not found.
21
- */
22
- export declare function readEscalation(projectDir: string): Promise<EscalationSignal | null>;
23
- /**
24
- * Clears the escalation signal.
25
- * @param projectDir The project directory.
26
- */
27
- export declare function clearEscalation(projectDir: string): Promise<void>;
5
+ export { writeEscalation, readEscalation, clearEscalation, removeFlowFromEscalation, escalationPath, normalizeEscalation, } from '@kintsugi-ai/core';
6
+ export type { EscalationSignal, RegressionEscalation, PlaybackFailureEscalation, EscalationFlowEntry, } from '@kintsugi-ai/core';
28
7
  //# sourceMappingURL=escalation.d.ts.map
@@ -1,48 +1,6 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- function getEscalationFilePath(projectDir) {
4
- return path.join(projectDir, '.kintsugi', '.escalation.json');
5
- }
6
1
  /**
7
- * Writes an escalation signal for the VS Code extension.
8
- * @param projectDir The project directory.
9
- * @param signal The escalation signal data.
2
+ * Escalation signals live in @kintsugi-ai/core (shared with the CLI and the
3
+ * VS Code extension). Re-exported here for the hook's own imports.
10
4
  */
11
- export async function writeEscalation(projectDir, signal) {
12
- const filePath = getEscalationFilePath(projectDir);
13
- const dir = path.dirname(filePath);
14
- await fs.mkdir(dir, { recursive: true });
15
- await fs.writeFile(filePath, JSON.stringify(signal, null, 2), 'utf-8');
16
- }
17
- /**
18
- * Reads the current escalation signal.
19
- * @param projectDir The project directory.
20
- * @returns The current escalation signal or null if not found.
21
- */
22
- export async function readEscalation(projectDir) {
23
- const filePath = getEscalationFilePath(projectDir);
24
- try {
25
- const content = await fs.readFile(filePath, 'utf-8');
26
- return JSON.parse(content);
27
- }
28
- catch {
29
- return null;
30
- }
31
- }
32
- /**
33
- * Clears the escalation signal.
34
- * @param projectDir The project directory.
35
- */
36
- export async function clearEscalation(projectDir) {
37
- const filePath = getEscalationFilePath(projectDir);
38
- try {
39
- await fs.unlink(filePath);
40
- }
41
- catch (err) {
42
- const isNotFound = typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT';
43
- if (!isNotFound) {
44
- throw err;
45
- }
46
- }
47
- }
5
+ export { writeEscalation, readEscalation, clearEscalation, removeFlowFromEscalation, escalationPath, normalizeEscalation, } from '@kintsugi-ai/core';
48
6
  //# sourceMappingURL=escalation.js.map
@@ -1,9 +1,13 @@
1
1
  import type { FlowMetadata } from '@kintsugi-ai/core';
2
2
  /**
3
- * Determines which flows are affected by a file change.
4
- * @param filePath The modified file path.
5
- * @param flows The list of available flows.
6
- * @returns Array of affected flow names.
3
+ * Matches changed files to the flows that cover them. A flow covers a file if:
4
+ * - the flow was saved with explicit `sourceFiles` containing the path, or
5
+ * - the flow's URL has a distinctive path segment (e.g. /products) that
6
+ * appears as a segment of the changed file's path (app/products/page.tsx).
7
+ * Flows without either signal match nothing (conservative for coverage
8
+ * nudging; turn-end checks still replay every flow regardless).
7
9
  */
8
- export declare function findAffectedFlows(filePath: string, flows: FlowMetadata[]): string[];
10
+ export declare function findAffectedFlows(changedFiles: string[], flows: FlowMetadata[]): FlowMetadata[];
11
+ /** True when any changed file is covered by at least one flow. */
12
+ export declare function anyFlowCovers(changedFiles: string[], flows: FlowMetadata[]): boolean;
9
13
  //# sourceMappingURL=flow-matcher.d.ts.map
@@ -1,12 +1,48 @@
1
1
  /**
2
- * Determines which flows are affected by a file change.
3
- * @param filePath The modified file path.
4
- * @param flows The list of available flows.
5
- * @returns Array of affected flow names.
2
+ * Matches changed files to the flows that cover them. A flow covers a file if:
3
+ * - the flow was saved with explicit `sourceFiles` containing the path, or
4
+ * - the flow's URL has a distinctive path segment (e.g. /products) that
5
+ * appears as a segment of the changed file's path (app/products/page.tsx).
6
+ * Flows without either signal match nothing (conservative for coverage
7
+ * nudging; turn-end checks still replay every flow regardless).
6
8
  */
7
- export function findAffectedFlows(filePath, flows) {
8
- // A conservative approach: for now, assume all flows might be affected by the UI change.
9
- // Future enhancements can check if the file path matches explicit mappings.
10
- return flows.map(f => f.name);
9
+ export function findAffectedFlows(changedFiles, flows) {
10
+ return flows.filter(flow => changedFiles.some(f => flowCoversFile(flow, f)));
11
+ }
12
+ /** True when any changed file is covered by at least one flow. */
13
+ export function anyFlowCovers(changedFiles, flows) {
14
+ return changedFiles.some(f => flows.some(flow => flowCoversFile(flow, f)));
15
+ }
16
+ function flowCoversFile(flow, file) {
17
+ const norm = file.replace(/\\/g, '/');
18
+ if (flow.sourceFiles && flow.sourceFiles.some(sf => pathsMatch(sf, norm))) {
19
+ return true;
20
+ }
21
+ // URL heuristic: a distinctive path segment of the flow's start URL
22
+ // (/products, /checkout) maps to source files under a matching segment
23
+ // (app/products/page.tsx, pages/checkout.tsx).
24
+ const segments = urlSegments(flow.url).filter(s => s.length > 2);
25
+ if (segments.length === 0)
26
+ return false;
27
+ const parts = norm.split('/');
28
+ return segments.some(seg => parts.some(part => part === seg || part.startsWith(`${seg}.`) || part.startsWith(`${seg}Page`)));
29
+ }
30
+ function pathsMatch(sourceFile, file) {
31
+ const a = sourceFile.replace(/\\/g, '/').replace(/^\.\//, '');
32
+ const b = file.replace(/\\/g, '/').replace(/^\.\//, '');
33
+ return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
34
+ }
35
+ /** Meaningful path segments of a URL ("/", route params and bare ids excluded). */
36
+ function urlSegments(url) {
37
+ let pathname = '';
38
+ try {
39
+ pathname = new URL(url).pathname;
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ return pathname
45
+ .split('/')
46
+ .filter(s => s.length > 0 && !s.startsWith(':') && !/^[0-9]+$/.test(s));
11
47
  }
12
48
  //# sourceMappingURL=flow-matcher.js.map
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { loadConfig, listFlows, loadFlow, captureCurrentState, compareFlow, formatAgentFeedback, buildClassifierContext, loadKintsugiEnv, DiffResult } from '@kintsugi-ai/core';
4
+ import { loadConfig, listFlows, loadFlow, captureCurrentState, compareFlow, formatAgentFeedback, buildClassifierContext, loadKintsugiEnv, getAccountStatus, isUrlReachable, readClassifierState, clearClassifierState, DiffResult, } from '@kintsugi-ai/core';
5
5
  import { detectAgent, outputFeedback, exitWithResult } from './agent-detect.js';
6
6
  import { extractTurnContext } from './context.js';
7
7
  import { writeEscalation, readEscalation, clearEscalation } from './escalation.js';
8
+ import { maybeBuildCoverageNudge } from './coverage-nudge.js';
8
9
  import { createLogger } from './logger.js';
9
10
  async function main() {
10
11
  const earlyWarnings = [];
@@ -65,7 +66,8 @@ async function main() {
65
66
  * Turn-end visual check: verifies every recorded flow in parallel (each in
66
67
  * its own browser context), classifies oversized diffs in parallel, and on a
67
68
  * regression blocks the agent's stop with fix-it feedback until it passes or
68
- * maxAttempts is reached.
69
+ * maxAttempts is reached — at which point the failed playback escalates to
70
+ * the user instead of blocking forever.
69
71
  */
70
72
  async function runTurnEndCheck(agent, input, projectDir, log) {
71
73
  // Pick up gitignored <project>/.kintsugi/.env before resolving the token
@@ -80,64 +82,155 @@ async function runTurnEndCheck(agent, input, projectDir, log) {
80
82
  bootstrapKey: true,
81
83
  projectDir,
82
84
  });
85
+ // Quota-exhausted fast path: while the free-plan limit (or monthly cap)
86
+ // holds, skip classify HTTP entirely and degrade unclassified pixel diffs
87
+ // to warnings — blocking on them was a false-positive loop. The state is
88
+ // re-verified against the live account so upgrades / freed quota recover
89
+ // immediately, and the monthly cap expires with its month.
90
+ let degraded = false;
91
+ const state = readClassifierState(projectDir);
92
+ if (state?.exhausted) {
93
+ const monthlyCapExpired = state.kind === 'monthly_cap_reached' &&
94
+ Date.now() - Date.parse(state.timestamp) > 32 * 24 * 60 * 60 * 1000;
95
+ if (monthlyCapExpired) {
96
+ clearClassifierState(projectDir);
97
+ }
98
+ else if (classifierContext && config.classifier.provider === 'kintsugi') {
99
+ const status = await getAccountStatus(classifierContext.apiToken, config.classifier.endpoint);
100
+ if (status && (status.plan === 'pro' || (status.flowLimit !== null && status.flowsUsed < status.flowLimit))) {
101
+ clearClassifierState(projectDir);
102
+ log.info('classifier quota recovered — resuming classification', { plan: status.plan, flowsUsed: status.flowsUsed });
103
+ }
104
+ else {
105
+ degraded = true;
106
+ }
107
+ }
108
+ else {
109
+ degraded = true;
110
+ }
111
+ if (degraded) {
112
+ log.warn('classifier quota exhausted — degraded mode (unclassified diffs warn only)', { kind: state.kind });
113
+ }
114
+ }
115
+ const effectiveClassifier = degraded ? undefined : classifierContext;
83
116
  const flows = await listFlows(projectDir);
84
117
  log.info('flows discovered', {
85
118
  flowsDir: path.join(projectDir, '.kintsugi', 'flows'),
86
119
  count: flows.length,
87
120
  names: flows.map(f => f.name),
88
121
  });
122
+ // Coverage nudge, computed once and delivered every qualifying turn:
123
+ // appended to the block reason when a regression already blocks (the
124
+ // model sees it), otherwise a stderr notice. Never blocks on its own.
125
+ const nudge = await maybeBuildCoverageNudge(projectDir, flows, log, config.uiFilePatterns);
89
126
  if (flows.length === 0) {
90
127
  log.info('no recorded flows — nothing to check');
128
+ if (nudge)
129
+ process.stderr.write(`${nudge}\n`);
91
130
  outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
92
131
  process.exit(0);
93
132
  }
94
133
  const maxAttempts = config.agent.maxRetries;
95
134
  const outcomes = await Promise.all(flows.map(async (flow) => {
96
135
  try {
97
- return await checkFlow(flow.name, config, classifierContext, projectDir, log);
136
+ return await checkFlow(flow.name, config, effectiveClassifier, projectDir, log, degraded);
98
137
  }
99
138
  catch (err) {
100
139
  log.error('flow check crashed', { flowName: flow.name, error: err.message });
101
140
  return { flowName: flow.name, isRegression: false };
102
141
  }
103
142
  }));
143
+ // Every flow skipped means the dev server is unreachable —
144
+ // infrastructure, not a regression. Never block over it.
145
+ const skipped = outcomes.filter(o => o.skippedReason);
146
+ if (skipped.length > 0) {
147
+ const urls = [...new Set(skipped.map(s => s.skippedReason))];
148
+ process.stderr.write(`Kintsugi: dev server not reachable (${urls.join(', ')}) — visual check skipped for ${skipped.length}/${outcomes.length} flow${skipped.length === 1 ? '' : 's'}. This is not a regression.\n`);
149
+ }
150
+ if (skipped.length === outcomes.length) {
151
+ log.warn('dev server unreachable — whole visual check skipped', { urls: [...new Set(skipped.map(s => s.skippedReason))] });
152
+ outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
153
+ process.exit(0);
154
+ }
155
+ if (degraded) {
156
+ const unclassified = outcomes.filter(o => o.comparisonResult?.result === DiffResult.CHANGED);
157
+ if (unclassified.length > 0) {
158
+ process.stderr.write(`Kintsugi (degraded — free plan flow limit reached): ${unclassified.length} flow${unclassified.length === 1 ? '' : 's'} show unclassified visual changes; not blocking. Run \`kintsugi upgrade\` for intent classification.\n`);
159
+ }
160
+ }
104
161
  const broken = outcomes.filter(o => o.isRegression);
105
162
  if (broken.length === 0) {
106
163
  // Clean pass — either nothing broke or the agent just fixed the
107
164
  // previous regression. Either way the agent may stop.
108
165
  log.info('turn-end check passed — clearing any previous escalation');
109
166
  await clearEscalation(projectDir);
167
+ if (nudge)
168
+ process.stderr.write(`${nudge}\n`);
110
169
  outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
111
170
  process.exit(0);
112
171
  }
113
172
  // The fresh check above is the source of truth; only treat a pending
114
173
  // 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,
174
+ const pending = await readEscalation(projectDir);
175
+ // A previous give-up already showed the user the failed playback for
176
+ // exactly these flows — stay non-blocking until the broken set changes.
177
+ const pendingFailure = pending?.type === 'playback_failure' ? pending : undefined;
178
+ if (pendingFailure && broken.every(b => pendingFailure.flows.includes(b.flowName))) {
179
+ log.info('already escalated to the user for these flows — allowing stop', {
180
+ flows: broken.map(b => b.flowName),
181
+ });
182
+ outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
183
+ process.exit(0);
184
+ }
185
+ const pendingRegression = pending && pending.type !== 'playback_failure' ? pending : undefined;
186
+ if (pendingRegression && pendingRegression.attempt >= maxAttempts) {
187
+ // Give up blocking; escalate the failing playback to the human.
188
+ const worst = broken.find(b => b.flowName === pendingRegression.flows[0]?.flowName) ?? broken[0];
189
+ const failedStep = worst.comparisonResult?.failedStep ?? 0;
190
+ await writeEscalation(projectDir, {
191
+ version: 2,
192
+ type: 'playback_failure',
193
+ flowName: worst.flowName,
194
+ flows: broken.map(b => b.flowName),
195
+ clipPath: worst.videoPath,
196
+ failedStep,
197
+ stepDescription: worst.failedStepDescription ?? `step ${failedStep}`,
198
+ analysis: formatAgentFeedback(worst.comparisonResult, worst.flowName).slice(0, 4000),
199
+ stepTimings: worst.stepTimings,
200
+ timestamp: new Date().toISOString(),
201
+ attempt: pendingRegression.attempt,
120
202
  maxAttempts,
121
203
  });
122
- await clearEscalation(projectDir);
204
+ log.warn('max fix attempts reached — escalating failed playback to the user, allowing stop', {
205
+ flows: broken.map(b => b.flowName),
206
+ attempt: pendingRegression.attempt,
207
+ maxAttempts,
208
+ });
209
+ process.stderr.write(`Kintsugi: the agent could not fix [${broken.map(b => b.flowName).join(', ')}] after ${maxAttempts} attempts — showing the failed playback to the user.\n`);
123
210
  outputFeedback(agent, '', { isStopEvent: true, hasRegression: false });
124
211
  process.exit(0);
125
212
  }
126
- const first = broken[0];
127
- const attempt = (pendingEscalation?.attempt || 0) + 1;
213
+ // Every broken flow reaches the escalation queue (the VS Code split view
214
+ // iterates it); the agent feedback below also covers all of them.
215
+ const attempt = (pendingRegression?.attempt || 0) + 1;
128
216
  await writeEscalation(projectDir, {
129
- flowName: first.flowName,
130
- result: first.comparisonResult,
131
- oldVideoPath: first.oldPath ?? '',
132
- newVideoPath: first.newPath ?? '',
217
+ version: 2,
218
+ flows: broken.map(o => ({
219
+ flowName: o.flowName,
220
+ result: o.comparisonResult,
221
+ oldImagePath: o.oldPath,
222
+ newImagePath: o.newPath,
223
+ videoPath: o.videoPath,
224
+ stepTimings: o.stepTimings,
225
+ })),
133
226
  timestamp: new Date().toISOString(),
134
227
  attempt,
135
228
  maxAttempts,
136
229
  });
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');
230
+ // Fix-it feedback covers every broken flow: aria diffs, image pairs, per-pair
231
+ // fixes — plus the coverage nudge while it is still relevant.
232
+ const combinedFeedback = broken.map(o => formatAgentFeedback(o.comparisonResult, o.flowName)).join('\n\n') +
233
+ (nudge ? `\n\n${nudge}` : '');
141
234
  log.warn('turn-end regression — blocking agent stop with feedback', {
142
235
  flows: broken.map(b => b.flowName),
143
236
  attempt,
@@ -147,34 +240,56 @@ async function runTurnEndCheck(agent, input, projectDir, log) {
147
240
  exitWithResult(agent, true);
148
241
  }
149
242
  /** Replays one flow and compares it against its baseline. */
150
- async function checkFlow(flowName, config, classifierContext, projectDir, log) {
243
+ async function checkFlow(flowName, config, classifierContext, projectDir, log, degraded) {
151
244
  const flowRecord = await loadFlow(projectDir, flowName);
152
245
  if (!flowRecord) {
153
246
  log.warn('flow metadata listed but could not be loaded', { flowName });
154
247
  return { flowName, isRegression: false };
155
248
  }
156
249
  log.info('checking flow', { flowName, stepCount: flowRecord.steps.length, baselineScreenshots: flowRecord.screenshotPaths.length });
250
+ // Same URL resolution as the MCP tools: the flow's own URL wins.
251
+ const url = flowRecord.metadata.url || config.devServerUrl;
252
+ // Pre-flight: an unreachable URL is infrastructure, not a regression —
253
+ // a wrong port must never turn every turn into a blocked "regression".
254
+ if (!(await isUrlReachable(url))) {
255
+ log.warn('flow URL unreachable — skipping replay', { flowName, url });
256
+ return { flowName, isRegression: false, skippedReason: url };
257
+ }
157
258
  const outputDir = path.join(projectDir, '.kintsugi', '.tmp', flowName);
158
259
  fs.mkdirSync(outputDir, { recursive: true });
159
260
  const currentState = await captureCurrentState({
160
- url: config.devServerUrl,
261
+ url,
161
262
  flow: flowRecord,
162
263
  outputDir,
163
264
  viewport: config.viewport,
164
265
  });
165
- log.info('current state captured', { flowName, url: config.devServerUrl, screenshots: currentState.screenshots.length, errors: currentState.errors.length });
266
+ log.info('current state captured', { flowName, url, screenshots: currentState.screenshots.length, errors: currentState.errors.length });
267
+ // Step 0 is always the navigate step (save_flow prepends it) — failing
268
+ // there means the page or server is unreachable, not a UI regression.
269
+ if (currentState.completedSteps === 0) {
270
+ const reason = currentState.errors[0] ?? 'navigation failed';
271
+ log.warn('replay could not reach the page — skipping flow', { flowName, url, reason });
272
+ return { flowName, isRegression: false, skippedReason: `${url} (${reason})` };
273
+ }
166
274
  const comparisonResult = await compareFlow(flowRecord, currentState, { outputDir, classifier: classifierContext });
167
275
  log.info('comparison finished', { flowName, result: comparisonResult.result });
168
276
  // Any definite visual change (CHANGED) or broken capture is a regression —
169
277
  // 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;
278
+ // classifier accepted the change. In degraded mode (quota exhausted, no
279
+ // classifier) CHANGED diffs only warn: blocking on unclassified pixel
280
+ // diffs was a false-positive loop.
281
+ const passes = comparisonResult.result === DiffResult.IDENTICAL ||
282
+ comparisonResult.result === DiffResult.MINOR ||
283
+ comparisonResult.result === DiffResult.INTENTIONAL;
284
+ const isRegression = !passes && !(degraded && comparisonResult.result === DiffResult.CHANGED);
174
285
  if (!isRegression) {
175
286
  if (comparisonResult.result === DiffResult.INTENTIONAL) {
176
287
  log.info('large visual change accepted as intentional by classifier', { flowName });
177
288
  }
289
+ // Clean replays don't keep their video — only escalations retain clips.
290
+ if (currentState.videoPath) {
291
+ await fs.promises.rm(path.dirname(currentState.videoPath), { recursive: true, force: true }).catch(() => { });
292
+ }
178
293
  return { flowName, isRegression: false, comparisonResult };
179
294
  }
180
295
  log.warn('REGRESSION detected', { flowName, result: comparisonResult.result });
@@ -185,6 +300,9 @@ async function checkFlow(flowName, config, classifierContext, projectDir, log) {
185
300
  comparisonResult,
186
301
  oldPath: flowRecord.screenshotPaths[failedIndex] ?? flowRecord.screenshotPaths[0] ?? '',
187
302
  newPath: currentState.screenshotPaths[failedIndex] ?? currentState.screenshotPaths[0] ?? '',
303
+ videoPath: currentState.videoPath,
304
+ stepTimings: currentState.stepTimings,
305
+ failedStepDescription: flowRecord.steps[failedIndex]?.description,
188
306
  };
189
307
  }
190
308
  function stdinLength() {
@@ -201,9 +319,10 @@ main().catch((e) => {
201
319
  log.error('hook crashed', e);
202
320
  }
203
321
  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);
322
+ // Exit 0 for every agent: a crashed check is not a check verdict, and a
323
+ // non-zero exit on Stop reads as a broken hook configuration (Claude Code
324
+ // shows it to the user as an error and the model gets nothing).
325
+ process.stderr.write(`kintsugi hook crashed (check skipped, turn allowed): ${e instanceof Error ? e.message : String(e)}\n`);
326
+ process.exit(0);
208
327
  });
209
328
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,14 +1,29 @@
1
1
  {
2
2
  "name": "@kintsugi-ai/hook",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent hook script for Kintsugi visual regression checks",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Munkhin/kintsugi.git",
9
+ "directory": "packages/hook"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Munkhin/kintsugi/issues"
13
+ },
14
+ "homepage": "https://github.com/Munkhin/kintsugi#readme",
5
15
  "type": "module",
6
16
  "main": "./dist/index.js",
7
17
  "bin": {
8
18
  "kintsugi-hook": "./dist/index.js"
9
19
  },
20
+ "files": [
21
+ "dist",
22
+ "!dist/**/*.test.*",
23
+ "!dist/**/*.map"
24
+ ],
10
25
  "dependencies": {
11
- "@kintsugi-ai/core": "0.1.0"
26
+ "@kintsugi-ai/core": "0.2.0"
12
27
  },
13
28
  "devDependencies": {
14
29
  "@types/node": "^22.0.0",
@@ -1,11 +0,0 @@
1
- [2026-09-23T12:36:50.957Z] hook invoked {"argv":["--help"],"cwd":"/Users/munch/Documents/coding/kintsugi/packages/hook","projectDir":"/Users/munch/Documents/coding/kintsugi/packages/hook","stdinBytes":0}
2
- [2026-09-23T12:36:50.957Z] WARN: stdin was empty — agent sent no hook payload
3
- [2026-09-23T12:36:50.957Z] agent detected {"agent":"codex","isStopEvent":false,"isExplicitStopEvent":false,"inputKeys":[]}
4
- [2026-09-23T12:36:50.957Z] non-stop event — deferring visual check to turn end
5
- [2026-09-23T12:36:53.296Z] hook invoked {"argv":["--event","stop"],"cwd":"/Users/munch/Documents/coding/kintsugi/packages/hook","projectDir":"/Users/munch/Documents/coding/kintsugi/packages/hook","stdinBytes":0}
6
- [2026-09-23T12:36:53.296Z] agent detected {"agent":"codex","isStopEvent":true,"isExplicitStopEvent":true,"inputKeys":[]}
7
- [2026-09-23T12:36:53.296Z] config loaded {"devServerUrl":"http://localhost:3000","classifierModel":"kintsugi-hosted"}
8
- [2026-09-23T12:36:54.063Z] WARN: could not obtain a kintsugi api key: fetch failed
9
- [2026-09-23T12:36:54.064Z] WARN: no API token found (env KINTSUGI_API_KEY, <project>/.kintsugi/.env, or ~/.kintsugi/.env) — classifier disabled, large diffs will escalate without classification
10
- [2026-09-23T12:36:54.065Z] flows discovered {"flowsDir":"/Users/munch/Documents/coding/kintsugi/packages/hook/.kintsugi/flows","count":0,"names":[]}
11
- [2026-09-23T12:36:54.065Z] no recorded flows — nothing to check
@@ -1 +0,0 @@
1
- {"version":3,"file":"agent-detect.d.ts","sourceRoot":"","sources":["../src/agent-detect.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,CAAC;AAEpE,MAAM,WAAW,SAAS;IAEtB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE;YACH,UAAU,CAAC,EAAE,MAAM,CAAC;YACpB,WAAW,CAAC,EAAE,MAAM,CAAC;YACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;SAC1B,CAAC;KACL,CAAC;IAGF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE;QACT,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,CAAC;IACF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,CA2BxD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,CAuB1F;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7D,IAAI,CAqCN;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,OAAO,GAAG,KAAK,CAY9E"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"agent-detect.js","sourceRoot":"","sources":["../src/agent-detect.ts"],"names":[],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=agent-detect.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"agent-detect.test.d.ts","sourceRoot":"","sources":["../src/agent-detect.test.ts"],"names":[],"mappings":""}