@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/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
|
package/dist/escalation.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
package/dist/escalation.js
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
package/dist/flow-matcher.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { FlowMetadata } from '@kintsugi-ai/core';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
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
|
package/dist/flow-matcher.js
CHANGED
|
@@ -1,12 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
261
|
+
url,
|
|
161
262
|
flow: flowRecord,
|
|
162
263
|
outputDir,
|
|
163
264
|
viewport: config.viewport,
|
|
164
265
|
});
|
|
165
|
-
log.info('current state captured', { flowName, url
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
process.
|
|
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.
|
|
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.
|
|
26
|
+
"@kintsugi-ai/core": "0.2.0"
|
|
12
27
|
},
|
|
13
28
|
"devDependencies": {
|
|
14
29
|
"@types/node": "^22.0.0",
|
package/.kintsugi/logs/hook.log
DELETED
|
@@ -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"}
|
package/dist/agent-detect.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"agent-detect.js","sourceRoot":"","sources":["../src/agent-detect.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"agent-detect.test.d.ts","sourceRoot":"","sources":["../src/agent-detect.test.ts"],"names":[],"mappings":""}
|