@elalfner/relevo 0.3.1 → 0.5.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/README.es.md +3 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/scripts/agent-guard.mjs +94 -0
- package/scripts/cli.mjs +67 -6
- package/scripts/doctor.mjs +10 -0
- package/scripts/init.mjs +1 -0
- package/scripts/lib/adapters/antigravity.mjs +99 -0
- package/scripts/lib/adapters/index.mjs +6 -0
- package/scripts/lib/adapters/install.mjs +127 -0
- package/scripts/lib/adapters/source.mjs +65 -0
- package/scripts/lib/adapters/tools.mjs +52 -0
- package/scripts/lib/agent-guard.mjs +188 -0
- package/scripts/lib/agent-workflow.mjs +46 -0
- package/scripts/lib/cli.mjs +44 -12
- package/scripts/lib/config.mjs +18 -7
- package/scripts/lib/doctor.mjs +92 -4
- package/scripts/lib/go-local.mjs +31 -4
- package/scripts/lib/init-tools.mjs +97 -0
- package/scripts/lib/init.mjs +38 -2
- package/scripts/lib/messages.mjs +16 -1
- package/scripts/lib/next-step.mjs +10 -1
- package/scripts/lib/protected-paths.mjs +10 -4
- package/scripts/lib/run-usage.mjs +1 -1
- package/scripts/lib/select.mjs +118 -0
- package/scripts/lib/status-data.mjs +16 -5
- package/scripts/lib/status.mjs +20 -5
- package/scripts/lib/toolchain-check.mjs +12 -0
- package/scripts/toolchain-check.mjs +49 -0
- package/skills/init/SKILL.md +5 -2
- package/skills/review/SKILL.md +10 -0
- package/stacks/supabase.md +63 -0
- package/templates/agents/antigravity-step.yml +70 -0
- package/templates/deploy-supabase.yml +75 -0
- package/templates/workflows/relevo-digest.yml +1 -1
- package/templates/workflows/relevo-go.yml +35 -1
- package/templates/workflows/relevo-queue.yml +3 -1
- package/templates/workflows/relevo-review.yml +1 -1
- package/templates/workflows/relevo-sla.yml +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The run guard: what Relevo adds around an agent that has no turn limit, no cost limit and an exit code that hides a
|
|
2
|
+
// refused tool. Antigravity CLI is that agent (see docs/pilot-findings.md). The guard reads the event stream of
|
|
3
|
+
// `agy -p --output-format stream-json` while it runs, counts the agent's turns and tokens, stops the process group at a
|
|
4
|
+
// limit, and turns a refusal into a failure. Everything that touches the outside (the child process, the clock, the
|
|
5
|
+
// files) is injected, so a test drives it with a fake child.
|
|
6
|
+
|
|
7
|
+
// Exit codes of the guard. 0 is the agent's own success; each stop has its own number so a workflow can tell them apart.
|
|
8
|
+
export const EXIT = { ok: 0, failed: 1, usage: 2, refused: 10, turns: 11, tokens: 12, time: 13, silent: 14 };
|
|
9
|
+
|
|
10
|
+
const STOP_TEXT = {
|
|
11
|
+
turns: (n) => `the agent reached the limit of ${n} turns (limits.run_max_turns)`,
|
|
12
|
+
tokens: (n) => `the agent reached the limit of ${n} tokens (limits.run_max_tokens)`,
|
|
13
|
+
time: (n) => `the agent reached the time limit of ${n} minutes`
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Folds the events of one run into its counts. A step is counted once, when it is DONE, however many times it was
|
|
18
|
+
* reported; a turn is a model response (`agent_response`), of the main agent or of a subagent.
|
|
19
|
+
*/
|
|
20
|
+
export class Tracker {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.seen = new Set();
|
|
23
|
+
this.turns = 0;
|
|
24
|
+
this.tokens = 0;
|
|
25
|
+
this.input = 0;
|
|
26
|
+
this.output = 0;
|
|
27
|
+
this.cached = 0;
|
|
28
|
+
this.denied = [];
|
|
29
|
+
this.result = null;
|
|
30
|
+
this.errors = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Feeds one line of the stream; returns the event, or null when the line is not JSON. */
|
|
34
|
+
feed(line) {
|
|
35
|
+
let e;
|
|
36
|
+
try {
|
|
37
|
+
e = JSON.parse(line);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (!e || typeof e !== 'object') return null;
|
|
42
|
+
if (e.event === 'result') this.result = e.result ?? {};
|
|
43
|
+
if (e.event !== 'step_update' || !e.step_update) return e;
|
|
44
|
+
const s = e.step_update;
|
|
45
|
+
if (s.step_type === 'tool' && s.state === 'ERROR' && /permission check failed/i.test(JSON.stringify(s.tool_info ?? {}))) {
|
|
46
|
+
const what = String(s.tool_info?.parameters?.CommandLine ?? s.tool_name ?? 'a tool').split(/\s+/)[0];
|
|
47
|
+
if (!this.denied.includes(what)) this.denied.push(what);
|
|
48
|
+
}
|
|
49
|
+
if (s.state !== 'DONE') return e;
|
|
50
|
+
const key = `${s.conversation_id}:${s.step_index}`;
|
|
51
|
+
if (this.seen.has(key)) return e;
|
|
52
|
+
this.seen.add(key);
|
|
53
|
+
if (s.step_type === 'agent_response') this.turns += 1;
|
|
54
|
+
if (s.usage) {
|
|
55
|
+
const total = Number(s.usage.total_tokens);
|
|
56
|
+
this.tokens += Number.isFinite(total) ? total : (Number(s.usage.input_tokens) || 0) + (Number(s.usage.output_tokens) || 0);
|
|
57
|
+
this.input += Number(s.usage.input_tokens) || 0;
|
|
58
|
+
this.output += Number(s.usage.output_tokens) || 0;
|
|
59
|
+
this.cached += Number(s.usage.cache_read_tokens) || 0;
|
|
60
|
+
}
|
|
61
|
+
return e;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What the final event says was refused, joined with what the steps showed. */
|
|
65
|
+
allDenied() {
|
|
66
|
+
const fromResult = (this.result?.denied_actions ?? []).map((d) => d.display_name ?? d.action ?? 'a tool');
|
|
67
|
+
return [...new Set([...this.denied, ...fromResult])];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One line per step for the log: the tool and, for a command, only its program, never its output or arguments. */
|
|
72
|
+
export function logLine(event) {
|
|
73
|
+
const s = event?.step_update;
|
|
74
|
+
if (!s || s.state !== 'DONE') return null;
|
|
75
|
+
if (s.step_type === 'tool') return `tool ${s.tool_name}${s.tool_name === 'run_command' ? ` (${String(s.tool_info?.parameters?.CommandLine ?? '').trim().split(/\s+/)[0]})` : ''}`;
|
|
76
|
+
if (s.step_type === 'agent_response') return 'model turn';
|
|
77
|
+
if (s.step_type === 'subagent') return 'subagent started';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Runs the agent under the limits. `spawn(command, args)` returns { pid, stdout, stderr, kill(signal), on }, with
|
|
83
|
+
* stdout and stderr readable streams and `on('close', (code) => ...)`. `limits`: { turns, tokens, minutes }.
|
|
84
|
+
* Resolves { code, summary }: the guard's exit code and the summary to write.
|
|
85
|
+
*/
|
|
86
|
+
export function runGuard({ command, args, limits, spawn, setTimer = setTimeout, clearTimer = clearTimeout, now = Date.now, log = () => {} }) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const tracker = new Tracker();
|
|
89
|
+
const started = now();
|
|
90
|
+
let stoppedBy = null;
|
|
91
|
+
let stderr = '';
|
|
92
|
+
let buffered = '';
|
|
93
|
+
let finished = false;
|
|
94
|
+
const child = spawn(command, args);
|
|
95
|
+
|
|
96
|
+
const stop = (why) => {
|
|
97
|
+
if (stoppedBy || finished) return;
|
|
98
|
+
stoppedBy = why;
|
|
99
|
+
log(`relevo guard: ${STOP_TEXT[why](limits[why === 'time' ? 'minutes' : why])}; stopping the agent`);
|
|
100
|
+
try {
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
} catch {
|
|
103
|
+
/* already gone */
|
|
104
|
+
}
|
|
105
|
+
killer = setTimer(() => {
|
|
106
|
+
try {
|
|
107
|
+
child.kill('SIGKILL');
|
|
108
|
+
} catch {
|
|
109
|
+
/* already gone */
|
|
110
|
+
}
|
|
111
|
+
}, 5000);
|
|
112
|
+
};
|
|
113
|
+
let killer = null;
|
|
114
|
+
const timer = limits.minutes ? setTimer(() => stop('time'), limits.minutes * 60 * 1000) : null;
|
|
115
|
+
|
|
116
|
+
const onLine = (line) => {
|
|
117
|
+
if (!line.trim()) return;
|
|
118
|
+
const event = tracker.feed(line);
|
|
119
|
+
const text = event ? logLine(event) : null;
|
|
120
|
+
if (text) log(text);
|
|
121
|
+
if (limits.turns && tracker.turns > limits.turns) stop('turns');
|
|
122
|
+
else if (limits.tokens && tracker.tokens > limits.tokens) stop('tokens');
|
|
123
|
+
};
|
|
124
|
+
child.stdout.on('data', (chunk) => {
|
|
125
|
+
buffered += chunk;
|
|
126
|
+
const lines = buffered.split('\n');
|
|
127
|
+
buffered = lines.pop();
|
|
128
|
+
lines.forEach(onLine);
|
|
129
|
+
});
|
|
130
|
+
child.stderr.on('data', (chunk) => {
|
|
131
|
+
stderr = `${stderr}${chunk}`.slice(-2000);
|
|
132
|
+
});
|
|
133
|
+
child.on('close', (exit) => {
|
|
134
|
+
finished = true;
|
|
135
|
+
if (timer) clearTimer(timer);
|
|
136
|
+
if (killer) clearTimer(killer);
|
|
137
|
+
if (buffered.trim()) onLine(buffered);
|
|
138
|
+
const denied = tracker.allDenied();
|
|
139
|
+
const r = tracker.result;
|
|
140
|
+
let code = EXIT.ok;
|
|
141
|
+
if (stoppedBy) code = EXIT[stoppedBy];
|
|
142
|
+
else if (denied.length > 0) code = EXIT.refused;
|
|
143
|
+
else if (!r) code = EXIT.silent;
|
|
144
|
+
else if (r.status !== 'SUCCESS' || exit !== 0) code = EXIT.failed;
|
|
145
|
+
const summary = {
|
|
146
|
+
type: 'result',
|
|
147
|
+
status: r?.status ?? 'NO_RESULT',
|
|
148
|
+
stopped_by: stoppedBy,
|
|
149
|
+
num_turns: tracker.turns,
|
|
150
|
+
usage: { input_tokens: tracker.input, output_tokens: tracker.output, cache_read_input_tokens: tracker.cached },
|
|
151
|
+
denied,
|
|
152
|
+
duration_seconds: Math.round((now() - started) / 1000),
|
|
153
|
+
exit_code: code,
|
|
154
|
+
stderr_tail: stderr
|
|
155
|
+
};
|
|
156
|
+
if (code === EXIT.refused) log(`relevo guard: the agent exited normally but a tool was refused (${denied.join(', ')}); failing the run`);
|
|
157
|
+
if (code === EXIT.silent) log('relevo guard: the agent ended without a result, so nothing was built');
|
|
158
|
+
resolve({ code, summary, response: r?.response ?? '' });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------- the settings of the run
|
|
164
|
+
|
|
165
|
+
// Programs an unattended build may run beyond the tools of its stack: the ones the skill `go` and the agents call, and
|
|
166
|
+
// the plain file utilities a developer uses. Nothing that talks to the network on its own is here (curl and wget are
|
|
167
|
+
// denied by name). `node` can run any script, as Bash can for Claude: a prefix list is not an operating-system boundary.
|
|
168
|
+
export const BASE_PROGRAMS = ['node', 'git', 'gh', 'openspec', 'echo', 'printf', 'ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'sort', 'uniq', 'diff', 'sed', 'awk', 'jq', 'tr', 'cut', 'mkdir', 'mv', 'cp', 'rm', 'touch', 'test', 'pwd'];
|
|
169
|
+
export const DENIED = ['read_url(*)', 'execute_url(*)', 'command(curl)', 'command(wget)'];
|
|
170
|
+
|
|
171
|
+
/** The first word of a command, which is the program a prefix rule names. */
|
|
172
|
+
const programOf = (command) => String(command ?? '').trim().split(/\s+/)[0];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The user-level settings file of `agy` for a run: the key provider, the denied web, and the commands that are
|
|
176
|
+
* allowed. `profile` is a resolved stack profile ({ commands, tools }); its programs are added.
|
|
177
|
+
*/
|
|
178
|
+
export function agySettings({ profile = null, extra = [] } = {}) {
|
|
179
|
+
const programs = new Set(BASE_PROGRAMS);
|
|
180
|
+
for (const c of Object.values(profile?.commands ?? {})) if (programOf(c)) programs.add(programOf(c));
|
|
181
|
+
for (const t of profile?.tools ?? []) if (t) programs.add(t);
|
|
182
|
+
for (const x of extra) programs.add(x);
|
|
183
|
+
DENIED.filter((d) => d.startsWith('command(')).forEach((d) => programs.delete(d.slice(8, -1)));
|
|
184
|
+
return {
|
|
185
|
+
modelProvider: 'gemini',
|
|
186
|
+
permissions: { deny: [...DENIED], allow: [...programs].sort().map((p) => `command(${p})`) }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One workflow for every agent. The shared workflow marks the parts that are about the agent with comment lines
|
|
2
|
+
// # relevo:<name> begin ... # relevo:<name> end
|
|
3
|
+
// and templates/agents/<agent>-step.yml holds, for an agent other than Claude, the lines that replace each block. Nothing
|
|
4
|
+
// else of the workflow changes, so a change to the gates, the queue or the reports is made once.
|
|
5
|
+
|
|
6
|
+
export const BLOCKS = ['credential', 'agent-step'];
|
|
7
|
+
|
|
8
|
+
const mark = (name, kind) => new RegExp(`^\\s*# relevo:${name} ${kind}\\s*$`);
|
|
9
|
+
|
|
10
|
+
/** The lines between the markers of a block, or null when the text has none; throws on a repeated or unbalanced one. */
|
|
11
|
+
export function blockRange(lines, name) {
|
|
12
|
+
const begins = lines.flatMap((l, i) => (mark(name, 'begin').test(l) ? [i] : []));
|
|
13
|
+
const ends = lines.flatMap((l, i) => (mark(name, 'end').test(l) ? [i] : []));
|
|
14
|
+
if (begins.length === 0 && ends.length === 0) return null;
|
|
15
|
+
if (begins.length !== 1 || ends.length !== 1 || ends[0] < begins[0]) throw new Error(`the block "${name}" must have one begin line followed by one end line`);
|
|
16
|
+
return { begin: begins[0], end: ends[0] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** { name: [lines] } of the blocks that a file of an agent defines. */
|
|
20
|
+
export function blocksOf(text) {
|
|
21
|
+
const lines = String(text).split('\n');
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const name of BLOCKS) {
|
|
24
|
+
const r = blockRange(lines, name);
|
|
25
|
+
if (r) out[name] = lines.slice(r.begin + 1, r.end);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The workflow for an agent. `claude` is the workflow as it is. Another agent gets the blocks of its file in place of
|
|
32
|
+
* the ones the workflow marks; a marked block the file does not define is an error, not a silent leftover of Claude.
|
|
33
|
+
*/
|
|
34
|
+
export function agentWorkflow(workflow, build, blocksText) {
|
|
35
|
+
if (build === undefined || build === 'claude') return workflow;
|
|
36
|
+
if (!blocksText) throw new Error(`no file of blocks for the agent "${build}"`);
|
|
37
|
+
const blocks = blocksOf(blocksText);
|
|
38
|
+
const lines = workflow.split('\n');
|
|
39
|
+
for (const name of BLOCKS) {
|
|
40
|
+
const r = blockRange(lines, name);
|
|
41
|
+
if (!r) continue;
|
|
42
|
+
if (!blocks[name]) throw new Error(`the agent "${build}" does not define the block "${name}" that this workflow marks`);
|
|
43
|
+
lines.splice(r.begin + 1, r.end - r.begin - 1, ...blocks[name]);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
package/scripts/lib/cli.mjs
CHANGED
|
@@ -9,8 +9,10 @@ export const SCOPES = ['user', 'project', 'local'];
|
|
|
9
9
|
export const USAGE = [
|
|
10
10
|
'usage: relevo <command> [--scope user|project|local]',
|
|
11
11
|
'',
|
|
12
|
-
'
|
|
13
|
-
'
|
|
12
|
+
' init set up Relevo for the tools your team uses (Claude Code, Antigravity), in the project or on the machine',
|
|
13
|
+
' relevo init [--tools claude,antigravity] [--scope project|machine] [--yes] [--force]',
|
|
14
|
+
' install add the marketplace and install the plugin; asks where in a terminal (project by default)',
|
|
15
|
+
' update update the marketplace and the plugin (without --scope, the installation of this project), and render again the plugins that init made; --force overwrites files edited by hand',
|
|
14
16
|
' doctor check this repository (the same as /relevo:doctor)',
|
|
15
17
|
' version print the version of this package',
|
|
16
18
|
'',
|
|
@@ -22,14 +24,44 @@ export function parseArgs(argv) {
|
|
|
22
24
|
const [command, ...rest] = argv;
|
|
23
25
|
if (!command || command === '--help' || command === '-h' || command === 'help') return { command: 'help' };
|
|
24
26
|
if (!['install', 'update', 'doctor', 'version'].includes(command)) return { error: `unknown command "${command}"` };
|
|
25
|
-
let scope =
|
|
27
|
+
let scope = null;
|
|
28
|
+
let force = false;
|
|
26
29
|
for (let i = 0; i < rest.length; i += 1) {
|
|
27
30
|
const arg = rest[i];
|
|
28
|
-
if (arg === '--
|
|
31
|
+
if (arg === '--force' && command === 'update') force = true;
|
|
32
|
+
else if (arg === '--scope') { scope = rest[i + 1]; i += 1; } else if (arg.startsWith('--scope=')) scope = arg.slice(8);
|
|
29
33
|
else return { error: `unknown option "${arg}"` };
|
|
30
34
|
}
|
|
31
|
-
if (!SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
|
|
32
|
-
return { command, scope };
|
|
35
|
+
if (scope !== null && !SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
|
|
36
|
+
return force ? { command, scope, force } : { command, scope };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether the folder is inside a git work tree; `run` is the injected runner. A missing git counts as no. */
|
|
40
|
+
export function isInsideGitRepo(run) {
|
|
41
|
+
const r = run('git', ['rev-parse', '--is-inside-work-tree']);
|
|
42
|
+
return r.code === 0 && r.output.trim() === 'true';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which scope an install uses. A given scope wins. In a terminal the person is asked, with the project preselected
|
|
47
|
+
* inside a git repository and the machine outside one; without a terminal nothing waits: the same default is used and
|
|
48
|
+
* `reason` says why, to be printed.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveScope({ given = null, interactive = false, inRepo = false }) {
|
|
51
|
+
if (given) return { scope: given };
|
|
52
|
+
const preferred = inRepo ? 'project' : 'user';
|
|
53
|
+
if (interactive) return { ask: true, preferred };
|
|
54
|
+
const why = inRepo ? 'this folder is inside a git repository, so the project scope is used' : 'this folder is not inside a git repository, so the machine scope is used';
|
|
55
|
+
return { scope: preferred, reason: `no --scope given and no terminal: ${why}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The choices of the list; the project says which folder gets the settings file. */
|
|
59
|
+
export function scopeChoices({ cwd, inRepo }) {
|
|
60
|
+
return [
|
|
61
|
+
{ value: 'project', label: `Project (${cwd})${inRepo ? '' : ' [not a git repository]'}: declared in .claude/settings.json, shared with your team` },
|
|
62
|
+
{ value: 'user', label: 'Machine: every project of this machine' },
|
|
63
|
+
{ value: 'local', label: 'Only me, in this project: not shared' }
|
|
64
|
+
];
|
|
33
65
|
}
|
|
34
66
|
|
|
35
67
|
/** The `claude` invocations of a command, in order. */
|
|
@@ -43,7 +75,7 @@ export function steps(command, scope) {
|
|
|
43
75
|
if (command === 'update') {
|
|
44
76
|
return [
|
|
45
77
|
{ label: 'update the marketplace', args: ['plugin', 'marketplace', 'update', MARKETPLACE_NAME] },
|
|
46
|
-
{ label: 'update the plugin', args: ['plugin', 'update', PLUGIN_ID, '--scope', scope] }
|
|
78
|
+
{ label: 'update the plugin', args: ['plugin', 'update', PLUGIN_ID, ...(scope ? ['--scope', scope] : [])] }
|
|
47
79
|
];
|
|
48
80
|
}
|
|
49
81
|
return [];
|
|
@@ -53,7 +85,7 @@ export function steps(command, scope) {
|
|
|
53
85
|
* Runs a command. Returns { code, lines }: the lines to print and the exit code. Stops at the first failing step
|
|
54
86
|
* and says which one, so the person knows what was already done.
|
|
55
87
|
*/
|
|
56
|
-
export function execute({ command, scope }, { run, has, version, doctorScript }) {
|
|
88
|
+
export function execute({ command, scope }, { run, has, version, doctorScript, cwd = process.cwd() }) {
|
|
57
89
|
const lines = [];
|
|
58
90
|
if (command === 'version') return { code: 0, lines: [version] };
|
|
59
91
|
if (command === 'doctor') {
|
|
@@ -74,9 +106,9 @@ export function execute({ command, scope }, { run, has, version, doctorScript })
|
|
|
74
106
|
return { code: r.code || 1, lines };
|
|
75
107
|
}
|
|
76
108
|
}
|
|
77
|
-
const where = { user: 'for this machine', project: 'for this repository
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
const where = { user: 'for this machine', project: 'for this repository', local: 'for this repository, only for you' }[scope];
|
|
110
|
+
const wrote = { user: 'your user settings', project: `.claude/settings.json of ${cwd}, so commit it to share it`, local: `.claude/settings.local.json of ${cwd}` }[scope];
|
|
111
|
+
if (command === 'install') lines.push(`Scope: ${scope} (${wrote}).`, `Relevo is installed ${where}. Restart Claude Code and run /relevo:doctor.`);
|
|
112
|
+
else lines.push('Relevo is up to date. Restart Claude Code to apply it.');
|
|
81
113
|
return { code: 0, lines };
|
|
82
114
|
}
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -25,7 +25,10 @@ export const SETTINGS = [
|
|
|
25
25
|
{ path: 'limits.review_sla_hours', type: 'int', min: 1, default: 8 },
|
|
26
26
|
{ path: 'limits.run_max_turns', type: 'int', min: 1, default: 100 },
|
|
27
27
|
{ path: 'limits.building_alert_hours', type: 'int', min: 1, default: 3 },
|
|
28
|
-
|
|
28
|
+
// The cost limit is the one a Claude run can enforce; an Antigravity run has none, so it takes a token limit instead.
|
|
29
|
+
{ path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true, default: null, requiredFor: 'claude', missing: 'unattended runs must always have a cost limit (amount in US dollars per run)', rejectedFor: 'antigravity', rejection: 'the agent has no cost limit; set limits.run_max_tokens instead, and note that the quota of the Google Cloud project that owns the key is the ceiling that really holds' },
|
|
30
|
+
{ path: 'limits.run_max_tokens', type: 'int', min: 1000, default: null, required: true, requiredFor: 'antigravity', missing: 'unattended runs must always have a limit, and for this agent it is a number of tokens per run', rejectedFor: 'claude', rejection: 'the run is limited by limits.run_max_cost' },
|
|
31
|
+
{ path: 'agent.build', type: 'enum', allowed: ['claude', 'antigravity'], default: 'claude' },
|
|
29
32
|
{ path: 'qa.retries', type: 'int', min: 0, default: 1 },
|
|
30
33
|
{ path: 'qa.rules_from', type: 'path-list', default: [] },
|
|
31
34
|
{ path: 'skills.allow_scripts', type: 'string[]', default: [] },
|
|
@@ -164,19 +167,23 @@ export function validateConfig(raw, { knownTargets = null, knownChecks = null }
|
|
|
164
167
|
|
|
165
168
|
// Per-setting checks and defaults.
|
|
166
169
|
const config = {};
|
|
170
|
+
// Which agent builds in the cloud decides which spending limit applies: it is read before the settings are checked.
|
|
171
|
+
const asked = getPath(user, 'agent.build');
|
|
172
|
+
const build = ['claude', 'antigravity'].includes(asked) ? asked : 'claude';
|
|
167
173
|
SETTINGS.forEach((setting) => {
|
|
168
174
|
const value = getPath(user, setting.path);
|
|
169
175
|
if (value === undefined || value === null) {
|
|
170
|
-
if (setting.required) {
|
|
171
|
-
errors.push({
|
|
172
|
-
path: setting.path,
|
|
173
|
-
message: `${setting.path} is required: unattended runs must always have a cost limit (amount in US dollars per run)`,
|
|
174
|
-
});
|
|
176
|
+
if (setting.requiredFor ? setting.requiredFor === build : setting.required) {
|
|
177
|
+
errors.push({ path: setting.path, message: `${setting.path} is required: ${setting.missing ?? 'it has no default'}` });
|
|
175
178
|
return;
|
|
176
179
|
}
|
|
177
180
|
setPath(config, setting.path, structuredClone(setting.default));
|
|
178
181
|
return;
|
|
179
182
|
}
|
|
183
|
+
if (setting.rejectedFor === build) {
|
|
184
|
+
errors.push({ path: setting.path, message: `${setting.path} has no effect with agent.build "${build}": ${setting.rejection}` });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
180
187
|
// A single file name is the same as a list of one.
|
|
181
188
|
const given = setting.type === 'path-list' && typeof value === 'string' ? [value] : value;
|
|
182
189
|
checkType(setting, given, errors);
|
|
@@ -237,6 +244,9 @@ export function validateConfig(raw, { knownTargets = null, knownChecks = null }
|
|
|
237
244
|
if (config.review.mode === 'roles' && config.team.approvers.length === 0) {
|
|
238
245
|
errors.push({ path: 'team.approvers', message: 'team.approvers must list at least one person when review.mode is "roles"' });
|
|
239
246
|
}
|
|
247
|
+
if (config.trust === 'public' && config.agent.build === 'antigravity') {
|
|
248
|
+
errors.push({ path: 'trust', message: 'trust "public" cannot be used with agent.build "antigravity" yet: its allow list is a prefix list and its run has no tool-level limits (docs/security.md)' });
|
|
249
|
+
}
|
|
240
250
|
if (config.trust === 'public' && config.review.mode === 'none') {
|
|
241
251
|
errors.push({ path: 'review.mode', message: 'trust "public" needs a human review of generated code: review.mode cannot be "none"' });
|
|
242
252
|
}
|
|
@@ -270,7 +280,8 @@ export function validateConfig(raw, { knownTargets = null, knownChecks = null }
|
|
|
270
280
|
});
|
|
271
281
|
}(user, ''));
|
|
272
282
|
flatKeys.forEach(({ key, path }) => {
|
|
273
|
-
|
|
283
|
+
// A setting of this file whose name only contains the word (limits.run_max_tokens) is not a credential.
|
|
284
|
+
if (/(api[_-]?key|secret|password|token)/i.test(key) && !SETTINGS.some((s) => s.path === path)) {
|
|
274
285
|
errors.push({
|
|
275
286
|
path,
|
|
276
287
|
message: `${path} looks like a credential setting; move it to the repository secret store, never to relevo.yml`,
|
package/scripts/lib/doctor.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export const SKILL_INSTALL = {
|
|
|
21
21
|
'grill-with-docs': 'run `npx skills add mattpocock/skills` and choose it',
|
|
22
22
|
grilling: 'run `npx skills add mattpocock/skills` and choose it',
|
|
23
23
|
'domain-modeling': 'run `npx skills add mattpocock/skills` and choose it',
|
|
24
|
+
supabase: 'run `npx skills add supabase/agent-skills --skill supabase`',
|
|
25
|
+
'supabase-postgres-best-practices': 'run `npx skills add supabase/agent-skills --skill supabase-postgres-best-practices`',
|
|
24
26
|
};
|
|
25
27
|
export const OPENSPEC_INSTALL = 'npm install --global @fission-ai/openspec (needs Node.js 20.19 or newer)';
|
|
26
28
|
export const WORKFLOW_FILES = ['relevo-go.yml', 'relevo-queue.yml', 'relevo-review.yml', 'relevo-sla.yml', 'relevo-digest.yml'];
|
|
@@ -35,6 +37,8 @@ const TOOL_INSTALL = {
|
|
|
35
37
|
php: 'install PHP 8.2 or newer: https://www.php.net/downloads',
|
|
36
38
|
composer: 'install Composer: https://getcomposer.org/download/',
|
|
37
39
|
npm: 'install Node.js, which includes npm: https://nodejs.org/',
|
|
40
|
+
supabase: 'install the Supabase CLI: brew install supabase/tap/supabase or see https://supabase.com/docs/guides/cli',
|
|
41
|
+
docker: 'install Docker: https://docs.docker.com/get-docker/ (macOS: brew install --cask docker or brew install orbstack)',
|
|
38
42
|
};
|
|
39
43
|
const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
40
44
|
|
|
@@ -121,6 +125,8 @@ export function gatherFacts(p) {
|
|
|
121
125
|
};
|
|
122
126
|
|
|
123
127
|
f.node = process.versions.node;
|
|
128
|
+
f.packageVersion = tryRun(() => JSON.parse(readFileSync(resolve(PLUGIN_ROOT, 'package.json'), 'utf8')).version);
|
|
129
|
+
f.adapters = p.adapters ? tryRun(() => p.adapters(), []) : [];
|
|
124
130
|
f.openspec = tryRun(() => String(p.tool('openspec', ['--version'])).trim());
|
|
125
131
|
f.git = tryRun(() => Boolean(p.tool('git', ['--version'])), false);
|
|
126
132
|
f.gh = tryRun(() => Boolean(p.tool('gh', ['--version'])), false);
|
|
@@ -153,6 +159,7 @@ export function gatherFacts(p) {
|
|
|
153
159
|
f.openspecRoot = p.exists('openspec/config.yaml') || p.exists('openspec/config.yml');
|
|
154
160
|
f.openspecRootTracked = tryRun(() => String(p.tool('git', ['ls-files', 'openspec'])).trim().length > 0, false);
|
|
155
161
|
f.evidenceIgnored = p.exists('.gitignore') && /^\/?evidence\/?\s*$/m.test(p.read('.gitignore'));
|
|
162
|
+
f.pluginDeclared = pluginDeclaration(p);
|
|
156
163
|
|
|
157
164
|
// Workflows: what runs on GitHub is what is on the default branch, not what is in the working copy, so once the
|
|
158
165
|
// repository is known they are read from there. `present` says whether the file is where it has to be; `local`
|
|
@@ -263,6 +270,22 @@ export function gatherFacts(p) {
|
|
|
263
270
|
}
|
|
264
271
|
}
|
|
265
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Whether the repository declares the Relevo plugin in the settings file that is committed, so that a teammate who
|
|
275
|
+
* clones it is offered the plugin: { declared } or, when the file is not valid JSON, { unreadable: true }. Only the
|
|
276
|
+
* shared file counts; the user's and the local settings do not reach anyone else.
|
|
277
|
+
*/
|
|
278
|
+
export function pluginDeclaration(p) {
|
|
279
|
+
const path = '.claude/settings.json';
|
|
280
|
+
if (!p.exists(path)) return { declared: false };
|
|
281
|
+
try {
|
|
282
|
+
const settings = JSON.parse(p.read(path));
|
|
283
|
+
return { declared: Boolean(settings?.extraKnownMarketplaces?.relevo) && settings?.enabledPlugins?.['relevo@relevo'] === true };
|
|
284
|
+
} catch {
|
|
285
|
+
return { unreadable: true };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
266
289
|
/** Reads a file: the plugin's own files from disk, the repository's through the probes. Null when absent. */
|
|
267
290
|
function readerFor(p, tryRun) {
|
|
268
291
|
const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
|
|
@@ -297,7 +320,20 @@ function gatherPlatforms(f, p, tryRun) {
|
|
|
297
320
|
const resolved = resolveProfile({ platform, pluginRoot, read });
|
|
298
321
|
const entry = { name, stack: platform.stack, path: platform.path, targets: platform.targets, profile: resolved.ok ? resolved.profile : null, errors: resolved.ok ? [] : resolved.errors, tools: [], targetTools: [] };
|
|
299
322
|
if (resolved.ok) {
|
|
300
|
-
|
|
323
|
+
(resolved.profile.requires_skills ?? []).forEach((name) => {
|
|
324
|
+
if (f.skills[name] === undefined && p.skillDirs) {
|
|
325
|
+
const dir = p.skillDirs.find((d) => p.exists(`${d}/${name}`));
|
|
326
|
+
f.skills[name] = dir !== undefined;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
entry.tools = resolved.profile.tools.map((tool) => {
|
|
330
|
+
if (tool === 'docker') {
|
|
331
|
+
const cliOk = available('docker');
|
|
332
|
+
const daemonOk = cliOk ? tryRun(() => Boolean(p.tool('docker', ['info'])), false) : false;
|
|
333
|
+
return { tool, ok: daemonOk, cliOk };
|
|
334
|
+
}
|
|
335
|
+
return { tool, ok: available(tool) };
|
|
336
|
+
});
|
|
301
337
|
entry.targetTools = platform.targets.map((target) => {
|
|
302
338
|
const tool = resolved.profile.targets[target] ?? '';
|
|
303
339
|
return { target, tool, ok: tool === '' ? true : available(tool) };
|
|
@@ -361,6 +397,8 @@ function gatherPlatforms(f, p, tryRun) {
|
|
|
361
397
|
function executionChecks(f) {
|
|
362
398
|
if (!Array.isArray(f.secrets)) return [];
|
|
363
399
|
const r = [];
|
|
400
|
+
// Antigravity builds with a Gemini key and has no cost limit of its own: the checks of the Anthropic credential do not apply.
|
|
401
|
+
if (f.config?.agent?.build === 'antigravity') return antigravityExecutionChecks(f);
|
|
364
402
|
const { mode } = credentialMode(f.secrets);
|
|
365
403
|
const goWorkflow = f.workflows?.['relevo-go.yml'];
|
|
366
404
|
const installed = Boolean(goWorkflow?.present);
|
|
@@ -387,6 +425,19 @@ function executionChecks(f) {
|
|
|
387
425
|
return r;
|
|
388
426
|
}
|
|
389
427
|
|
|
428
|
+
/** Where `go` builds when the agent is Antigravity: the key by name, and what stands in for the limits it lacks. */
|
|
429
|
+
function antigravityExecutionChecks(f) {
|
|
430
|
+
const r = [];
|
|
431
|
+
const installed = Boolean(f.workflows?.['relevo-go.yml']?.present);
|
|
432
|
+
if (f.secrets.includes('GEMINI_API_KEY')) r.push(pass('execution.credential', 'the cloud builds with a Gemini key: Antigravity has no cost limit, so limits.run_max_tokens (enforced by the run guard) and the quota of the Google Cloud project that owns the key are the ceiling; use a key made only for Relevo'));
|
|
433
|
+
else r.push(pass('execution.credential', installed ? 'no GEMINI_API_KEY secret: the cloud cannot build (see the warning about the secret)' : 'no GEMINI_API_KEY and no go workflow: go builds on machines only'));
|
|
434
|
+
const variable = f.cloudVariable ?? null;
|
|
435
|
+
if (String(variable ?? '').trim().toLowerCase() === 'off') r.push(pass('execution.cloud', `the cloud is paused (${CLOUD_VARIABLE} is off): ready issues are built on machines with /relevo:go`));
|
|
436
|
+
else if (cloudBuilds({ workflowPresent: installed, variable })) r.push(pass('execution.cloud', `the cloud builds ready issues (set the repository variable ${CLOUD_VARIABLE} to off to pause it)`));
|
|
437
|
+
else r.push(pass('execution.cloud', 'the go workflow is not installed: ready issues are built on machines with /relevo:go'));
|
|
438
|
+
return r;
|
|
439
|
+
}
|
|
440
|
+
|
|
390
441
|
/**
|
|
391
442
|
* The linked repositories (`links`): each one readable, and the read-only secret for private ones. Nothing is said
|
|
392
443
|
* when `links` is empty. The secret is checked by name only; doctor never reads its value.
|
|
@@ -426,10 +477,14 @@ function platformChecks(f) {
|
|
|
426
477
|
(e.profile.ignored ?? []).forEach((x) => {
|
|
427
478
|
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`));
|
|
428
479
|
});
|
|
429
|
-
e.tools.forEach(({ tool, ok }) => {
|
|
480
|
+
e.tools.forEach(({ tool, ok, cliOk }) => {
|
|
430
481
|
if (seen.has(`tool:${tool}`)) return;
|
|
431
482
|
seen.add(`tool:${tool}`);
|
|
432
|
-
|
|
483
|
+
if (tool === 'docker' && !ok && cliOk) {
|
|
484
|
+
r.push(warn(`tools.docker`, `docker is installed but the engine is stopped (platform ${e.name}, stack ${e.stack})`, 'start Docker Desktop or OrbStack'));
|
|
485
|
+
} else {
|
|
486
|
+
r.push(ok ? pass(`tools.${tool}`, `${tool} is installed`) : fail(`tools.${tool}`, `${tool} is not installed (platform ${e.name}, stack ${e.stack})`, TOOL_INSTALL[tool] ?? `install ${tool}`));
|
|
487
|
+
}
|
|
433
488
|
});
|
|
434
489
|
e.targetTools.forEach(({ target, tool, ok }) => {
|
|
435
490
|
const id = `tools.target.${e.name}.${target}`;
|
|
@@ -440,6 +495,8 @@ function platformChecks(f) {
|
|
|
440
495
|
});
|
|
441
496
|
e.profile.requires_skills.forEach((name) => {
|
|
442
497
|
if (f.skills[name] === undefined) r.push(warn(`skills.${name}`, `platform ${e.name} requires the skill ${name}; it was not checked`, SKILL_INSTALL[name] ?? `install the skill ${name}`));
|
|
498
|
+
else if (!f.skills[name]) r.push(warn(`skills.${name}`, `platform ${e.name} requires the skill ${name}, which is not installed`, SKILL_INSTALL[name] ?? `install the skill ${name}`));
|
|
499
|
+
else r.push(pass(`skills.${name}`, `skill ${name} is installed (platform ${e.name})`));
|
|
443
500
|
});
|
|
444
501
|
});
|
|
445
502
|
|
|
@@ -485,6 +542,7 @@ export function runChecks(f) {
|
|
|
485
542
|
r.push(f.gh ? pass('tools.gh', 'the GitHub CLI is installed') : fail('tools.gh', 'the GitHub CLI (gh) is not installed', 'install it from https://cli.github.com'));
|
|
486
543
|
if (f.gh) r.push(f.ghAuth ? pass('tools.gh-auth', 'signed in to GitHub') : fail('tools.gh-auth', 'the GitHub CLI is not signed in', 'run `gh auth login`'));
|
|
487
544
|
r.push(...platformChecks(f));
|
|
545
|
+
r.push(...adapterChecks(f));
|
|
488
546
|
|
|
489
547
|
// skills that think invokes
|
|
490
548
|
REQUIRED_SKILLS.forEach((name) => {
|
|
@@ -504,6 +562,7 @@ export function runChecks(f) {
|
|
|
504
562
|
if (f.openspecRoot && f.openspecRootTracked) r.push(pass('openspec.root', 'the OpenSpec root is tracked by git'));
|
|
505
563
|
else r.push(fail('openspec.root', 'the OpenSpec root is missing or not tracked by git, so a clean checkout (and the runner) has no `openspec/`', 'run /relevo:init, then commit openspec/config.yaml and the .gitkeep files'));
|
|
506
564
|
r.push(f.evidenceIgnored ? pass('evidence.ignored', 'evidence/ is ignored by git') : fail('evidence.ignored', 'evidence/ is not in .gitignore; `review` would put screenshots in git', 'add the line `evidence/` to .gitignore (or run /relevo:init)'));
|
|
565
|
+
r.push(...pluginDeclaredChecks(f));
|
|
507
566
|
|
|
508
567
|
// GitHub side
|
|
509
568
|
if (!f.repo || !f.ghAuth) {
|
|
@@ -546,7 +605,10 @@ export function runChecks(f) {
|
|
|
546
605
|
|
|
547
606
|
// automation readiness
|
|
548
607
|
if (f.secrets === null) r.push(warn('automation.secret', 'could not list the repository secrets', 'check that you may read them, or look in Settings > Secrets'));
|
|
549
|
-
else if (f.
|
|
608
|
+
else if (f.config?.agent?.build === 'antigravity') {
|
|
609
|
+
if (f.secrets.includes('GEMINI_API_KEY')) r.push(pass('automation.secret', 'a model credential secret exists (GEMINI_API_KEY)'));
|
|
610
|
+
else r.push(warn('automation.secret', 'no GEMINI_API_KEY secret, so the cloud cannot build: a ready label only leaves a comment that says to run /relevo:go on a machine', 'for the cloud, add a key made only for Relevo, in its own Google Cloud project with a quota, to the repository secrets (init never touches it); for local builds nothing is needed'));
|
|
611
|
+
} else if (f.secrets.some((n) => ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(n))) r.push(pass('automation.secret', 'a model credential secret exists'));
|
|
550
612
|
else r.push(warn('automation.secret', 'no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN secret, so the cloud cannot build: a ready label only leaves a comment that says to run /relevo:go on a machine', 'for the cloud, add one to the repository secrets (init never touches it; for a subscription token remove all whitespace before storing it); for local builds nothing is needed'));
|
|
551
613
|
|
|
552
614
|
Object.entries(f.workflows).forEach(([file, w]) => {
|
|
@@ -595,6 +657,15 @@ function toolchainChecks(f) {
|
|
|
595
657
|
});
|
|
596
658
|
}
|
|
597
659
|
|
|
660
|
+
/** Does the committed settings file declare the plugin? It is a local fact, so it needs no GitHub sign-in. */
|
|
661
|
+
function pluginDeclaredChecks(f) {
|
|
662
|
+
const r = [];
|
|
663
|
+
if (f.pluginDeclared?.unreadable) r.push(warn('plugin.declared', '.claude/settings.json is not valid JSON, so it cannot be checked whether the repository declares the Relevo plugin', 'fix the file; /relevo:init then offers to declare the plugin'));
|
|
664
|
+
else if (f.pluginDeclared?.declared) r.push(pass('plugin.declared', 'the repository declares the Relevo plugin in .claude/settings.json, so a teammate who opens it is offered it'));
|
|
665
|
+
else if (f.pluginDeclared) r.push(warn('plugin.declared', 'the repository does not declare the Relevo plugin in .claude/settings.json: a teammate who clones it is not offered the plugin and finds no /relevo: commands', 'run /relevo:init, or claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin and claude plugin install relevo@relevo --scope project, and commit .claude/settings.json'));
|
|
666
|
+
return r;
|
|
667
|
+
}
|
|
668
|
+
|
|
598
669
|
/**
|
|
599
670
|
* The installed plugin and what the workflows pin. A package that lost a folder cannot run; a workflow that clones
|
|
600
671
|
* the plugin repository for its scripts (the layout before the package) or has no pin is told to be replaced; a pin
|
|
@@ -614,6 +685,23 @@ function pluginInstallChecks(f) {
|
|
|
614
685
|
return r;
|
|
615
686
|
}
|
|
616
687
|
|
|
688
|
+
/**
|
|
689
|
+
* The plugins that `relevo init` rendered for a tool other than Claude Code: a file that is gone is a failure (the
|
|
690
|
+
* plugin cannot run), a file of another package version or edited by hand is a warning, because a team chooses when it
|
|
691
|
+
* moves and may keep an edit on purpose.
|
|
692
|
+
*/
|
|
693
|
+
function adapterChecks(f) {
|
|
694
|
+
return (f.adapters ?? []).flatMap((a) => {
|
|
695
|
+
const id = `adapters.antigravity.${a.scope}`;
|
|
696
|
+
const name = `the Antigravity plugin of the ${a.scope} (${a.dir})`;
|
|
697
|
+
const r = [];
|
|
698
|
+
if (a.missing.length > 0) r.push(fail(id, `${name} lacks ${a.missing.slice(0, 5).join(', ')}${a.missing.length > 5 ? ` and ${a.missing.length - 5} more` : ''}`, 'run `relevo update`'));
|
|
699
|
+
if (f.packageVersion && a.package !== f.packageVersion) r.push(warn(`${id}.version`, `${name} was rendered by Relevo ${a.package}, and this one is ${f.packageVersion}`, 'run `relevo update`'));
|
|
700
|
+
if (a.edited.length > 0) r.push(warn(`${id}.edited`, `${name} has files edited by hand: ${a.edited.slice(0, 5).join(', ')}`, 'keep them, or run `relevo update --force` to overwrite them'));
|
|
701
|
+
return r.length > 0 ? r : [pass(id, `${name} is complete and up to date`)];
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
617
705
|
/** Text report. */
|
|
618
706
|
export function renderReport(results) {
|
|
619
707
|
const tag = { pass: 'pass', fail: 'FAIL', warn: 'warn' };
|