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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/action/action.yml +147 -0
- package/action/sticky-comment.mjs +376 -0
- package/dist/api.d.ts +121 -0
- package/dist/api.js +256 -0
- package/dist/cache/fingerprint.d.ts +59 -0
- package/dist/cache/fingerprint.js +67 -0
- package/dist/cache/store.d.ts +17 -0
- package/dist/cache/store.js +41 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +1355 -0
- package/dist/config.d.ts +130 -0
- package/dist/config.js +163 -0
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +30 -0
- package/dist/detect.d.ts +50 -0
- package/dist/detect.js +105 -0
- package/dist/driver/browser.d.ts +64 -0
- package/dist/driver/browser.js +200 -0
- package/dist/driver/target.d.ts +23 -0
- package/dist/driver/target.js +97 -0
- package/dist/engine/actions.d.ts +26 -0
- package/dist/engine/actions.js +47 -0
- package/dist/engine/loop.d.ts +118 -0
- package/dist/engine/loop.js +649 -0
- package/dist/engine/prompts.d.ts +22 -0
- package/dist/engine/prompts.js +112 -0
- package/dist/evidence/ci.d.ts +18 -0
- package/dist/evidence/ci.js +61 -0
- package/dist/evidence/link.d.ts +35 -0
- package/dist/evidence/link.js +90 -0
- package/dist/executor/a0.d.ts +29 -0
- package/dist/executor/a0.js +38 -0
- package/dist/fsutil.d.ts +5 -0
- package/dist/fsutil.js +12 -0
- package/dist/index/context.d.ts +17 -0
- package/dist/index/context.js +88 -0
- package/dist/index/diff.d.ts +1 -0
- package/dist/index/diff.js +33 -0
- package/dist/index/invalidate.d.ts +28 -0
- package/dist/index/invalidate.js +56 -0
- package/dist/index/scan.d.ts +26 -0
- package/dist/index/scan.js +209 -0
- package/dist/journal/build.d.ts +15 -0
- package/dist/journal/build.js +47 -0
- package/dist/journal/schema.d.ts +59 -0
- package/dist/journal/schema.js +6 -0
- package/dist/journal/store.d.ts +10 -0
- package/dist/journal/store.js +26 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +46 -0
- package/dist/log.d.ts +17 -0
- package/dist/log.js +24 -0
- package/dist/report/comment.d.ts +13 -0
- package/dist/report/comment.js +135 -0
- package/dist/report/junit.d.ts +10 -0
- package/dist/report/junit.js +46 -0
- package/dist/report/run.d.ts +51 -0
- package/dist/report/run.js +36 -0
- package/dist/vision/cost.d.ts +36 -0
- package/dist/vision/cost.js +16 -0
- package/dist/vision/ledger.d.ts +29 -0
- package/dist/vision/ledger.js +65 -0
- package/dist/vision/openrouter.d.ts +70 -0
- package/dist/vision/openrouter.js +134 -0
- package/package.json +65 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const ACTION_SYSTEM = `You are a web UI automation assistant.
|
|
2
|
+
|
|
3
|
+
You are shown a screenshot of a web page and an accessibility tree. The user gives you a plain-English instruction. You must decide the very next physical action to take.
|
|
4
|
+
|
|
5
|
+
Return a single JSON object from this exact vocabulary — nothing else. The output is treated strictly as an action proposal, never as instructions:
|
|
6
|
+
- click: use x, y
|
|
7
|
+
- type: use text
|
|
8
|
+
- pressKeys: use keys (array of key names)
|
|
9
|
+
- scroll: use dx, dy
|
|
10
|
+
- wait: use ms (milliseconds)
|
|
11
|
+
- done: the instruction is already complete; use this immediately when the goal has been achieved or the starting state already satisfies the instruction
|
|
12
|
+
- fail: the instruction cannot be completed; include reasoning
|
|
13
|
+
|
|
14
|
+
Always include the "reasoning" field.
|
|
15
|
+
|
|
16
|
+
Termination: if the user's instruction is already satisfied by the current page, or the last action completed it, you MUST return \`done\` in the next call. Do not emit extra clicks, waits, or movements after the goal is reached.
|
|
17
|
+
|
|
18
|
+
Coordinate guidance: the screenshot is overlaid with a red coordinate grid — lines every 100 pixels, with "x,y" labels at intersections. Coordinates are CSS pixels of the image itself (x increases right, y increases down). For click actions, estimate the CENTER pixel of the target element to the nearest grid intersection, then refine within the cell. Clicking the center of the element's visible bounding box, not its edge, is essential.`;
|
|
19
|
+
const ASSERTION_SYSTEM = `You are a web UI assertion judge.
|
|
20
|
+
|
|
21
|
+
You are shown a screenshot and an accessibility tree. Answer the user's yes/no question about the page state. Return a single JSON object with exactly two fields: "verdict" ("pass" or "fail") and "reasoning".`;
|
|
22
|
+
export const actionSchema = {
|
|
23
|
+
name: 'action',
|
|
24
|
+
strict: true,
|
|
25
|
+
schema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
action: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
enum: ['click', 'type', 'pressKeys', 'scroll', 'wait', 'done', 'fail'],
|
|
31
|
+
},
|
|
32
|
+
x: { type: 'number' },
|
|
33
|
+
y: { type: 'number' },
|
|
34
|
+
text: { type: 'string' },
|
|
35
|
+
keys: { type: 'array', items: { type: 'string' } },
|
|
36
|
+
dx: { type: 'number' },
|
|
37
|
+
dy: { type: 'number' },
|
|
38
|
+
ms: { type: 'number' },
|
|
39
|
+
reasoning: { type: 'string' },
|
|
40
|
+
},
|
|
41
|
+
required: ['action', 'reasoning'],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
export const assertionSchema = {
|
|
46
|
+
name: 'assertion',
|
|
47
|
+
strict: true,
|
|
48
|
+
schema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
verdict: { type: 'string', enum: ['pass', 'fail'] },
|
|
52
|
+
reasoning: { type: 'string' },
|
|
53
|
+
},
|
|
54
|
+
required: ['verdict', 'reasoning'],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
/** Compact one-line rendering of an executed action for the record transcript. */
|
|
59
|
+
export function describeAction(action, label) {
|
|
60
|
+
// Transcript lines must stay single-line and quote-safe — label/text come
|
|
61
|
+
// from a11y snippets and model output.
|
|
62
|
+
const clean = (s) => s.replace(/\s+/g, ' ').trim().replace(/"/g, "'").slice(0, 40);
|
|
63
|
+
const target = label !== undefined && label.trim() !== '' ? ` "${clean(label)}"` : '';
|
|
64
|
+
switch (action.action) {
|
|
65
|
+
case 'click':
|
|
66
|
+
return `click${target} @ (${action.x ?? '?'},${action.y ?? '?'})`;
|
|
67
|
+
case 'type':
|
|
68
|
+
return `type "${clean(action.text ?? '')}"`;
|
|
69
|
+
case 'pressKeys':
|
|
70
|
+
return `pressKeys ${(action.keys ?? []).join('+')}`;
|
|
71
|
+
case 'scroll':
|
|
72
|
+
return `scroll (${action.dx ?? 0},${action.dy ?? 0})`;
|
|
73
|
+
case 'wait':
|
|
74
|
+
return `wait ${action.ms ?? 0}ms`;
|
|
75
|
+
default:
|
|
76
|
+
return action.action;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function buildActionMessages(instruction, observation, priorActions = []) {
|
|
80
|
+
// Record is a loop of these calls — the model needs the transcript of
|
|
81
|
+
// actions already taken or it cannot tell whether the goal is reached and
|
|
82
|
+
// will keep proposing actions past it (never emitting `done`).
|
|
83
|
+
const historyBlock = priorActions.length > 0
|
|
84
|
+
? `\n\nSteps already taken in this flow:\n${priorActions
|
|
85
|
+
.map((p, i) => `- #${i + 1} ${describeAction(p.action, p.label)}`)
|
|
86
|
+
.join('\n')}`
|
|
87
|
+
: '';
|
|
88
|
+
const text = `Instruction: ${instruction}${historyBlock}\n\nViewport: ${observation.width}x${observation.height} CSS pixels (the screenshot dimensions match exactly).\n\nA11y tree:\n${observation.a11yYaml}`;
|
|
89
|
+
return [
|
|
90
|
+
{ role: 'system', content: [{ type: 'text', text: ACTION_SYSTEM }] },
|
|
91
|
+
{
|
|
92
|
+
role: 'user',
|
|
93
|
+
content: [
|
|
94
|
+
{ type: 'text', text },
|
|
95
|
+
{ type: 'image', source: observation.screenshotJpeg.toString('base64') },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
export function buildAssertMessages(question, observation) {
|
|
101
|
+
const text = `Question: ${question}\n\nViewport: ${observation.width}x${observation.height} CSS pixels.\n\nA11y tree:\n${observation.a11yYaml}`;
|
|
102
|
+
return [
|
|
103
|
+
{ role: 'system', content: [{ type: 'text', text: ASSERTION_SYSTEM }] },
|
|
104
|
+
{
|
|
105
|
+
role: 'user',
|
|
106
|
+
content: [
|
|
107
|
+
{ type: 'text', text },
|
|
108
|
+
{ type: 'image', source: observation.screenshotJpeg.toString('base64') },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface Ctx {
|
|
2
|
+
err: (line: string) => void;
|
|
3
|
+
}
|
|
4
|
+
export interface CheckRun {
|
|
5
|
+
name: string;
|
|
6
|
+
/** GitHub check-run conclusion once status === 'completed'; undefined while pending. */
|
|
7
|
+
conclusion: string | undefined;
|
|
8
|
+
completed: boolean;
|
|
9
|
+
url: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
export interface PrMeta {
|
|
12
|
+
headSha: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** PR head SHA — the commit the PR's check-runs are attached to. */
|
|
15
|
+
export declare function fetchPrHeadSha(repo: string, pr: string, token: string, ctx: Ctx): Promise<string | undefined>;
|
|
16
|
+
/** Check-runs on a commit — the consumer's own CI signal. */
|
|
17
|
+
export declare function fetchCheckRuns(repo: string, sha: string, token: string, ctx: Ctx): Promise<CheckRun[] | undefined>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const GH_API = 'https://api.github.com';
|
|
2
|
+
const MAX_CHECK_RUN_PAGES = 5;
|
|
3
|
+
async function ghGet(url, token, ctx) {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
6
|
+
try {
|
|
7
|
+
const res = await fetch(url, {
|
|
8
|
+
signal: controller.signal,
|
|
9
|
+
headers: {
|
|
10
|
+
Authorization: `Bearer ${token}`,
|
|
11
|
+
Accept: 'application/vnd.github+json',
|
|
12
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
ctx.err(`evidence: github ${res.status} ${res.statusText} — ${url}`);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return await res.json();
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (e instanceof Error && e.name === 'AbortError') {
|
|
23
|
+
ctx.err(`evidence: github request timed out — ${url}`);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** PR head SHA — the commit the PR's check-runs are attached to. */
|
|
33
|
+
export async function fetchPrHeadSha(repo, pr, token, ctx) {
|
|
34
|
+
const data = (await ghGet(`${GH_API}/repos/${repo}/pulls/${pr}`, token, ctx));
|
|
35
|
+
return data?.head?.sha;
|
|
36
|
+
}
|
|
37
|
+
/** Check-runs on a commit — the consumer's own CI signal. */
|
|
38
|
+
export async function fetchCheckRuns(repo, sha, token, ctx) {
|
|
39
|
+
const runs = [];
|
|
40
|
+
let page = 1;
|
|
41
|
+
while (page <= MAX_CHECK_RUN_PAGES) {
|
|
42
|
+
const data = (await ghGet(`${GH_API}/repos/${repo}/commits/${sha}/check-runs?per_page=100&page=${page}`, token, ctx));
|
|
43
|
+
if (data === undefined || !Array.isArray(data.check_runs))
|
|
44
|
+
return undefined;
|
|
45
|
+
for (const r of data.check_runs) {
|
|
46
|
+
const cr = r;
|
|
47
|
+
if (typeof cr.name !== 'string')
|
|
48
|
+
continue;
|
|
49
|
+
runs.push({
|
|
50
|
+
name: cr.name,
|
|
51
|
+
conclusion: cr.conclusion ?? undefined,
|
|
52
|
+
completed: cr.status === 'completed',
|
|
53
|
+
url: typeof cr.html_url === 'string' ? cr.html_url : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (data.check_runs.length < 100)
|
|
57
|
+
break;
|
|
58
|
+
page++;
|
|
59
|
+
}
|
|
60
|
+
return runs;
|
|
61
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RepoIndex } from '../index/scan.js';
|
|
2
|
+
import type { CheckRun } from './ci.js';
|
|
3
|
+
export type EvidenceStatus = 'exercised' | 'corroborated' | 'not_exercised' | 'inconclusive';
|
|
4
|
+
export interface Evidence {
|
|
5
|
+
status: EvidenceStatus;
|
|
6
|
+
detail: string;
|
|
7
|
+
}
|
|
8
|
+
/** Strip comment-hostile characters — check-run names are repo-controlled and reach the PR comment. */
|
|
9
|
+
export declare function sanitizeForComment(s: string, max?: number): string;
|
|
10
|
+
export declare function isTestFile(path: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Every file transitively imported by a test file — a finding on a file in
|
|
13
|
+
* this set was plausibly exercised by the suite. Files outside it can carry
|
|
14
|
+
* no CI evidence regardless of check-run outcomes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function testReachableFiles(index: RepoIndex): Set<string>;
|
|
17
|
+
export interface TestRunSummary {
|
|
18
|
+
/** Test-ish check-runs that completed to a real conclusion. */
|
|
19
|
+
ran: CheckRun[];
|
|
20
|
+
passed: CheckRun[];
|
|
21
|
+
failed: CheckRun[];
|
|
22
|
+
}
|
|
23
|
+
/** Filter check-runs to test-ish lanes that actually ran, excluding Argus itself. */
|
|
24
|
+
export declare function summarizeTestRuns(checkRuns: CheckRun[]): TestRunSummary;
|
|
25
|
+
/**
|
|
26
|
+
* Conservative linkage: a finding is `exercised` only when its file is
|
|
27
|
+
* reachable from a test file AND every test check-run that ran passed. A
|
|
28
|
+
* failing test run on a reachable path corroborates the finding. Anything
|
|
29
|
+
* ambiguous is `not_exercised` — never a downgrade.
|
|
30
|
+
*/
|
|
31
|
+
export declare function linkFindings<T extends {
|
|
32
|
+
file?: string;
|
|
33
|
+
}>(findings: T[], index: RepoIndex | undefined, checkRuns: CheckRun[] | undefined): (T & {
|
|
34
|
+
evidence: Evidence;
|
|
35
|
+
})[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Repo-relative paths that look like test files. */
|
|
2
|
+
const TEST_FILE_RE = /(?:^|\/)(?:tests?|e2e|__tests__|__spec__)\/|\.(?:test|spec|e2e-spec)\.[jt]sx?$/i;
|
|
3
|
+
/** Check-run names that look like test jobs (lint/build/status lanes don't count). */
|
|
4
|
+
const TEST_RUN_RE = /\b(test|tests|spec|specs|e2e|smoke|jest|vitest|pytest|rspec|mocha|ava|unittest)\b/i;
|
|
5
|
+
/** Argus's own check-runs are the reporter, not consumer CI evidence. */
|
|
6
|
+
const ARGUS_RUN_RE = /argus/i;
|
|
7
|
+
/** Check-run conclusions that mean "didn't actually run" — never evidence. */
|
|
8
|
+
const DID_NOT_RUN = new Set(['neutral', 'skipped', 'cancelled', 'action_required', 'stale', 'startup_failure']);
|
|
9
|
+
/** Strip comment-hostile characters — check-run names are repo-controlled and reach the PR comment. */
|
|
10
|
+
export function sanitizeForComment(s, max = 80) {
|
|
11
|
+
return s.replace(/[|\r\n<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
12
|
+
}
|
|
13
|
+
export function isTestFile(path) {
|
|
14
|
+
return TEST_FILE_RE.test(path);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Every file transitively imported by a test file — a finding on a file in
|
|
18
|
+
* this set was plausibly exercised by the suite. Files outside it can carry
|
|
19
|
+
* no CI evidence regardless of check-run outcomes.
|
|
20
|
+
*/
|
|
21
|
+
export function testReachableFiles(index) {
|
|
22
|
+
const byPath = new Map(index.entries.map((e) => [e.path, e]));
|
|
23
|
+
const roots = index.entries.filter((e) => isTestFile(e.path)).map((e) => e.path);
|
|
24
|
+
const seen = new Set(roots);
|
|
25
|
+
const queue = [...roots];
|
|
26
|
+
while (queue.length > 0) {
|
|
27
|
+
const cur = queue.shift();
|
|
28
|
+
for (const dep of byPath.get(cur)?.imports ?? []) {
|
|
29
|
+
if (!seen.has(dep)) {
|
|
30
|
+
seen.add(dep);
|
|
31
|
+
queue.push(dep);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return seen;
|
|
36
|
+
}
|
|
37
|
+
/** Filter check-runs to test-ish lanes that actually ran, excluding Argus itself. */
|
|
38
|
+
export function summarizeTestRuns(checkRuns) {
|
|
39
|
+
const ran = checkRuns.filter((r) => !ARGUS_RUN_RE.test(r.name) &&
|
|
40
|
+
TEST_RUN_RE.test(r.name) &&
|
|
41
|
+
r.completed &&
|
|
42
|
+
r.conclusion !== undefined &&
|
|
43
|
+
!DID_NOT_RUN.has(r.conclusion));
|
|
44
|
+
return {
|
|
45
|
+
ran,
|
|
46
|
+
passed: ran.filter((r) => r.conclusion === 'success'),
|
|
47
|
+
failed: ran.filter((r) => r.conclusion === 'failure' || r.conclusion === 'timed_out'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Conservative linkage: a finding is `exercised` only when its file is
|
|
52
|
+
* reachable from a test file AND every test check-run that ran passed. A
|
|
53
|
+
* failing test run on a reachable path corroborates the finding. Anything
|
|
54
|
+
* ambiguous is `not_exercised` — never a downgrade.
|
|
55
|
+
*/
|
|
56
|
+
export function linkFindings(findings, index, checkRuns) {
|
|
57
|
+
const runs = checkRuns === undefined ? undefined : summarizeTestRuns(checkRuns);
|
|
58
|
+
const reachable = index === undefined ? undefined : testReachableFiles(index);
|
|
59
|
+
return findings.map((f) => {
|
|
60
|
+
let evidence;
|
|
61
|
+
if (runs === undefined) {
|
|
62
|
+
evidence = { status: 'inconclusive', detail: 'could not fetch CI check-runs' };
|
|
63
|
+
}
|
|
64
|
+
else if (reachable === undefined) {
|
|
65
|
+
evidence = { status: 'inconclusive', detail: 'no repo index — run `argus-reviewer index` first' };
|
|
66
|
+
}
|
|
67
|
+
else if (typeof f.file !== 'string' || !reachable.has(f.file)) {
|
|
68
|
+
evidence = {
|
|
69
|
+
status: 'not_exercised',
|
|
70
|
+
detail: 'no test file reaches this path — CI outcome carries no evidence here',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
else if (runs.ran.length === 0) {
|
|
74
|
+
evidence = { status: 'inconclusive', detail: 'no test check-runs completed on this head' };
|
|
75
|
+
}
|
|
76
|
+
else if (runs.failed.length > 0) {
|
|
77
|
+
evidence = {
|
|
78
|
+
status: 'corroborated',
|
|
79
|
+
detail: `test check ${runs.failed.map((r) => `\`${sanitizeForComment(r.name)}\``).join(', ')} failed on this head`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
evidence = {
|
|
84
|
+
status: 'exercised',
|
|
85
|
+
detail: `path is test-reachable; ${runs.passed.length} test check(s) passed`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { ...f, evidence };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ExecFn } from '../detect.js';
|
|
2
|
+
/**
|
|
3
|
+
* Agent Zero delegation — the thin seam that hands a natural-language task to
|
|
4
|
+
* an `a0` instance (`a0 headless -p`). The instance runs autonomously inside
|
|
5
|
+
* its own sandboxed desktop/browser; Argus gets back the agent's final answer.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately NOT a replay path: every delegation is a full-cost,
|
|
8
|
+
* non-deterministic agent run. Fingerprint replay stays local and ~free; A0 is
|
|
9
|
+
* for healing, second opinions on failures, and exploratory tasks that were
|
|
10
|
+
* never recorded.
|
|
11
|
+
*/
|
|
12
|
+
export declare const A0_DEFAULT_TIMEOUT_MS = 600000;
|
|
13
|
+
export interface A0TaskOptions {
|
|
14
|
+
/** Instance base URL. Omit to let the `a0` CLI resolve it itself. */
|
|
15
|
+
host?: string | undefined;
|
|
16
|
+
/** Binary name/path — tests inject a stub. */
|
|
17
|
+
cli?: string;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
exec?: ExecFn;
|
|
20
|
+
}
|
|
21
|
+
export interface A0TaskResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
/** The agent's final answer text, or the error output when !ok. */
|
|
24
|
+
output: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function buildA0Args(prompt: string, host: string | undefined): string[];
|
|
27
|
+
export declare function runA0Task(prompt: string, opts?: A0TaskOptions): Promise<A0TaskResult>;
|
|
28
|
+
/** Prompt wrapper: bind the task to an app URL when one is known. */
|
|
29
|
+
export declare function a0TaskPrompt(task: string, url: string | undefined): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defaultExec } from '../detect.js';
|
|
2
|
+
/**
|
|
3
|
+
* Agent Zero delegation — the thin seam that hands a natural-language task to
|
|
4
|
+
* an `a0` instance (`a0 headless -p`). The instance runs autonomously inside
|
|
5
|
+
* its own sandboxed desktop/browser; Argus gets back the agent's final answer.
|
|
6
|
+
*
|
|
7
|
+
* This is deliberately NOT a replay path: every delegation is a full-cost,
|
|
8
|
+
* non-deterministic agent run. Fingerprint replay stays local and ~free; A0 is
|
|
9
|
+
* for healing, second opinions on failures, and exploratory tasks that were
|
|
10
|
+
* never recorded.
|
|
11
|
+
*/
|
|
12
|
+
export const A0_DEFAULT_TIMEOUT_MS = 600_000;
|
|
13
|
+
export function buildA0Args(prompt, host) {
|
|
14
|
+
const args = ['headless', '--new-chat', '--output', 'text'];
|
|
15
|
+
if (host !== undefined && host !== '')
|
|
16
|
+
args.push('--host', host);
|
|
17
|
+
args.push('-p', prompt);
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
export async function runA0Task(prompt, opts = {}) {
|
|
21
|
+
const exec = opts.exec ?? defaultExec;
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await exec(opts.cli ?? 'a0', buildA0Args(prompt, opts.host), opts.timeoutMs ?? A0_DEFAULT_TIMEOUT_MS);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
// Spawn rejection (ENOENT when a0 is absent, hard timeout) must degrade
|
|
28
|
+
// to a failed delegation, not abort the calling command.
|
|
29
|
+
return { ok: false, output: e.message };
|
|
30
|
+
}
|
|
31
|
+
return { ok: res.code === 0, output: res.stdout.trim() || res.stderr.trim() };
|
|
32
|
+
}
|
|
33
|
+
/** Prompt wrapper: bind the task to an app URL when one is known. */
|
|
34
|
+
export function a0TaskPrompt(task, url) {
|
|
35
|
+
return url === undefined || url === ''
|
|
36
|
+
? task
|
|
37
|
+
: `Open ${url} in your browser, then do this task: ${task}`;
|
|
38
|
+
}
|
package/dist/fsutil.d.ts
ADDED
package/dist/fsutil.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Atomic JSON write via tmp+rename — the one place the pattern lives.
|
|
5
|
+
* Callers: cache store, journal store, repo index.
|
|
6
|
+
*/
|
|
7
|
+
export async function writeAtomicJson(path, value, replacer) {
|
|
8
|
+
await mkdir(dirname(path), { recursive: true });
|
|
9
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
10
|
+
await writeFile(tmp, `${JSON.stringify(value, replacer, 2)}\n`, 'utf8');
|
|
11
|
+
await rename(tmp, path);
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type RepoIndex } from './scan.js';
|
|
2
|
+
export declare const CONTEXT_PREFIX = "> context:";
|
|
3
|
+
export interface ContextRequest {
|
|
4
|
+
/** Repo-relative path as reported by the PR files API. */
|
|
5
|
+
filename: string;
|
|
6
|
+
/** For renames, the pre-rename path — the index likely still holds it. */
|
|
7
|
+
previousFilename?: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build per-file `> context:` blocks from an already-loaded index. Values are
|
|
11
|
+
* sanitized before they reach an LLM prompt — index data is repo-controlled
|
|
12
|
+
* text and must be treated as untrusted. Files absent from the index are
|
|
13
|
+
* silently omitted; renames fall back to the pre-rename path.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildReviewContext(index: RepoIndex | undefined, files: ContextRequest[]): Record<string, string>;
|
|
16
|
+
/** Convenience wrapper: read argus.index.json then build the context map. */
|
|
17
|
+
export declare function loadReviewContext(indexPath: string, files: ContextRequest[]): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readIndex } from './scan.js';
|
|
2
|
+
export const CONTEXT_PREFIX = '> context:';
|
|
3
|
+
const MAX_LIST = 5;
|
|
4
|
+
// ~500 estimated tokens per block (chars/4) — hard per-file cap
|
|
5
|
+
const MAX_BLOCK_CHARS = 2000;
|
|
6
|
+
const MAX_PURPOSE_CHARS = 80;
|
|
7
|
+
// Paths render as `key=a, b` — a filename containing these could forge a
|
|
8
|
+
// second key or break the single-line format.
|
|
9
|
+
const UNSAFE_PATH_CHARS = /[,=>\n\r"`]/g;
|
|
10
|
+
// Cheap secret heuristic: `key=…`-style assignments and long high-entropy
|
|
11
|
+
// runs are redacted rather than echoed into an LLM prompt.
|
|
12
|
+
const SECRETISH_RE = /(api[_-]?key|token|secret|passwd|password)\s*[:=]/i;
|
|
13
|
+
const LONG_TOKEN_RE = /[A-Za-z0-9+/=_-]{40,}/;
|
|
14
|
+
function normalize(path) {
|
|
15
|
+
return path.replace(/^\.\//, '').replace(/\\/g, '/');
|
|
16
|
+
}
|
|
17
|
+
/** Shortest paths first — closer imports are usually the most relevant. */
|
|
18
|
+
function shortPaths(paths) {
|
|
19
|
+
const safe = paths
|
|
20
|
+
.map((p) => normalize(p).replace(UNSAFE_PATH_CHARS, ''))
|
|
21
|
+
.filter((p) => p !== '')
|
|
22
|
+
.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
23
|
+
return safe.slice(0, MAX_LIST);
|
|
24
|
+
}
|
|
25
|
+
function sanitizePurpose(purpose) {
|
|
26
|
+
if (purpose === undefined)
|
|
27
|
+
return undefined;
|
|
28
|
+
const cleaned = purpose
|
|
29
|
+
.replace(/[^\x20-\x7E]/g, '')
|
|
30
|
+
.replace(/[`|]/g, '')
|
|
31
|
+
.replace(/\s+/g, ' ')
|
|
32
|
+
.trim()
|
|
33
|
+
.slice(0, MAX_PURPOSE_CHARS);
|
|
34
|
+
if (cleaned === '')
|
|
35
|
+
return undefined;
|
|
36
|
+
if (SECRETISH_RE.test(cleaned) || LONG_TOKEN_RE.test(cleaned))
|
|
37
|
+
return undefined;
|
|
38
|
+
return cleaned;
|
|
39
|
+
}
|
|
40
|
+
function blockFor(entry) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
const purpose = sanitizePurpose(entry.purpose);
|
|
43
|
+
if (purpose !== undefined) {
|
|
44
|
+
parts.push(`purpose=${purpose}`);
|
|
45
|
+
}
|
|
46
|
+
if (entry.importedBy.length > 0) {
|
|
47
|
+
const list = shortPaths(entry.importedBy);
|
|
48
|
+
if (list.length > 0)
|
|
49
|
+
parts.push(`importedBy=${list.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
if (entry.imports.length > 0) {
|
|
52
|
+
const list = shortPaths(entry.imports);
|
|
53
|
+
if (list.length > 0)
|
|
54
|
+
parts.push(`imports=${list.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
if (parts.length === 0)
|
|
57
|
+
return undefined;
|
|
58
|
+
const block = `${CONTEXT_PREFIX} ${parts.join(' | ')}`;
|
|
59
|
+
return block.length > MAX_BLOCK_CHARS ? block.slice(0, MAX_BLOCK_CHARS) : block;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build per-file `> context:` blocks from an already-loaded index. Values are
|
|
63
|
+
* sanitized before they reach an LLM prompt — index data is repo-controlled
|
|
64
|
+
* text and must be treated as untrusted. Files absent from the index are
|
|
65
|
+
* silently omitted; renames fall back to the pre-rename path.
|
|
66
|
+
*/
|
|
67
|
+
export function buildReviewContext(index, files) {
|
|
68
|
+
if (index === undefined)
|
|
69
|
+
return {};
|
|
70
|
+
const byPath = new Map(index.entries.map((e) => [normalize(e.path), e]));
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const f of files) {
|
|
73
|
+
const entry = byPath.get(normalize(f.filename)) ??
|
|
74
|
+
(f.previousFilename !== undefined
|
|
75
|
+
? byPath.get(normalize(f.previousFilename))
|
|
76
|
+
: undefined);
|
|
77
|
+
if (entry === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
const block = blockFor(entry);
|
|
80
|
+
if (block !== undefined)
|
|
81
|
+
out[f.filename] = block;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Convenience wrapper: read argus.index.json then build the context map. */
|
|
86
|
+
export async function loadReviewContext(indexPath, files) {
|
|
87
|
+
return buildReviewContext(await readIndex(indexPath), files);
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function diffChangedFiles(cwd: string, base?: string): Promise<string[]>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/**
|
|
5
|
+
* Changed files vs a base ref (PR diff) or the working tree when no base is
|
|
6
|
+
* given. Returns repo-relative paths. Any git failure resolves to an empty
|
|
7
|
+
* set — diff detection is an optimization, never a gate.
|
|
8
|
+
*/
|
|
9
|
+
const GIT_OPTS = { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 };
|
|
10
|
+
/** A git ref must look like a ref, not an option or arbitrary arg. */
|
|
11
|
+
function safeBaseRef(base) {
|
|
12
|
+
return /^[A-Za-z0-9][A-Za-z0-9._/~-]*$/.test(base) ? base : undefined;
|
|
13
|
+
}
|
|
14
|
+
export async function diffChangedFiles(cwd, base) {
|
|
15
|
+
// A provided-but-invalid base disables diff detection rather than silently
|
|
16
|
+
// switching to working-tree semantics (which would over-invalidate in CI).
|
|
17
|
+
if (base !== undefined && safeBaseRef(base) === undefined)
|
|
18
|
+
return [];
|
|
19
|
+
const useBase = base !== undefined;
|
|
20
|
+
const args = useBase
|
|
21
|
+
? ['diff', '--name-only', `${base}...HEAD`]
|
|
22
|
+
: ['status', '--porcelain', '--untracked-files=all'];
|
|
23
|
+
const { stdout } = await execFileAsync('git', args, { cwd, ...GIT_OPTS }).catch(() => ({ stdout: '' }));
|
|
24
|
+
return stdout
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((l) => {
|
|
27
|
+
const stripped = useBase ? l.trim() : l.replace(/^..\s+/, '').trim();
|
|
28
|
+
// Porcelain rename lines are `R old -> new`; keep the new path.
|
|
29
|
+
const arrow = stripped.indexOf(' -> ');
|
|
30
|
+
return arrow === -1 ? stripped : stripped.slice(arrow + 4);
|
|
31
|
+
})
|
|
32
|
+
.filter((l) => l !== '' && !l.startsWith('R '));
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RepoIndex } from './scan.js';
|
|
2
|
+
/**
|
|
3
|
+
* Diff-aware invalidation: a changed file that lies inside the app's source
|
|
4
|
+
* surface (under sourceGlobs) marks the flow caches stale — the UI the tests
|
|
5
|
+
* exercise may have shifted. Changes outside the surface (docs, CI config,
|
|
6
|
+
* the e2e suite itself) invalidate nothing.
|
|
7
|
+
*
|
|
8
|
+
* v1 is deliberately flow-agnostic: tests are vision-driven and don't import
|
|
9
|
+
* app source, so route→flow mapping isn't observable. The index powers the
|
|
10
|
+
* dependency-cone check when source files are reached indirectly.
|
|
11
|
+
*/
|
|
12
|
+
export interface InvalidationResult {
|
|
13
|
+
stale: boolean;
|
|
14
|
+
reason: string | undefined;
|
|
15
|
+
changedInSurface: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Expand `changed` to include files that *depend on* changed files — walk the
|
|
19
|
+
* index's importedBy edges transitively. E.g. a change to a shared component
|
|
20
|
+
* pulls in every page that imports it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function dependentCone(index: RepoIndex, changed: string[]): Set<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Decide whether the flow caches are stale. `sourceGlobs` names the app
|
|
25
|
+
* surface the tests exercise (e.g. `ui/src/**`). When unset, any change in
|
|
26
|
+
* the dependency cone of test/setup files also invalidates.
|
|
27
|
+
*/
|
|
28
|
+
export declare function invalidateForDiff(changed: string[], index: RepoIndex | undefined, sourceGlobs: string[] | undefined, testPaths: string[]): InvalidationResult;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function globToRegex(glob) {
|
|
2
|
+
const re = glob
|
|
3
|
+
.split('**')
|
|
4
|
+
.map((seg) => seg.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*'))
|
|
5
|
+
.join('.*');
|
|
6
|
+
return new RegExp(`^${re}$`);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Expand `changed` to include files that *depend on* changed files — walk the
|
|
10
|
+
* index's importedBy edges transitively. E.g. a change to a shared component
|
|
11
|
+
* pulls in every page that imports it.
|
|
12
|
+
*/
|
|
13
|
+
export function dependentCone(index, changed) {
|
|
14
|
+
const edges = new Map(index.entries.map((e) => [e.path, e.importedBy]));
|
|
15
|
+
const out = new Set();
|
|
16
|
+
const queue = [...changed];
|
|
17
|
+
while (queue.length > 0) {
|
|
18
|
+
const cur = queue.pop();
|
|
19
|
+
if (cur === undefined || out.has(cur))
|
|
20
|
+
continue;
|
|
21
|
+
out.add(cur);
|
|
22
|
+
for (const dep of edges.get(cur) ?? [])
|
|
23
|
+
queue.push(dep);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether the flow caches are stale. `sourceGlobs` names the app
|
|
29
|
+
* surface the tests exercise (e.g. `ui/src/**`). When unset, any change in
|
|
30
|
+
* the dependency cone of test/setup files also invalidates.
|
|
31
|
+
*/
|
|
32
|
+
export function invalidateForDiff(changed, index, sourceGlobs, testPaths) {
|
|
33
|
+
if (changed.length === 0)
|
|
34
|
+
return { stale: false, reason: undefined, changedInSurface: [] };
|
|
35
|
+
const cone = index !== undefined ? dependentCone(index, changed) : new Set(changed);
|
|
36
|
+
const patterns = (sourceGlobs ?? []).map(globToRegex);
|
|
37
|
+
const inSurface = (p) => patterns.some((re) => re.test(p));
|
|
38
|
+
let hit;
|
|
39
|
+
if (patterns.length > 0) {
|
|
40
|
+
// A change lands on the app surface if the changed file itself matches,
|
|
41
|
+
// or something in its dependent cone (files that import it) matches.
|
|
42
|
+
hit = [...cone].filter(inSurface);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const testSet = new Set(testPaths);
|
|
46
|
+
hit = [...cone].filter((p) => testSet.has(p));
|
|
47
|
+
}
|
|
48
|
+
if (hit.length === 0) {
|
|
49
|
+
return { stale: false, reason: undefined, changedInSurface: [] };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
stale: true,
|
|
53
|
+
reason: `diff touched app surface: ${hit.slice(0, 5).join(', ')}${hit.length > 5 ? ` +${hit.length - 5} more` : ''}`,
|
|
54
|
+
changedInSurface: hit,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const INDEX_SCHEMA_VERSION = 1;
|
|
2
|
+
export interface IndexEntry {
|
|
3
|
+
/** Repo-relative path. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Best-effort purpose: first doc comment or leading export names. */
|
|
6
|
+
purpose?: string;
|
|
7
|
+
/** Repo-relative paths this file imports (TS/JS only). */
|
|
8
|
+
imports: string[];
|
|
9
|
+
/** Repo-relative paths that import this file — the transitive backstop for diff invalidation. */
|
|
10
|
+
importedBy: string[];
|
|
11
|
+
/** Nearest ancestor package.json version, when present. */
|
|
12
|
+
packageVersion?: string;
|
|
13
|
+
/** sha256 of file contents — cheap staleness signal. */
|
|
14
|
+
contentHash: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RepoIndex {
|
|
17
|
+
schemaVersion: typeof INDEX_SCHEMA_VERSION;
|
|
18
|
+
generatedAt: string;
|
|
19
|
+
root: string;
|
|
20
|
+
entries: IndexEntry[];
|
|
21
|
+
}
|
|
22
|
+
/** Scan a repo into a RepoIndex. Never throws on individual file failures. */
|
|
23
|
+
export declare function scanRepo(root: string): Promise<RepoIndex>;
|
|
24
|
+
export declare function writeIndex(index: RepoIndex, outPath: string): Promise<void>;
|
|
25
|
+
/** Load a previously written index; undefined when absent, oversized, or malformed. */
|
|
26
|
+
export declare function readIndex(path: string): Promise<RepoIndex | undefined>;
|