argus-reviewer-e2e 0.3.1 → 0.4.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 (89) hide show
  1. package/README.md +84 -71
  2. package/action/action.yml +129 -11
  3. package/action/approval-review.mjs +13 -3
  4. package/action/bootstrap.mjs +2 -0
  5. package/action/emit-review.mjs +16 -0
  6. package/action/runtime.mjs +20 -0
  7. package/action/sticky-comment.cjs +1260 -479
  8. package/dist/cli.d.ts +103 -7
  9. package/dist/cli.js +1202 -186
  10. package/dist/config.d.ts +96 -11
  11. package/dist/config.js +102 -4
  12. package/dist/detect.d.ts +29 -2
  13. package/dist/detect.js +98 -7
  14. package/dist/driver/browser.d.ts +32 -0
  15. package/dist/driver/browser.js +56 -1
  16. package/dist/driver/target.d.ts +4 -1
  17. package/dist/driver/target.js +27 -6
  18. package/dist/engine/actions.d.ts +5 -0
  19. package/dist/engine/actions.js +8 -0
  20. package/dist/engine/explore.d.ts +78 -0
  21. package/dist/engine/explore.js +373 -0
  22. package/dist/engine/loop.d.ts +2 -2
  23. package/dist/engine/loop.js +8 -8
  24. package/dist/engine/prompts.d.ts +28 -1
  25. package/dist/engine/prompts.js +88 -0
  26. package/dist/evidence/ci.d.ts +13 -1
  27. package/dist/evidence/ci.js +38 -3
  28. package/dist/evidence/gate.d.ts +8 -0
  29. package/dist/evidence/gate.js +1 -1
  30. package/dist/evidence/link.js +1 -1
  31. package/dist/executor/a0.d.ts +114 -1
  32. package/dist/executor/a0.js +216 -4
  33. package/dist/fsutil.d.ts +3 -2
  34. package/dist/fsutil.js +7 -4
  35. package/dist/journal/schema.d.ts +1 -1
  36. package/dist/log.d.ts +2 -1
  37. package/dist/log.js +10 -2
  38. package/dist/mention.d.ts +45 -0
  39. package/dist/mention.js +107 -0
  40. package/dist/pipeline/app.d.ts +126 -0
  41. package/dist/pipeline/app.js +250 -0
  42. package/dist/pipeline/budget.d.ts +1 -0
  43. package/dist/pipeline/budget.js +1 -1
  44. package/dist/pipeline/verify.d.ts +20 -3
  45. package/dist/pipeline/verify.js +189 -35
  46. package/dist/probe/persist.d.ts +68 -0
  47. package/dist/probe/persist.js +184 -0
  48. package/dist/probe/queue.d.ts +12 -0
  49. package/dist/probe/queue.js +10 -2
  50. package/dist/report/brand-assets.generated.d.ts +9 -0
  51. package/dist/report/brand-assets.generated.js +8 -0
  52. package/dist/report/comment.d.ts +99 -6
  53. package/dist/report/comment.js +292 -103
  54. package/dist/report/html.d.ts +50 -0
  55. package/dist/report/html.js +879 -0
  56. package/dist/report/manifest.d.ts +29 -0
  57. package/dist/report/manifest.js +37 -0
  58. package/dist/report/run.d.ts +54 -1
  59. package/dist/report/run.js +34 -9
  60. package/dist/report/viewmodel.d.ts +91 -0
  61. package/dist/report/viewmodel.js +241 -0
  62. package/dist/review/adjudicate.d.ts +6 -6
  63. package/dist/review/adjudicate.js +2 -2
  64. package/dist/review/inline.d.ts +44 -0
  65. package/dist/review/inline.js +95 -0
  66. package/dist/review/packs.d.ts +21 -0
  67. package/dist/review/packs.js +47 -0
  68. package/dist/review/scope.d.ts +16 -0
  69. package/dist/review/scope.js +74 -0
  70. package/dist/review/secrets.d.ts +10 -10
  71. package/dist/review/secrets.js +7 -7
  72. package/dist/review/testfiles.d.ts +18 -0
  73. package/dist/review/testfiles.js +26 -0
  74. package/dist/review/triage.d.ts +1 -1
  75. package/dist/review/triage.js +10 -10
  76. package/dist/review/validate.d.ts +41 -0
  77. package/dist/review/validate.js +76 -0
  78. package/dist/ui/errors.d.ts +54 -0
  79. package/dist/ui/errors.js +236 -0
  80. package/dist/ui/style.d.ts +34 -0
  81. package/dist/ui/style.js +48 -0
  82. package/dist/ui/summary.d.ts +38 -0
  83. package/dist/ui/summary.js +101 -0
  84. package/dist/vision/cost.d.ts +1 -1
  85. package/dist/vision/decisions.d.ts +9 -3
  86. package/dist/vision/decisions.js +31 -21
  87. package/dist/vision/openrouter.d.ts +4 -0
  88. package/dist/vision/openrouter.js +30 -4
  89. package/package.json +11 -2
