@elalfner/relevo 0.4.0 → 0.6.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.es.md +2 -0
  3. package/README.md +2 -0
  4. package/package.json +1 -1
  5. package/scripts/agent-guard.mjs +94 -0
  6. package/scripts/cli.mjs +48 -12
  7. package/scripts/doctor.mjs +10 -0
  8. package/scripts/go-worktree.mjs +40 -0
  9. package/scripts/init.mjs +1 -0
  10. package/scripts/lib/adapters/antigravity.mjs +99 -0
  11. package/scripts/lib/adapters/index.mjs +6 -0
  12. package/scripts/lib/adapters/install.mjs +127 -0
  13. package/scripts/lib/adapters/source.mjs +65 -0
  14. package/scripts/lib/adapters/tools.mjs +52 -0
  15. package/scripts/lib/agent-guard.mjs +188 -0
  16. package/scripts/lib/agent-workflow.mjs +46 -0
  17. package/scripts/lib/cli.mjs +7 -3
  18. package/scripts/lib/config.mjs +18 -7
  19. package/scripts/lib/doctor.mjs +79 -5
  20. package/scripts/lib/go-local.mjs +35 -7
  21. package/scripts/lib/go-worktree.mjs +85 -0
  22. package/scripts/lib/init-tools.mjs +97 -0
  23. package/scripts/lib/init.mjs +31 -4
  24. package/scripts/lib/protected-paths.mjs +10 -4
  25. package/scripts/lib/run-usage.mjs +1 -1
  26. package/scripts/lib/select.mjs +58 -0
  27. package/scripts/lib/worktree-path.mjs +11 -0
  28. package/skills/go/SKILL.md +13 -1
  29. package/skills/init/SKILL.md +5 -4
  30. package/skills/review/SKILL.md +10 -0
  31. package/stacks/flutter.md +45 -7
  32. package/stacks/supabase.md +63 -0
  33. package/templates/agents/antigravity-step.yml +70 -0
  34. package/templates/deploy-supabase.yml +75 -0
  35. package/templates/workflows/relevo-digest.yml +1 -1
  36. package/templates/workflows/relevo-go.yml +5 -1
  37. package/templates/workflows/relevo-queue.yml +3 -1
  38. package/templates/workflows/relevo-review.yml +1 -1
  39. package/templates/workflows/relevo-sla.yml +1 -1
