@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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.es.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +1 -1
  5. package/scripts/agent-guard.mjs +94 -0
  6. package/scripts/cli.mjs +67 -6
  7. package/scripts/doctor.mjs +10 -0
  8. package/scripts/init.mjs +1 -0
  9. package/scripts/lib/adapters/antigravity.mjs +99 -0
  10. package/scripts/lib/adapters/index.mjs +6 -0
  11. package/scripts/lib/adapters/install.mjs +127 -0
  12. package/scripts/lib/adapters/source.mjs +65 -0
  13. package/scripts/lib/adapters/tools.mjs +52 -0
  14. package/scripts/lib/agent-guard.mjs +188 -0
  15. package/scripts/lib/agent-workflow.mjs +46 -0
  16. package/scripts/lib/cli.mjs +44 -12
  17. package/scripts/lib/config.mjs +18 -7
  18. package/scripts/lib/doctor.mjs +92 -4
  19. package/scripts/lib/go-local.mjs +31 -4
  20. package/scripts/lib/init-tools.mjs +97 -0
  21. package/scripts/lib/init.mjs +38 -2
  22. package/scripts/lib/messages.mjs +16 -1
  23. package/scripts/lib/next-step.mjs +10 -1
  24. package/scripts/lib/protected-paths.mjs +10 -4
  25. package/scripts/lib/run-usage.mjs +1 -1
  26. package/scripts/lib/select.mjs +118 -0
  27. package/scripts/lib/status-data.mjs +16 -5
  28. package/scripts/lib/status.mjs +20 -5
  29. package/scripts/lib/toolchain-check.mjs +12 -0
  30. package/scripts/toolchain-check.mjs +49 -0
  31. package/skills/init/SKILL.md +5 -2
  32. package/skills/review/SKILL.md +10 -0
  33. package/stacks/supabase.md +63 -0
  34. package/templates/agents/antigravity-step.yml +70 -0
  35. package/templates/deploy-supabase.yml +75 -0
  36. package/templates/workflows/relevo-digest.yml +1 -1
  37. package/templates/workflows/relevo-go.yml +35 -1
  38. package/templates/workflows/relevo-queue.yml +3 -1
  39. package/templates/workflows/relevo-review.yml +1 -1
  40. 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
+ }
@@ -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
- ' install add the marketplace and install the plugin (default scope: user)',
13
- ' update update the marketplace and the plugin',
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 = 'user';
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 === '--scope') { scope = rest[i + 1]; i += 1; } else if (arg.startsWith('--scope=')) scope = arg.slice(8);
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 (declared in .claude/settings.json, so commit it to share it)', local: 'for this repository, only for you' }[scope];
78
- lines.push(command === 'install'
79
- ? `Relevo is installed ${where}. Restart Claude Code and run /relevo:doctor.`
80
- : 'Relevo is up to date. Restart Claude Code to apply it.');
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
  }
@@ -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
- { path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true },
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
- if (/(api[_-]?key|secret|password|token)/i.test(key)) {
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`,
@@ -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
- entry.tools = resolved.profile.tools.map((tool) => ({ tool, ok: available(tool) }));
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
- 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}`));
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.secrets.some((n) => ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(n))) r.push(pass('automation.secret', 'a model credential secret exists'));
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' };