@@ -1,4 +1,7 @@
1
- import { defaultExec } from '../detect.js';
1
+ import { buildA0ChildEnv, defaultExec, defaultProbe, resolveA0Host, } from '../detect.js';
2
+ // The allowlist lives in detect.ts beside the exec seam — re-exported here
3
+ // so `executor/a0` stays the single import site for delegation internals.
4
+ export { buildA0ChildEnv };
2
5
  /**
3
6
  * Agent Zero delegation — the thin seam that hands a natural-language task to
4
7
  * an `a0` instance (`a0 headless -p`). The instance runs autonomously inside
@@ -8,8 +11,18 @@ import { defaultExec } from '../detect.js';
8
11
  * non-deterministic agent run. Fingerprint replay stays local and ~free; A0 is
9
12
  * for healing, second opinions on failures, and exploratory tasks that were
10
13
  * never recorded.
14
+ *
15
+ * The `verify --a0` lane caps completed delegations at `inconclusive`: the
16
+ * host round-trip is proven (#53), but the agent's answer is self-reported
17
+ * evidence, never a `passed` verdict.
11
18
  */
12
19
  export const A0_DEFAULT_TIMEOUT_MS = 600_000;
20
+ /** Delegations a `verify --a0` lane may run — config.a0.maxTasks overrides. */
21
+ export const A0_LANE_MAX_TASKS = 1;
22
+ /** Lane detail file the a0 runner writes and `runVerify` reads back. */
23
+ export const A0_LANE_REPORT = 'a0-lane.json';
24
+ /** Reported posture: the round-trip is verified (#53); the answer is not. */
25
+ export const A0_LIVE_LABEL = 'self-reported';
13
26
  export function buildA0Args(prompt, host) {
14
27
  const args = ['headless', '--new-chat', '--output', 'text'];
15
28
  if (host !== undefined && host !== '')
@@ -19,16 +32,28 @@ export function buildA0Args(prompt, host) {
19
32
  }
20
33
  export async function runA0Task(prompt, opts = {}) {
21
34
  const exec = opts.exec ?? defaultExec;
35
+ // The allowlisted child env is the default, not the opt-in — every
36
+ // delegation path (lane, heal, delegate) gets the R12 sanitization
37
+ // unless a caller deliberately passes a different env.
38
+ const baseEnv = opts.env ?? buildA0ChildEnv(process.env);
22
39
  let res;
23
40
  try {
24
- res = await exec(opts.cli ?? 'a0', buildA0Args(prompt, opts.host), opts.timeoutMs ?? A0_DEFAULT_TIMEOUT_MS);
41
+ res = await exec(opts.cli ?? 'a0', buildA0Args(prompt, opts.host), opts.timeoutMs ?? A0_DEFAULT_TIMEOUT_MS, undefined, { baseEnv });
25
42
  }
26
43
  catch (e) {
27
44
  // Spawn rejection (ENOENT when a0 is absent, hard timeout) must degrade
28
45
  // to a failed delegation, not abort the calling command.
29
- return { ok: false, output: e.message };
46
+ return { ok: false, output: e.message, spawnError: true };
30
47
  }
31
- return { ok: res.code === 0, output: res.stdout.trim() || res.stderr.trim() };
48
+ return {
49
+ ok: res.code === 0,
50
+ output: res.stdout.trim() || res.stderr.trim(),
51
+ timedOut: res.timedOut,
52
+ // ExecResult.spawnError is authoritative when the executor sets it —
53
+ // a completed run that merely prints "not found" to stderr must not
54
+ // misclassify. The sniff remains for injected execs without the field.
55
+ spawnError: res.spawnError ?? /ENOENT|not found|no such file/i.test(res.stderr),
56
+ };
32
57
  }
33
58
  /** Prompt wrapper: bind the task to an app URL when one is known. */
34
59
  export function a0TaskPrompt(task, url) {
@@ -36,3 +61,190 @@ export function a0TaskPrompt(task, url) {
36
61
  ? task
37
62
  : `Open ${url} in your browser, then do this task: ${task}`;
38
63
  }
64
+ /** Render the typed payload into the delegated prompt — sanitized surface. */
65
+ export function buildA0LanePrompt(payload) {
66
+ const lines = [
67
+ 'You are an escalation agent for the Argus PR reviewer.',
68
+ `Task: ${payload.task}`,
69
+ ];
70
+ if (payload.targetUrl !== undefined && payload.targetUrl !== '') {
71
+ lines.push(`Target: open ${payload.targetUrl} and work against that application.`);
72
+ }
73
+ if (payload.intendedHeadSha !== undefined && payload.intendedHeadSha !== '') {
74
+ lines.push(`The evidence must describe the PR head ${payload.intendedHeadSha}.`);
75
+ }
76
+ if (payload.failureSummary !== undefined && payload.failureSummary !== '') {
77
+ lines.push(`Local failure being escalated: ${payload.failureSummary}`);
78
+ }
79
+ if (payload.evidenceRefs !== undefined && payload.evidenceRefs.length > 0) {
80
+ lines.push(`Local evidence you may consult: ${payload.evidenceRefs.join(', ')}`);
81
+ }
82
+ lines.push('Report concisely what you observed and whether the task goal is met.', 'Do not attempt to access provider keys, tokens, or repository secrets.');
83
+ return lines.join('\n');
84
+ }
85
+ /**
86
+ * Whether `url` names a loopback target — the remote-a0/loopback-target
87
+ * refusal depends on this being complete: the whole 127.0.0.0/8 range,
88
+ * wildcard/zero hosts, `*.localhost`, and IPv4-mapped forms, not just the
89
+ * canonical `127.0.0.1`/`localhost` literals. `file:` URLs count too — a
90
+ * remote host's filesystem is not this machine's. DNS names that merely
91
+ * resolve to loopback are not caught (no lookup by design — fail-open on
92
+ * hostnames is deliberate here).
93
+ */
94
+ export function isLoopback(url) {
95
+ try {
96
+ if (new URL(url).protocol === 'file:')
97
+ return true;
98
+ const host = new URL(url).hostname.replace(/\.$/, '').toLowerCase();
99
+ if (host === 'localhost' || host.endsWith('.localhost'))
100
+ return true;
101
+ // Node keeps IPv6 brackets in .hostname and normalizes mapped forms:
102
+ // [::1] stays, [::ffff:127.0.0.1] arrives as [::ffff:7f00:1], and
103
+ // short/integer IPv4 (127.1, 2130706433) normalizes to dotted-quad.
104
+ if (host === '[::1]' || host === '[::]')
105
+ return true;
106
+ const mapped = host.match(/^\[::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})\]$/i);
107
+ if (mapped !== null) {
108
+ const hi = parseInt(mapped[1] ?? 'x', 16);
109
+ const lo = parseInt(mapped[2] ?? 'x', 16);
110
+ const first = (hi >> 8) & 0xff;
111
+ if (first === 127 || (hi === 0 && lo === 0))
112
+ return true;
113
+ }
114
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
115
+ if (v4 !== null) {
116
+ const octets = v4.slice(1).map((g) => Number(g ?? ''));
117
+ if (octets.every((n) => Number.isInteger(n) && n <= 255) && (octets[0] === 127 || octets[0] === 0)) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ /**
128
+ * `verify --a0` lane: explicit selection only, every preflight outcome
129
+ * recorded, zero spend before the host is proven reachable, and a hard
130
+ * `inconclusive` ceiling — a completed delegation is evidence, and the
131
+ * agent's report of what it saw is self-reported, not a verdict.
132
+ */
133
+ export async function runA0Lane(input) {
134
+ const started = Date.now();
135
+ const deps = input.deps ?? {};
136
+ const note = deps.note ?? (() => undefined);
137
+ const done = (status, reason, extra) => ({
138
+ lane: 'a0',
139
+ status,
140
+ reason,
141
+ summary: status === 'inconclusive' ? `delegation returned — ${A0_LIVE_LABEL}` : reason,
142
+ host: undefined,
143
+ hostSource: undefined,
144
+ targetReachable: undefined,
145
+ task: input.task,
146
+ output: undefined,
147
+ tasks: 0,
148
+ durationMs: Date.now() - started,
149
+ metered: false,
150
+ ...extra,
151
+ });
152
+ // Executable lanes are gated on the same trust resolution as config
153
+ // loading (SECURITY.md): an untrusted checkout never resolves a host or
154
+ // spawns the a0 CLI — blocked before any preflight.
155
+ if (!input.trusted) {
156
+ return done('blocked', 'verify --a0 is an executable lane — requires a trusted checkout');
157
+ }
158
+ if (input.task === undefined || input.task.trim() === '') {
159
+ return done('blocked', 'no delegated task configured — set a0 task via config app.task or the app lane');
160
+ }
161
+ // Preflight 1: host resolution — config wins, then the CLI's own chain
162
+ // (env, ~/.agent-zero/.env, localhost probe). Missing both ends = nothing
163
+ // to reach. A configured a0.url skips the probe chain entirely.
164
+ const resolveHost = deps.resolveHost ??
165
+ (async () => {
166
+ const r = await resolveA0Host(input.env, {
167
+ ...(deps.probe !== undefined ? { probe: deps.probe } : {}),
168
+ });
169
+ return { host: r.host, source: r.source };
170
+ });
171
+ const cliVersion = deps.cliVersion ??
172
+ (async () => {
173
+ // The presence probe spawns the a0 binary too — it gets the same
174
+ // allowlisted env as the delegation or the whole contract is moot.
175
+ const res = await (deps.exec ?? defaultExec)(deps.cli ?? 'a0', ['--version'], 5_000, undefined, { baseEnv: buildA0ChildEnv(input.env) });
176
+ return res.code === 0 ? res.stdout.trim() : undefined;
177
+ });
178
+ const configured = input.a0?.url !== undefined && input.a0.url !== '';
179
+ const [resolved, version] = await Promise.all([
180
+ configured ? Promise.resolve({ host: undefined, source: undefined }) : resolveHost(),
181
+ cliVersion(),
182
+ ]);
183
+ const host = input.a0?.url ?? resolved.host;
184
+ const hostSource = input.a0?.url !== undefined ? 'config' : resolved.source;
185
+ if (version === undefined && host === undefined) {
186
+ return done('unavailable', 'no Agent Zero found — install the a0 CLI or set a0.url / AGENT_ZERO_HOST');
187
+ }
188
+ if (host === undefined) {
189
+ // CLI present, host unresolved — the CLI may still self-resolve, but the
190
+ // lane must prove a host before spending: record the gap honestly.
191
+ return done('unavailable', 'a0 CLI is installed but no host resolved — set AGENT_ZERO_HOST or a0.url');
192
+ }
193
+ // Preflight 2: the host must answer as Agent Zero — a URL that serves
194
+ // something else is worse than no host.
195
+ const probe = deps.probe ?? defaultProbe;
196
+ if (!(await probe(host, 5_000))) {
197
+ return done('unavailable', `a0 host did not answer as Agent Zero: ${host}`, {
198
+ host,
199
+ hostSource,
200
+ });
201
+ }
202
+ // Preflight 3: can the host reach the target? A remote a0 cannot drive a
203
+ // loopback app on this machine — that's a scope refusal, not a failure.
204
+ if (input.targetUrl !== undefined &&
205
+ isLoopback(input.targetUrl) &&
206
+ !isLoopback(host)) {
207
+ return done('blocked', `a0 host ${host} is remote but the target ${input.targetUrl} is loopback — the host cannot reach it`, { host, hostSource, targetReachable: 'refused' });
208
+ }
209
+ const maxTasks = input.a0?.maxTasks ?? A0_LANE_MAX_TASKS;
210
+ const timeoutMs = input.a0?.timeoutMs ?? A0_DEFAULT_TIMEOUT_MS;
211
+ note(`a0 lane: delegating 1 task to ${host} (max ${maxTasks}, ` +
212
+ `${Math.round(timeoutMs / 1000)}s wall-clock, usage unmetered, ${A0_LIVE_LABEL})`);
213
+ const payload = buildA0LanePrompt({
214
+ task: input.task,
215
+ targetUrl: input.targetUrl,
216
+ intendedHeadSha: input.intendedHeadSha,
217
+ failureSummary: input.failureSummary ?? undefined,
218
+ evidenceRefs: undefined,
219
+ });
220
+ const res = await runA0Task(payload, {
221
+ host,
222
+ timeoutMs,
223
+ env: buildA0ChildEnv(input.env),
224
+ ...(deps.exec !== undefined ? { exec: deps.exec } : {}),
225
+ ...(deps.cli !== undefined ? { cli: deps.cli } : {}),
226
+ });
227
+ if (res.spawnError === true) {
228
+ return done('unavailable', `a0 delegation could not start: ${res.output}`, {
229
+ host,
230
+ hostSource,
231
+ });
232
+ }
233
+ if (res.timedOut === true) {
234
+ return done('inconclusive', `a0 delegation timed out after ${timeoutMs}ms`, {
235
+ host,
236
+ hostSource,
237
+ output: res.output !== '' ? res.output : undefined,
238
+ tasks: 1,
239
+ });
240
+ }
241
+ // Every completed delegation — success or reported failure — is evidence
242
+ // whose truth rests on the agent's own report: inconclusive, never passed.
243
+ return done('inconclusive', res.ok ? undefined : `a0 delegation reported failure: ${res.output}`, {
244
+ host,
245
+ hostSource,
246
+ targetReachable: input.targetUrl !== undefined ? 'assumed' : 'unchecked',
247
+ output: res.output !== '' ? res.output : undefined,
248
+ tasks: 1,
249
+ });
250
+ }
package/dist/fsutil.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Atomic JSON write via tmp+rename — the one place the pattern lives.
3
- * Callers: cache store, journal store, repo index.
2
+ * Atomic text write via tmp+rename — the one place the pattern lives.
3
+ * Callers: cache store, journal store, repo index, evidence report.
4
4
  */
5
+ export declare function writeAtomicText(path: string, text: string): Promise<void>;
5
6
  export declare function writeAtomicJson(path: string, value: unknown, replacer?: (key: string, v: unknown) => unknown): Promise<void>;
package/dist/fsutil.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { mkdir, rename, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
  /**
4
- * Atomic JSON write via tmp+rename — the one place the pattern lives.
5
- * Callers: cache store, journal store, repo index.
4
+ * Atomic text write via tmp+rename — the one place the pattern lives.
5
+ * Callers: cache store, journal store, repo index, evidence report.
6
6
  */
7
- export async function writeAtomicJson(path, value, replacer) {
7
+ export async function writeAtomicText(path, text) {
8
8
  await mkdir(dirname(path), { recursive: true });
9
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');
10
+ await writeFile(tmp, text, 'utf8');
11
11
  await rename(tmp, path);
12
12
  }
13
+ export async function writeAtomicJson(path, value, replacer) {
14
+ await writeAtomicText(path, `${JSON.stringify(value, replacer, 2)}\n`);
15
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export declare const JOURNAL_SCHEMA_VERSION = 1;
7
7
  export interface ErrorRecord {
8
- /** Pipeline stage: boot | target | locate | assert | heal | report | index */
8
+ /** Pipeline stage: boot | target | locate | assert | heal | explore | report | index */
9
9
  stage: string;
10
10
  message: string;
11
11
  /** Minimal context: step instruction, model, page URL — never secrets. */
package/dist/log.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * default 'warn'. Debug emits model call excerpts and recovery paths —
4
4
  * never secret values.
5
5
  */
6
+ import { type Styler } from './ui/style.js';
6
7
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
7
8
  export interface Logger {
8
9
  level: LogLevel;
@@ -13,5 +14,5 @@ export interface Logger {
13
14
  }
14
15
  export declare function createLogger(level: LogLevel, sink: {
15
16
  err: (line: string) => void;
16
- }, live?: (level: LogLevel, msg: string) => void): Logger;
17
+ }, live?: (level: LogLevel, msg: string) => void, style?: Styler): Logger;
17
18
  export declare function resolveLogLevel(env: Record<string, string | undefined>, configured?: string): LogLevel;
package/dist/log.js CHANGED
@@ -1,8 +1,16 @@
1
+ /**
2
+ * Tiny leveled logger. Level comes from ARGUS_DEBUG=1 or config.logLevel;
3
+ * default 'warn'. Debug emits model call excerpts and recovery paths —
4
+ * never secret values.
5
+ */
6
+ import { PLAIN } from './ui/style.js';
1
7
  const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
2
- export function createLogger(level, sink, live) {
8
+ export function createLogger(level, sink, live, style = PLAIN) {
9
+ // DESIGN.md 7.7: a dim level word (bold for error) instead of `[level]`.
10
+ const prefix = (l) => (l === 'error' ? style.bold(l) : style.dim(l));
3
11
  const emit = (l, msg) => {
4
12
  if (ORDER[l] >= ORDER[level])
5
- sink.err(`[${l}] ${msg}`);
13
+ sink.err(`${prefix(l)}: ${msg}`);
6
14
  live?.(l, msg);
7
15
  };
8
16
  // `live` receives every level regardless of `level` — the local dashboard
@@ -0,0 +1,45 @@
1
+ import { type PrMeta } from './evidence/ci.js';
2
+ /**
3
+ * `@argus` mention commands on PR comments (roadmap E3.U5). The mention
4
+ * lane runs on `issue_comment` events — strictly more privileged than
5
+ * `pull_request` (repo secrets + write-capable GITHUB_TOKEN are present),
6
+ * so it NEVER checks out the PR head. Review operates on a base-ref
7
+ * checkout with the PR diff fetched via the API — the same diff-only
8
+ * posture `code-review` already uses.
9
+ */
10
+ export type MentionName = 'review' | 'record' | 'persist' | 'help';
11
+ export interface MentionCommand {
12
+ name: MentionName;
13
+ /** `record` flow description, quotes already stripped. */
14
+ arg?: string;
15
+ }
16
+ /**
17
+ * Parse a comment body into a whitelisted mention command. The mention must
18
+ * open the comment — a bare `@argus` in the middle of prose is not a command.
19
+ * `@argus` alone and `@argus help` both yield `help`; anything that isn't a
20
+ * whitelisted verb yields `unknown` so the caller can reply with the menu.
21
+ */
22
+ export declare function parseMention(body: string): MentionCommand | 'unknown' | undefined;
23
+ export interface MentionGate {
24
+ allowed: boolean;
25
+ /** Reply text for denials the commenter should see; undefined → silent. */
26
+ reply?: string;
27
+ }
28
+ /**
29
+ * Two-part gate. `association` is the *commenter's* `author_association`
30
+ * (not the PR author's): only MEMBER/OWNER/COLLABORATOR may drive Argus.
31
+ * For fork-head PRs every command additionally needs the `argus-probe`
32
+ * label covering the current head SHA, and `record`/`persist` are disabled
33
+ * outright — they would execute or persist artifacts derived from code the
34
+ * label was meant to bound. `help` needs no label: it replies with a fixed
35
+ * menu and executes nothing.
36
+ */
37
+ export declare function mayRunMention(cmd: MentionCommand, association: string | undefined, meta: PrMeta | undefined): MentionGate;
38
+ export declare const MENTION_HELP: string;
39
+ /**
40
+ * Post the mention reply as an issue comment. Best-effort — a failed reply
41
+ * logs and returns false rather than failing the dispatch.
42
+ */
43
+ export declare function postIssueComment(repo: string, issue: string, body: string, token: string, ctx: {
44
+ err: (line: string) => void;
45
+ }): Promise<boolean>;
@@ -0,0 +1,107 @@
1
+ import { isTrustedAssociation, PROBE_LABEL } from './evidence/ci.js';
2
+ import { labelCoversHead } from './evidence/gate.js';
3
+ const NAMES = new Set(['review', 'record', 'persist', 'help']);
4
+ /**
5
+ * Parse a comment body into a whitelisted mention command. The mention must
6
+ * open the comment — a bare `@argus` in the middle of prose is not a command.
7
+ * `@argus` alone and `@argus help` both yield `help`; anything that isn't a
8
+ * whitelisted verb yields `unknown` so the caller can reply with the menu.
9
+ */
10
+ export function parseMention(body) {
11
+ const first = body.trimStart().split('\n', 1)[0]?.trim() ?? '';
12
+ const m = /^@argus\b\s*(.*)$/i.exec(first);
13
+ if (m === null)
14
+ return undefined;
15
+ const rest = (m[1] ?? '').trim();
16
+ if (rest === '')
17
+ return { name: 'help' };
18
+ const verb = rest.split(/\s+/, 1)[0]?.toLowerCase() ?? '';
19
+ if (!NAMES.has(verb))
20
+ return 'unknown';
21
+ const name = verb;
22
+ if (name !== 'record')
23
+ return { name };
24
+ // `record "sign in with google"` — quotes optional; cap the flow text.
25
+ // Newlines/backticks are stripped: the arg is commenter-controlled text
26
+ // echoed into a public reply.
27
+ const arg = rest
28
+ .slice(verb.length)
29
+ .trim()
30
+ .replace(/^["']|["']$/g, '')
31
+ .replace(/[\r\n`]+/g, ' ')
32
+ .replace(/\s+/g, ' ')
33
+ .trim()
34
+ .slice(0, 200);
35
+ return { name, ...(arg !== '' ? { arg } : {}) };
36
+ }
37
+ /**
38
+ * Two-part gate. `association` is the *commenter's* `author_association`
39
+ * (not the PR author's): only MEMBER/OWNER/COLLABORATOR may drive Argus.
40
+ * For fork-head PRs every command additionally needs the `argus-probe`
41
+ * label covering the current head SHA, and `record`/`persist` are disabled
42
+ * outright — they would execute or persist artifacts derived from code the
43
+ * label was meant to bound. `help` needs no label: it replies with a fixed
44
+ * menu and executes nothing.
45
+ */
46
+ export function mayRunMention(cmd, association, meta) {
47
+ if (!isTrustedAssociation(association))
48
+ return { allowed: false };
49
+ if (cmd.name === 'help')
50
+ return { allowed: true };
51
+ if (meta === undefined) {
52
+ return { allowed: false, reply: "I can't see this PR's metadata — try again in a moment." };
53
+ }
54
+ if (!meta.isFork)
55
+ return { allowed: true };
56
+ if (cmd.name === 'record' || cmd.name === 'persist') {
57
+ return {
58
+ allowed: false,
59
+ reply: `\`@argus ${cmd.name}\` isn't available on fork PRs — record and persist run inside the repo's trust boundary.`,
60
+ };
61
+ }
62
+ if (!meta.labels.includes(PROBE_LABEL) || !labelCoversHead(meta)) {
63
+ return {
64
+ allowed: false,
65
+ reply: `This PR comes from a fork — a maintainer can enable review by applying \`${PROBE_LABEL}\` to the current head.`,
66
+ };
67
+ }
68
+ return { allowed: true };
69
+ }
70
+ export const MENTION_HELP = 'Commands: `@argus review` — re-run code review on the latest head · ' +
71
+ '`@argus record "<flow>"` — record a test flow against the app · ' +
72
+ '`@argus persist` — turn a reproduced probe into a regression-test PR · ' +
73
+ '`@argus help` — this menu.';
74
+ const GH_API = 'https://api.github.com';
75
+ /**
76
+ * Post the mention reply as an issue comment. Best-effort — a failed reply
77
+ * logs and returns false rather than failing the dispatch.
78
+ */
79
+ export async function postIssueComment(repo, issue, body, token, ctx) {
80
+ const controller = new AbortController();
81
+ const timeout = setTimeout(() => controller.abort(), 30_000);
82
+ try {
83
+ const res = await fetch(`${GH_API}/repos/${repo}/issues/${issue}/comments`, {
84
+ method: 'POST',
85
+ signal: controller.signal,
86
+ headers: {
87
+ Authorization: `Bearer ${token}`,
88
+ Accept: 'application/vnd.github+json',
89
+ 'X-GitHub-Api-Version': '2022-11-28',
90
+ 'Content-Type': 'application/json',
91
+ },
92
+ body: JSON.stringify({ body }),
93
+ });
94
+ if (!res.ok) {
95
+ ctx.err(`mention: reply post failed — github ${res.status} ${res.statusText}`);
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+ catch (e) {
101
+ ctx.err(`mention: reply post failed — ${e.message}`);
102
+ return false;
103
+ }
104
+ finally {
105
+ clearTimeout(timeout);
106
+ }
107
+ }
@@ -0,0 +1,126 @@
1
+ import type { AppExpectation, Config, Target } from '../config.js';
2
+ import type { BrowserDriver, PageCapture } from '../driver/browser.js';
3
+ import { TargetProcess } from '../driver/target.js';
4
+ import { Actions } from '../engine/actions.js';
5
+ import { type ExpectationContext, type ExploreResult } from '../engine/explore.js';
6
+ import type { VisionClient } from '../engine/loop.js';
7
+ import type { ErrorRecord } from '../journal/schema.js';
8
+ import type { Logger } from '../log.js';
9
+ import type { LaneStatus } from '../report/manifest.js';
10
+ import type { CallCost } from '../vision/cost.js';
11
+ import { Ledger } from '../vision/ledger.js';
12
+ /** Lane detail file the app runner writes and `runVerify` reads back. */
13
+ export declare const APP_LANE_REPORT = "app-lane.json";
14
+ /** Wall-clock bound when config.app.timeoutMs is unset. */
15
+ export declare const APP_LANE_DEFAULT_TIMEOUT_MS = 120000;
16
+ /**
17
+ * Lane detail record written to `app-lane.json`. `runVerify` merges `status`,
18
+ * `reason`, `summary`, `model`, and the call records into the manifest lane;
19
+ * the rest is evidence for the operator surfaces (TUI, dashboard, comment).
20
+ */
21
+ export interface AppLaneReport {
22
+ lane: 'app';
23
+ status: LaneStatus;
24
+ reason: string | undefined;
25
+ summary: string | undefined;
26
+ task: string | undefined;
27
+ expected: AppExpectation | undefined;
28
+ targetUrl: string | undefined;
29
+ finalUrl: string | undefined;
30
+ expectedMet: boolean;
31
+ stopReason: string | undefined;
32
+ steps: {
33
+ action: string;
34
+ url: string;
35
+ note?: string;
36
+ }[];
37
+ visited: number;
38
+ anomalies: PageCapture[];
39
+ notes: ErrorRecord[];
40
+ artifacts: {
41
+ video: string | undefined;
42
+ };
43
+ calls: CallCost[];
44
+ visionCalls: number;
45
+ visionCostUsd: number;
46
+ durationMs: number;
47
+ model: string | undefined;
48
+ }
49
+ interface ExpectationCheck {
50
+ check: (ctx: ExpectationContext) => Promise<boolean>;
51
+ /** Human-readable form for the lane record, e.g. `text "Done"` + url /x/. */
52
+ describe: string;
53
+ }
54
+ /**
55
+ * Compile the configured expected state into one AND-ed predicate. An
56
+ * invalid url regex throws here — caught at preflight so a broken marker
57
+ * blocks the lane instead of failing mid-run.
58
+ */
59
+ export declare function buildExpectationCheck(expected: AppExpectation): ExpectationCheck;
60
+ export interface AppTaskInput {
61
+ driver: BrowserDriver;
62
+ actions: Actions;
63
+ client: VisionClient;
64
+ ledger: Ledger;
65
+ config: Config;
66
+ targetUrl: string;
67
+ task: string;
68
+ expected: AppExpectation;
69
+ timeoutMs: number;
70
+ maxSteps?: number | undefined;
71
+ /**
72
+ * The lane's own spend bound, forwarded to the explore loop — without it
73
+ * `explore.budgetUsd` (a different lane's knob) throttles the app lane.
74
+ */
75
+ budgetLimitUsd?: number | undefined;
76
+ logger?: Logger | undefined;
77
+ }
78
+ /**
79
+ * The bounded task loop: ExploreLoop substrate + task + expectation +
80
+ * wall-clock deadline. The lane — not the model's `done` — decides pass:
81
+ * the expected-state predicate is re-verified on a fresh observation after
82
+ * the loop stops, so a page that merely loads can never pass.
83
+ */
84
+ export declare function runAppTask(input: AppTaskInput): Promise<{
85
+ result: ExploreResult;
86
+ expectedMet: boolean;
87
+ verifyError: string | undefined;
88
+ }>;
89
+ export interface AppLaneDeps {
90
+ /** Browser launch — default Chromium with error-capture taps on. */
91
+ launchDriver?: (config: Config) => Promise<BrowserDriver>;
92
+ /** Vision client factory — lazy key resolution stays with the caller. */
93
+ createClient?: (config: Config) => VisionClient;
94
+ /** URL readiness probe. */
95
+ waitForReady?: (url: string, timeoutMs: number) => Promise<void>;
96
+ /** Target-command boot. */
97
+ startTarget?: (spec: Target) => Promise<TargetProcess>;
98
+ /** Consumer page-setup hook (config.pageSetup module) — trusted only. */
99
+ applyPageSetup?: (driver: BrowserDriver) => Promise<void>;
100
+ logger?: Logger;
101
+ }
102
+ export interface AppLaneInput {
103
+ config: Config;
104
+ /** Resolved target URL — `--url` or `config.target.url`. */
105
+ url: string | undefined;
106
+ /** The checkout's trust lane — gates the whole executable lane. */
107
+ trusted: boolean;
108
+ /** `--task` override wins over `config.app.task`. */
109
+ task: string | undefined;
110
+ /** Flag-level expected-state markers win over `config.app.expected`. */
111
+ expected: AppExpectation | undefined;
112
+ /**
113
+ * Fully-resolved lane cap (`app.budgetUsd ?? ARGUS_BUDGET_USD ??
114
+ * budgetUsd`) — the caller resolves precedence so the enforced Ledger
115
+ * bound is exactly the one the manifest reports.
116
+ */
117
+ budgetLimitUsd?: number;
118
+ deps?: AppLaneDeps;
119
+ }
120
+ /**
121
+ * Full `verify --app` lifecycle: contract preflight → trust/reachability →
122
+ * bounded task loop → lane record. Every status path produces a report —
123
+ * the lane can fail loudly, never silently.
124
+ */
125
+ export declare function runAppLane(input: AppLaneInput): Promise<AppLaneReport>;
126
+ export {};