@@ -0,0 +1,52 @@
1
+ // The tools of an agent, said in the words of the tool that will run it. Claude Code's subagents have every tool
2
+ // unless told otherwise (`disallowedTools`); Antigravity's have none beyond a small base unless a list is given, so
3
+ // the list is built here. A Claude tool with no entry stops the render: a wrong name can hang a subagent.
4
+
5
+ /** What a subagent of Claude Code can use when nothing restricts it, as far as Relevo's agents rely on it. */
6
+ export const CLAUDE_AGENT_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'];
7
+
8
+ /**
9
+ * Claude tool -> the Antigravity tools that do the same. Names are the ones the CLI reports in the `init` event of
10
+ * `agy -p --output-format stream-json` (checked by tests/adapter-antigravity.test.mjs when RELEVO_AGY_CHECK=1).
11
+ * An empty list means the tool has no counterpart and is left out on purpose.
12
+ */
13
+ export const ANTIGRAVITY_TOOLS = {
14
+ Read: ['view_file', 'list_dir'],
15
+ Write: ['write_to_file'],
16
+ Edit: ['replace_file_content', 'multi_replace_file_content'],
17
+ NotebookEdit: [],
18
+ Bash: ['run_command'],
19
+ Grep: ['grep_search'],
20
+ Glob: ['find_by_name']
21
+ };
22
+
23
+ export class UnmappedToolError extends Error {
24
+ constructor(tool) {
25
+ super(`the Claude tool "${tool}" has no entry in the Antigravity tool table (scripts/lib/adapters/tools.mjs)`);
26
+ this.tool = tool;
27
+ }
28
+ }
29
+
30
+ const names = (v) =>
31
+ (Array.isArray(v) ? v : String(v ?? '').split(','))
32
+ .map((s) => String(s).trim())
33
+ .filter(Boolean);
34
+
35
+ /** The Claude tools an agent ends up with, from its `tools` (a list) or from everything minus `disallowedTools`. */
36
+ export function claudeToolsOf(meta) {
37
+ if (meta.tools !== undefined) return names(meta.tools);
38
+ const denied = new Set(names(meta.disallowedTools));
39
+ return CLAUDE_AGENT_TOOLS.filter((t) => !denied.has(t));
40
+ }
41
+
42
+ /** The Antigravity tool names for an agent, without repeats, in a stable order. */
43
+ export function antigravityToolsOf(meta) {
44
+ const out = [];
45
+ for (const tool of claudeToolsOf(meta)) {
46
+ if (!(tool in ANTIGRAVITY_TOOLS)) throw new UnmappedToolError(tool);
47
+ for (const t of ANTIGRAVITY_TOOLS[tool]) if (!out.includes(t)) out.push(t);
48
+ }
49
+ const denied = names(meta.disallowedTools);
50
+ for (const tool of denied) if (!(tool in ANTIGRAVITY_TOOLS)) throw new UnmappedToolError(tool);
51
+ return out;
52
+ }
@@ -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`,
@@ -13,7 +13,7 @@ import { loadConfig } from './config.mjs';
13
13
  import { labelDefinitions } from './labels.mjs';
14
14
  import { credentialMode, cloudBuilds, CLOUD_VARIABLE } from './execution-mode.mjs';
15
15
  import { SKILL_DIRS, SIZE_BUDGET_BYTES, folderHash, lockDifferences, resolveRoleSkills, skillBudget } from './role-skills.mjs';
16
- import { ROLES, profileLocations, profileLookups, resolveProfile } from './stack-profile.mjs';
16
+ import { ROLES, profileLocations, profileLookups, repositoryProfilePath, resolveProfile } from './stack-profile.mjs';
17
17
 
18
18
  export const REQUIRED_SKILLS = ['openspec-explore', 'grill-with-docs', 'grilling', 'domain-modeling'];
19
19
  export const SKILL_INSTALL = {
@@ -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,11 +320,32 @@ 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) };
321
340
  });
341
+ // The cloud and a fresh clone read the repository's layer from git, not from this working copy. `null` means
342
+ // git could not say (no git, or not a repository).
343
+ if (resolved.profile.layers.includes('repository')) {
344
+ const path = repositoryProfilePath(platform.stack);
345
+ if (!f.git) entry.repositoryLayerTracked = null;
346
+ else if (tryRun(() => { p.tool('git', ['ls-files', '--error-unmatch', path]); return true; }, false)) entry.repositoryLayerTracked = true;
347
+ else entry.repositoryLayerTracked = tryRun(() => { p.tool('git', ['rev-parse', '--is-inside-work-tree']); return true; }, false) ? false : null;
348
+ }
322
349
  }
323
350
  f.platformFacts.push(entry);
324
351
  });
@@ -378,6 +405,8 @@ function gatherPlatforms(f, p, tryRun) {
378
405
  function executionChecks(f) {
379
406
  if (!Array.isArray(f.secrets)) return [];
380
407
  const r = [];
408
+ // Antigravity builds with a Gemini key and has no cost limit of its own: the checks of the Anthropic credential do not apply.
409
+ if (f.config?.agent?.build === 'antigravity') return antigravityExecutionChecks(f);
381
410
  const { mode } = credentialMode(f.secrets);
382
411
  const goWorkflow = f.workflows?.['relevo-go.yml'];
383
412
  const installed = Boolean(goWorkflow?.present);
@@ -404,6 +433,19 @@ function executionChecks(f) {
404
433
  return r;
405
434
  }
406
435
 
436
+ /** Where `go` builds when the agent is Antigravity: the key by name, and what stands in for the limits it lacks. */
437
+ function antigravityExecutionChecks(f) {
438
+ const r = [];
439
+ const installed = Boolean(f.workflows?.['relevo-go.yml']?.present);
440
+ 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'));
441
+ 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'));
442
+ const variable = f.cloudVariable ?? null;
443
+ 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`));
444
+ 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)`));
445
+ else r.push(pass('execution.cloud', 'the go workflow is not installed: ready issues are built on machines with /relevo:go'));
446
+ return r;
447
+ }
448
+
407
449
  /**
408
450
  * The linked repositories (`links`): each one readable, and the read-only secret for private ones. Nothing is said
409
451
  * when `links` is empty. The secret is checked by name only; doctor never reads its value.
@@ -440,13 +482,22 @@ function platformChecks(f) {
440
482
  }
441
483
  const layers = e.profile.layers.map((l) => (l === 'plugin' ? 'default of the plugin' : `.relevo/stacks/${e.stack}.md`)).join(' + ');
442
484
  r.push(pass(`platform.${e.name}.profile`, `platform ${e.name} (${e.stack}, ${e.path}): profile = ${layers}`));
485
+ if (e.repositoryLayerTracked === false) {
486
+ const path = `.relevo/stacks/${e.stack}.md`;
487
+ const effect = e.profile.layers.includes('plugin') ? `use only the plugin's profile for ${e.stack}, without your rules` : `have no profile for ${e.stack}`;
488
+ r.push(warn(`platform.${e.name}.profile.tracked`, `platform ${e.name}: ${path} is not tracked by git, so the cloud run and a fresh clone ${effect}`, `commit ${path} and push it with the rest of the repository's Relevo files`));
489
+ }
443
490
  (e.profile.ignored ?? []).forEach((x) => {
444
491
  r.push(warn(`platform.${e.name}.ignored.${x.key}`, `platform ${e.name}: the repository profile sets ${x.key} = "${x.value}", which is ignored (the plugin keeps "${x.kept}")`, `a command is replaced with platforms.${e.name}.checks in relevo.yml, not in the profile`));
445
492
  });
446
- e.tools.forEach(({ tool, ok }) => {
493
+ e.tools.forEach(({ tool, ok, cliOk }) => {
447
494
  if (seen.has(`tool:${tool}`)) return;
448
495
  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}`));
496
+ if (tool === 'docker' && !ok && cliOk) {
497
+ r.push(warn(`tools.docker`, `docker is installed but the engine is stopped (platform ${e.name}, stack ${e.stack})`, 'start Docker Desktop or OrbStack'));
498
+ } else {
499
+ 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}`));
500
+ }
450
501
  });
451
502
  e.targetTools.forEach(({ target, tool, ok }) => {
452
503
  const id = `tools.target.${e.name}.${target}`;
@@ -457,6 +508,8 @@ function platformChecks(f) {
457
508
  });
458
509
  e.profile.requires_skills.forEach((name) => {
459
510
  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}`));
511
+ 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}`));
512
+ else r.push(pass(`skills.${name}`, `skill ${name} is installed (platform ${e.name})`));
460
513
  });
461
514
  });
462
515
 
@@ -502,6 +555,7 @@ export function runChecks(f) {
502
555
  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
556
  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
557
  r.push(...platformChecks(f));
558
+ r.push(...adapterChecks(f));
505
559
 
506
560
  // skills that think invokes
507
561
  REQUIRED_SKILLS.forEach((name) => {
@@ -564,7 +618,10 @@ export function runChecks(f) {
564
618
 
565
619
  // automation readiness
566
620
  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'));
621
+ else if (f.config?.agent?.build === 'antigravity') {
622
+ if (f.secrets.includes('GEMINI_API_KEY')) r.push(pass('automation.secret', 'a model credential secret exists (GEMINI_API_KEY)'));
623
+ 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'));
624
+ } 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
625
  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
626
 
570
627
  Object.entries(f.workflows).forEach(([file, w]) => {
@@ -641,6 +698,23 @@ function pluginInstallChecks(f) {
641
698
  return r;
642
699
  }
643
700
 
701
+ /**
702
+ * The plugins that `relevo init` rendered for a tool other than Claude Code: a file that is gone is a failure (the
703
+ * plugin cannot run), a file of another package version or edited by hand is a warning, because a team chooses when it
704
+ * moves and may keep an edit on purpose.
705
+ */
706
+ function adapterChecks(f) {
707
+ return (f.adapters ?? []).flatMap((a) => {
708
+ const id = `adapters.antigravity.${a.scope}`;
709
+ const name = `the Antigravity plugin of the ${a.scope} (${a.dir})`;
710
+ const r = [];
711
+ 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`'));
712
+ 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`'));
713
+ 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'));
714
+ return r.length > 0 ? r : [pass(id, `${name} is complete and up to date`)];
715
+ });
716
+ }
717
+
644
718
  /** Text report. */
645
719
  export function renderReport(results) {
646
720
  const tag = { pass: 'pass', fail: 'FAIL', warn: 'warn' };
@@ -5,16 +5,39 @@
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 { basename, dirname, join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { basename, 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';
13
+ import { WORKTREES_FOLDER, worktreePath } from './worktree-path.mjs';
11
14
 
12
- export const WORKTREES_FOLDER = '.relevo-worktrees';
15
+ export { WORKTREES_FOLDER };
16
+ // The guard is part of the plugin, not of the project that is built, so it is found next to this file.
17
+ const GUARD = fileURLToPath(new URL('../agent-guard.mjs', import.meta.url));
18
+ // The same time limit as the cloud step of the Antigravity agent.
19
+ export const GUARD_TIMEOUT_MINUTES = 50;
13
20
 
14
- /** What a local run will do, without doing it: the worktree and the `claude` command. Pure. */
21
+ /** What a local run will do, without doing it: the worktree and the `claude` or `agy` command. Pure. */
15
22
  export function planLocalRun({ issue, repoRoot, config }) {
16
23
  const base = config.branches.base;
17
- const worktree = join(dirname(repoRoot), WORKTREES_FOLDER, `${basename(repoRoot)}-${issue}`);
24
+ const worktree = worktreePath(repoRoot, issue);
25
+ const isAntigravity = config.agent?.build === 'antigravity';
26
+ const command = isAntigravity
27
+ ? ['node', [
28
+ GUARD,
29
+ 'run',
30
+ '--prompt',
31
+ `/relevo:go ${issue}`,
32
+ '--config',
33
+ 'relevo.yml',
34
+ '--timeout-minutes',
35
+ String(GUARD_TIMEOUT_MINUTES),
36
+ // outside the worktree, so a clean end can remove it and the agent finds the tree clean
37
+ '--summary',
38
+ join(tmpdir(), `relevo-guard-${basename(repoRoot)}-${issue}.json`),
39
+ ]]
40
+ : ['claude', ['-p', `/relevo:go ${issue}`, ...runFlags(config)]];
18
41
  return {
19
42
  issue,
20
43
  base,
@@ -23,9 +46,9 @@ export function planLocalRun({ issue, repoRoot, config }) {
23
46
  ['fetch', 'origin', base],
24
47
  ['worktree', 'add', '--detach', worktree, `origin/${base}`],
25
48
  ],
26
- claude: ['claude', ['-p', `/relevo:go ${issue}`, ...runFlags(config)]],
49
+ claude: command,
27
50
  // 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' },
51
+ env: isAntigravity ? {} : { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' },
29
52
  };
30
53
  }
31
54
 
@@ -45,6 +68,7 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
45
68
  return { status: 2, worktree: null, removed: false, messages: ['not inside a git repository'] };
46
69
  }
47
70
  const plan = planLocalRun({ issue: String(issue), repoRoot, config });
71
+ const isAntigravity = config.agent?.build === 'antigravity';
48
72
 
49
73
  if (dryRun) {
50
74
  say(`worktree: ${plan.worktree} (from origin/${plan.base})`);
@@ -84,7 +108,10 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
84
108
  status = runner.stream(plan.claude[0], plan.claude[1], { cwd: plan.worktree, env: plan.env });
85
109
  } catch (err) {
86
110
  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}`);
111
+ const missing = config.agent?.build === 'antigravity'
112
+ ? 'the agy or node command was not found: install the Antigravity CLI'
113
+ : 'the claude command was not found: install Claude Code and the Relevo plugin';
114
+ say(err.code === 'ENOENT' ? missing : `the run could not start: ${err.message}`);
88
115
  }
89
116
 
90
117
  if (status === 0) {
@@ -97,6 +124,7 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
97
124
  return { status: 0, worktree: plan.worktree, removed: false, messages };
98
125
  }
99
126
  }
127
+ if (isAntigravity && status === 2) say('the run guard refused to start (usage error): see its message above');
100
128
  say(`the run ended with status ${status}; the worktree is kept for inspection: ${plan.worktree}`);
101
129
  return { status, worktree: plan.worktree, removed: false, messages };
102
130
  }