@elalfner/relevo 0.4.0 → 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.
@@ -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
+ ' 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]',
12
14
  ' install add the marketplace and install the plugin; asks where in a terminal (project by default)',
13
- ' update update the marketplace and the plugin (without --scope, the installation of this project)',
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
  '',
@@ -23,13 +25,15 @@ export function parseArgs(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
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
35
  if (scope !== null && !SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
32
- return { command, scope };
36
+ return force ? { command, scope, force } : { command, scope };
33
37
  }
34
38
 
35
39
  /** Whether the folder is inside a git work tree; `run` is the injected runner. A missing git counts as no. */
@@ -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);
@@ -314,7 +320,20 @@ function gatherPlatforms(f, p, tryRun) {
314
320
  const resolved = resolveProfile({ platform, pluginRoot, read });
315
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: [] };
316
322
  if (resolved.ok) {
317
- 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
+ });
318
337
  entry.targetTools = platform.targets.map((target) => {
319
338
  const tool = resolved.profile.targets[target] ?? '';
320
339
  return { target, tool, ok: tool === '' ? true : available(tool) };
@@ -378,6 +397,8 @@ function gatherPlatforms(f, p, tryRun) {
378
397
  function executionChecks(f) {
379
398
  if (!Array.isArray(f.secrets)) return [];
380
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);
381
402
  const { mode } = credentialMode(f.secrets);
382
403
  const goWorkflow = f.workflows?.['relevo-go.yml'];
383
404
  const installed = Boolean(goWorkflow?.present);
@@ -404,6 +425,19 @@ function executionChecks(f) {
404
425
  return r;
405
426
  }
406
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
+
407
441
  /**
408
442
  * The linked repositories (`links`): each one readable, and the read-only secret for private ones. Nothing is said
409
443
  * when `links` is empty. The secret is checked by name only; doctor never reads its value.
@@ -443,10 +477,14 @@ function platformChecks(f) {
443
477
  (e.profile.ignored ?? []).forEach((x) => {
444
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`));
445
479
  });
446
- e.tools.forEach(({ tool, ok }) => {
480
+ e.tools.forEach(({ tool, ok, cliOk }) => {
447
481
  if (seen.has(`tool:${tool}`)) return;
448
482
  seen.add(`tool:${tool}`);
449
- 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
+ }
450
488
  });
451
489
  e.targetTools.forEach(({ target, tool, ok }) => {
452
490
  const id = `tools.target.${e.name}.${target}`;
@@ -457,6 +495,8 @@ function platformChecks(f) {
457
495
  });
458
496
  e.profile.requires_skills.forEach((name) => {
459
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})`));
460
500
  });
461
501
  });
462
502
 
@@ -502,6 +542,7 @@ export function runChecks(f) {
502
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'));
503
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`'));
504
544
  r.push(...platformChecks(f));
545
+ r.push(...adapterChecks(f));
505
546
 
506
547
  // skills that think invokes
507
548
  REQUIRED_SKILLS.forEach((name) => {
@@ -564,7 +605,10 @@ export function runChecks(f) {
564
605
 
565
606
  // automation readiness
566
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'));
567
- 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'));
568
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'));
569
613
 
570
614
  Object.entries(f.workflows).forEach(([file, w]) => {
@@ -641,6 +685,23 @@ function pluginInstallChecks(f) {
641
685
  return r;
642
686
  }
643
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
+
644
705
  /** Text report. */
645
706
  export function renderReport(results) {
646
707
  const tag = { pass: 'pass', fail: 'FAIL', warn: 'warn' };
@@ -5,16 +5,38 @@
5
5
  // capture(cmd, args, { cwd }) -> stdout (throws when the command fails)
6
6
  // stream(cmd, args, { cwd, env }) -> exit status (the output goes to the terminal); throws with code ENOENT when missing
7
7
 
8
+ import { tmpdir } from 'node:os';
8
9
  import { basename, dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
9
11
  import { cloudBuilds } from './execution-mode.mjs';
10
12
  import { runFlags } from './run-flags.mjs';
11
13
 
12
14
  export const WORKTREES_FOLDER = '.relevo-worktrees';
15
+ // The guard is part of the plugin, not of the project that is built, so it is found next to this file.
16
+ const GUARD = fileURLToPath(new URL('../agent-guard.mjs', import.meta.url));
17
+ // The same time limit as the cloud step of the Antigravity agent.
18
+ export const GUARD_TIMEOUT_MINUTES = 50;
13
19
 
14
- /** What a local run will do, without doing it: the worktree and the `claude` command. Pure. */
20
+ /** What a local run will do, without doing it: the worktree and the `claude` or `agy` command. Pure. */
15
21
  export function planLocalRun({ issue, repoRoot, config }) {
16
22
  const base = config.branches.base;
17
23
  const worktree = join(dirname(repoRoot), WORKTREES_FOLDER, `${basename(repoRoot)}-${issue}`);
24
+ const isAntigravity = config.agent?.build === 'antigravity';
25
+ const command = isAntigravity
26
+ ? ['node', [
27
+ GUARD,
28
+ 'run',
29
+ '--prompt',
30
+ `/relevo:go ${issue}`,
31
+ '--config',
32
+ 'relevo.yml',
33
+ '--timeout-minutes',
34
+ String(GUARD_TIMEOUT_MINUTES),
35
+ // outside the worktree, so a clean end can remove it and the agent finds the tree clean
36
+ '--summary',
37
+ join(tmpdir(), `relevo-guard-${basename(repoRoot)}-${issue}.json`),
38
+ ]]
39
+ : ['claude', ['-p', `/relevo:go ${issue}`, ...runFlags(config)]];
18
40
  return {
19
41
  issue,
20
42
  base,
@@ -23,9 +45,9 @@ export function planLocalRun({ issue, repoRoot, config }) {
23
45
  ['fetch', 'origin', base],
24
46
  ['worktree', 'add', '--detach', worktree, `origin/${base}`],
25
47
  ],
26
- claude: ['claude', ['-p', `/relevo:go ${issue}`, ...runFlags(config)]],
48
+ claude: command,
27
49
  // Without this the agent may start a subagent in the background and end the run before it finishes.
28
- env: { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' },
50
+ env: isAntigravity ? {} : { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' },
29
51
  };
30
52
  }
31
53
 
@@ -45,6 +67,7 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
45
67
  return { status: 2, worktree: null, removed: false, messages: ['not inside a git repository'] };
46
68
  }
47
69
  const plan = planLocalRun({ issue: String(issue), repoRoot, config });
70
+ const isAntigravity = config.agent?.build === 'antigravity';
48
71
 
49
72
  if (dryRun) {
50
73
  say(`worktree: ${plan.worktree} (from origin/${plan.base})`);
@@ -84,7 +107,10 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
84
107
  status = runner.stream(plan.claude[0], plan.claude[1], { cwd: plan.worktree, env: plan.env });
85
108
  } catch (err) {
86
109
  status = err.code === 'ENOENT' ? 127 : 1;
87
- say(err.code === 'ENOENT' ? 'the claude command was not found: install Claude Code and the Relevo plugin' : `the run could not start: ${err.message}`);
110
+ const missing = config.agent?.build === 'antigravity'
111
+ ? 'the agy or node command was not found: install the Antigravity CLI'
112
+ : 'the claude command was not found: install Claude Code and the Relevo plugin';
113
+ say(err.code === 'ENOENT' ? missing : `the run could not start: ${err.message}`);
88
114
  }
89
115
 
90
116
  if (status === 0) {
@@ -97,6 +123,7 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
97
123
  return { status: 0, worktree: plan.worktree, removed: false, messages };
98
124
  }
99
125
  }
126
+ if (isAntigravity && status === 2) say('the run guard refused to start (usage error): see its message above');
100
127
  say(`the run ended with status ${status}; the worktree is kept for inspection: ${plan.worktree}`);
101
128
  return { status, worktree: plan.worktree, removed: false, messages };
102
129
  }
@@ -0,0 +1,97 @@
1
+ // `relevo init`: set up Relevo for the tools a team uses, in the project or on the machine, and `relevo update` for the
2
+ // plugins that were rendered. Claude Code keeps being installed by `claude plugin` (see cli.mjs); another tool gets a
3
+ // plugin rendered from the same sources (see adapters/). Everything that talks to the outside is injected.
4
+ import { execute } from './cli.mjs';
5
+ import { findRecords, installAntigravity } from './adapters/install.mjs';
6
+
7
+ export const TOOLS = [
8
+ { value: 'claude', label: 'Claude Code: the plugin, which updates itself and adds no files to the repository' },
9
+ { value: 'antigravity', label: 'Antigravity: the IDE, the App and the CLI, with a plugin rendered for them' }
10
+ ];
11
+ export const INIT_SCOPES = ['project', 'machine'];
12
+
13
+ /** Parses what follows `init`: { tools, scope, yes, force } or { error }. `user` is accepted for `machine`. */
14
+ export function parseInitArgs(rest) {
15
+ const out = { command: 'init', tools: null, scope: null };
16
+ for (let i = 0; i < rest.length; i += 1) {
17
+ const arg = rest[i];
18
+ const take = (name) => (arg === name ? rest[++i] : arg.slice(name.length + 1));
19
+ if (arg === '--tools' || arg.startsWith('--tools=')) out.tools = String(take('--tools') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
20
+ else if (arg === '--scope' || arg.startsWith('--scope=')) out.scope = take('--scope');
21
+ else if (arg === '--yes' || arg === '-y') out.yes = true;
22
+ else if (arg === '--force') out.force = true;
23
+ else return { error: `unknown option "${arg}"` };
24
+ }
25
+ if (out.scope === 'user') out.scope = 'machine';
26
+ if (out.scope !== null && !INIT_SCOPES.includes(out.scope)) return { error: `the scope of init must be project or machine` };
27
+ if (out.tools) {
28
+ const unknown = out.tools.filter((t) => !TOOLS.some((x) => x.value === t));
29
+ if (unknown.length) return { error: `unknown tool "${unknown[0]}"; the tools are ${TOOLS.map((t) => t.value).join(', ')}` };
30
+ if (out.tools.length === 0) return { error: '--tools needs at least one tool' };
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** Like resolveScope of cli.mjs, with the choices project and machine. */
36
+ export function resolveInitScope({ given = null, interactive = false, inRepo = false }) {
37
+ if (given) return { scope: given };
38
+ const preferred = inRepo ? 'project' : 'machine';
39
+ if (interactive) return { ask: true, preferred };
40
+ const why = inRepo ? 'this folder is inside a git repository, so the project is used' : 'this folder is not inside a git repository, so the machine is used';
41
+ return { scope: preferred, reason: `no --scope given and no terminal: ${why}` };
42
+ }
43
+
44
+ export function initScopeChoices({ cwd, inRepo }) {
45
+ return [
46
+ { value: 'project', label: `Project (${cwd})${inRepo ? '' : ' [not a git repository]'}: files in the repository, shared with your team` },
47
+ { value: 'machine', label: 'Machine: every project of this machine' }
48
+ ];
49
+ }
50
+
51
+ /** The top folder of the git repository of the current folder, or null. */
52
+ export function repoRoot(run) {
53
+ const r = run('git', ['rev-parse', '--show-toplevel']);
54
+ return r.code === 0 && r.output.trim() ? r.output.trim() : null;
55
+ }
56
+
57
+ const NEXT_STEP = 'Next: /relevo:init (the skill) sets up the labels and the rules of the repository; it is a separate step from this command.';
58
+
59
+ /** Sets up each tool, in order, and stops at the first failure. Returns { code, lines }. */
60
+ export function runInit({ tools, scope, force = false }, deps) {
61
+ const lines = [];
62
+ for (const tool of tools) {
63
+ lines.push(`== ${TOOLS.find((t) => t.value === tool).label.split(':')[0]}`);
64
+ const r =
65
+ tool === 'claude'
66
+ ? execute({ command: 'install', scope: scope === 'machine' ? 'user' : 'project' }, deps)
67
+ : installAntigravity({ packageRoot: deps.packageRoot, scope, root: deps.root, home: deps.home, run: deps.run, has: deps.has, force });
68
+ lines.push(...r.lines);
69
+ if (r.code !== 0) return { code: r.code, lines };
70
+ }
71
+ lines.push(NEXT_STEP);
72
+ return { code: 0, lines };
73
+ }
74
+
75
+ /**
76
+ * Updates Claude Code as before and renders again the plugins that `init` made. With neither, it says what it always
77
+ * said. The code is the worst of the parts, and each part says what it did.
78
+ */
79
+ export function runUpdate({ scope = null, force = false }, deps) {
80
+ const records = findRecords({ root: deps.root, home: deps.home });
81
+ if (records.length === 0) return execute({ command: 'update', scope }, deps);
82
+ const lines = [];
83
+ let code = 0;
84
+ if (deps.has('claude')) {
85
+ lines.push('== Claude Code');
86
+ const r = execute({ command: 'update', scope }, deps);
87
+ lines.push(...r.lines);
88
+ code = r.code;
89
+ }
90
+ for (const rec of records) {
91
+ lines.push(`== Antigravity (${rec.scope})`);
92
+ const r = installAntigravity({ packageRoot: deps.packageRoot, scope: rec.scope, root: deps.root, home: deps.home, run: deps.run, has: deps.has, force, update: true });
93
+ lines.push(...r.lines);
94
+ code = code || r.code;
95
+ }
96
+ return { code, lines };
97
+ }