@elalfner/relevo 0.6.0 → 0.7.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "relevo",
3
3
  "displayName": "Relevo",
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "description": "An automatic issue-to-merge workflow where an agent builds and people verify.",
6
6
  "author": {
7
7
  "name": "Elias Alfaro"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elalfner/relevo",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Relevo: an automatic issue-to-merge workflow where an agent builds and people verify.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // Says whether the working tree may switch branches: a dev server of the address under review that runs from it
3
+ // would lose its files. Usage:
4
+ // node scripts/dev-server-guard.mjs --url http://localhost:5173
5
+ // Prints { code, servers, reason } as JSON.
6
+ // exit 0: no server of that address runs from the working tree exit 1: one does (stop it before switching)
7
+ // exit 3: it cannot tell (no lsof, or the folder cannot be read): do not switch exit 2: bad arguments
8
+
9
+ import { realpathSync } from 'node:fs';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { guard } from './lib/dev-server-guard.mjs';
12
+
13
+ const i = process.argv.indexOf('--url');
14
+ const url = i === -1 ? undefined : process.argv[i + 1];
15
+ try {
16
+ if (!url || !/^https?:$/.test(new URL(url).protocol)) throw new Error();
17
+ } catch {
18
+ console.error('usage: dev-server-guard.mjs --url <http address>');
19
+ process.exit(2);
20
+ }
21
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
22
+ if (top.status !== 0) {
23
+ console.error('not inside a git repository');
24
+ process.exit(2);
25
+ }
26
+ const lsof = (args) => {
27
+ const r = spawnSync('lsof', args, { encoding: 'utf8' });
28
+ return { status: r.error ? 127 : r.status ?? 1, stdout: r.stdout ?? '' };
29
+ };
30
+ const result = guard({ url, repoRoot: top.stdout.trim() }, { lsof, realpath: realpathSync });
31
+ console.log(JSON.stringify(result));
32
+ process.exit(result.code);
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // Starts, checks and stops the dev servers the agent needs for lines that require another configuration. Usage:
3
+ // node scripts/extra-server.mjs start --port 5174 [--set NAME=value ...] [--unset NAME ...] -- npx vite --port 5174 --strictPort
4
+ // node scripts/extra-server.mjs check --port 5174 after web-preflight.mjs --wait: the port is served by that server
5
+ // node scripts/extra-server.mjs stop --port 5174 | --all
6
+ // Each server runs in a process group of its own, logs to evidence/servers/<port>.log and is recorded in
7
+ // evidence/servers.json (override the folder with --dir). Only recorded servers are ever stopped, so the person's own
8
+ // server is never touched. The environment files are never read; their names only decide whether --unset can work.
9
+ // exit 0: done exit 1: not recorded, or the port is not served by the recorded server (it was stopped)
10
+ // exit 2: bad arguments exit 3: the port is busy exit 4: --unset with environment files present exit 5: no lsof
11
+
12
+ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import { join } from 'node:path';
15
+ import { checkServer, parseSet, startServer, stopServers } from './lib/extra-server.mjs';
16
+
17
+ const dash = process.argv.indexOf('--');
18
+ const argv = dash === -1 ? process.argv.slice(2) : process.argv.slice(2, dash);
19
+ const command = dash === -1 ? [] : process.argv.slice(dash + 1);
20
+ const [action] = argv;
21
+ const values = (name) => argv.flatMap((a, i) => (a === `--${name}` ? [argv[i + 1]] : []));
22
+ const usage = (message) => {
23
+ if (message) console.error(message);
24
+ console.error('usage: extra-server.mjs start --port <p> [--set NAME=value] [--unset NAME] -- <command>\n extra-server.mjs check --port <p>\n extra-server.mjs stop --port <p> | --all');
25
+ process.exit(2);
26
+ };
27
+
28
+ const dir = values('dir')[0] ?? 'evidence';
29
+ const file = join(dir, 'servers.json');
30
+ const port = values('port')[0] === undefined ? null : Number(values('port')[0]);
31
+ const lsof = (args) => {
32
+ const r = spawnSync('lsof', args, { encoding: 'utf8' });
33
+ return { status: r.error ? 127 : r.status ?? 1, stdout: r.stdout ?? '' };
34
+ };
35
+ const deps = {
36
+ lsof,
37
+ dir,
38
+ baseEnv: process.env,
39
+ listDir: () => readdirSync(process.cwd()),
40
+ load: () => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : []),
41
+ save: (records) => {
42
+ mkdirSync(dir, { recursive: true });
43
+ writeFileSync(file, `${JSON.stringify(records, null, 2)}\n`);
44
+ },
45
+ spawn: (cmd, env, log) => {
46
+ mkdirSync(join(dir, 'servers'), { recursive: true });
47
+ const fd = openSync(log, 'a');
48
+ const child = spawn(cmd[0], cmd.slice(1), { detached: true, stdio: ['ignore', fd, fd], env });
49
+ child.unref();
50
+ return child.pid;
51
+ },
52
+ pgidOf: (pid) => Number(spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim()),
53
+ kill: (pgid) => {
54
+ try {
55
+ process.kill(-pgid, 'SIGTERM');
56
+ } catch (error) {
57
+ if (error.code !== 'ESRCH') throw error;
58
+ }
59
+ },
60
+ };
61
+
62
+ let result;
63
+ try {
64
+ if (action === 'start') {
65
+ if (port === null) usage('start needs --port');
66
+ result = startServer({ port, set: parseSet(values('set')), unset: values('unset'), command }, deps);
67
+ } else if (action === 'check') {
68
+ if (port === null) usage('check needs --port');
69
+ result = checkServer({ port }, deps);
70
+ } else if (action === 'stop') {
71
+ if (port === null && !argv.includes('--all')) usage('stop needs --port or --all');
72
+ result = stopServers({ port, all: argv.includes('--all') }, deps);
73
+ } else usage();
74
+ } catch (error) {
75
+ usage(error.message);
76
+ }
77
+ (result.code === 0 ? console.log : console.error)(result.message);
78
+ process.exit(result.code);
@@ -0,0 +1,49 @@
1
+ // Which processes listen on a port and where they run from, through lsof. A review does not switch branches under a
2
+ // dev server that runs from the working tree, because the server would lose its files.
3
+ // Implements "Review restores the base branch upon completion" of openspec/specs/review/spec.md.
4
+
5
+ import { sep } from 'node:path';
6
+
7
+ /** The pids listening on a TCP port; null when lsof cannot be run. `lsof(args)` returns { status, stdout }. */
8
+ export function listeners(port, lsof) {
9
+ const r = lsof(['-nP', `-tiTCP:${port}`, '-sTCP:LISTEN']);
10
+ if (r.status === 127) return null;
11
+ // lsof exits 1 with nothing printed when no process matches.
12
+ return String(r.stdout ?? '').split('\n').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0);
13
+ }
14
+
15
+ /** The current folder of a process; null when lsof cannot tell. */
16
+ export function cwdOf(pid, lsof) {
17
+ const r = lsof(['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
18
+ if (r.status !== 0) return null;
19
+ const line = String(r.stdout ?? '').split('\n').find((l) => l.startsWith('n'));
20
+ return line ? line.slice(1) : null;
21
+ }
22
+
23
+ /** The port of an http address, with the default of its scheme. */
24
+ export function portOf(url) {
25
+ const u = new URL(url);
26
+ return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
27
+ }
28
+
29
+ /**
30
+ * May the working tree switch branches? Looks at the processes listening on the port of `url`.
31
+ * Returns { code, servers }: 0 none runs from the repository, 1 one does, 3 it cannot tell.
32
+ * deps: lsof(args) -> { status, stdout }, realpath(path) -> path.
33
+ */
34
+ export function guard({ url, repoRoot }, { lsof, realpath }) {
35
+ const pids = listeners(portOf(url), lsof);
36
+ if (pids === null) return { code: 3, servers: [], reason: 'lsof is not available, so the folder of the server cannot be known' };
37
+ const root = realpath(repoRoot);
38
+ const servers = pids.map((pid) => {
39
+ const cwd = cwdOf(pid, lsof);
40
+ let real = null;
41
+ try {
42
+ real = cwd ? realpath(cwd) : null;
43
+ } catch { /* a folder that no longer exists is outside the repository */ }
44
+ return { pid, cwd, inside: real !== null && (real === root || real.startsWith(`${root}${sep}`)) };
45
+ });
46
+ if (servers.some((s) => s.inside)) return { code: 1, servers, reason: 'a server of the address under review runs from the working tree' };
47
+ if (servers.some((s) => s.cwd === null)) return { code: 3, servers, reason: 'the folder of a server on that port cannot be read' };
48
+ return { code: 0, servers, reason: null };
49
+ }
@@ -0,0 +1,84 @@
1
+ // Dev servers the agent starts for a line that needs the app with another configuration. Each one runs in a process
2
+ // group of its own on a port that was free, is recorded, and is the only kind of server the agent may stop.
3
+ // Implements "Servers started by the agent are its own" and "Environment files of a reviewed app are never read" of
4
+ // openspec/specs/review/spec.md.
5
+
6
+ import { join } from 'node:path';
7
+ import { listeners } from './dev-server-guard.mjs';
8
+
9
+ export const EXIT = { refused: 1, busy: 3, envFiles: 4, noLsof: 5 };
10
+
11
+ // Example files hold no values the app loads.
12
+ const EXAMPLES = new Set(['.env.example', '.env.sample', '.env.template']);
13
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+
15
+ /** The environment files a dev server loads by itself, by name only: `.env` and `.env.*`, without the examples. */
16
+ export function envFiles(names) {
17
+ return names.filter((n) => /^\.env(\..+)?$/.test(n) && !EXAMPLES.has(n)).sort();
18
+ }
19
+
20
+ /** The environment of the server: the current one with `set` applied and `unset` removed. */
21
+ export function serverEnv(base, { set = {}, unset = [] }) {
22
+ const env = { ...base, ...set };
23
+ unset.forEach((n) => { delete env[n]; });
24
+ return env;
25
+ }
26
+
27
+ /** Parse `NAME=value` pairs of --set; throws on a bad name. */
28
+ export function parseSet(pairs) {
29
+ return Object.fromEntries(pairs.map((p) => {
30
+ const i = p.indexOf('=');
31
+ const name = i === -1 ? p : p.slice(0, i);
32
+ if (i === -1 || !NAME_RE.test(name)) throw new Error(`--set needs NAME=value (got ${p})`);
33
+ return [name, p.slice(i + 1)];
34
+ }));
35
+ }
36
+
37
+ const fail = (code, message) => ({ code, message });
38
+
39
+ /**
40
+ * Start a server. deps: lsof(args), listDir() -> names in the project folder, spawn(command, env, logFile) -> pgid,
41
+ * load() -> records, save(records), baseEnv, dir (the evidence folder, for the log).
42
+ */
43
+ export function startServer({ port, set = {}, unset = [], command }, deps) {
44
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) return fail(2, 'the port must be a number between 1024 and 65535');
45
+ if (!command || command.length === 0) return fail(2, 'give the command that starts the server after --');
46
+ const busy = listeners(port, deps.lsof);
47
+ if (busy === null) return fail(EXIT.noLsof, 'lsof is not available, so a free port cannot be told apart: leave this line for the person');
48
+ if (busy.length > 0) return fail(EXIT.busy, `port ${port} is busy (pid ${busy.join(', ')}): choose another; the server already there is not touched`);
49
+ const files = envFiles(deps.listDir());
50
+ if (unset.length > 0 && files.length > 0) {
51
+ return fail(EXIT.envFiles, `a variable cannot be removed while ${files.join(', ')} exist${files.length === 1 ? 's' : ''}: the dev server loads them by itself. Pre-check this line by reading the code, or leave it for the person`);
52
+ }
53
+ const records = deps.load();
54
+ const log = join(deps.dir ?? 'evidence', 'servers', `${port}.log`);
55
+ const pgid = deps.spawn(command, serverEnv(deps.baseEnv, { set, unset }), log);
56
+ records.push({ port, pgid, command: command.join(' '), set: Object.keys(set), unset });
57
+ deps.save(records);
58
+ return { code: 0, message: `started on port ${port} (process group ${pgid}); log in ${log}`, pgid };
59
+ }
60
+
61
+ /**
62
+ * Confirm that the process listening on the port belongs to the recorded server; when it does not (the server moved
63
+ * to another port, or failed), stop the recorded group and forget it.
64
+ */
65
+ export function checkServer({ port }, deps) {
66
+ const records = deps.load();
67
+ const record = records.find((r) => r.port === port);
68
+ if (!record) return fail(EXIT.refused, `no server started by this script is recorded on port ${port}`);
69
+ const pids = listeners(port, deps.lsof) ?? [];
70
+ if (pids.some((pid) => deps.pgidOf(pid) === record.pgid)) return { code: 0, message: `port ${port} is served by the recorded server` };
71
+ deps.kill(record.pgid);
72
+ deps.save(records.filter((r) => r !== record));
73
+ return fail(EXIT.refused, `port ${port} is not served by the server that was started (it may have moved to another port): it was stopped`);
74
+ }
75
+
76
+ /** Stop recorded servers only: one port, or all of them. A port with no record stops nothing. */
77
+ export function stopServers({ port = null, all = false }, deps) {
78
+ const records = deps.load();
79
+ const chosen = all ? records : records.filter((r) => r.port === port);
80
+ if (!all && chosen.length === 0) return fail(EXIT.refused, `no server started by this script is recorded on port ${port}: nothing was stopped`);
81
+ chosen.forEach((r) => deps.kill(r.pgid));
82
+ deps.save(records.filter((r) => !chosen.includes(r)));
83
+ return { code: 0, message: chosen.length === 0 ? 'no server to stop' : `stopped ${chosen.map((r) => `port ${r.port}`).join(', ')}` };
84
+ }
@@ -1,12 +1,14 @@
1
1
  // How `review` gets a log and captures for a platform and a target, and whether the agent may drive it.
2
2
  // Implements "The review picks its adapter from the platform and the target" of openspec/specs/review/spec.md.
3
3
 
4
+ import { pickLanguage } from './language.mjs';
5
+
4
6
  const DEFAULT_TARGET = { flutter: 'android', vue: 'web' };
5
7
 
6
8
  const ADAPTERS = {
7
9
  'flutter/android': { kind: 'device', log: 'adb logcat', capture: 'adb screencap', driver: 'flutter_driver', needs: ['adb'] },
8
- 'flutter/web': { kind: 'browser', log: 'the browser runner', capture: 'the browser runner', driver: 'flutter_driver', needs: ['a browser'] },
9
- 'vue/web': { kind: 'browser', log: 'the browser runner', capture: 'the browser runner', driver: 'playwright', needs: ['playwright'] },
10
+ 'flutter/web': { kind: 'browser', log: 'the browser runner', capture: 'capture(name, check) of the browser runner', driver: 'flutter_driver', needs: ['a browser'] },
11
+ 'vue/web': { kind: 'browser', log: 'the browser runner', capture: 'capture(name, check) of the browser runner', driver: 'playwright', needs: ['playwright'] },
10
12
  };
11
13
 
12
14
  const NOT_AVAILABLE = {
@@ -35,3 +37,14 @@ export function adapterFor({ stack, target }) {
35
37
  export function needsAutopilot(adapter) {
36
38
  return adapter.supported && adapter.kind === 'browser';
37
39
  }
40
+
41
+ const ADVICE = {
42
+ en: 'A browser has no manual review. Set review.autopilot to verify in relevo.yml (precheck is the cautious option) and commit it to the base branch. You still confirm every line, and lines about appearance are never marked automatically.',
43
+ es: 'Un navegador no tiene revisión manual. Pon review.autopilot en verify en relevo.yml (precheck es la opción prudente) y haz commit en la rama base. Tú sigues confirmando cada línea, y las líneas sobre apariencia nunca se marcan automáticamente.',
44
+ };
45
+
46
+ /** What to tell the person when a browser review cannot start because review.autopilot is off; null otherwise. */
47
+ export function autopilotAdvice(adapter, { autopilot = 'off', language = 'en' } = {}) {
48
+ if (!needsAutopilot(adapter) || autopilot !== 'off') return null;
49
+ return ADVICE[pickLanguage(language)];
50
+ }
@@ -8,8 +8,14 @@ export const MAX_SENTENCES = 2;
8
8
  export const MAX_LINE = 160;
9
9
 
10
10
  const LABELS = {
11
- en: { asked: 'Asked', outcome: 'Outcome', first: 'Check first', count: (s, e) => `To check: ${s} scenario(s), ${e} edge case(s)` },
12
- es: { asked: 'Se pidió', outcome: 'Resultado', first: 'Revisa primero', count: (s, e) => `Por revisar: ${s} escenario(s), ${e} caso(s) borde` },
11
+ en: {
12
+ asked: 'Asked', outcome: 'Outcome', first: 'Check first', count: (s, e) => `To check: ${s} scenario(s), ${e} edge case(s)`,
13
+ autopilot: 'The agent walks the lines below first; you confirm every line, and lines about appearance are never marked automatically.',
14
+ },
15
+ es: {
16
+ asked: 'Se pidió', outcome: 'Resultado', first: 'Revisa primero', count: (s, e) => `Por revisar: ${s} escenario(s), ${e} caso(s) borde`,
17
+ autopilot: 'El agente recorre primero las líneas de abajo; tú confirmas cada línea, y las líneas sobre apariencia nunca se marcan automáticamente.',
18
+ },
13
19
  };
14
20
  const RISK_RE = /(?:Riskiest area, check it first|Zona más riesgosa, revísala primero):\s*(.+)/;
15
21
 
@@ -26,9 +32,10 @@ export function firstSentences(text, max = MAX_SENTENCES) {
26
32
  /**
27
33
  * issue { number, title, body }
28
34
  * built the text of the `built` comment, when there is one (the risk line is taken from it)
35
+ * autopilot review.autopilot; with precheck or verify a fixed note says the person still confirms every line
29
36
  * Returns the brief as a string of about five lines.
30
37
  */
31
- export function reviewBrief({ issue, built = '', language = 'en' }) {
38
+ export function reviewBrief({ issue, built = '', language = 'en', autopilot = 'off' }) {
32
39
  const L = LABELS[language === 'es' ? 'es' : 'en'];
33
40
  const parsed = parseIssueBody(issue.body);
34
41
  const lines = [`#${issue.number} ${clean(issue.title)}`];
@@ -39,5 +46,6 @@ export function reviewBrief({ issue, built = '', language = 'en' }) {
39
46
  const risk = String(built).match(RISK_RE)?.[1];
40
47
  if (risk) lines.push(`${L.first}: ${clean(risk)}`);
41
48
  lines.push(L.count(parsed.scenarios, parsed.edgeCases));
42
- return lines.map(cap).join('\n');
49
+ const note = ['precheck', 'verify'].includes(autopilot) ? `\n${L.autopilot}` : '';
50
+ return `${lines.map(cap).join('\n')}${note}`;
43
51
  }
@@ -1,6 +1,8 @@
1
1
  // State of one on-device review, and the rule that decides whether a pull request may be opened.
2
2
  // Implements "Pull request only after verification" of openspec/specs/review/spec.md.
3
3
 
4
+ import { createHash } from 'node:crypto';
5
+
4
6
  export const STATUSES = ['pending', 'ok', 'fail'];
5
7
 
6
8
  const same = (a, b) => a.class === b.class && a.excerpt === b.excerpt;
@@ -78,6 +80,8 @@ export function syncState(state, items) {
78
80
  state.audit = null;
79
81
  state.waivers = [];
80
82
  state.agreed = false;
83
+ // The code changed, so the review script must be read by QA again.
84
+ state.qaScript = null;
81
85
  return state;
82
86
  }
83
87
 
@@ -93,12 +97,34 @@ export function setItem(state, id, status, note = null) {
93
97
  return state;
94
98
  }
95
99
 
96
- const driving = (state) => {
100
+ /**
101
+ * Throws unless the agent may drive the review: autopilot is not off and the person confirmed the data.
102
+ * With `open`, the review must also not be agreed yet (the browser runner asks for it).
103
+ */
104
+ export function assertDriving(state, { open = false } = {}) {
97
105
  const mode = state.autopilot ?? 'off';
98
106
  if (mode === 'off') throw new Error('the agent does not drive the review: review.autopilot is "off"');
99
107
  if (!state.envConfirmed) throw new Error('the person has not confirmed in writing that the app points to non-production data: run "confirm-env" after they do');
108
+ if (open && state.agreed) throw new Error('the review is closed: the person already agreed to open the pull request');
100
109
  return mode;
101
- };
110
+ }
111
+
112
+ /** The sha256 of a review script, as `qa-pass` records it and the runner compares it. */
113
+ export const scriptHash = (text) => createHash('sha256').update(String(text)).digest('hex');
114
+
115
+ /** QA passed this exact script; any later change to it needs a new pass. */
116
+ export function qaPass(state, path, text) {
117
+ state.qaScript = { path, sha256: scriptHash(text) };
118
+ return state;
119
+ }
120
+
121
+ /** Record a screenshot that was taken. Captures belong to the open review, so one after the agreement is refused. */
122
+ export function recordCapture(state, path) {
123
+ if (state.agreed) throw new Error('the review is closed: captures are taken while the person tests, before the agreement');
124
+ state.captures ??= [];
125
+ if (!state.captures.includes(path)) state.captures.push(path);
126
+ return state;
127
+ }
102
128
 
103
129
  /** The person confirmed that the app under review does not point to production data. */
104
130
  export function confirmEnv(state) {
@@ -108,7 +134,7 @@ export function confirmEnv(state) {
108
134
 
109
135
  /** The agent's own result for a line, apart from the person's confirmation. Modes precheck and verify only. */
110
136
  export function precheckItem(state, id, result, note = null) {
111
- driving(state);
137
+ assertDriving(state);
112
138
  if (!['ok', 'fail'].includes(result)) throw new Error('a pre-check result is ok or fail');
113
139
  const item = state.items.find((i) => i.id === id);
114
140
  if (!item) throw new Error(`there is no checklist item ${id}`);
@@ -119,7 +145,7 @@ export function precheckItem(state, id, result, note = null) {
119
145
 
120
146
  /** The agent marks a line as verified automatically, saying how. Mode verify only; the line becomes ok but stays labelled. */
121
147
  export function autoItem(state, id, how) {
122
- const mode = driving(state);
148
+ const mode = assertDriving(state);
123
149
  if (mode !== 'verify') throw new Error(`a line can be verified automatically only when review.autopilot is "verify" (it is "${mode}")`);
124
150
  const why = String(how ?? '').trim();
125
151
  if (!why) throw new Error('say how the line was verified: auto <number> <the assertion, finder or log that proved it>');
@@ -0,0 +1,87 @@
1
+ // What a browser review checks before it drives: the project's dependencies, Playwright and its browser, and the
2
+ // address under review with the module scripts its page loads. Everything that touches the machine is injected.
3
+ // Implements "A browser review checks its tools and the app before driving" of openspec/specs/review/spec.md.
4
+
5
+ export const EXIT = { node_modules: 3, playwright: 4, browser: 5, url: 6 };
6
+
7
+ const START_SCRIPTS = ['dev', 'serve', 'start'];
8
+
9
+ /** The command that starts the app, from the scripts of package.json; null when there is none. */
10
+ export function startCommand(pkgText) {
11
+ let scripts = {};
12
+ try {
13
+ scripts = JSON.parse(pkgText ?? '{}').scripts ?? {};
14
+ } catch { /* an unreadable package.json names no command */ }
15
+ const name = START_SCRIPTS.find((s) => typeof scripts[s] === 'string');
16
+ return name ? `npm run ${name}` : null;
17
+ }
18
+
19
+ /** The variable names of an example environment file (`NAME=value` lines); values are ignored. */
20
+ export function envNames(text) {
21
+ return [...String(text ?? '').matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]);
22
+ }
23
+
24
+ /** The same-origin module scripts that an HTML page loads, as absolute URLs. */
25
+ export function moduleScripts(html, pageUrl) {
26
+ const origin = new URL(pageUrl).origin;
27
+ return [...String(html ?? '').matchAll(/<script\b[^>]*>/gi)]
28
+ .map((m) => m[0])
29
+ .filter((tag) => /\btype\s*=\s*["']?module\b/i.test(tag))
30
+ .map((tag) => tag.match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1])
31
+ .filter(Boolean)
32
+ .map((src) => new URL(src, pageUrl))
33
+ .filter((u) => u.origin === origin)
34
+ .map((u) => u.href);
35
+ }
36
+
37
+ /** Does the page answer below 400 after redirects, and so do its module scripts? Returns { ok, detail }. */
38
+ export async function checkUrl(url, { fetch, timeout = 5000 }) {
39
+ const get = async (address) => {
40
+ try {
41
+ const response = await fetch(address, { redirect: 'follow', signal: AbortSignal.timeout(timeout) });
42
+ return { status: response.status, text: response.status < 400 ? await response.text() : '' };
43
+ } catch (error) {
44
+ return { status: null, error: String(error?.cause?.code ?? error?.name ?? error?.message ?? error) };
45
+ }
46
+ };
47
+ const page = await get(url);
48
+ if (page.status === null) return { ok: false, detail: `${url} does not answer (${page.error})` };
49
+ if (page.status >= 400) return { ok: false, detail: `${url} answered ${page.status}` };
50
+ for (const script of moduleScripts(page.text, url)) {
51
+ const r = await get(script);
52
+ if (r.status === null) return { ok: false, detail: `${script} does not answer (${r.error})` };
53
+ if (r.status >= 400) return { ok: false, detail: `${script} answered ${r.status}` };
54
+ }
55
+ return { ok: true, detail: null };
56
+ }
57
+
58
+ /**
59
+ * Runs the checks in order and stops at the first failure. Returns { ok, check, detail, start, env, code }.
60
+ * deps: exists(path), readFile(path) (null when missing), loadPlaywright(), fetch, sleep(ms), now().
61
+ * Only `.env.example` is read for variable names; the real environment files never are.
62
+ * With `wait`, the address is retried every 500 ms until it answers or the time is over.
63
+ */
64
+ export async function webPreflight({ cwd, url, timeout = 5000, wait = 0 }, deps) {
65
+ const { exists, readFile, loadPlaywright, fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = deps;
66
+ const at = (name) => `${cwd}/${name}`;
67
+ const start = startCommand(readFile(at('package.json')));
68
+ const env = envNames(readFile(at('.env.example')));
69
+ const result = (check, detail) => ({ ok: check === null, check, detail, start, env, code: check === null ? 0 : EXIT[check] });
70
+
71
+ if (!exists(at('node_modules'))) return result('node_modules', 'the project has no node_modules: run npm install first');
72
+ const playwright = loadPlaywright();
73
+ if (!playwright) return result('playwright', 'Playwright does not resolve from the project');
74
+ let executable = null;
75
+ try {
76
+ executable = playwright.chromium.executablePath();
77
+ } catch { /* an unknown browser build has no path */ }
78
+ if (!executable || !exists(executable)) return result('browser', 'the Chromium of Playwright is not installed');
79
+
80
+ const deadline = now() + wait;
81
+ for (;;) {
82
+ const answer = await checkUrl(url, { fetch, timeout });
83
+ if (answer.ok) return result(null, null);
84
+ if (now() >= deadline) return result('url', answer.detail);
85
+ await sleep(500);
86
+ }
87
+ }
@@ -1,10 +1,25 @@
1
1
  // The rules of a driven web review that must not vary: which origins a page may reach, what a review script may
2
2
  // contain, how a secret is kept out of the output, and the log the audit reads.
3
3
  // Implements "The agent operates the app only within fixed limits", "A web review script is reviewed before it runs",
4
- // "Credentials of a driven review are names, not values" and "The audit understands a browser" of openspec/specs/review/spec.md.
4
+ // "Credentials of a driven review are names, not values", "The audit understands a browser",
5
+ // "A browser review takes captures through the runner" and "A failing page is reported as such" of openspec/specs/review/spec.md.
6
+
7
+ import { createRequire } from 'node:module';
8
+ import { isAbsolute, join, relative } from 'node:path';
9
+ import { safeCaptureName } from './device.mjs';
5
10
 
6
11
  export const WEB_PACKAGE = 'web';
7
12
 
13
+ /** Playwright as the project resolves it, else as the plugin does; null when neither has it. */
14
+ export function loadPlaywright(cwd = process.cwd()) {
15
+ for (const base of [join(cwd, 'package.json'), import.meta.url]) {
16
+ try {
17
+ return createRequire(base)('playwright');
18
+ } catch { /* try the next place */ }
19
+ }
20
+ return null;
21
+ }
22
+
8
23
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
9
24
  // URLs that do not reach the network.
10
25
  const OFFLINE_PROTOCOLS = new Set(['data:', 'blob:', 'about:']);
@@ -41,6 +56,15 @@ const FORBIDDEN = [
41
56
  [/\bglobalThis\s*\.\s*process\b/, 'it reaches the process'],
42
57
  [/\bfetch\s*\(/, 'it makes a request outside the browser with fetch'],
43
58
  [/\brequire\.resolve\b|\bimport\.meta\b/, 'it inspects the module system'],
59
+ [/\bscreenshot\s*\(|\[\s*['"`]screenshot['"`]\s*\]/, 'it takes a screenshot itself; take it with capture(name, check), which saves it in the output folder'],
60
+ [/\.\s*pdf\s*\(/, 'it saves a PDF of the page'],
61
+ [/\btracing\b/, 'it records a trace'],
62
+ [/\brecord(?:Video|Har)\b/, 'it records a video or a HAR'],
63
+ [/\brouteFromHAR\b/, 'it reads a HAR file'],
64
+ [/\.\s*saveAs\s*\(/, 'it saves a download'],
65
+ [/\.\s*storageState\s*\(/, 'it saves the storage state'],
66
+ [/\.\s*setInputFiles\s*\(/, 'it uploads a local file to the page'],
67
+ [/(?:^|[{,\s])['"]?path['"]?\s*:/m, 'it passes a file path to a browser call (a "path:" option)'],
44
68
  ];
45
69
 
46
70
  /**
@@ -63,6 +87,63 @@ export function scanReviewScript(text) {
63
87
  return problems;
64
88
  }
65
89
 
90
+ /**
91
+ * The capture(name, check, { fullPage }) helper given to a review script. `check` asserts the state the shot shows and
92
+ * runs first: when it fails no screenshot is taken and the capture is recorded as failed, without throwing into the
93
+ * script. A good capture is saved as <out>/<name>.png. Nothing is returned, so the image never reaches the script.
94
+ * `captures` collects { name, ok, path | error }, the path relative to `cwd` when the file is under it; a repeated
95
+ * name replaces the earlier entry, as its file does.
96
+ */
97
+ export function makeCapture({ page, out, captures, cwd = process.cwd() }) {
98
+ const keep = (entry) => {
99
+ const i = captures.findIndex((c) => c.name === entry.name);
100
+ if (i === -1) captures.push(entry);
101
+ else captures[i] = entry;
102
+ };
103
+ return async (name, check, options = {}) => {
104
+ const safe = safeCaptureName(name);
105
+ if (typeof check !== 'function') throw new Error(`capture("${safe}", check): check is the assertion of the state the shot shows, and it is required`);
106
+ const unknown = Object.keys(options ?? {}).filter((k) => k !== 'fullPage');
107
+ if (unknown.length > 0) throw new Error(`capture("${safe}"): the only option is fullPage (got ${unknown.join(', ')})`);
108
+ try {
109
+ await check();
110
+ } catch (error) {
111
+ keep({ name: safe, ok: false, error: String(error?.message ?? error).split('\n')[0] });
112
+ return undefined;
113
+ }
114
+ const file = join(out, `${safe}.png`);
115
+ await page.screenshot({ path: file, fullPage: options?.fullPage === true });
116
+ const near = relative(cwd, file);
117
+ keep({ name: safe, ok: true, path: near && !near.startsWith('..') && !isAbsolute(near) ? near : file });
118
+ return undefined;
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The page's own failure during a run, to name before a script error: the first uncaught error of the page or the
124
+ * first response of 400 or more from the origin under review. `events` are those of formatBrowserLog. Returns a
125
+ * string or null.
126
+ */
127
+ export function probableCause(events, baseUrl) {
128
+ let origin;
129
+ try {
130
+ origin = new URL(baseUrl).origin;
131
+ } catch {
132
+ return null;
133
+ }
134
+ const hit = events.find((e) => {
135
+ if (e.type === 'pageerror') return true;
136
+ if (e.type !== 'response') return false;
137
+ try {
138
+ return new URL(String(e.text).split(' ').pop()).origin === origin;
139
+ } catch {
140
+ return false;
141
+ }
142
+ });
143
+ if (!hit) return null;
144
+ return hit.type === 'pageerror' ? `uncaught ${hit.text}` : `response ${hit.text}`;
145
+ }
146
+
66
147
  /** Replace the value of every secret by [redacted:NAME]. `secrets` maps a variable name to its value. */
67
148
  export function redact(text, secrets = {}) {
68
149
  let out = String(text ?? '');
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // Prints how `review` works for a platform of relevo.yml, as JSON. Usage:
3
3
  // node scripts/review-adapter.mjs --platform web [--target web] [--config relevo.yml]
4
- // Output: { supported, stack, target, kind, log, capture, driver, needs, reason, platform, needs_autopilot }
4
+ // Output: { supported, stack, target, kind, log, capture, driver, needs, reason, platform, needs_autopilot, advice }
5
+ // `advice` is what to tell the person, in the language of relevo.yml, when a browser review cannot start with review.autopilot off.
5
6
  // exit 0: supported exit 1: no adapter for this platform and target (the reason is in the output) exit 2: bad usage or invalid relevo.yml
6
7
 
7
8
  import { existsSync, readFileSync } from 'node:fs';
8
9
  import { resolve } from 'node:path';
9
10
  import { loadConfig } from './lib/config.mjs';
10
- import { adapterFor, needsAutopilot, targetOf } from './lib/review-adapters.mjs';
11
+ import { adapterFor, autopilotAdvice, needsAutopilot, targetOf } from './lib/review-adapters.mjs';
11
12
 
12
13
  const arg = (name) => {
13
14
  const i = process.argv.indexOf(`--${name}`);
@@ -28,5 +29,6 @@ if (!platform) fail(`there is no platform "${name}" in relevo.yml (platforms: ${
28
29
 
29
30
  const target = arg('target') ?? targetOf(platform);
30
31
  const adapter = adapterFor({ stack: platform.stack, target });
31
- console.log(JSON.stringify({ ...adapter, platform: name, needs_autopilot: needsAutopilot(adapter) }));
32
+ const advice = autopilotAdvice(adapter, { autopilot: loaded.config.review?.autopilot, language: loaded.config.language });
33
+ console.log(JSON.stringify({ ...adapter, platform: name, needs_autopilot: needsAutopilot(adapter), advice }));
32
34
  process.exit(adapter.supported ? 0 : 1);
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Prints a short brief of an issue for the person who reviews it. Usage:
3
3
  // node scripts/review-brief.mjs --issue 42 --title "Birthday list" --body-file issue.md [--built-file comment.md] [--language en|es]
4
+ // [--autopilot off|precheck|verify]
4
5
  // exit 0: printed exit 2: bad arguments
5
6
 
6
7
  import { readFileSync } from 'node:fs';
@@ -14,7 +15,7 @@ const number = arg('issue');
14
15
  const title = arg('title');
15
16
  const bodyFile = arg('body-file');
16
17
  if (!number || title === undefined || !bodyFile) {
17
- console.error('usage: review-brief.mjs --issue <n> --title "<title>" --body-file <file> [--built-file <file>] [--language en|es]');
18
+ console.error('usage: review-brief.mjs --issue <n> --title "<title>" --body-file <file> [--built-file <file>] [--language en|es] [--autopilot off|precheck|verify]');
18
19
  process.exit(2);
19
20
  }
20
21
  const read = (f) => {
@@ -26,4 +27,4 @@ const read = (f) => {
26
27
  }
27
28
  };
28
29
  const built = arg('built-file') ? read(arg('built-file')) : '';
29
- console.log(reviewBrief({ issue: { number, title, body: read(bodyFile) }, built, language: arg('language') ?? 'en' }));
30
+ console.log(reviewBrief({ issue: { number, title, body: read(bodyFile) }, built, language: arg('language') ?? 'en', autopilot: arg('autopilot') ?? 'off' }));
@@ -3,7 +3,8 @@
3
3
  // review-state.mjs init <change-dir> [--autopilot off|precheck|verify] build the checklist from the change's scenarios
4
4
  // review-state.mjs set <id> <ok|fail|pending> [note ...]
5
5
  // review-state.mjs sync <change-dir> after a fix: keep confirmed lines, retest failed ones, add new scenarios
6
- // review-state.mjs capture <path> record a screenshot that was taken
6
+ // review-state.mjs capture <path> record a screenshot that was taken (refused after "agree")
7
+ // review-state.mjs qa-pass <script> QA passed this browser review script; the runner runs only this exact content
7
8
  // review-state.mjs audit [<log-file>|-] [--package <id>] [--pid <n>]
8
9
  // audit the device log (stdin by default), write the report;
9
10
  // with --package only that app's lines are audited
@@ -24,7 +25,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
24
25
  import { dirname, join } from 'node:path';
25
26
  import { checklistFromChange } from './lib/checklist.mjs';
26
27
  import { auditLog, auditReport, filterByApp } from './lib/logaudit.mjs';
27
- import { applyWaivers, autoItem, autoSection, confirmEnv, decidePr, newState, precheckItem, setItem, syncState, waivedSection, waiveFinding } from './lib/review-state.mjs';
28
+ import { applyWaivers, autoItem, autoSection, confirmEnv, decidePr, newState, precheckItem, qaPass, recordCapture, setItem, syncState, waivedSection, waiveFinding } from './lib/review-state.mjs';
28
29
 
29
30
  const argv = process.argv.slice(2);
30
31
  const si = argv.indexOf('--state');
@@ -72,12 +73,18 @@ try {
72
73
  }
73
74
  case 'capture': {
74
75
  if (!rest[0]) fail('usage: review-state.mjs capture <path>');
75
- const state = load();
76
- state.captures.push(rest[0]);
76
+ const state = recordCapture(load(), rest[0]);
77
77
  save(state);
78
78
  console.log(`recorded ${rest[0]}`);
79
79
  break;
80
80
  }
81
+ case 'qa-pass': {
82
+ if (!rest[0] || !existsSync(rest[0])) fail('usage: review-state.mjs qa-pass <script>');
83
+ const state = qaPass(load(), rest[0], readFileSync(rest[0], 'utf8'));
84
+ save(state);
85
+ console.log(`QA passed ${rest[0]} (sha256 ${state.qaScript.sha256.slice(0, 12)}); any change to it needs a new pass`);
86
+ break;
87
+ }
81
88
  case 'drain': {
82
89
  const file = join(dirname(statePath), 'device.log');
83
90
  const text = readFileSync(0, 'utf8');
@@ -164,7 +171,7 @@ try {
164
171
  break;
165
172
  }
166
173
  default:
167
- fail('usage: review-state.mjs <init|set|sync|capture|drain|audit|waive|waived|confirm-env|precheck|auto|auto-list|agree|show|decide> [...]');
174
+ fail('usage: review-state.mjs <init|set|sync|capture|qa-pass|drain|audit|waive|waived|confirm-env|precheck|auto|auto-list|agree|show|decide> [...]');
168
175
  }
169
176
  } catch (err) {
170
177
  fail(err.message);
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Checks that a browser review can drive: the project's dependencies, Playwright and its Chromium, and the address
3
+ // under review with the module scripts its page loads. Usage:
4
+ // node scripts/web-preflight.mjs --url http://localhost:5173 [--timeout 5000] [--wait 30000]
5
+ // With --wait it is also the readiness probe of a server the agent started: the address is retried until it answers.
6
+ // Prints { ok, check, detail, start, env } as JSON: `start` is the command that starts the app, `env` the variable
7
+ // names of .env.example (the real environment files are never read).
8
+ // exit 0: ready exit 3: no node_modules (run npm install) exit 4: Playwright does not resolve
9
+ // exit 5: the Chromium of Playwright is missing exit 6: the address or one of its modules does not answer below 400
10
+ // exit 2: bad arguments
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { loadPlaywright } from './lib/web-review.mjs';
14
+ import { webPreflight } from './lib/web-preflight.mjs';
15
+
16
+ const arg = (name) => {
17
+ const i = process.argv.indexOf(`--${name}`);
18
+ return i === -1 ? undefined : process.argv[i + 1];
19
+ };
20
+ const number = (name, fallback) => {
21
+ const v = arg(name);
22
+ if (v === undefined) return fallback;
23
+ const n = Number(v);
24
+ if (!Number.isInteger(n) || n < 0) {
25
+ console.error(`--${name} must be a number of milliseconds`);
26
+ process.exit(2);
27
+ }
28
+ return n;
29
+ };
30
+
31
+ const url = arg('url');
32
+ try {
33
+ if (!url || !/^https?:$/.test(new URL(url).protocol)) throw new Error();
34
+ } catch {
35
+ console.error('usage: web-preflight.mjs --url <http address> [--timeout 5000] [--wait 30000]');
36
+ process.exit(2);
37
+ }
38
+ const cwd = process.cwd();
39
+ const result = await webPreflight({ cwd, url, timeout: number('timeout', 5000), wait: number('wait', 0) }, {
40
+ exists: existsSync,
41
+ readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null),
42
+ loadPlaywright: () => loadPlaywright(cwd),
43
+ fetch: globalThis.fetch,
44
+ });
45
+ const { code, ...shown } = result;
46
+ console.log(JSON.stringify(shown));
47
+ process.exit(code);
@@ -1,23 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  // Runs a review script in a browser of its own and reports what it checked. Usage:
3
3
  // node scripts/web-review.mjs --script evidence/review-web.mjs --url http://localhost:5173 \
4
- // [--allow-origins https://staging.example.com] [--secret-env TEST_USER,TEST_PASS] [--out evidence] [--headed]
4
+ // [--allow-origins https://staging.example.com] [--secret-env TEST_USER,TEST_PASS] [--out evidence]
5
+ // [--state evidence/review-state.json] [--headed]
5
6
  // The script is the agent's, written from the scenarios and reviewed by QA. Its default export receives
6
- // { page, context, baseUrl, line, credentials }: line(n, how, fn) runs fn and records the checklist line n as
7
+ // { page, context, baseUrl, line, credentials, capture }: line(n, how, fn) runs fn and records the checklist line n as
7
8
  // auto-ok or auto-fail; credentials(name) gives a test credential taken from the environment variable `name`,
8
- // which must be in --secret-env. The browser has a clean profile and can reach only localhost, the page under
9
- // review and the allowed origins; every secret value is redacted from what is written.
10
- // Writes <out>/web-log.txt (the log the audit reads, with --package web) and <out>/web-result.json.
11
- // exit 0: the script ran (lines can still have failed) exit 1: the script was refused, Playwright is missing,
12
- // the browser could not start, or the script failed outside a line exit 2: bad arguments
13
-
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
- import { createRequire } from 'node:module';
9
+ // which must be in --secret-env; capture(name, check, { fullPage }) runs the assertion `check` and, when it holds,
10
+ // saves <out>/<name>.png and records it in the review state (nothing of the image is given back or printed).
11
+ // Before anything runs, the review state must say the autopilot is on, the data was confirmed, the review is not
12
+ // agreed yet, and QA passed this exact script (review-state.mjs qa-pass). The browser has a clean profile and can
13
+ // reach only localhost, the page under review and the allowed origins; every secret value is redacted from what is
14
+ // written, and the script's own console goes to <out>/script-console.txt, never to this output.
15
+ // Writes <out>/web-log.txt (the log the audit reads, with --package web) and <out>/web-result.json. Each run against
16
+ // another server uses an --out of its own; only the log of the normal run is audited.
17
+ // exit 0: the script ran (lines and captures can still have failed) exit 1: the review state refused the run,
18
+ // the script was refused, Playwright is missing, the browser could not start, or the script failed outside a line
19
+ // exit 2: bad arguments
20
+
21
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
16
22
  import { join, resolve } from 'node:path';
17
23
  import { pathToFileURL } from 'node:url';
18
- import { formatBrowserLog, isAllowedOrigin, redact, scanReviewScript } from './lib/web-review.mjs';
24
+ import { format } from 'node:util';
25
+ import { assertDriving, recordCapture, scriptHash } from './lib/review-state.mjs';
26
+ import { formatBrowserLog, isAllowedOrigin, loadPlaywright, makeCapture, probableCause, redact, scanReviewScript } from './lib/web-review.mjs';
19
27
 
20
28
  const RUN_LIMIT_MS = 120000;
29
+ // The runner's own output. The script's console is taken over while it runs, so these are kept apart.
30
+ const say = console.log.bind(console);
31
+ const tell = console.error.bind(console);
21
32
 
22
33
  const arg = (name) => {
23
34
  const i = process.argv.indexOf(`--${name}`);
@@ -25,13 +36,13 @@ const arg = (name) => {
25
36
  };
26
37
  const list = (name) => (arg(name) ?? '').split(',').map((s) => s.trim()).filter(Boolean);
27
38
  const stop = (code, message) => {
28
- console.error(message);
39
+ tell(message);
29
40
  process.exit(code);
30
41
  };
31
42
 
32
43
  const scriptPath = arg('script');
33
44
  const baseUrl = arg('url');
34
- if (!scriptPath || !baseUrl || !existsSync(scriptPath)) stop(2, 'usage: web-review.mjs --script <file> --url <url> [--allow-origins a,b] [--secret-env A,B] [--out evidence] [--headed]');
45
+ if (!scriptPath || !baseUrl || !existsSync(scriptPath)) stop(2, 'usage: web-review.mjs --script <file> --url <url> [--allow-origins a,b] [--secret-env A,B] [--out evidence] [--state evidence/review-state.json] [--headed]');
35
46
  let origin;
36
47
  try {
37
48
  origin = new URL(baseUrl);
@@ -41,26 +52,38 @@ try {
41
52
  const allowed = list('allow-origins');
42
53
  const secretNames = list('secret-env');
43
54
  const out = resolve(arg('out') ?? 'evidence');
55
+ const statePath = resolve(arg('state') ?? 'evidence/review-state.json');
56
+
57
+ const readState = () => JSON.parse(readFileSync(statePath, 'utf8'));
58
+ if (!existsSync(statePath)) stop(1, `no review in progress (${statePath} not found): the runner drives only a review started with review-state.mjs init`);
59
+ try {
60
+ assertDriving(readState(), { open: true });
61
+ } catch (error) {
62
+ stop(1, `the review state refused the run before any browser started: ${error.message}`);
63
+ }
44
64
 
45
- const problems = scanReviewScript(readFileSync(scriptPath, 'utf8'));
65
+ const source = readFileSync(scriptPath, 'utf8');
66
+ const problems = scanReviewScript(source);
46
67
  if (problems.length > 0) stop(1, `the review script was refused before any browser started:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
68
+ if (readState().qaScript?.sha256 !== scriptHash(source)) {
69
+ stop(1, `the review script is not the one QA passed: launch relevo:qa on it, and after QA PASSED run review-state.mjs qa-pass ${scriptPath}`);
70
+ }
47
71
 
48
72
  const secrets = Object.fromEntries(secretNames.filter((n) => process.env[n]).map((n) => [n, process.env[n]]));
49
73
  const hide = (text) => redact(text, secrets);
50
74
 
51
- function loadPlaywright() {
52
- for (const base of [join(process.cwd(), 'package.json'), import.meta.url]) {
53
- try {
54
- return createRequire(base)('playwright');
55
- } catch { /* try the next place */ }
56
- }
57
- return null;
58
- }
59
75
  const playwright = loadPlaywright();
60
- if (!playwright) stop(1, 'Playwright cannot be resolved from this project: install it with "npm install -D playwright" and "npx playwright install chromium"');
76
+ if (!playwright) stop(1, 'Playwright cannot be resolved from this project: with the person\'s permission, install it with "npm install --no-save playwright" and "npx playwright install chromium", then restore package-lock.json if npm changed it');
77
+
78
+ if (arg('out') === undefined && existsSync(join(out, 'web-log.txt'))) {
79
+ tell(`warning: ${join(out, 'web-log.txt')} is replaced; a run against another server uses an --out of its own (evidence/run-<port>)`);
80
+ }
81
+ mkdirSync(out, { recursive: true });
61
82
 
62
83
  const events = [];
63
84
  const results = [];
85
+ const captures = [];
86
+ const scriptConsole = [];
64
87
  let scriptError = null;
65
88
  let pagesLoaded = 0;
66
89
  let browser;
@@ -80,6 +103,13 @@ const credentials = (name) => {
80
103
  return secrets[name];
81
104
  };
82
105
 
106
+ // From here on the script's console goes to a file, also after a timeout: the runner prints with say and tell.
107
+ function takeOverConsole() {
108
+ ['log', 'info', 'warn', 'error', 'debug', 'trace', 'dir', 'table'].forEach((level) => {
109
+ console[level] = (...args) => { scriptConsole.push(`${level} ${format(...args)}`); };
110
+ });
111
+ }
112
+
83
113
  async function run() {
84
114
  try {
85
115
  browser = await playwright.chromium.launch({ headless: !process.argv.includes('--headed') });
@@ -105,11 +135,14 @@ async function run() {
105
135
  if (r.request().isNavigationRequest() && r.request().frame() === page.mainFrame() && r.status() < 400) pagesLoaded += 1;
106
136
  });
107
137
  page.on('response', (r) => { if (r.status() >= 400) events.push({ type: 'response', text: `${r.status()} ${r.request().method()} ${r.url()}` }); });
138
+ // The real path, so a capture is recorded relative to the working directory even through a symbolic link.
139
+ const capture = makeCapture({ page, out: realpathSync(out), captures });
108
140
 
109
141
  try {
142
+ takeOverConsole();
110
143
  const mod = await import(pathToFileURL(resolve(scriptPath)).href);
111
144
  if (typeof mod.default !== 'function') throw new Error('the script must export a default async function');
112
- await mod.default({ page, context, baseUrl, line, credentials });
145
+ await mod.default({ page, context, baseUrl, line, credentials, capture });
113
146
  } catch (error) {
114
147
  scriptError = String(error?.message ?? error).split('\n')[0];
115
148
  }
@@ -124,11 +157,29 @@ clearTimeout(timer);
124
157
  clearTimeout(backstop);
125
158
  if (browser) await browser.close().catch(() => {});
126
159
 
127
- mkdirSync(out, { recursive: true });
128
160
  // A browser that never loaded a page proves nothing: the log is left empty so the audit is inconclusive.
129
161
  writeFileSync(join(out, 'web-log.txt'), pagesLoaded === 0 ? '' : hide(formatBrowserLog(events, { origin: origin.host })));
130
- writeFileSync(join(out, 'web-result.json'), hide(`${JSON.stringify({ url: baseUrl, lines: results, blocked: events.filter((e) => e.type === 'blocked').length, pagesLoaded, scriptError }, null, 2)}\n`));
131
- results.sort((a, b) => a.n - b.n).forEach((r) => console.log(hide(`line ${r.n}: ${r.status} - ${r.how}${r.error ? ` (${r.error})` : ''}`)));
132
- console.log(`${events.length} event(s) written to ${join(out, 'web-log.txt')}; audit it with: review-state.mjs audit --package web`);
133
- if (scriptError) stop(1, hide(`the script failed outside a line: ${scriptError}`));
162
+ writeFileSync(join(out, 'web-result.json'), hide(`${JSON.stringify({ url: baseUrl, lines: results, captures, blocked: events.filter((e) => e.type === 'blocked').length, pagesLoaded, scriptError }, null, 2)}\n`));
163
+ writeFileSync(join(out, 'script-console.txt'), hide(scriptConsole.map((l) => `${l}\n`).join('')));
164
+
165
+ // The state is read again, so a line set while the browser ran is not lost.
166
+ const saved = captures.filter((c) => c.ok);
167
+ if (saved.length > 0) {
168
+ try {
169
+ const state = readState();
170
+ saved.forEach((c) => recordCapture(state, c.path));
171
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
172
+ } catch (error) {
173
+ tell(`the captures could not be recorded in ${statePath}: ${error.message}`);
174
+ }
175
+ }
176
+
177
+ results.sort((a, b) => a.n - b.n).forEach((r) => say(hide(`line ${r.n}: ${r.status} - ${r.how}${r.error ? ` (${r.error})` : ''}`)));
178
+ captures.forEach((c) => say(hide(c.ok ? `captured ${c.path}` : `capture ${c.name}: failed (${c.error})`)));
179
+ say(`${events.length} event(s) written to ${join(out, 'web-log.txt')}; audit it with: review-state.mjs audit --package web`);
180
+ if (scriptError) {
181
+ const cause = probableCause(events, baseUrl);
182
+ if (cause) tell(hide(`the page itself failed: ${cause}`));
183
+ stop(1, hide(`the script failed outside a line: ${scriptError}`));
184
+ }
134
185
  process.exit(0);
@@ -19,6 +19,7 @@ Scripts live in `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `node`. They hol
19
19
  - Never patch code during the review. A defect found here goes through `/relevo:fix`.
20
20
  - Never waive a finding, and never suggest or hint that one could be waived: a waiver is the person's decision, written by them as `waive <number> <reason>`.
21
21
  - Never commit the `evidence/` folder, and never force-push.
22
+ - Never read the project's environment files (`.env`, `.env.local` and the like); only `.env.example` and the specification give variable names. Never stop, restart or signal a server you did not start with `extra-server.mjs`, above all the one the person started.
22
23
 
23
24
  The only `adb` commands you may run are: list devices, clear or read the log (`logcat`), and the screenshot taken by `capture.mjs`.
24
25
 
@@ -31,7 +32,8 @@ Stop with a clear message, changing nothing, if any of these fails:
31
32
  - `node ${CLAUDE_PLUGIN_ROOT}/scripts/validate-config.mjs relevo.yml` exits non-zero. Show its output.
32
33
  - `git check-ignore -q evidence/` fails: the evidence folder is not ignored by git. Tell the person to add `evidence/` to `.gitignore`.
33
34
  - The issue is not `built` (or `in-review`, to resume). Say which state it is in and what to do (`/relevo:go`, `/relevo:fix`).
34
- - `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-adapter.mjs --platform <platform>` (the platform of the issue, from `platform-of.mjs`) exits 1: no review is possible for that platform and target; show the `reason` and stop, changing nothing. If it prints `needs_autopilot: true` and `review.autopilot` is `off`, stop and tell the person to set `review.autopilot` to `precheck` or `verify` in `relevo.yml`: a browser has no manual review.
35
+ - `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-adapter.mjs --platform <platform>` (the platform of the issue, from `platform-of.mjs`) exits 1: no review is possible for that platform and target; show the `reason` and stop, changing nothing. If it prints `needs_autopilot: true` and `review.autopilot` is `off`, stop and show its `advice` as it is: it recommends `verify`, names `precheck` as the cautious option, and is already in the language of `relevo.yml`.
36
+ - Not a stop, a warning: if `git status --porcelain -- relevo.yml` prints anything, tell the person that `relevo.yml` has uncommitted changes, that this review uses them as they are, and that they belong on the base branch through a commit of their own. Never stage or commit `relevo.yml` in this review.
35
37
  - For an adapter whose kind is `device`: `node ${CLAUDE_PLUGIN_ROOT}/scripts/device.mjs` exits non-zero. Show its message: it says what to connect or authorise. Keep the serial it prints as `<serial>`. A `browser` adapter needs no device.
36
38
 
37
39
  Find the repository (`$GITHUB_REPOSITORY`, or `gh repo view --json nameWithOwner -q .nameWithOwner`), the issue's branch (`git branch -r --list "origin/feature/$ARGUMENTS-*"`) and its change folder: the one `openspec list --json` names that starts with `$ARGUMENTS-` (`openspec/changes/<name>`).
@@ -77,14 +79,28 @@ If `evidence/review-state.json` exists for this issue, resume: show the confirme
77
79
 
78
80
  Otherwise: `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-state.mjs init openspec/changes/<change folder> --autopilot <review.autopilot>`, then clear the log so the audit only sees this session: `adb -s <serial> logcat -c`. Then ask the person to start the app, or restart it if it was already open, so that the log names its process and the audit can ignore other apps on the device.
79
81
 
82
+ For a `browser` adapter there is no `adb` and no device log: after `init`, run the preflight against the address under review (the one the person gives; for Vue, `http://localhost:5173` when they give none):
83
+
84
+ ```bash
85
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/web-preflight.mjs --url <address>
86
+ ```
87
+
88
+ It prints JSON and exits by what is missing. Tell the person in their language:
89
+
90
+ - `3`: there is no `node_modules`; ask them to run `npm install` first, and stop until they say it is done.
91
+ - `4` or `5`: Playwright or its Chromium is missing. Ask for permission to install them; only on a yes run `npm install --no-save playwright` and `npx playwright install chromium`, then `git diff --quiet -- package-lock.json || git restore -- package-lock.json` (npm can rewrite the lock file even with `--no-save`), and run the preflight again.
92
+ - `6`: the app does not answer, or one of its modules answers 400 or more (`detail` names it). Keep its `start` and `env` for step 3: the person starts the app there. If the app was already running and a module fails, tell them to restart their server; never do it yourself.
93
+ - `0`: ready.
94
+
80
95
  ### 3. Brief the person, then stop
81
96
 
82
- Tell the person to start the app on the device themselves (for example `flutter run -d <serial>` in another terminal). Then show:
97
+ Tell the person to start the app on the device themselves (for example `flutter run -d <serial>` in another terminal). For a `browser` adapter, tell them the exact command to start it in their own terminal: the `start` of the preflight, with the variable names of its `env` and the address under review. Then show:
83
98
 
84
- 0. The **brief**, computed and not written by you: save the issue body and the `built` comment outside the working tree (`gh issue view $ARGUMENTS --json title,body,comments`), then `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-brief.mjs --issue $ARGUMENTS --title "<title>" --body-file <body> --built-file <the built comment> --language <language>`. It prints about five lines: what was asked, the outcome, what to check first and how many lines there are to check. Show them as they are.
85
- 1. A **shot list**: two or three screenshots worth taking, named like `01_initial`, `02_action`, `03_result`, taken from the scenarios. For a change with nothing visible, say there are no screenshots to take and the log will be audited anyway.
99
+ 0. The **brief**, computed and not written by you: save the issue body and the `built` comment outside the working tree (`gh issue view $ARGUMENTS --json title,body,comments`), then `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-brief.mjs --issue $ARGUMENTS --title "<title>" --body-file <body> --built-file <the built comment> --language <language> --autopilot <review.autopilot>`. It prints about five lines: what was asked, the outcome, what to check first and how many lines there are to check, and, with `precheck` or `verify`, a fixed note that the person confirms every line. Show them as they are.
100
+ 1. A **shot list**: two or three screenshots worth taking, named like `01_initial`, `02_action`, `03_result`, taken from the scenarios. For a change with nothing visible, say there are no screenshots to take and the log will be audited anyway. In a browser review, each shot also names what proves the page shows that state and no other shot's (the text of the alert, the heading, the URL): it becomes the check of its capture.
86
101
  2. The **checklist**: `node ${CLAUDE_PLUGIN_ROOT}/scripts/checklist.mjs openspec/changes/<change folder>`, one numbered line per scenario, edge cases included. Point out any step the person must do outside the app, because you will not do it.
87
- 3. What they can write: `capture <name>`, `ok <numbers>` (or `ok all`), `fail <number> <what went wrong>`, `retest <numbers>`, `done`. They may write in any language; understand the intent.
102
+ 3. With `review.autopilot` `precheck` or `verify`, the **plan of the walk**, so the person does not have to ask: one line per checklist line you walk yourself, `<n>. <method>: <how>`, where the method is `browser` (a Playwright assertion), `command` (an npm script of `package.json`, such as typecheck, lint, test or build), `read` (code, CI or docs) or `server <port>` (a run against a server you start with another configuration: say which variables are set or removed). Then the lines left to the person. Every configuration you will start is listed here, before the person confirms the data (Autopilot, step 1), so that confirmation covers them too.
103
+ 4. What they can write: `capture <name>`, `ok <numbers>` (or `ok all`), `fail <number> <what went wrong>`, `retest <numbers>`, `done`. They may write in any language; understand the intent.
88
104
 
89
105
  **Then end your turn.** Make no further tool calls until the person writes. Do not advance on your own and do not run the log audit early.
90
106
 
@@ -92,7 +108,7 @@ Tell the person to start the app on the device themselves (for example `flutter
92
108
 
93
109
  Handle each message as it comes:
94
110
 
95
- - `capture <name>`: `node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs <name>`, then record it with `review-state.mjs capture <path>`. Reply with the saved path only. If the script rejects the name, show its message and ask for another.
111
+ - `capture <name>`: `node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs <name>`, then record it with `review-state.mjs capture <path>`. Reply with the saved path only. If the script rejects the name, show its message and ask for another. In a browser review there is no device to capture: add the shot, with its check, to the shot list and to the review script, which then needs QA again (Autopilot, step 4).
96
112
  - `ok <numbers>`: `review-state.mjs set <n> ok` for each. `fail <n> <note>`: `review-state.mjs set <n> fail <note>`. `retest <numbers>`: `review-state.mjs set <n> pending` for each, so the line must be confirmed again (useful after a fix).
97
113
  - A bug or something that looks wrong: do not edit anything. Record it as a `fail` and say the fix goes through `/relevo:fix $ARGUMENTS`; the review can resume afterwards.
98
114
  - `waive <number> <reason>`: the person's decision, available once the audit has findings; run `review-state.mjs waive <number> <reason>`. It refuses an empty reason and an inconclusive audit; show its message.
@@ -102,14 +118,21 @@ After **each** message of the person, keep the device log from being lost to its
102
118
 
103
119
  Never go to step 5 without an explicit `done` (or the equivalent in the person's language).
104
120
 
121
+ Captures belong to this step: the shot list is taken while the person tests, before the agreement, and `review-state.mjs capture` refuses after `agree`. If the person asks for screenshots after agreeing, point to the files already in `evidence/` and do not drive again.
122
+
105
123
  ### Autopilot (only when `review.autopilot` is `precheck` or `verify`)
106
124
 
107
125
  When it is `off` skip this section: the person does everything. The person always confirms the lines and gives the agreement; nothing here replaces that.
108
126
 
109
127
  1. **Confirm the data.** Before any driven step, ask the person to confirm in writing that the app points to data that is not production. Record it only after they say so: `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-state.mjs confirm-env`. Without it `precheck` and `auto` are refused.
110
- 2. **Walk the lines.** `precheck`: after you walk a line, record your own result with `review-state.mjs precheck <n> ok|fail <note>`; the line stays pending until the person writes `ok`. `verify`: in addition, when the `THEN` of a line was proved by an assertion, a semantic finder or the log, record `review-state.mjs auto <n> <what proved it>`; the person confirms the other lines. A line about appearance, animation or fidelity to an image is never marked automatically.
128
+ 2. **Walk the lines.** `precheck`: after you walk a line, record your own result with `review-state.mjs precheck <n> ok|fail <note>`; the line stays pending until the person writes `ok`. `verify`: in addition, when the `THEN` of a line was proved by an assertion, a semantic finder or the log, or by the exit code of a project command, record `review-state.mjs auto <n> <what proved it>`; the person confirms the other lines. A line about appearance, animation or fidelity to an image is never marked automatically. A line checked only by reading code, CI or docs is recorded with `precheck` and what you found, never with `auto`.
129
+ - **Project commands.** Run the npm script of `package.json` that the line names (typecheck, lint, test, build), never a script that fixes or formats files. Run `git status --porcelain` before and after: if a tracked file changed, restore it with `git restore -- <file>` and tell the person which file changed. Record the command and its exit code as what proved the line.
111
130
  3. **Flutter on a device.** Drive through the Dart and Flutter MCP tools: `launch_app`, `connect_dart_tooling_daemon`, then `flutter_driver` (`get_health`, `waitFor`, `tap`, `enter_text`, `scroll`, `screenshot`), `get_runtime_errors`, `get_app_logs`, and `hot_reload` after a micro-fix. It needs `lib/main_driver.dart` with `enableFlutterDriverExtension()`. Rules: never pass `timeout` to `flutter_driver` (it breaks the MCP server); find widgets only by semantic finders (text, key, tooltip, type), never by coordinates; if a finder fails twice, stop driving and go back to manual for that line; the log of the MCP server is the source of the audit, and if it does not answer or is empty the audit is inconclusive.
112
- 4. **A browser (Vue, or Flutter on the web).** Write a Playwright script from the scenarios to `evidence/review-web.mjs`: it exports a default async function that receives `{ page, baseUrl, line, credentials }`, uses `node:assert/strict`, and calls `line(<checklist number>, "<what is asserted>", async () => { ... })` once per line it can check. Every `THEN` it covers must have an assertion that would fail if the feature did not exist (text, URL, response, absence of errors), not a screenshot. A test login is read with `credentials("NAME")` where `NAME` is an environment variable the person names; you never read, print or store its value. Then launch `relevo:qa` on the script, read-only. Only if it answers `QA PASSED`, run `node ${CLAUDE_PLUGIN_ROOT}/scripts/web-review.mjs --script evidence/review-web.mjs --url <the address of the app> --allow-origins <review.allowed_origins> --secret-env <the names>`. The browser is its own, with a clean profile, and can reach only `localhost`, the address under review and `review.allowed_origins`; a refused script is not run in any other way. Its `auto-ok` lines are recorded as in step 2, its `auto-fail` lines as `precheck <n> fail` with the message, and `evidence/web-log.txt` is the log of step 5 (audit with `--package web`). The script checks are a tripwire, not a sandbox: if it touches anything sensitive, show it to the person before it runs.
131
+ 4. **A browser (Vue, or Flutter on the web).** Write a Playwright script from the scenarios to `evidence/review-web.mjs`: it exports a default async function that receives `{ page, baseUrl, line, credentials, capture }`, uses `node:assert/strict`, and calls `line(<checklist number>, "<what is asserted>", async () => { ... })` once per line it can check. Every `THEN` it covers must have an assertion that would fail if the feature did not exist (text, URL, response, absence of errors), not a screenshot. A test login is read with `credentials("NAME")` where `NAME` is an environment variable the person names; you never read, print or store its value.
132
+ - **Screenshots.** Turn the shot list into `capture("<name>", async () => { <the check of that shot> }, { fullPage: true })` calls. The check runs first, and when it fails no screenshot is taken and the capture is reported as failed. `capture` is the only way to take one: the runner refuses `page.screenshot`, any file path, and anything the script prints goes to `script-console.txt`, never to you. You never open the PNG files; their paths are all you see.
133
+ - **QA.** Then launch `relevo:qa` on the script, read-only, with the shot list: it rejects a `capture` name that is not on the shot list, and a check that would also hold for another shot (for example only `[role="alert"]` for two different errors). Only if it answers `QA PASSED`, record that exact script with `node ${CLAUDE_PLUGIN_ROOT}/scripts/review-state.mjs qa-pass evidence/review-web.mjs`. Any change to the script afterwards, even one port, needs `relevo:qa` and `qa-pass` again: the runner refuses a script whose content QA did not pass.
134
+ - **The normal run.** Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/web-preflight.mjs --url <the address of the app>` again: it must exit 0. Then run `node ${CLAUDE_PLUGIN_ROOT}/scripts/web-review.mjs --script evidence/review-web.mjs --url <the address of the app> --allow-origins <review.allowed_origins> --secret-env <the names> --out evidence`. The runner itself refuses before any browser starts when the state has no confirmation of the data, the autopilot is `off` or the review is already agreed. The browser is its own, with a clean profile, and can reach only `localhost`, the address under review and `review.allowed_origins`; a refused script is not run in any other way. Its `auto-ok` lines are recorded as in step 2, its `auto-fail` lines as `precheck <n> fail` with the message, its captures are already recorded in the review state, and `evidence/web-log.txt` is the log of step 5 (audit with `--package web`). When it says `the page itself failed`, that is the cause to tell the person, not the selector timeout after it. The script checks are a tripwire, not a sandbox: if it touches anything sensitive, show it to the person before it runs.
135
+ - **Runs with another configuration** (the `server <port>` lines of the plan). Start each server yourself on a free localhost port with `node ${CLAUDE_PLUGIN_ROOT}/scripts/extra-server.mjs start --port <port> [--set NAME=value] [--unset NAME] -- npx vite --port <port> --strictPort`. It refuses a busy port and refuses `--unset` while an environment file exists (the dev server loads it by itself): then that line is pre-checked by reading the code or left for the person. Wait until it answers with `web-preflight.mjs --url http://localhost:<port> --wait 30000`, confirm it with `extra-server.mjs check --port <port>`, and run the same script with `--url http://localhost:<port> --out evidence/run-<port>`: each run has an output folder of its own, and these logs are never audited, because they show errors on purpose. Stop each one afterwards with `extra-server.mjs stop --port <port>`. The server the person started is never stopped or restarted.
113
136
  5. **Never** drive in the person's own browser, on an origin that is not allowed, with coordinates, with `adb shell input`, Appium or Maestro, or send a message or make a call in another app.
114
137
 
115
138
  ### 5. Audit the log
@@ -119,6 +142,12 @@ package=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/app-id.mjs)
119
142
  adb -s <serial> logcat -d -v threadtime | node ${CLAUDE_PLUGIN_ROOT}/scripts/review-state.mjs audit --accumulated --package "$package"
120
143
  ```
121
144
 
145
+ For a `browser` adapter there is no device: audit only the log of the normal run, never one of `evidence/run-<port>/`:
146
+
147
+ ```bash
148
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/review-state.mjs audit evidence/web-log.txt --package web
149
+ ```
150
+
122
151
  `app-id.mjs` prints the application id of the project. The audit then looks only at the lines written by that app's processes, so other apps on the device are neither audited nor quoted. If `app-id.mjs` finds no id, run the audit without `--package`.
123
152
 
124
153
  It prints `REVIEW PASSED`, `REVIEW REJECTED` with the numbered findings, or `REVIEW INCONCLUSIVE`, and writes `evidence/review-report.md`. **Inconclusive** means the log was empty or had no line of the app: it proves nothing, it is not a pass and it cannot be waived. Ask the person to start the app again, repeat the steps, and audit once more.
@@ -150,11 +179,20 @@ Only when `decide` exited 0.
150
179
  ### 8. Evidence
151
180
 
152
181
  - `evidence.policy: local_only`: delete the `evidence/` folder now. Nothing is attached anywhere.
153
- - `evidence.policy: attach_to_pr`: GitHub has no API for attaching images, so you cannot upload them. List the files in `evidence/` and ask the person to drag them into the pull request description. Keep the folder until they say they did, then delete it.
182
+ - `evidence.policy: attach_to_pr`: GitHub has no API for attaching images, so you cannot upload them. List only the images, in every run folder (`find evidence -name '*.png'`), and ask the person to drag them into the pull request description; the logs, results and scripts repeat what the body already says. Keep the folder until they say they did, then delete it. When there is no image, delete the folder without asking and say there was nothing to attach.
154
183
 
155
184
  ### 9. Return to base branch
156
185
 
157
- Restore the working tree to the base branch so the workspace remains clean on the base branch for other tasks or subsequent reviews, but only where that is possible: in the main checkout, and when no other worktree holds the base branch.
186
+ Restore the working tree to the base branch so the workspace remains clean on the base branch for other tasks or subsequent reviews, but only where that is possible: in the main checkout, when no other worktree holds the base branch, and when no dev server of the address under review runs from the working tree (switching would remove its files under it and break it until it is restarted).
187
+
188
+ For a `browser` adapter, first stop the servers you started, then ask the guard:
189
+
190
+ ```bash
191
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/extra-server.mjs stop --all
192
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/dev-server-guard.mjs --url <the address of the app>
193
+ ```
194
+
195
+ Switch only when the guard exits 0. On 1 a server runs from the working tree; on 3 the guard cannot tell. In both cases do not switch: say why, and that the person can stop their server and run `git switch <base>` themselves.
158
196
 
159
197
  ```bash
160
198
  [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ] && ! git worktree list --porcelain | grep -qx "branch refs/heads/<base>" && git switch <base>
@@ -9,7 +9,7 @@ name: relevo-digest
9
9
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
10
10
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
11
11
  env:
12
- RELEVO_VERSION: '0.6.0'
12
+ RELEVO_VERSION: '0.7.0'
13
13
 
14
14
  on:
15
15
  schedule:
@@ -15,7 +15,7 @@ name: relevo-go
15
15
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
16
16
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
17
17
  env:
18
- RELEVO_VERSION: '0.6.0'
18
+ RELEVO_VERSION: '0.7.0'
19
19
 
20
20
  on:
21
21
  issues:
@@ -10,7 +10,7 @@ name: relevo-queue
10
10
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
11
11
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
12
12
  env:
13
- RELEVO_VERSION: '0.6.0'
13
+ RELEVO_VERSION: '0.7.0'
14
14
 
15
15
  on:
16
16
  issues:
@@ -10,7 +10,7 @@ name: relevo-review
10
10
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
11
11
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
12
12
  env:
13
- RELEVO_VERSION: '0.6.0'
13
+ RELEVO_VERSION: '0.7.0'
14
14
 
15
15
  on:
16
16
  pull_request_review:
@@ -8,7 +8,7 @@ name: relevo-sla
8
8
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
9
9
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
10
10
  env:
11
- RELEVO_VERSION: '0.6.0'
11
+ RELEVO_VERSION: '0.7.0'
12
12
 
13
13
  on:
14
14
  schedule: