@elalfner/relevo 0.5.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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/dev-server-guard.mjs +32 -0
- package/scripts/extra-server.mjs +78 -0
- package/scripts/go-worktree.mjs +40 -0
- package/scripts/lib/dev-server-guard.mjs +49 -0
- package/scripts/lib/doctor.mjs +14 -1
- package/scripts/lib/extra-server.mjs +84 -0
- package/scripts/lib/go-local.mjs +4 -3
- package/scripts/lib/go-worktree.mjs +85 -0
- package/scripts/lib/init.mjs +2 -2
- package/scripts/lib/review-adapters.mjs +15 -2
- package/scripts/lib/review-brief.mjs +12 -4
- package/scripts/lib/review-state.mjs +30 -4
- package/scripts/lib/web-preflight.mjs +87 -0
- package/scripts/lib/web-review.mjs +82 -1
- package/scripts/lib/worktree-path.mjs +11 -0
- package/scripts/review-adapter.mjs +5 -3
- package/scripts/review-brief.mjs +3 -2
- package/scripts/review-state.mjs +12 -5
- package/scripts/web-preflight.mjs +47 -0
- package/scripts/web-review.mjs +80 -29
- package/skills/go/SKILL.md +13 -1
- package/skills/init/SKILL.md +2 -2
- package/skills/review/SKILL.md +48 -10
- package/stacks/flutter.md +45 -7
- package/templates/workflows/relevo-digest.yml +1 -1
- package/templates/workflows/relevo-go.yml +1 -1
- package/templates/workflows/relevo-queue.yml +1 -1
- package/templates/workflows/relevo-review.yml +1 -1
- package/templates/workflows/relevo-sla.yml +1 -1
package/package.json
CHANGED
|
@@ -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,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The worktree of an interactive `go` in Claude Code. Usage:
|
|
3
|
+
// node scripts/go-worktree.mjs create <issue> [--config relevo.yml] prints {"worktree","base"} as JSON
|
|
4
|
+
// node scripts/go-worktree.mjs remove <issue>
|
|
5
|
+
// exit 0: done 1: git failed 2: bad usage 3: the worktree of the issue already exists 4: already in a linked worktree
|
|
6
|
+
// The skill reads exit 3 as "stop" and exit 4 as "build where you are".
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { loadConfig } from './lib/config.mjs';
|
|
12
|
+
import { createWorktree, EXIT, removeWorktree } from './lib/go-worktree.mjs';
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const [command, issue] = args;
|
|
16
|
+
const configAt = args.indexOf('--config');
|
|
17
|
+
const fail = (message, code = EXIT.usage) => {
|
|
18
|
+
console.error(message);
|
|
19
|
+
process.exit(code);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const runner = {
|
|
23
|
+
capture: (cmd, cmdArgs, options) => execFileSync(cmd, cmdArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: options.cwd }),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let result;
|
|
27
|
+
if (command === 'create') {
|
|
28
|
+
const configPath = resolve(configAt === -1 ? 'relevo.yml' : args[configAt + 1]);
|
|
29
|
+
if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /relevo:init to create it.`);
|
|
30
|
+
const loaded = loadConfig(readFileSync(configPath, 'utf8'));
|
|
31
|
+
if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
|
|
32
|
+
result = createWorktree({ issue, config: loaded.config, runner, exists: existsSync });
|
|
33
|
+
if (result.status === EXIT.ok) console.log(JSON.stringify({ worktree: result.worktree, base: result.base }));
|
|
34
|
+
} else if (command === 'remove') {
|
|
35
|
+
result = removeWorktree({ issue, runner, exists: existsSync });
|
|
36
|
+
} else {
|
|
37
|
+
fail('usage: go-worktree.mjs create|remove <issue>');
|
|
38
|
+
}
|
|
39
|
+
result.messages.forEach((m) => console.error(m));
|
|
40
|
+
process.exit(result.status);
|
|
@@ -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
|
+
}
|
package/scripts/lib/doctor.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { loadConfig } from './config.mjs';
|
|
|
13
13
|
import { labelDefinitions } from './labels.mjs';
|
|
14
14
|
import { credentialMode, cloudBuilds, CLOUD_VARIABLE } from './execution-mode.mjs';
|
|
15
15
|
import { SKILL_DIRS, SIZE_BUDGET_BYTES, folderHash, lockDifferences, resolveRoleSkills, skillBudget } from './role-skills.mjs';
|
|
16
|
-
import { ROLES, profileLocations, profileLookups, resolveProfile } from './stack-profile.mjs';
|
|
16
|
+
import { ROLES, profileLocations, profileLookups, repositoryProfilePath, resolveProfile } from './stack-profile.mjs';
|
|
17
17
|
|
|
18
18
|
export const REQUIRED_SKILLS = ['openspec-explore', 'grill-with-docs', 'grilling', 'domain-modeling'];
|
|
19
19
|
export const SKILL_INSTALL = {
|
|
@@ -338,6 +338,14 @@ function gatherPlatforms(f, p, tryRun) {
|
|
|
338
338
|
const tool = resolved.profile.targets[target] ?? '';
|
|
339
339
|
return { target, tool, ok: tool === '' ? true : available(tool) };
|
|
340
340
|
});
|
|
341
|
+
// The cloud and a fresh clone read the repository's layer from git, not from this working copy. `null` means
|
|
342
|
+
// git could not say (no git, or not a repository).
|
|
343
|
+
if (resolved.profile.layers.includes('repository')) {
|
|
344
|
+
const path = repositoryProfilePath(platform.stack);
|
|
345
|
+
if (!f.git) entry.repositoryLayerTracked = null;
|
|
346
|
+
else if (tryRun(() => { p.tool('git', ['ls-files', '--error-unmatch', path]); return true; }, false)) entry.repositoryLayerTracked = true;
|
|
347
|
+
else entry.repositoryLayerTracked = tryRun(() => { p.tool('git', ['rev-parse', '--is-inside-work-tree']); return true; }, false) ? false : null;
|
|
348
|
+
}
|
|
341
349
|
}
|
|
342
350
|
f.platformFacts.push(entry);
|
|
343
351
|
});
|
|
@@ -474,6 +482,11 @@ function platformChecks(f) {
|
|
|
474
482
|
}
|
|
475
483
|
const layers = e.profile.layers.map((l) => (l === 'plugin' ? 'default of the plugin' : `.relevo/stacks/${e.stack}.md`)).join(' + ');
|
|
476
484
|
r.push(pass(`platform.${e.name}.profile`, `platform ${e.name} (${e.stack}, ${e.path}): profile = ${layers}`));
|
|
485
|
+
if (e.repositoryLayerTracked === false) {
|
|
486
|
+
const path = `.relevo/stacks/${e.stack}.md`;
|
|
487
|
+
const effect = e.profile.layers.includes('plugin') ? `use only the plugin's profile for ${e.stack}, without your rules` : `have no profile for ${e.stack}`;
|
|
488
|
+
r.push(warn(`platform.${e.name}.profile.tracked`, `platform ${e.name}: ${path} is not tracked by git, so the cloud run and a fresh clone ${effect}`, `commit ${path} and push it with the rest of the repository's Relevo files`));
|
|
489
|
+
}
|
|
477
490
|
(e.profile.ignored ?? []).forEach((x) => {
|
|
478
491
|
r.push(warn(`platform.${e.name}.ignored.${x.key}`, `platform ${e.name}: the repository profile sets ${x.key} = "${x.value}", which is ignored (the plugin keeps "${x.kept}")`, `a command is replaced with platforms.${e.name}.checks in relevo.yml, not in the profile`));
|
|
479
492
|
});
|
|
@@ -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
|
+
}
|
package/scripts/lib/go-local.mjs
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// stream(cmd, args, { cwd, env }) -> exit status (the output goes to the terminal); throws with code ENOENT when missing
|
|
7
7
|
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
|
-
import { basename,
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { cloudBuilds } from './execution-mode.mjs';
|
|
12
12
|
import { runFlags } from './run-flags.mjs';
|
|
13
|
+
import { WORKTREES_FOLDER, worktreePath } from './worktree-path.mjs';
|
|
13
14
|
|
|
14
|
-
export
|
|
15
|
+
export { WORKTREES_FOLDER };
|
|
15
16
|
// The guard is part of the plugin, not of the project that is built, so it is found next to this file.
|
|
16
17
|
const GUARD = fileURLToPath(new URL('../agent-guard.mjs', import.meta.url));
|
|
17
18
|
// The same time limit as the cloud step of the Antigravity agent.
|
|
@@ -20,7 +21,7 @@ export const GUARD_TIMEOUT_MINUTES = 50;
|
|
|
20
21
|
/** What a local run will do, without doing it: the worktree and the `claude` or `agy` command. Pure. */
|
|
21
22
|
export function planLocalRun({ issue, repoRoot, config }) {
|
|
22
23
|
const base = config.branches.base;
|
|
23
|
-
const worktree =
|
|
24
|
+
const worktree = worktreePath(repoRoot, issue);
|
|
24
25
|
const isAntigravity = config.agent?.build === 'antigravity';
|
|
25
26
|
const command = isAntigravity
|
|
26
27
|
? ['node', [
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The worktree of an interactive `go` in Claude Code. Implements "An interactive Claude Code run builds in its own
|
|
2
|
+
// worktree" of the execution-modes specification: the skill makes the worktree here, enters it with EnterWorktree, and
|
|
3
|
+
// asks for its removal here when the run ends cleanly.
|
|
4
|
+
//
|
|
5
|
+
// Every process goes through an injected runner so the logic is tested without git or a network:
|
|
6
|
+
// capture(cmd, args, { cwd }) -> stdout (throws when the command fails)
|
|
7
|
+
|
|
8
|
+
import { worktreePath } from './worktree-path.mjs';
|
|
9
|
+
|
|
10
|
+
/** Exit codes of `go-worktree.mjs`. `exists` and `linked` are answers, not failures: the skill reads them. */
|
|
11
|
+
export const EXIT = { ok: 0, failed: 1, usage: 2, exists: 3, linked: 4 };
|
|
12
|
+
|
|
13
|
+
const fail = (status, message) => ({ status, worktree: null, messages: [message] });
|
|
14
|
+
// git prints progress ("Preparing worktree ...") before the reason, so the reason is the `fatal:` or `error:` line, or the last one.
|
|
15
|
+
function firstLine(err) {
|
|
16
|
+
const lines = String(err.stderr ?? err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
17
|
+
return lines.find((l) => /^(fatal|error):/.test(l)) ?? lines.at(-1) ?? 'git failed';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function context(issue, runner) {
|
|
21
|
+
if (!/^\d+$/.test(String(issue))) return { early: fail(EXIT.usage, 'give the issue number: go-worktree.mjs create|remove <issue>') };
|
|
22
|
+
try {
|
|
23
|
+
const repoRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], {}).trim();
|
|
24
|
+
const [gitDir, commonDir] = runner
|
|
25
|
+
.capture('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], { cwd: repoRoot })
|
|
26
|
+
.trim()
|
|
27
|
+
.split('\n');
|
|
28
|
+
return { repoRoot, linked: gitDir !== commonDir };
|
|
29
|
+
} catch {
|
|
30
|
+
return { early: fail(EXIT.usage, 'not inside a git repository (or git is older than 2.31)') };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Make the worktree of an issue from `origin/<base>`. `linked` when the session is already in a linked worktree (the
|
|
36
|
+
* build runs where it is), `exists` when the worktree of the issue is already there (the same issue twice).
|
|
37
|
+
*/
|
|
38
|
+
export function createWorktree({ issue, config, runner, exists = () => false }) {
|
|
39
|
+
const ctx = context(issue, runner);
|
|
40
|
+
if (ctx.early) return ctx.early;
|
|
41
|
+
if (ctx.linked) {
|
|
42
|
+
return fail(EXIT.linked, 'this session is already in a linked worktree: build here, no worktree is made');
|
|
43
|
+
}
|
|
44
|
+
const base = config.branches.base;
|
|
45
|
+
const worktree = worktreePath(ctx.repoRoot, issue);
|
|
46
|
+
if (exists(worktree)) {
|
|
47
|
+
return {
|
|
48
|
+
status: EXIT.exists,
|
|
49
|
+
worktree,
|
|
50
|
+
messages: [`${worktree} already exists: another run is building #${issue}, or a failed one left it. Finish it or remove it first (git worktree remove --force "${worktree}")`],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
// A folder deleted by hand leaves a registration that makes `worktree add` fail; prune only drops those.
|
|
55
|
+
runner.capture('git', ['worktree', 'prune'], { cwd: ctx.repoRoot });
|
|
56
|
+
runner.capture('git', ['fetch', 'origin', base], { cwd: ctx.repoRoot });
|
|
57
|
+
runner.capture('git', ['worktree', 'add', '--detach', worktree, `origin/${base}`], { cwd: ctx.repoRoot });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return fail(EXIT.failed, `could not prepare the worktree: ${firstLine(err)}`);
|
|
60
|
+
}
|
|
61
|
+
return { status: EXIT.ok, worktree, base, messages: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove the worktree of an issue, after a clean end. Run it from the main working tree, not from inside. */
|
|
65
|
+
export function removeWorktree({ issue, runner, exists = () => true }) {
|
|
66
|
+
const ctx = context(issue, runner);
|
|
67
|
+
if (ctx.early) return ctx.early;
|
|
68
|
+
if (ctx.linked) return fail(EXIT.linked, 'leave the worktree first (ExitWorktree with keep), then remove it from the main working tree');
|
|
69
|
+
const worktree = worktreePath(ctx.repoRoot, issue);
|
|
70
|
+
if (!exists(worktree)) {
|
|
71
|
+
// The folder may be gone while git still lists it: drop that registration so the issue can be built again.
|
|
72
|
+
try {
|
|
73
|
+
runner.capture('git', ['worktree', 'prune'], { cwd: ctx.repoRoot });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { status: EXIT.failed, worktree, messages: [`could not prune the worktrees: ${firstLine(err)}; run git worktree prune`] };
|
|
76
|
+
}
|
|
77
|
+
return { status: EXIT.ok, worktree, messages: [`${worktree} is not there: nothing to remove`] };
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
runner.capture('git', ['worktree', 'remove', '--force', worktree], { cwd: ctx.repoRoot });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { status: EXIT.failed, worktree, messages: [`could not remove ${worktree}: ${firstLine(err)}; run git worktree remove --force "${worktree}"`] };
|
|
83
|
+
}
|
|
84
|
+
return { status: EXIT.ok, worktree, messages: [] };
|
|
85
|
+
}
|
package/scripts/lib/init.mjs
CHANGED
|
@@ -208,11 +208,11 @@ export function planInit({ facts, answers, templates, current, admin }) {
|
|
|
208
208
|
if (facts.configText !== null) items.push(item('config', facts.config ? 'ok' : 'differs', facts.config ? 'relevo.yml exists and is valid' : `relevo.yml exists but is invalid: ${facts.configErrors.join('; ')}`));
|
|
209
209
|
else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
|
|
210
210
|
|
|
211
|
-
// optional stack profiles of the repository (created only when asked for
|
|
211
|
+
// optional stack profiles of the repository (created only when asked for; init never commits them, the person does)
|
|
212
212
|
(answers.stackProfiles ?? []).forEach((stack) => {
|
|
213
213
|
const path = `.relevo/stacks/${stack}.md`;
|
|
214
214
|
if ((current.stackProfiles ?? {})[stack]) items.push(item(`stack-profile.${stack}`, 'ok', `${path} exists`));
|
|
215
|
-
else items.push(item(`stack-profile.${stack}`, 'todo', `Create ${path}, a commented profile that adds to the plugin's (
|
|
215
|
+
else items.push(item(`stack-profile.${stack}`, 'todo', `Create ${path}, a commented profile that adds to the plugin's (left for you to commit)`, { path, content: stackProfileTemplate(stack) }));
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
// labels
|
|
@@ -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: {
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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>');
|