argus-reviewer-e2e 0.1.2

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/action/action.yml +147 -0
  4. package/action/sticky-comment.mjs +376 -0
  5. package/dist/api.d.ts +121 -0
  6. package/dist/api.js +256 -0
  7. package/dist/cache/fingerprint.d.ts +59 -0
  8. package/dist/cache/fingerprint.js +67 -0
  9. package/dist/cache/store.d.ts +17 -0
  10. package/dist/cache/store.js +41 -0
  11. package/dist/cli.d.ts +25 -0
  12. package/dist/cli.js +1355 -0
  13. package/dist/config.d.ts +130 -0
  14. package/dist/config.js +163 -0
  15. package/dist/debug.d.ts +1 -0
  16. package/dist/debug.js +30 -0
  17. package/dist/detect.d.ts +50 -0
  18. package/dist/detect.js +105 -0
  19. package/dist/driver/browser.d.ts +64 -0
  20. package/dist/driver/browser.js +200 -0
  21. package/dist/driver/target.d.ts +23 -0
  22. package/dist/driver/target.js +97 -0
  23. package/dist/engine/actions.d.ts +26 -0
  24. package/dist/engine/actions.js +47 -0
  25. package/dist/engine/loop.d.ts +118 -0
  26. package/dist/engine/loop.js +649 -0
  27. package/dist/engine/prompts.d.ts +22 -0
  28. package/dist/engine/prompts.js +112 -0
  29. package/dist/evidence/ci.d.ts +18 -0
  30. package/dist/evidence/ci.js +61 -0
  31. package/dist/evidence/link.d.ts +35 -0
  32. package/dist/evidence/link.js +90 -0
  33. package/dist/executor/a0.d.ts +29 -0
  34. package/dist/executor/a0.js +38 -0
  35. package/dist/fsutil.d.ts +5 -0
  36. package/dist/fsutil.js +12 -0
  37. package/dist/index/context.d.ts +17 -0
  38. package/dist/index/context.js +88 -0
  39. package/dist/index/diff.d.ts +1 -0
  40. package/dist/index/diff.js +33 -0
  41. package/dist/index/invalidate.d.ts +28 -0
  42. package/dist/index/invalidate.js +56 -0
  43. package/dist/index/scan.d.ts +26 -0
  44. package/dist/index/scan.js +209 -0
  45. package/dist/journal/build.d.ts +15 -0
  46. package/dist/journal/build.js +47 -0
  47. package/dist/journal/schema.d.ts +59 -0
  48. package/dist/journal/schema.js +6 -0
  49. package/dist/journal/store.d.ts +10 -0
  50. package/dist/journal/store.js +26 -0
  51. package/dist/live.d.ts +2 -0
  52. package/dist/live.js +46 -0
  53. package/dist/log.d.ts +17 -0
  54. package/dist/log.js +24 -0
  55. package/dist/report/comment.d.ts +13 -0
  56. package/dist/report/comment.js +135 -0
  57. package/dist/report/junit.d.ts +10 -0
  58. package/dist/report/junit.js +46 -0
  59. package/dist/report/run.d.ts +51 -0
  60. package/dist/report/run.js +36 -0
  61. package/dist/vision/cost.d.ts +36 -0
  62. package/dist/vision/cost.js +16 -0
  63. package/dist/vision/ledger.d.ts +29 -0
  64. package/dist/vision/ledger.js +65 -0
  65. package/dist/vision/openrouter.d.ts +70 -0
  66. package/dist/vision/openrouter.js +134 -0
  67. package/package.json +65 -0
