@kintsugi-ai/core 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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/agentConfig.d.ts +17 -0
  4. package/dist/agentConfig.js +91 -0
  5. package/dist/agentInstructions.d.ts +11 -0
  6. package/dist/agentInstructions.js +58 -0
  7. package/dist/auth.d.ts +35 -0
  8. package/dist/auth.js +121 -1
  9. package/dist/baseline.d.ts +9 -0
  10. package/dist/baseline.js +47 -0
  11. package/dist/capturer.js +6 -0
  12. package/dist/classifier-state.d.ts +13 -0
  13. package/dist/classifier-state.js +39 -0
  14. package/dist/comparator.d.ts +7 -0
  15. package/dist/comparator.js +6 -0
  16. package/dist/config.js +0 -6
  17. package/dist/escalation.d.ts +55 -0
  18. package/dist/escalation.js +99 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +5 -0
  21. package/dist/net.d.ts +7 -0
  22. package/dist/net.js +20 -0
  23. package/dist/setupPrompt.js +4 -2
  24. package/dist/types.d.ts +9 -4
  25. package/package.json +16 -1
  26. package/dist/auth.d.ts.map +0 -1
  27. package/dist/auth.js.map +0 -1
  28. package/dist/baseline.d.ts.map +0 -1
  29. package/dist/baseline.js.map +0 -1
  30. package/dist/benchmark.d.ts.map +0 -1
  31. package/dist/benchmark.js.map +0 -1
  32. package/dist/capturer.d.ts.map +0 -1
  33. package/dist/capturer.js.map +0 -1
  34. package/dist/classifier.d.ts.map +0 -1
  35. package/dist/classifier.js.map +0 -1
  36. package/dist/comparator.d.ts.map +0 -1
  37. package/dist/comparator.js.map +0 -1
  38. package/dist/comparator.test.d.ts +0 -2
  39. package/dist/comparator.test.d.ts.map +0 -1
  40. package/dist/comparator.test.js +0 -73
  41. package/dist/comparator.test.js.map +0 -1
  42. package/dist/config.d.ts.map +0 -1
  43. package/dist/config.js.map +0 -1
  44. package/dist/config.test.d.ts +0 -2
  45. package/dist/config.test.d.ts.map +0 -1
  46. package/dist/config.test.js +0 -67
  47. package/dist/config.test.js.map +0 -1
  48. package/dist/domdiff.d.ts.map +0 -1
  49. package/dist/domdiff.js.map +0 -1
  50. package/dist/feedback.d.ts.map +0 -1
  51. package/dist/feedback.js.map +0 -1
  52. package/dist/flowid.d.ts.map +0 -1
  53. package/dist/flowid.js.map +0 -1
  54. package/dist/index.d.ts.map +0 -1
  55. package/dist/index.js.map +0 -1
  56. package/dist/recorder.d.ts.map +0 -1
  57. package/dist/recorder.js.map +0 -1
  58. package/dist/setupPrompt.d.ts.map +0 -1
  59. package/dist/setupPrompt.js.map +0 -1
  60. package/dist/types.d.ts.map +0 -1
  61. package/dist/types.js.map +0 -1
  62. package/dist/vision.d.ts +0 -34
  63. package/dist/vision.d.ts.map +0 -1
  64. package/dist/vision.js +0 -139
  65. package/dist/vision.js.map +0 -1
  66. package/src/auth.ts +0 -195
  67. package/src/baseline.ts +0 -125
  68. package/src/benchmark.ts +0 -307
  69. package/src/capturer.ts +0 -105
  70. package/src/classifier.ts +0 -258
  71. package/src/comparator.test.ts +0 -97
  72. package/src/comparator.ts +0 -217
  73. package/src/config.test.ts +0 -80
  74. package/src/config.ts +0 -83
  75. package/src/domdiff.ts +0 -62
  76. package/src/feedback.ts +0 -64
  77. package/src/flowid.ts +0 -14
  78. package/src/index.ts +0 -13
  79. package/src/recorder.ts +0 -96
  80. package/src/setupPrompt.ts +0 -26
  81. package/src/types.ts +0 -144
  82. package/tsconfig.json +0 -9
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,16 @@
1
+ # @kintsugi-ai/core
2
+
3
+ Core engine for [Kintsugi](https://github.com/Munkhin/kintsugi), the visual UX
4
+ regression guard for AI coding agents.
5
+
6
+ - **Capture** — headless Playwright replay of recorded flows (screenshots,
7
+ aria snapshots, optional video) via `captureCurrentState`.
8
+ - **Compare** — pixel-diff (pixelmatch) per step with configurable thresholds;
9
+ large diffs are escalated to a classifier tier for intent analysis.
10
+ - **Flow storage** — save/load/list/delete flows under `.kintsugi/flows/<name>/`
11
+ (`metadata.json`, `steps.json`, `screenshots/`, `aria/`).
12
+ - **Classifier client** — hosted `POST /v1/classify` with self-hosted fallback.
13
+
14
+ This is a library package; use it through the
15
+ [`@kintsugi-ai/cli`](https://www.npmjs.com/package/@kintsugi-ai/cli) or the
16
+ [MCP server](https://www.npmjs.com/package/@kintsugi-ai/mcp).
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Stop-hook command registered with each agent. Defaults to the published npm
3
+ * package; set KINTSUGI_HOOK_COMMAND to override (e.g. "node /path/to/packages/hook/dist/index.js
4
+ * --event stop" for development).
5
+ */
6
+ export declare function stopHookCommand(): string;
7
+ /** Antigravity (agy): Stop entry under the kintsugi-visual-guard key in .agents/hooks.json. */
8
+ export declare function writeAntigravityHook(projectDir: string): Promise<void>;
9
+ /** Claude Code: Stop entry (and removal of legacy per-edit PostToolUse) in .claude/settings.json. */
10
+ export declare function writeClaudeStopHook(projectDir: string): Promise<void>;
11
+ /**
12
+ * Codex CLI: Stop entry in .codex/hooks.json — top-level `Stop` array (NOT the
13
+ * Claude settings `hooks.Stop` nesting; the VS Code writer shipped that wrong
14
+ * shape for a while — this module is now the single source of truth).
15
+ */
16
+ export declare function writeCodexStopHook(projectDir: string): Promise<void>;
17
+ //# sourceMappingURL=agentConfig.d.ts.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared agent hook-config writer — the single source of truth for how the
3
+ * Stop hook is registered with Claude Code, Codex CLI, and Antigravity.
4
+ * Used by `kintsugi init` (CLI) and the VS Code extension's auto-setup, which
5
+ * previously carried diverging copies.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ /**
10
+ * Stop-hook command registered with each agent. Defaults to the published npm
11
+ * package; set KINTSUGI_HOOK_COMMAND to override (e.g. "node /path/to/packages/hook/dist/index.js
12
+ * --event stop" for development).
13
+ */
14
+ export function stopHookCommand() {
15
+ return process.env.KINTSUGI_HOOK_COMMAND?.trim() || 'npx -y @kintsugi-ai/hook --event stop';
16
+ }
17
+ /** Matches a registered hook entry that runs Kintsugi, in current or legacy form. */
18
+ const KINTSUGI_HOOK_PATTERN = /kintsugi-hook|@kintsugi-ai\/hook/;
19
+ async function readJsonIfExists(filePath) {
20
+ try {
21
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
22
+ }
23
+ catch (err) {
24
+ if (err.code !== 'ENOENT')
25
+ throw err;
26
+ return {};
27
+ }
28
+ }
29
+ async function writeJson(filePath, data) {
30
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
31
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
32
+ }
33
+ /** Antigravity (agy): Stop entry under the kintsugi-visual-guard key in .agents/hooks.json. */
34
+ export async function writeAntigravityHook(projectDir) {
35
+ const hooksPath = path.join(projectDir, '.agents', 'hooks.json');
36
+ const data = await readJsonIfExists(hooksPath);
37
+ data['kintsugi-visual-guard'] = {
38
+ Stop: [{ type: 'command', command: stopHookCommand() }],
39
+ };
40
+ await writeJson(hooksPath, data);
41
+ }
42
+ /** Claude Code: Stop entry (and removal of legacy per-edit PostToolUse) in .claude/settings.json. */
43
+ export async function writeClaudeStopHook(projectDir) {
44
+ const settingsPath = path.join(projectDir, '.claude', 'settings.json');
45
+ const data = await readJsonIfExists(settingsPath);
46
+ if (!data.hooks)
47
+ data.hooks = {};
48
+ removeKintsugiHooks(data.hooks, 'PostToolUse');
49
+ // Always re-write the Stop entry so command overrides (KINTSUGI_HOOK_COMMAND)
50
+ // and upgrades from legacy `npx kintsugi-hook` entries take effect.
51
+ removeKintsugiHooks(data.hooks, 'Stop');
52
+ if (!Array.isArray(data.hooks.Stop))
53
+ data.hooks.Stop = [];
54
+ data.hooks.Stop.push({ hooks: [{ type: 'command', command: stopHookCommand() }] });
55
+ await writeJson(settingsPath, data);
56
+ }
57
+ /**
58
+ * Codex CLI: Stop entry in .codex/hooks.json — top-level `Stop` array (NOT the
59
+ * Claude settings `hooks.Stop` nesting; the VS Code writer shipped that wrong
60
+ * shape for a while — this module is now the single source of truth).
61
+ */
62
+ export async function writeCodexStopHook(projectDir) {
63
+ const hooksPath = path.join(projectDir, '.codex', 'hooks.json');
64
+ const data = await readJsonIfExists(hooksPath);
65
+ removeKintsugiHooks(data, 'PostToolUse');
66
+ removeKintsugiHooks(data, 'Stop');
67
+ if (!Array.isArray(data.Stop))
68
+ data.Stop = [];
69
+ data.Stop.push({ hooks: [{ type: 'command', command: stopHookCommand() }] });
70
+ await writeJson(hooksPath, data);
71
+ }
72
+ /**
73
+ * Removes kintsugi hook entries registered under the given event name
74
+ * (e.g. legacy per-edit PostToolUse entries, or Stop entries that need a
75
+ * command refresh).
76
+ */
77
+ function removeKintsugiHooks(hooks, eventName) {
78
+ if (!Array.isArray(hooks[eventName]))
79
+ return false;
80
+ const filtered = hooks[eventName].filter((h) => !KINTSUGI_HOOK_PATTERN.test(JSON.stringify(h)));
81
+ if (filtered.length === hooks[eventName].length)
82
+ return false;
83
+ if (filtered.length === 0) {
84
+ delete hooks[eventName];
85
+ }
86
+ else {
87
+ hooks[eventName] = filtered;
88
+ }
89
+ return true;
90
+ }
91
+ //# sourceMappingURL=agentConfig.js.map
@@ -0,0 +1,11 @@
1
+ export declare const INSTRUCTIONS_BEGIN = "<!-- kintsugi:begin -->";
2
+ export declare const INSTRUCTIONS_END = "<!-- kintsugi:end -->";
3
+ export declare function getCoverageInstructions(): string;
4
+ /**
5
+ * Insert or refresh the marker block in one instruction file. Creates the
6
+ * file if missing; replaces only the content between the markers otherwise.
7
+ */
8
+ export declare function injectInstructionsFile(filePath: string): Promise<void>;
9
+ /** Inject the block into AGENTS.md and CLAUDE.md at the project root. */
10
+ export declare function injectAgentInstructions(projectDir: string): Promise<void>;
11
+ //# sourceMappingURL=agentInstructions.d.ts.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The persistent agent instruction block injected into AGENTS.md / CLAUDE.md
3
+ * at project root by `kintsugi init`. Marker-delimited so re-running init can
4
+ * update the block in place without touching anything else in the file.
5
+ */
6
+ import { promises as fs } from 'fs';
7
+ import path from 'path';
8
+ export const INSTRUCTIONS_BEGIN = '<!-- kintsugi:begin -->';
9
+ export const INSTRUCTIONS_END = '<!-- kintsugi:end -->';
10
+ export function getCoverageInstructions() {
11
+ return `${INSTRUCTIONS_BEGIN}
12
+ ## UI regression coverage (Kintsugi)
13
+
14
+ Recorded flows are replayed at the end of every agent turn to catch UI regressions.
15
+
16
+ - When you build a NEW user-facing page, view, or feature, record a flow for it with the \`save_flow\` MCP tool (stable selectors, under 8 steps), pass the source files it covers in \`source_files\`, then call \`capture_baseline\`.
17
+ - When you substantially change an existing flow's behavior, re-record that flow and refresh its baseline.
18
+ - Do not record flows for trivial styling-only changes.
19
+ ${INSTRUCTIONS_END}`;
20
+ }
21
+ /**
22
+ * Insert or refresh the marker block in one instruction file. Creates the
23
+ * file if missing; replaces only the content between the markers otherwise.
24
+ */
25
+ export async function injectInstructionsFile(filePath) {
26
+ const block = getCoverageInstructions();
27
+ let existing = '';
28
+ try {
29
+ existing = await fs.readFile(filePath, 'utf-8');
30
+ }
31
+ catch {
32
+ existing = '';
33
+ }
34
+ let next;
35
+ const beginIdx = existing.indexOf(INSTRUCTIONS_BEGIN);
36
+ if (beginIdx === -1) {
37
+ next = existing ? `${existing.replace(/\s*$/, '')}\n\n${block}\n` : `${block}\n`;
38
+ }
39
+ else {
40
+ const endIdx = existing.indexOf(INSTRUCTIONS_END, beginIdx);
41
+ if (endIdx === -1) {
42
+ // Corrupt marker (begin without end): append a fresh block after it.
43
+ next = `${existing.replace(/\s*$/, '')}\n\n${block}\n`;
44
+ }
45
+ else {
46
+ next = existing.slice(0, beginIdx) + block + existing.slice(endIdx + INSTRUCTIONS_END.length);
47
+ }
48
+ }
49
+ if (next !== existing) {
50
+ await fs.writeFile(filePath, next, 'utf-8');
51
+ }
52
+ }
53
+ /** Inject the block into AGENTS.md and CLAUDE.md at the project root. */
54
+ export async function injectAgentInstructions(projectDir) {
55
+ await injectInstructionsFile(path.join(projectDir, 'AGENTS.md'));
56
+ await injectInstructionsFile(path.join(projectDir, 'CLAUDE.md'));
57
+ }
58
+ //# sourceMappingURL=agentInstructions.js.map
package/dist/auth.d.ts CHANGED
@@ -8,6 +8,10 @@ type Logger = {
8
8
  export declare function computeDeviceHash(): string;
9
9
  /** Writes (or replaces) one KEY=VALUE line in ~/.kintsugi/.env, preserving other lines. */
10
10
  export declare function writeGlobalEnvValue(key: string, value: string): string;
11
+ /** Removes one KEY=VALUE line from an env file (if present). */
12
+ export declare function removeEnvValueAt(envPath: string, key: string): void;
13
+ /** Removes the key from ~/.kintsugi/.env and <project>/.kintsugi/.env. */
14
+ export declare function removeApiTokenEverywhere(tokenEnvVar: string, projectDir?: string): void;
11
15
  /**
12
16
  * Returns the hosted-API key, bootstrapping an anonymous free key (written to
13
17
  * ~/.kintsugi/.env) when none exists. Returns undefined on network failure —
@@ -27,8 +31,20 @@ export interface ApiAccountStatus {
27
31
  upgradeUrl?: string;
28
32
  }
29
33
  export declare function getAccountStatus(apiKey: string, endpoint: string, fetchImpl?: typeof fetch): Promise<ApiAccountStatus | undefined>;
34
+ /**
35
+ * Deregisters a flow fingerprint server-side so deleted/renamed/rewritten
36
+ * flows stop occupying free-plan quota. Best-effort: self-hosted or offline
37
+ * setups return false and callers carry on — the server stays authoritative
38
+ * at classify time regardless.
39
+ */
40
+ export declare function deregisterFlow(apiKey: string, endpoint: string, flowHash: string, fetchImpl?: typeof fetch): Promise<boolean>;
30
41
  /** Stripe Payment Link URL for the $29/mo Pro upgrade, or undefined when billing is unavailable. */
31
42
  export declare function createCheckoutUrl(apiKey: string, endpoint: string, fetchImpl?: typeof fetch): Promise<string | undefined>;
43
+ /** Stripe Customer Portal URL (cancel/update card), or undefined when unavailable. */
44
+ export declare function createPortalUrl(apiKey: string, endpoint: string, fetchImpl?: typeof fetch): Promise<{
45
+ url?: string;
46
+ error?: string;
47
+ }>;
32
48
  /**
33
49
  * Polls account status until the plan becomes 'pro' (the Stripe webhook flips
34
50
  * it server-side shortly after payment) or the timeout elapses. Called from
@@ -41,6 +57,25 @@ export declare function waitForPlanUpgrade(apiKey: string, endpoint: string, opt
41
57
  shouldContinue?: () => boolean;
42
58
  onPoll?: (status: ApiAccountStatus | undefined) => void;
43
59
  }): Promise<ApiAccountStatus | undefined>;
60
+ /**
61
+ * Polls account status until the plan becomes 'free' again (portal cancel →
62
+ * subscription.deleted webhook). Called from `kintsugi cancel`.
63
+ */
64
+ export declare function waitForPlanDowngrade(apiKey: string, endpoint: string, options?: {
65
+ intervalMs?: number;
66
+ timeoutMs?: number;
67
+ fetchImpl?: typeof fetch;
68
+ shouldContinue?: () => boolean;
69
+ onPoll?: (status: ApiAccountStatus | undefined) => void;
70
+ }): Promise<ApiAccountStatus | undefined>;
71
+ /**
72
+ * Rotates the API key server-side: the account (plan, subscription, flow
73
+ * quota) is kept, the old key stops working. Returns the new raw key exactly
74
+ * once — write it where the next machine can read it before losing it.
75
+ */
76
+ export declare function rotateApiKey(apiKey: string, endpoint: string, fetchImpl?: typeof fetch): Promise<string | undefined>;
77
+ /** Deletes the account (key + flows + usage, subscription cancelled). */
78
+ export declare function deleteAccount(apiKey: string, endpoint: string, fetchImpl?: typeof fetch): Promise<boolean>;
44
79
  /**
45
80
  * Shared classifier-context builder used by the hook, `kintsugi check`, and
46
81
  * the MCP server — one code path decides when classification is active.
package/dist/auth.js CHANGED
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import os from 'node:os';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { resolveApiToken, globalKintsugiEnvPath } from './classifier.js';
10
+ import { writeClassifierState } from './classifier-state.js';
10
11
  /** Stable per-machine fingerprint used to issue anonymous keys. */
11
12
  export function computeDeviceHash() {
12
13
  return createHash('sha256').update(`${os.hostname()}|${os.homedir()}`).digest('hex').slice(0, 32);
@@ -14,6 +15,30 @@ export function computeDeviceHash() {
14
15
  /** Writes (or replaces) one KEY=VALUE line in ~/.kintsugi/.env, preserving other lines. */
15
16
  export function writeGlobalEnvValue(key, value) {
16
17
  const envPath = globalKintsugiEnvPath();
18
+ writeEnvValueAt(envPath, key, value);
19
+ return envPath;
20
+ }
21
+ /** Removes one KEY=VALUE line from an env file (if present). */
22
+ export function removeEnvValueAt(envPath, key) {
23
+ let content = '';
24
+ try {
25
+ content = fs.readFileSync(envPath, 'utf-8');
26
+ }
27
+ catch {
28
+ return; // no file, nothing to remove
29
+ }
30
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ const re = new RegExp(`^${escapedKey}=.*\\n?`, 'm');
32
+ fs.writeFileSync(envPath, content.replace(re, ''));
33
+ }
34
+ /** Removes the key from ~/.kintsugi/.env and <project>/.kintsugi/.env. */
35
+ export function removeApiTokenEverywhere(tokenEnvVar, projectDir) {
36
+ removeEnvValueAt(globalKintsugiEnvPath(), tokenEnvVar);
37
+ if (projectDir) {
38
+ removeEnvValueAt(path.join(projectDir, '.kintsugi', '.env'), tokenEnvVar);
39
+ }
40
+ }
41
+ function writeEnvValueAt(envPath, key, value) {
17
42
  fs.mkdirSync(path.dirname(envPath), { recursive: true });
18
43
  let content = '';
19
44
  try {
@@ -31,7 +56,6 @@ export function writeGlobalEnvValue(key, value) {
31
56
  ? content + line
32
57
  : content + '\n' + line;
33
58
  fs.writeFileSync(envPath, updated.endsWith('\n') ? updated : updated + '\n');
34
- return envPath;
35
59
  }
36
60
  /**
37
61
  * Returns the hosted-API key, bootstrapping an anonymous free key (written to
@@ -77,6 +101,24 @@ export async function getAccountStatus(apiKey, endpoint, fetchImpl) {
77
101
  return undefined;
78
102
  }
79
103
  }
104
+ /**
105
+ * Deregisters a flow fingerprint server-side so deleted/renamed/rewritten
106
+ * flows stop occupying free-plan quota. Best-effort: self-hosted or offline
107
+ * setups return false and callers carry on — the server stays authoritative
108
+ * at classify time regardless.
109
+ */
110
+ export async function deregisterFlow(apiKey, endpoint, flowHash, fetchImpl) {
111
+ try {
112
+ const res = await (fetchImpl ?? fetch)(`${endpoint.replace(/\/$/, '')}/flows/${encodeURIComponent(flowHash)}`, {
113
+ method: 'DELETE',
114
+ headers: { Authorization: `Bearer ${apiKey}` },
115
+ });
116
+ return res.ok;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
80
122
  /** Stripe Payment Link URL for the $29/mo Pro upgrade, or undefined when billing is unavailable. */
81
123
  export async function createCheckoutUrl(apiKey, endpoint, fetchImpl) {
82
124
  try {
@@ -93,6 +135,22 @@ export async function createCheckoutUrl(apiKey, endpoint, fetchImpl) {
93
135
  return undefined;
94
136
  }
95
137
  }
138
+ /** Stripe Customer Portal URL (cancel/update card), or undefined when unavailable. */
139
+ export async function createPortalUrl(apiKey, endpoint, fetchImpl) {
140
+ try {
141
+ const res = await (fetchImpl ?? fetch)(`${endpoint.replace(/\/$/, '')}/billing/portal`, {
142
+ method: 'POST',
143
+ headers: { Authorization: `Bearer ${apiKey}` },
144
+ });
145
+ const body = (await res.json().catch(() => ({})));
146
+ if (!res.ok)
147
+ return { error: body.error ?? `portal_${res.status}` };
148
+ return { url: body.url, error: body.error };
149
+ }
150
+ catch {
151
+ return { error: 'unreachable' };
152
+ }
153
+ }
96
154
  /**
97
155
  * Polls account status until the plan becomes 'pro' (the Stripe webhook flips
98
156
  * it server-side shortly after payment) or the timeout elapses. Called from
@@ -110,6 +168,55 @@ export async function waitForPlanUpgrade(apiKey, endpoint, options = {}) {
110
168
  }
111
169
  return undefined;
112
170
  }
171
+ /**
172
+ * Polls account status until the plan becomes 'free' again (portal cancel →
173
+ * subscription.deleted webhook). Called from `kintsugi cancel`.
174
+ */
175
+ export async function waitForPlanDowngrade(apiKey, endpoint, options = {}) {
176
+ const interval = options.intervalMs ?? 3000;
177
+ const deadline = Date.now() + (options.timeoutMs ?? 180_000);
178
+ while (Date.now() < deadline && (options.shouldContinue?.() ?? true)) {
179
+ const status = await getAccountStatus(apiKey, endpoint, options.fetchImpl);
180
+ if (status?.plan === 'free')
181
+ return status;
182
+ options.onPoll?.(status);
183
+ await new Promise(resolve => setTimeout(resolve, interval));
184
+ }
185
+ return undefined;
186
+ }
187
+ /**
188
+ * Rotates the API key server-side: the account (plan, subscription, flow
189
+ * quota) is kept, the old key stops working. Returns the new raw key exactly
190
+ * once — write it where the next machine can read it before losing it.
191
+ */
192
+ export async function rotateApiKey(apiKey, endpoint, fetchImpl) {
193
+ try {
194
+ const res = await (fetchImpl ?? fetch)(`${endpoint.replace(/\/$/, '')}/keys/rotate`, {
195
+ method: 'POST',
196
+ headers: { Authorization: `Bearer ${apiKey}` },
197
+ });
198
+ if (!res.ok)
199
+ return undefined;
200
+ const body = (await res.json());
201
+ return body.key;
202
+ }
203
+ catch {
204
+ return undefined;
205
+ }
206
+ }
207
+ /** Deletes the account (key + flows + usage, subscription cancelled). */
208
+ export async function deleteAccount(apiKey, endpoint, fetchImpl) {
209
+ try {
210
+ const res = await (fetchImpl ?? fetch)(`${endpoint.replace(/\/$/, '')}/keys/me`, {
211
+ method: 'DELETE',
212
+ headers: { Authorization: `Bearer ${apiKey}` },
213
+ });
214
+ return res.ok;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
113
220
  /**
114
221
  * Shared classifier-context builder used by the hook, `kintsugi check`, and
115
222
  * the MCP server — one code path decides when classification is active.
@@ -141,11 +248,24 @@ export async function buildClassifierContext(config, options = {}) {
141
248
  endpoint: config.classifier.endpoint,
142
249
  turnContext: options.context || '(none)',
143
250
  });
251
+ const projectDir = options.projectDir;
144
252
  return {
145
253
  config: config.classifier,
146
254
  apiToken,
147
255
  context: options.context,
148
256
  logger: log,
257
+ // Report free-plan exhaustion to <project>/.kintsugi/.classifier-state.json
258
+ // so the VS Code extension can flip its status bar and offer the upgrade.
259
+ onFlowLimit: projectDir
260
+ ? info => {
261
+ try {
262
+ writeClassifierState(projectDir, { exhausted: true, ...info });
263
+ }
264
+ catch {
265
+ // status file is best-effort — never fail the check over it
266
+ }
267
+ }
268
+ : undefined,
149
269
  };
150
270
  }
151
271
  //# sourceMappingURL=auth.js.map
@@ -4,5 +4,14 @@ export declare function listFlows(projectDir: string): Promise<FlowMetadata[]>;
4
4
  export declare function loadFlow(projectDir: string, name: string): Promise<FlowRecording>;
5
5
  export declare function saveFlow(projectDir: string, recording: FlowRecording): Promise<void>;
6
6
  export declare function deleteFlow(projectDir: string, name: string): Promise<void>;
7
+ /** Moves a flow to a new name, rewriting metadata.name. Throws if the target exists. */
8
+ export declare function renameFlow(projectDir: string, oldName: string, newName: string): Promise<void>;
9
+ /**
10
+ * Removes baseline artifacts for steps that no longer exist after a flow is
11
+ * re-saved with fewer steps — otherwise loadFlow's sorted-glob fallback can
12
+ * pair stale screenshots with new steps and fabricate regressions. The replay
13
+ * video of the old flow is stale as well.
14
+ */
15
+ export declare function pruneBaselineArtifacts(projectDir: string, name: string, stepCount: number): Promise<void>;
7
16
  export declare function updateBaseline(projectDir: string, name: string, capture: CaptureResult): Promise<void>;
8
17
  //# sourceMappingURL=baseline.d.ts.map
package/dist/baseline.js CHANGED
@@ -95,6 +95,53 @@ export async function saveFlow(projectDir, recording) {
95
95
  export async function deleteFlow(projectDir, name) {
96
96
  const flowDir = getFlowDir(projectDir, name);
97
97
  await fs.rm(flowDir, { recursive: true, force: true });
98
+ // Replay scratch dir for this flow goes with it.
99
+ await fs.rm(path.join(projectDir, '.kintsugi', '.tmp', name), { recursive: true, force: true });
100
+ }
101
+ /** Moves a flow to a new name, rewriting metadata.name. Throws if the target exists. */
102
+ export async function renameFlow(projectDir, oldName, newName) {
103
+ const oldDir = getFlowDir(projectDir, oldName);
104
+ const newDir = getFlowDir(projectDir, newName);
105
+ const metaExists = await fs.stat(path.join(oldDir, 'metadata.json')).then(() => true).catch(() => false);
106
+ if (!metaExists) {
107
+ throw new Error(`Flow "${oldName}" not found`);
108
+ }
109
+ const targetExists = await fs.stat(path.join(newDir, 'metadata.json')).then(() => true).catch(() => false);
110
+ if (targetExists) {
111
+ throw new Error(`A flow named "${newName}" already exists`);
112
+ }
113
+ await fs.rename(oldDir, newDir);
114
+ const metaPath = path.join(newDir, 'metadata.json');
115
+ const metadata = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
116
+ metadata.name = newName;
117
+ await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
118
+ // Stale scratch dir under the old name; scratch under the new name, if any, is stale too.
119
+ await fs.rm(path.join(projectDir, '.kintsugi', '.tmp', oldName), { recursive: true, force: true });
120
+ }
121
+ /**
122
+ * Removes baseline artifacts for steps that no longer exist after a flow is
123
+ * re-saved with fewer steps — otherwise loadFlow's sorted-glob fallback can
124
+ * pair stale screenshots with new steps and fabricate regressions. The replay
125
+ * video of the old flow is stale as well.
126
+ */
127
+ export async function pruneBaselineArtifacts(projectDir, name, stepCount) {
128
+ const flowDir = getFlowDir(projectDir, name);
129
+ for (const dir of [path.join(flowDir, 'screenshots'), path.join(flowDir, 'aria')]) {
130
+ let files = [];
131
+ try {
132
+ files = await fs.readdir(dir);
133
+ }
134
+ catch {
135
+ continue;
136
+ }
137
+ for (const file of files) {
138
+ const match = /^(?:step_|aria_step_)(\d+)\.(?:png|yml)$/.exec(file);
139
+ if (match && Number(match[1]) >= stepCount) {
140
+ await fs.rm(path.join(dir, file), { force: true });
141
+ }
142
+ }
143
+ }
144
+ await fs.rm(path.join(flowDir, 'video.webm'), { force: true });
98
145
  }
99
146
  export async function updateBaseline(projectDir, name, capture) {
100
147
  const flowDir = getFlowDir(projectDir, name);
package/dist/capturer.js CHANGED
@@ -24,10 +24,12 @@ export async function captureCurrentState(options) {
24
24
  recordVideo: { dir: videoDir }
25
25
  });
26
26
  const page = await context.newPage();
27
+ const captureStart = Date.now();
27
28
  const result = {
28
29
  screenshots: [],
29
30
  screenshotPaths: [],
30
31
  ariaSnapshots: [],
32
+ stepTimings: [],
31
33
  completedSteps: 0,
32
34
  totalSteps: flow.steps.length,
33
35
  errors: []
@@ -37,6 +39,7 @@ export async function captureCurrentState(options) {
37
39
  let navigated = false;
38
40
  for (let i = 0; i < flow.steps.length; i++) {
39
41
  const step = flow.steps[i];
42
+ const stepStartMs = Date.now() - captureStart;
40
43
  try {
41
44
  if (!navigated && page.url() === 'about:blank' && step.action !== 'navigate') {
42
45
  await page.goto(url, { waitUntil: 'networkidle' });
@@ -73,9 +76,12 @@ export async function captureCurrentState(options) {
73
76
  result.screenshots.push(buffer);
74
77
  result.screenshotPaths.push(screenshotPath);
75
78
  result.ariaSnapshots.push(aria);
79
+ result.stepTimings?.push({ startMs: stepStartMs, endMs: Date.now() - captureStart });
76
80
  result.completedSteps++;
77
81
  }
78
82
  catch (err) {
83
+ // A timing window for the failed attempt too, so playback clips can frame it.
84
+ result.stepTimings?.push({ startMs: stepStartMs, endMs: Date.now() - captureStart });
79
85
  result.errors.push(`Step ${i} (${step.action}) failed: ${err.message}`);
80
86
  break;
81
87
  }
@@ -0,0 +1,13 @@
1
+ export interface ClassifierState {
2
+ exhausted: boolean;
3
+ kind?: 'flow_limit_reached' | 'monthly_cap_reached';
4
+ flowsUsed?: number;
5
+ flowLimit?: number;
6
+ upgradeUrl?: string;
7
+ timestamp: string;
8
+ }
9
+ export declare function classifierStatePath(projectDir: string): string;
10
+ export declare function writeClassifierState(projectDir: string, state: Omit<ClassifierState, 'timestamp'>): void;
11
+ export declare function readClassifierState(projectDir: string): ClassifierState | undefined;
12
+ export declare function clearClassifierState(projectDir: string): void;
13
+ //# sourceMappingURL=classifier-state.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Classifier quota state, shared between the processes that classify (hook,
3
+ * `kintsugi check`, MCP) and the VS Code extension. When a hosted-classifier
4
+ * call hits the free plan's limits (429 flow_limit_reached/monthly_cap_reached)
5
+ * the writer records the exhaustion in <project>/.kintsugi/.classifier-state.json
6
+ * so the extension can flip its status bar to "Classifier Off" and offer the
7
+ * upgrade path. The file lives inside the gitignored .kintsugi dir.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ export function classifierStatePath(projectDir) {
12
+ return path.join(projectDir, '.kintsugi', '.classifier-state.json');
13
+ }
14
+ export function writeClassifierState(projectDir, state) {
15
+ const filePath = classifierStatePath(projectDir);
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
+ fs.writeFileSync(filePath, JSON.stringify({ ...state, timestamp: new Date().toISOString() }, null, 2));
18
+ }
19
+ export function readClassifierState(projectDir) {
20
+ try {
21
+ const raw = fs.readFileSync(classifierStatePath(projectDir), 'utf-8');
22
+ const parsed = JSON.parse(raw);
23
+ if (typeof parsed.exhausted !== 'boolean')
24
+ return undefined;
25
+ return parsed;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ export function clearClassifierState(projectDir) {
32
+ try {
33
+ fs.unlinkSync(classifierStatePath(projectDir));
34
+ }
35
+ catch {
36
+ // already gone
37
+ }
38
+ }
39
+ //# sourceMappingURL=classifier-state.js.map
@@ -9,6 +9,13 @@ export interface ClassifierContext {
9
9
  info: (msg: string, data?: unknown) => void;
10
10
  warn: (msg: string, data?: unknown) => void;
11
11
  };
12
+ /** Invoked when the hosted classifier reports the free plan's limits are exhausted (429). */
13
+ onFlowLimit?: (info: {
14
+ kind: 'flow_limit_reached' | 'monthly_cap_reached';
15
+ flowsUsed?: number;
16
+ flowLimit?: number;
17
+ upgradeUrl?: string;
18
+ }) => void;
12
19
  }
13
20
  export interface CompareOptions {
14
21
  outputDir?: string;
@@ -167,6 +167,12 @@ async function classifySteps(candidates, baseline, current, classifier) {
167
167
  flowsUsed: err.flowsUsed,
168
168
  flowLimit: err.flowLimit,
169
169
  });
170
+ classifier.onFlowLimit?.({
171
+ kind: err.kind,
172
+ flowsUsed: err.flowsUsed,
173
+ flowLimit: err.flowLimit,
174
+ upgradeUrl: err.upgradeUrl,
175
+ });
170
176
  const where = err.kind === 'monthly_cap_reached'
171
177
  ? 'monthly classification cap reached on the free plan'
172
178
  : `free plan flow limit reached (${err.flowsUsed ?? '?'}/${err.flowLimit ?? '?'} flows guarded)`;
package/dist/config.js CHANGED
@@ -29,20 +29,14 @@ export function getDefaultConfig() {
29
29
  type: 'auto',
30
30
  maxRetries: 3,
31
31
  },
32
- recording: {
33
- passive: true,
34
- collectorPort: 4321,
35
- },
36
32
  uiFilePatterns: ['src/**/*.tsx', 'src/**/*.ts', 'src/**/*.jsx', 'src/**/*.js'],
37
33
  };
38
34
  }
39
35
  export async function ensureKintsugiDir(projectDir) {
40
36
  const kintsugiDir = path.join(projectDir, '.kintsugi');
41
37
  const flowsDir = path.join(kintsugiDir, 'flows');
42
- const recordingsDir = path.join(kintsugiDir, 'recordings');
43
38
  await fs.mkdir(kintsugiDir, { recursive: true });
44
39
  await fs.mkdir(flowsDir, { recursive: true });
45
- await fs.mkdir(recordingsDir, { recursive: true });
46
40
  }
47
41
  export async function loadConfig(projectDir) {
48
42
  const configPath = path.join(projectDir, '.kintsugi', 'config.json');