package/dist/api.d.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { BrowserDriver } from './driver/browser.js';
2
+ import { VisionClient } from './engine/loop.js';
3
+ import { AssertionResult } from './engine/prompts.js';
4
+ import { FingerprintRecord } from './cache/fingerprint.js';
5
+ import { Ledger, LedgerState } from './vision/ledger.js';
6
+ import { Config, defineConfig } from './config.js';
7
+ import { ErrorRecord } from './journal/schema.js';
8
+ import { Logger } from './log.js';
9
+ export { defineConfig };
10
+ /**
11
+ * Test-facing API (R13). Test files are plain TypeScript using a `td` object:
12
+ *
13
+ * test('landing', async (td) => {
14
+ * await td.find('the "See pricing" button').click()
15
+ * await td.type('user@example.com')
16
+ * await td.pressKeys(['tab'])
17
+ * const ok = await td.assert('The pricing section is visible')
18
+ * })
19
+ *
20
+ * `td` calls resolve elements through the engine's fingerprint cache — a
21
+ * cache hit costs zero vision calls, a miss grounds (or heals) via the model.
22
+ */
23
+ export interface TypeOptions {
24
+ /**
25
+ * When true, `text` is a secret name: the typed value comes from
26
+ * `config.secrets[name]` (or the `name` environment variable) and is never
27
+ * sent to the model. The model only resolves which field receives it.
28
+ */
29
+ secret?: boolean;
30
+ }
31
+ export interface TdHandle {
32
+ click(): Promise<void>;
33
+ doubleClick(): Promise<void>;
34
+ }
35
+ export interface Td {
36
+ find(description: string): TdHandle;
37
+ type(text: string, options?: TypeOptions): Promise<void>;
38
+ pressKeys(keys: string[]): Promise<void>;
39
+ assert(question: string): Promise<AssertionResult>;
40
+ wait(ms: number): Promise<void>;
41
+ scroll(dx: number, dy: number): Promise<void>;
42
+ }
43
+ export interface TdStepRecord {
44
+ instruction: string;
45
+ action: string;
46
+ ok: boolean;
47
+ healed: boolean;
48
+ model: string | undefined;
49
+ reason: string | undefined;
50
+ }
51
+ export interface TdAssertRecord extends AssertionResult {
52
+ question: string;
53
+ cached: boolean;
54
+ }
55
+ export interface HealEvent {
56
+ instruction: string;
57
+ model: string | undefined;
58
+ }
59
+ export interface TdSessionOptions {
60
+ driver: BrowserDriver;
61
+ client: VisionClient;
62
+ config: Config;
63
+ /** Flow name used to load/save the fingerprint cache for this test. */
64
+ flowName?: string;
65
+ env?: NodeJS.ProcessEnv;
66
+ /**
67
+ * Set by the run path when diff-aware invalidation fired — marks every
68
+ * loaded fingerprint `stale` so they re-ground on first use.
69
+ */
70
+ staleReason?: string;
71
+ logger?: Logger;
72
+ }
73
+ export declare function normalizeKey(key: string): string;
74
+ /**
75
+ * One session per executed test. Owns the ledger + engine, replays the
76
+ * per-test fingerprint cache, and records steps/heals/asserts for the report.
77
+ */
78
+ export declare class TdSession {
79
+ private readonly opts;
80
+ readonly td: Td;
81
+ readonly ledger: Ledger;
82
+ readonly steps: TdStepRecord[];
83
+ readonly asserts: TdAssertRecord[];
84
+ readonly healEvents: HealEvent[];
85
+ private readonly engine;
86
+ private readonly actions;
87
+ private readonly flow;
88
+ private readonly fingerprints;
89
+ private _failed;
90
+ private _failureReason;
91
+ private constructor();
92
+ static create(opts: TdSessionOptions): Promise<TdSession>;
93
+ get visionCalls(): number;
94
+ /** Non-fatal anomalies observed by the engine — journaled as evidence. */
95
+ get errorRecords(): ErrorRecord[];
96
+ get failed(): boolean;
97
+ get failureReason(): string | undefined;
98
+ get ledgerState(): LedgerState;
99
+ /** Persist the (possibly healed) fingerprints back to the cache (R4, R5). */
100
+ save(): Promise<void>;
101
+ private _record;
102
+ private _locate;
103
+ private _makeTd;
104
+ }
105
+ export interface RegisteredTest {
106
+ name: string;
107
+ fn: (td: Td) => void | Promise<void>;
108
+ }
109
+ /** Register a test; called by test files at import time. */
110
+ export declare function test(name: string, fn: (td: Td) => void | Promise<void>): void;
111
+ /** Drain the registry — the runner calls this after importing a test file. */
112
+ export declare function takeTests(): RegisteredTest[];
113
+ /** Bind the session the ambient `td` proxy delegates to. Internal to the runner. */
114
+ export declare function bindSession(session: TdSession | undefined): void;
115
+ /**
116
+ * Ambient `td` for test files that use top-level calls instead of `test()`.
117
+ * The CLI also exposes this as `globalThis.td` while importing test files.
118
+ */
119
+ export declare const td: Td;
120
+ /** Render a recorded flow as a plain-TS test file (R13). */
121
+ export declare function renderTestFile(flowName: string, steps: FingerprintRecord[]): string;
package/dist/api.js ADDED
@@ -0,0 +1,256 @@
1
+ import { Actions } from './engine/actions.js';
2
+ import { Engine } from './engine/loop.js';
3
+ import { loadFlow, saveFlow } from './cache/store.js';
4
+ import { Ledger } from './vision/ledger.js';
5
+ import { defineConfig } from './config.js';
6
+ export { defineConfig };
7
+ const KEY_ALIASES = {
8
+ tab: 'Tab',
9
+ enter: 'Enter',
10
+ return: 'Enter',
11
+ escape: 'Escape',
12
+ esc: 'Escape',
13
+ backspace: 'Backspace',
14
+ delete: 'Delete',
15
+ space: ' ',
16
+ up: 'ArrowUp',
17
+ down: 'ArrowDown',
18
+ left: 'ArrowLeft',
19
+ right: 'ArrowRight',
20
+ shift: 'Shift',
21
+ control: 'Control',
22
+ ctrl: 'Control',
23
+ alt: 'Alt',
24
+ meta: 'Meta',
25
+ cmd: 'Meta',
26
+ home: 'Home',
27
+ end: 'End',
28
+ pageup: 'PageUp',
29
+ pagedown: 'PageDown',
30
+ };
31
+ export function normalizeKey(key) {
32
+ if (key.includes('+')) {
33
+ return key
34
+ .split('+')
35
+ .map((part) => normalizeKey(part.trim()))
36
+ .join('+');
37
+ }
38
+ const lowered = key.toLowerCase();
39
+ const alias = KEY_ALIASES[lowered];
40
+ if (alias !== undefined)
41
+ return alias;
42
+ if (key.length === 1)
43
+ return key;
44
+ return key.charAt(0).toUpperCase() + key.slice(1);
45
+ }
46
+ /**
47
+ * One session per executed test. Owns the ledger + engine, replays the
48
+ * per-test fingerprint cache, and records steps/heals/asserts for the report.
49
+ */
50
+ export class TdSession {
51
+ opts;
52
+ td;
53
+ ledger;
54
+ steps = [];
55
+ asserts = [];
56
+ healEvents = [];
57
+ engine;
58
+ actions;
59
+ flow;
60
+ fingerprints = [];
61
+ _failed = false;
62
+ _failureReason;
63
+ constructor(opts, flow) {
64
+ this.opts = opts;
65
+ this.flow = flow;
66
+ if (opts.staleReason !== undefined && flow !== undefined) {
67
+ const reason = opts.staleReason;
68
+ flow.steps = flow.steps.map((step) => ({ ...step, stale: reason }));
69
+ }
70
+ this.ledger = new Ledger(opts.config.budgetUsd);
71
+ this.actions = new Actions(opts.driver);
72
+ this.engine = new Engine({
73
+ driver: opts.driver,
74
+ actions: this.actions,
75
+ client: opts.client,
76
+ ledger: this.ledger,
77
+ config: opts.config,
78
+ ...(flow?.asserts !== undefined ? { initialAsserts: flow.asserts } : {}),
79
+ ...(opts.logger !== undefined ? { logger: opts.logger } : {}),
80
+ });
81
+ this.td = this._makeTd();
82
+ }
83
+ static async create(opts) {
84
+ const flow = opts.flowName !== undefined && opts.config.cacheDir !== undefined
85
+ ? await loadFlow(opts.config.cacheDir, opts.flowName)
86
+ : undefined;
87
+ return new TdSession(opts, flow);
88
+ }
89
+ get visionCalls() {
90
+ return this.engine.visionCalls;
91
+ }
92
+ /** Non-fatal anomalies observed by the engine — journaled as evidence. */
93
+ get errorRecords() {
94
+ return this.engine.errorRecords;
95
+ }
96
+ get failed() {
97
+ return this._failed;
98
+ }
99
+ get failureReason() {
100
+ return this._failureReason;
101
+ }
102
+ get ledgerState() {
103
+ return this.ledger.state;
104
+ }
105
+ /** Persist the (possibly healed) fingerprints back to the cache (R4, R5). */
106
+ async save() {
107
+ if (this.opts.flowName !== undefined && this.opts.config.cacheDir !== undefined) {
108
+ await saveFlow(this.opts.config.cacheDir, this.opts.flowName, this.fingerprints, this.engine.assertEntries);
109
+ }
110
+ }
111
+ _record(record) {
112
+ this.steps.push(record);
113
+ if (!record.ok) {
114
+ this._failed = true;
115
+ this._failureReason = record.reason ?? `${record.action} failed`;
116
+ }
117
+ if (record.healed) {
118
+ this.healEvents.push({ instruction: record.instruction, model: record.model });
119
+ }
120
+ }
121
+ async _locate(description) {
122
+ const index = this.fingerprints.length;
123
+ const cached = this.flow?.steps[index];
124
+ const result = await this.engine.locate(`locate: ${description}`, cached);
125
+ if (!result.ok || result.point === undefined || result.fingerprint === undefined) {
126
+ this._record({
127
+ instruction: description,
128
+ action: 'find',
129
+ ok: false,
130
+ healed: false,
131
+ model: result.model,
132
+ reason: result.reason,
133
+ });
134
+ throw new Error(`td.find("${description}") failed: ${result.reason ?? 'unknown reason'}`);
135
+ }
136
+ this.fingerprints.push(result.fingerprint);
137
+ this._record({
138
+ instruction: description,
139
+ action: 'find',
140
+ ok: true,
141
+ healed: result.healed,
142
+ model: result.model,
143
+ reason: undefined,
144
+ });
145
+ return result.point;
146
+ }
147
+ _makeTd() {
148
+ return {
149
+ find: (description) => ({
150
+ click: async () => {
151
+ const point = await this._locate(description);
152
+ await this.actions.click(point.x, point.y);
153
+ },
154
+ doubleClick: async () => {
155
+ const point = await this._locate(description);
156
+ await this.actions.doubleClick(point.x, point.y);
157
+ },
158
+ }),
159
+ type: async (text, options) => {
160
+ if (options?.secret === true) {
161
+ const value = this.opts.config.secrets?.[text] ?? this.opts.env?.[text];
162
+ if (value === undefined) {
163
+ this._failed = true;
164
+ this._failureReason = `secret "${text}" not found in config.secrets or environment`;
165
+ throw new Error(this._failureReason);
166
+ }
167
+ // The model only picks the field; the secret value never leaves the
168
+ // harness (prompt-injection mitigation).
169
+ const point = await this._locate(`the "${text}" input field`);
170
+ await this.actions.click(point.x, point.y);
171
+ await this.actions.type(value);
172
+ return;
173
+ }
174
+ await this.actions.type(text);
175
+ },
176
+ pressKeys: async (keys) => {
177
+ await this.actions.pressKeys(keys.map(normalizeKey));
178
+ },
179
+ assert: async (question) => {
180
+ const result = await this.engine.assert(question);
181
+ this.asserts.push({ question, ...result });
182
+ if (result.verdict === 'fail') {
183
+ this._failed = true;
184
+ this._failureReason = `assert("${question}") failed: ${result.reasoning}`;
185
+ }
186
+ return result;
187
+ },
188
+ wait: async (ms) => {
189
+ await this.actions.wait(ms);
190
+ },
191
+ scroll: async (dx, dy) => {
192
+ await this.actions.scroll(dx, dy);
193
+ },
194
+ };
195
+ }
196
+ }
197
+ const registeredTests = [];
198
+ let currentSession;
199
+ /** Register a test; called by test files at import time. */
200
+ export function test(name, fn) {
201
+ registeredTests.push({ name, fn });
202
+ }
203
+ /** Drain the registry — the runner calls this after importing a test file. */
204
+ export function takeTests() {
205
+ return registeredTests.splice(0, registeredTests.length);
206
+ }
207
+ /** Bind the session the ambient `td` proxy delegates to. Internal to the runner. */
208
+ export function bindSession(session) {
209
+ currentSession = session;
210
+ }
211
+ function session() {
212
+ if (currentSession === undefined) {
213
+ throw new Error('td used outside an argus-reviewer run — no active session');
214
+ }
215
+ return currentSession;
216
+ }
217
+ /**
218
+ * Ambient `td` for test files that use top-level calls instead of `test()`.
219
+ * The CLI also exposes this as `globalThis.td` while importing test files.
220
+ */
221
+ export const td = {
222
+ find: (description) => session().td.find(description),
223
+ type: (text, options) => session().td.type(text, options),
224
+ pressKeys: (keys) => session().td.pressKeys(keys),
225
+ assert: (question) => session().td.assert(question),
226
+ wait: (ms) => session().td.wait(ms),
227
+ scroll: (dx, dy) => session().td.scroll(dx, dy),
228
+ };
229
+ /** Render a recorded flow as a plain-TS test file (R13). */
230
+ export function renderTestFile(flowName, steps) {
231
+ const lines = steps.map((step) => {
232
+ const instruction = JSON.stringify(step.instruction);
233
+ switch (step.action.action) {
234
+ case 'click':
235
+ return ` await td.find(${instruction}).click()`;
236
+ case 'type':
237
+ return ` await td.type(${JSON.stringify(step.action.text ?? '')})`;
238
+ case 'pressKeys':
239
+ return ` await td.pressKeys(${JSON.stringify(step.action.keys ?? [])})`;
240
+ case 'wait':
241
+ return ` await td.wait(${step.action.ms ?? 0})`;
242
+ case 'scroll':
243
+ return ` await td.scroll(${step.action.dx ?? 0}, ${step.action.dy ?? 0})`;
244
+ default:
245
+ return ` // TODO: unsupported recorded action ${JSON.stringify(step.action.action)}`;
246
+ }
247
+ });
248
+ return [
249
+ `// Recorded by argus-reviewer: ${flowName}`,
250
+ `// Run with: npx argus-reviewer run`,
251
+ `test(${JSON.stringify(flowName)}, async (td) => {`,
252
+ ...lines,
253
+ '})',
254
+ '',
255
+ ].join('\n');
256
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Fingerprint record per KTD5: the durable, replayable identity of a single
3
+ * model-resolved step. Resolve compares a freshly captured element region
4
+ * against the stored hash and verifies the a11y snippet is still present.
5
+ *
6
+ * The region hash is a simple 64-bit average-hash over the raw bytes of the
7
+ * cropped JPEG screenshot region. It is fast, dependency-free, and stable
8
+ * enough for pinned-viewport fixtures.
9
+ */
10
+ export interface Bbox {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+ export interface Point {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ export interface ActionPayload {
21
+ action: 'click' | 'type' | 'pressKeys' | 'scroll' | 'wait' | 'done' | 'fail';
22
+ x?: number;
23
+ y?: number;
24
+ text?: string;
25
+ keys?: string[];
26
+ dx?: number;
27
+ dy?: number;
28
+ ms?: number;
29
+ }
30
+ export interface FingerprintRecord {
31
+ instruction: string;
32
+ action: ActionPayload;
33
+ bbox: Bbox;
34
+ clickPoint: Point;
35
+ model: string;
36
+ a11ySnippet: string;
37
+ regionHash: string;
38
+ /**
39
+ * Set by diff-aware invalidation before replay: the reason this entry is
40
+ * considered stale. Presence means "re-ground on next use" — the entry is
41
+ * kept for reference/heal context.
42
+ */
43
+ stale?: string;
44
+ }
45
+ export interface ResolveResult {
46
+ matched: boolean;
47
+ currentHash: string;
48
+ regionMatched: boolean;
49
+ a11yMatched: boolean;
50
+ }
51
+ export declare function computeRegionHash(buffer: Buffer, bits?: number): string;
52
+ export declare class Fingerprint {
53
+ readonly record: FingerprintRecord;
54
+ constructor(record: FingerprintRecord);
55
+ resolve(regionScreenshot: Buffer, a11yYaml: string): ResolveResult;
56
+ mismatch(regionScreenshot: Buffer, a11yYaml: string): boolean;
57
+ }
58
+ /** FNV-1a 32-bit hash - stable content hash for the a11y snapshot text. */
59
+ export declare function fnv1a(text: string): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Fingerprint record per KTD5: the durable, replayable identity of a single
3
+ * model-resolved step. Resolve compares a freshly captured element region
4
+ * against the stored hash and verifies the a11y snippet is still present.
5
+ *
6
+ * The region hash is a simple 64-bit average-hash over the raw bytes of the
7
+ * cropped JPEG screenshot region. It is fast, dependency-free, and stable
8
+ * enough for pinned-viewport fixtures.
9
+ */
10
+ export function computeRegionHash(buffer, bits = 64) {
11
+ if (buffer.length === 0) {
12
+ return '0'.repeat(Math.ceil(bits / 4));
13
+ }
14
+ const chunk = Math.max(1, Math.floor(buffer.length / bits));
15
+ const means = [];
16
+ let total = 0;
17
+ for (let i = 0; i < bits; i++) {
18
+ const start = i * chunk;
19
+ const end = Math.min(start + chunk, buffer.length);
20
+ let sum = 0;
21
+ let count = 0;
22
+ for (let j = start; j < end; j++) {
23
+ sum += buffer[j] ?? 0;
24
+ count++;
25
+ }
26
+ const mean = count > 0 ? sum / count : 0;
27
+ means.push(mean);
28
+ total += mean;
29
+ }
30
+ const globalMean = total / bits;
31
+ let hash = 0n;
32
+ for (let i = 0; i < bits; i++) {
33
+ if ((means[i] ?? 0) >= globalMean) {
34
+ hash |= 1n << BigInt(i);
35
+ }
36
+ }
37
+ return hash.toString(16).padStart(Math.ceil(bits / 4), '0');
38
+ }
39
+ export class Fingerprint {
40
+ record;
41
+ constructor(record) {
42
+ this.record = record;
43
+ }
44
+ resolve(regionScreenshot, a11yYaml) {
45
+ const currentHash = computeRegionHash(regionScreenshot);
46
+ const regionMatched = currentHash === this.record.regionHash;
47
+ const a11yMatched = a11yYaml.includes(this.record.a11ySnippet);
48
+ return {
49
+ matched: regionMatched && a11yMatched,
50
+ currentHash,
51
+ regionMatched,
52
+ a11yMatched,
53
+ };
54
+ }
55
+ mismatch(regionScreenshot, a11yYaml) {
56
+ return !this.resolve(regionScreenshot, a11yYaml).matched;
57
+ }
58
+ }
59
+ /** FNV-1a 32-bit hash - stable content hash for the a11y snapshot text. */
60
+ export function fnv1a(text) {
61
+ let h = 0x811c9dc5;
62
+ for (let i = 0; i < text.length; i++) {
63
+ h ^= text.charCodeAt(i);
64
+ h = Math.imul(h, 0x01000193);
65
+ }
66
+ return (h >>> 0).toString(16).padStart(8, '0');
67
+ }
@@ -0,0 +1,17 @@
1
+ import { FingerprintRecord } from './fingerprint.js';
2
+ /** A cached assertion verdict, keyed on (question, page-state hash). */
3
+ export interface CachedAssert {
4
+ question: string;
5
+ /** FNV-1a hash of the a11y snapshot at assert time — stable across pixel noise. */
6
+ a11yHash: string;
7
+ verdict: 'pass' | 'fail';
8
+ reasoning: string;
9
+ model: string | undefined;
10
+ }
11
+ export interface FlowCache {
12
+ steps: FingerprintRecord[];
13
+ asserts?: CachedAssert[];
14
+ }
15
+ export declare function flowPath(cacheDir: string, flowName: string): string;
16
+ export declare function loadFlow(cacheDir: string, flowName: string): Promise<FlowCache | undefined>;
17
+ export declare function saveFlow(cacheDir: string, flowName: string, steps: FingerprintRecord[], asserts?: CachedAssert[]): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { writeAtomicJson } from '../fsutil.js';
4
+ function sortKeys(_, value) {
5
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
6
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
7
+ return Object.fromEntries(entries);
8
+ }
9
+ return value;
10
+ }
11
+ export function flowPath(cacheDir, flowName) {
12
+ return join(cacheDir, `${flowName}.json`);
13
+ }
14
+ export async function loadFlow(cacheDir, flowName) {
15
+ const path = flowPath(cacheDir, flowName);
16
+ try {
17
+ const raw = await readFile(path, 'utf8');
18
+ const parsed = JSON.parse(raw);
19
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
20
+ return undefined;
21
+ }
22
+ const { steps, asserts } = parsed;
23
+ if (!Array.isArray(steps)) {
24
+ return undefined;
25
+ }
26
+ const out = { steps: steps };
27
+ if (Array.isArray(asserts))
28
+ out.asserts = asserts;
29
+ return out;
30
+ }
31
+ catch (e) {
32
+ const code = e.code;
33
+ if (code === 'ENOENT') {
34
+ return undefined;
35
+ }
36
+ throw e;
37
+ }
38
+ }
39
+ export async function saveFlow(cacheDir, flowName, steps, asserts) {
40
+ await writeAtomicJson(flowPath(cacheDir, flowName), { steps, asserts: asserts ?? [] }, sortKeys);
41
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { Config } from './config.js';
3
+ import { type ExecFn } from './detect.js';
4
+ import { BrowserDriver } from './driver/browser.js';
5
+ import { VisionClient } from './engine/loop.js';
6
+ export interface CliDeps {
7
+ cwd?: string;
8
+ env?: NodeJS.ProcessEnv;
9
+ out?: (line: string) => void;
10
+ err?: (line: string) => void;
11
+ /** Inject a vision client (tests stub this; default builds OpenRouterClient). */
12
+ createClient?: (config: Config) => VisionClient;
13
+ /** Inject a driver factory (tests may stub browser launch). */
14
+ launchDriver?: (config: Config) => Promise<BrowserDriver>;
15
+ /** Inject a subprocess runner (tests stub `a0`/`gh` detection + delegation). */
16
+ exec?: ExecFn;
17
+ }
18
+ export declare function main(argv: string[], deps?: CliDeps): Promise<number>;
19
+ interface PrFile {
20
+ filename: string;
21
+ previous_filename?: string;
22
+ patch?: string;
23
+ }
24
+ export declare function buildPatchChunks(files: PrFile[], contexts?: Record<string, string>): string[];
25
+ export {};