@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,85 @@
1
+ // The worktree of an interactive `go` in Claude Code. Implements "An interactive Claude Code run builds in its own
2
+ // worktree" of the execution-modes specification: the skill makes the worktree here, enters it with EnterWorktree, and
3
+ // asks for its removal here when the run ends cleanly.
4
+ //
5
+ // Every process goes through an injected runner so the logic is tested without git or a network:
6
+ // capture(cmd, args, { cwd }) -> stdout (throws when the command fails)
7
+
8
+ import { worktreePath } from './worktree-path.mjs';
9
+
10
+ /** Exit codes of `go-worktree.mjs`. `exists` and `linked` are answers, not failures: the skill reads them. */
11
+ export const EXIT = { ok: 0, failed: 1, usage: 2, exists: 3, linked: 4 };
12
+
13
+ const fail = (status, message) => ({ status, worktree: null, messages: [message] });
14
+ // git prints progress ("Preparing worktree ...") before the reason, so the reason is the `fatal:` or `error:` line, or the last one.
15
+ function firstLine(err) {
16
+ const lines = String(err.stderr ?? err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean);
17
+ return lines.find((l) => /^(fatal|error):/.test(l)) ?? lines.at(-1) ?? 'git failed';
18
+ }
19
+
20
+ function context(issue, runner) {
21
+ if (!/^\d+$/.test(String(issue))) return { early: fail(EXIT.usage, 'give the issue number: go-worktree.mjs create|remove <issue>') };
22
+ try {
23
+ const repoRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], {}).trim();
24
+ const [gitDir, commonDir] = runner
25
+ .capture('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], { cwd: repoRoot })
26
+ .trim()
27
+ .split('\n');
28
+ return { repoRoot, linked: gitDir !== commonDir };
29
+ } catch {
30
+ return { early: fail(EXIT.usage, 'not inside a git repository (or git is older than 2.31)') };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Make the worktree of an issue from `origin/<base>`. `linked` when the session is already in a linked worktree (the
36
+ * build runs where it is), `exists` when the worktree of the issue is already there (the same issue twice).
37
+ */
38
+ export function createWorktree({ issue, config, runner, exists = () => false }) {
39
+ const ctx = context(issue, runner);
40
+ if (ctx.early) return ctx.early;
41
+ if (ctx.linked) {
42
+ return fail(EXIT.linked, 'this session is already in a linked worktree: build here, no worktree is made');
43
+ }
44
+ const base = config.branches.base;
45
+ const worktree = worktreePath(ctx.repoRoot, issue);
46
+ if (exists(worktree)) {
47
+ return {
48
+ status: EXIT.exists,
49
+ worktree,
50
+ messages: [`${worktree} already exists: another run is building #${issue}, or a failed one left it. Finish it or remove it first (git worktree remove --force "${worktree}")`],
51
+ };
52
+ }
53
+ try {
54
+ // A folder deleted by hand leaves a registration that makes `worktree add` fail; prune only drops those.
55
+ runner.capture('git', ['worktree', 'prune'], { cwd: ctx.repoRoot });
56
+ runner.capture('git', ['fetch', 'origin', base], { cwd: ctx.repoRoot });
57
+ runner.capture('git', ['worktree', 'add', '--detach', worktree, `origin/${base}`], { cwd: ctx.repoRoot });
58
+ } catch (err) {
59
+ return fail(EXIT.failed, `could not prepare the worktree: ${firstLine(err)}`);
60
+ }
61
+ return { status: EXIT.ok, worktree, base, messages: [] };
62
+ }
63
+
64
+ /** Remove the worktree of an issue, after a clean end. Run it from the main working tree, not from inside. */
65
+ export function removeWorktree({ issue, runner, exists = () => true }) {
66
+ const ctx = context(issue, runner);
67
+ if (ctx.early) return ctx.early;
68
+ if (ctx.linked) return fail(EXIT.linked, 'leave the worktree first (ExitWorktree with keep), then remove it from the main working tree');
69
+ const worktree = worktreePath(ctx.repoRoot, issue);
70
+ if (!exists(worktree)) {
71
+ // The folder may be gone while git still lists it: drop that registration so the issue can be built again.
72
+ try {
73
+ runner.capture('git', ['worktree', 'prune'], { cwd: ctx.repoRoot });
74
+ } catch (err) {
75
+ return { status: EXIT.failed, worktree, messages: [`could not prune the worktrees: ${firstLine(err)}; run git worktree prune`] };
76
+ }
77
+ return { status: EXIT.ok, worktree, messages: [`${worktree} is not there: nothing to remove`] };
78
+ }
79
+ try {
80
+ runner.capture('git', ['worktree', 'remove', '--force', worktree], { cwd: ctx.repoRoot });
81
+ } catch (err) {
82
+ return { status: EXIT.failed, worktree, messages: [`could not remove ${worktree}: ${firstLine(err)}; run git worktree remove --force "${worktree}"`] };
83
+ }
84
+ return { status: EXIT.ok, worktree, messages: [] };
85
+ }
@@ -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
+ }
@@ -8,12 +8,14 @@
8
8
  import { loadConfig } from './config.mjs';
9
9
  import { LEGACY_PLATFORMS } from './platforms.mjs';
10
10
  import { labelDefinitions } from './labels.mjs';
11
+ import { agentWorkflow } from './agent-workflow.mjs';
11
12
  import { OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WORKFLOW_FILES } from './doctor.mjs';
12
13
 
13
14
  export const REVIEW_MODES = ['none', 'peer', 'roles'];
14
15
 
15
16
  export const RUNS = ['cloud', 'local', 'both'];
16
17
  export const CREDENTIALS = ['api_key', 'subscription'];
18
+ export const AGENT_BUILDS = ['claude', 'antigravity'];
17
19
  const API_KEY_LINE = ' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}';
18
20
  const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}';
19
21
 
@@ -87,7 +89,12 @@ export function renderConfig(template, answers = {}) {
87
89
  if (answers.specWriter !== undefined) out = set(out, 'spec_writer', answers.specWriter ?? 'null');
88
90
  if (answers.reviewMode) out = set(out, 'mode', answers.reviewMode);
89
91
  if (answers.merger) out = set(out, 'merger', answers.merger);
90
- if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
92
+ if (answers.agentBuild === 'antigravity') {
93
+ // The agent has no cost limit: the limit of the run is a number of tokens, and the agent is named in its own group.
94
+ out = out.replace(/^(\s*)run_max_cost:[^\n]*$/m, '$1run_max_tokens: 2000000 # REQUIRED with agent.build antigravity: tokens per unattended run; choose your own');
95
+ out = `${out.trimEnd()}\n\n# Which agent builds in the cloud: claude (the default) or antigravity.\nagent:\n build: antigravity\n`;
96
+ if (answers.runMaxTokens !== undefined) out = set(out, 'run_max_tokens', answers.runMaxTokens);
97
+ } else if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
91
98
  if (answers.language) out = set(out, 'language', answers.language);
92
99
  const check = loadConfig(out);
93
100
  if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
@@ -178,6 +185,20 @@ export function planInit({ facts, answers, templates, current, admin }) {
178
185
  if (!facts.openspec) items.push(item('install.openspec', 'todo', 'Install the OpenSpec command-line tool', { install: true, command: 'npm install --global @fission-ai/openspec', detail: OPENSPEC_INSTALL }));
179
186
  REQUIRED_SKILLS.filter((n) => !facts.skills[n]).forEach((n) => items.push(item(`install.skill.${n}`, 'todo', `Install the ${n} skill`, { install: true, command: n === 'openspec-explore' ? 'openspec init' : 'npx skills add mattpocock/skills', detail: SKILL_INSTALL[n] })));
180
187
 
188
+ const configuredPlatforms = facts.config?.platforms ?? answers.platforms ?? (answers.platform ? { app: LEGACY_PLATFORMS[answers.platform] } : {});
189
+ const hasSupabase = Object.values(configuredPlatforms).some((p) => p?.stack === 'supabase');
190
+ if (hasSupabase) {
191
+ ['supabase', 'supabase-postgres-best-practices'].forEach((skill) => {
192
+ if (facts.skills?.[skill] === false) {
193
+ items.push(item(`install.skill.${skill}`, 'todo', `Install the ${skill} skill`, {
194
+ install: true,
195
+ command: `npx skills add supabase/agent-skills --skill ${skill}`,
196
+ detail: SKILL_INSTALL[skill] ?? `run \`npx skills add supabase/agent-skills --skill ${skill}\``,
197
+ }));
198
+ }
199
+ });
200
+ }
201
+
181
202
  // the plugin, declared in the project so that a teammate who clones the repository is offered it
182
203
  if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
183
204
  else if (facts.pluginDeclared?.unreadable) items.push(item('install.plugin-project', 'manual', '.claude/settings.json is not valid JSON: fix it, then run init again to declare the Relevo plugin in the project'));
@@ -187,11 +208,11 @@ export function planInit({ facts, answers, templates, current, admin }) {
187
208
  if (facts.configText !== null) items.push(item('config', facts.config ? 'ok' : 'differs', facts.config ? 'relevo.yml exists and is valid' : `relevo.yml exists but is invalid: ${facts.configErrors.join('; ')}`));
188
209
  else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
189
210
 
190
- // optional stack profiles of the repository (created only when asked for, and never committed by init)
211
+ // optional stack profiles of the repository (created only when asked for; init never commits them, the person does)
191
212
  (answers.stackProfiles ?? []).forEach((stack) => {
192
213
  const path = `.relevo/stacks/${stack}.md`;
193
214
  if ((current.stackProfiles ?? {})[stack]) items.push(item(`stack-profile.${stack}`, 'ok', `${path} exists`));
194
- else items.push(item(`stack-profile.${stack}`, 'todo', `Create ${path}, a commented profile that adds to the plugin's (not committed)`, { path, content: stackProfileTemplate(stack) }));
215
+ else items.push(item(`stack-profile.${stack}`, 'todo', `Create ${path}, a commented profile that adds to the plugin's (left for you to commit)`, { path, content: stackProfileTemplate(stack) }));
195
216
  });
196
217
 
197
218
  // labels
@@ -225,10 +246,13 @@ export function planInit({ facts, answers, templates, current, admin }) {
225
246
  const credential = answers.credential ?? 'api_key';
226
247
  if (!RUNS.includes(runs)) throw new Error(`runs must be one of ${RUNS.join(', ')} (got "${runs}")`);
227
248
  if (!CREDENTIALS.includes(credential)) throw new Error(`credential must be one of ${CREDENTIALS.join(', ')} (got "${credential}")`);
249
+ const build = answers.agentBuild ?? 'claude';
250
+ if (!AGENT_BUILDS.includes(build)) throw new Error(`agentBuild must be one of ${AGENT_BUILDS.join(', ')} (got "${build}")`);
228
251
  const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
229
252
  WORKFLOW_FILES.forEach((file) => {
230
253
  if (runs === 'local' && cloudFiles.includes(file)) return;
231
- const wanted = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
254
+ const shared = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
255
+ const wanted = agentWorkflow(shared, build, templates.agentBlocks);
232
256
  const have = current.workflows[file] ?? null;
233
257
  if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
234
258
  else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
@@ -262,6 +286,9 @@ export function planInit({ facts, answers, templates, current, admin }) {
262
286
  if ((facts.secrets ?? []).includes('RELEVO_LINKS_TOKEN')) items.push(item('manual.links-token', 'ok', 'The RELEVO_LINKS_TOKEN secret exists (init never reads it)'));
263
287
  else items.push(item('manual.links-token', 'manual', `If ${links.join(', ')} ${links.length > 1 ? 'are' : 'is'} private, create a fine-grained token with read access to issues and pull requests and store it yourself as the secret RELEVO_LINKS_TOKEN (init never reads or writes it; Relevo only reads with it, and public repositories need none)`));
264
288
  }
289
+ if (hasSupabase) {
290
+ items.push(item('manual.supabase-mcp', 'manual', 'To explore your Supabase database in Claude Code or Antigravity during /relevo:think, consider connecting the Supabase MCP server (see https://supabase.com/docs/guides/ai-tools)'));
291
+ }
265
292
  items.push(item('manual.push', 'manual', 'Commit and push the new files to the default branch: a workflow only runs from there'));
266
293
 
267
294
  return items;
@@ -2,10 +2,15 @@
2
2
  //
3
3
  // Two tiers:
4
4
  // hard never allowed, whatever the issue says: workflows, the Relevo configuration, the folders of skills and
5
- // the file with the project rules (a defect is fixed in the code, not by rewriting the rule).
5
+ // the file with the project rules (a defect is fixed in the code, not by rewriting the rule), and what carries
6
+ // the instructions or the permissions of another agent (`.agents/`, `.gemini/`, `AGENTS.md`, `GEMINI.md`):
7
+ // a run that could edit them could rewrite its own instructions.
6
8
  // soft dependency manifests and lock files: allowed only when the issue lists them in an `Allow-paths:` line.
7
9
 
8
- export const HARD_PATTERNS = ['.github/', 'relevo.yml', '.relevo/', '.claude/skills/', '.agents/skills/', 'skills-lock.json'];
10
+ export const HARD_PATTERNS = ['.github/', 'relevo.yml', '.relevo/', '.claude/skills/', '.agents/', '.gemini/', 'skills-lock.json'];
11
+
12
+ // Files that an agent reads from the folder of whatever it edits, so they are protected at any depth.
13
+ export const HARD_ANY_DEPTH = ['AGENTS.md', 'GEMINI.md'];
9
14
 
10
15
  export const SOFT_FILES = [
11
16
  'pubspec.yaml', 'pubspec.lock',
@@ -60,7 +65,8 @@ const matchesHard = (path, pattern) => {
60
65
 
61
66
  /** The hard rule a path breaks, or null. */
62
67
  export function hardRule(path, { rulesFile = null } = {}) {
63
- return hardPatterns(rulesFile).find((pattern) => matchesHard(path, pattern)) ?? null;
68
+ const name = clean(path).split('/').pop();
69
+ return hardPatterns(rulesFile).find((pattern) => matchesHard(path, pattern)) ?? HARD_ANY_DEPTH.find((f) => f === name) ?? null;
64
70
  }
65
71
 
66
72
  /** Is the file a dependency manifest or lock file (soft tier)? */
@@ -87,7 +93,7 @@ export function checkProtected(paths, { allow = [], rulesFile = null } = {}) {
87
93
 
88
94
  /** Entries of an `Allow-paths:` list that name (or would cover) a hard-protected path: they can never be allowed. */
89
95
  export function hardPathsInAllow(allow, { rulesFile = null } = {}) {
90
- const probes = [...HARD_PATTERNS.map((pattern) => (pattern.endsWith('/') ? `${pattern}x` : pattern)), ...ruleFiles(rulesFile)].filter(Boolean);
96
+ const probes = [...HARD_PATTERNS.map((pattern) => (pattern.endsWith('/') ? `${pattern}x` : pattern)), ...HARD_ANY_DEPTH, ...ruleFiles(rulesFile)].filter(Boolean);
91
97
  return (allow ?? []).filter((entry) => hardRule(entry, { rulesFile }) || probes.some((probe) => matchesGlob(probe, entry)));
92
98
  }
93
99
 
@@ -57,7 +57,7 @@ export function usageLine(usage, { subscription = false } = {}) {
57
57
  // Messages the Claude CLI and the API document for a usage limit, a quota or an empty credit balance. These patterns
58
58
  // are not from a failed run seen here (see docs/pilot-findings.md); a wrong guess only changes the wording of a
59
59
  // comment on a run that already failed.
60
- const LIMIT_RE = /usage limit|limit reached|rate[ _-]?limit|credit balance is too low|exceeded your (current )?quota|insufficient[ _]quota/i;
60
+ const LIMIT_RE = /usage limit|limit reached|rate[ _-]?limit|credit balance is too low|exceeded your (current )?quota|quota exceeded|resource[ _]exhausted|insufficient[ _]quota/i;
61
61
 
62
62
  /** Does the log show that a usage limit, a quota or the credit was reached? */
63
63
  export function reachedUsageLimit(text) {
@@ -58,3 +58,61 @@ export function select({ title, choices, defaultIndex = 0, input, output, hint =
58
58
  draw();
59
59
  });
60
60
  }
61
+
62
+ /**
63
+ * Like `select`, but several choices can be ticked: Space (or the digit of a choice) toggles, Enter confirms.
64
+ * choices: [{ value, label }]; initial: the values ticked at the start. Resolves the array of ticked values in the
65
+ * order of `choices` (possibly empty), or null when the person cancels.
66
+ */
67
+ export function selectMany({ title, choices, initial = [], input, output, hint = 'Arrows to move, Space or a number to tick, Enter to confirm, Esc to cancel' }) {
68
+ if (!input?.isTTY || typeof input.setRawMode !== 'function') return Promise.reject(new Error('the list needs a terminal'));
69
+ if (!Array.isArray(choices) || choices.length === 0) return Promise.reject(new Error('the list has no choices'));
70
+ return new Promise((resolve) => {
71
+ const ticked = new Set(initial);
72
+ let index = 0;
73
+ let drawn = 0;
74
+ const draw = () => {
75
+ const lines = [title, ...choices.map((c, i) => `${i === index ? '>' : ' '} [${ticked.has(c.value) ? 'x' : ' '}] ${i + 1}. ${c.label}`), hint];
76
+ output.write(`${drawn ? UP(drawn) : ''}\r${CLEAR}${lines.join('\n')}\n`);
77
+ drawn = lines.length;
78
+ };
79
+ const finish = (value) => {
80
+ input.removeListener('keypress', onKey);
81
+ try {
82
+ input.setRawMode(false);
83
+ } finally {
84
+ input.pause?.();
85
+ output.write(SHOW);
86
+ }
87
+ resolve(value);
88
+ };
89
+ const toggle = (i) => {
90
+ const v = choices[i].value;
91
+ if (ticked.has(v)) ticked.delete(v);
92
+ else ticked.add(v);
93
+ };
94
+ function onKey(str, key = {}) {
95
+ try {
96
+ if (key.ctrl && (key.name === 'c' || key.name === 'd')) return finish(null);
97
+ if (key.name === 'escape' || str === 'q') return finish(null);
98
+ if (key.name === 'return' || key.name === 'enter') return finish(choices.map((c) => c.value).filter((v) => ticked.has(v)));
99
+ if (key.name === 'up' || str === 'k') index = (index + choices.length - 1) % choices.length;
100
+ else if (key.name === 'down' || str === 'j') index = (index + 1) % choices.length;
101
+ else if (key.name === 'space' || str === ' ') toggle(index);
102
+ else if (/^[1-9]$/.test(str ?? '') && Number(str) <= choices.length) toggle(Number(str) - 1);
103
+ else return undefined;
104
+ draw();
105
+ } catch (err) {
106
+ finish(null);
107
+ throw err;
108
+ }
109
+ return undefined;
110
+ }
111
+ emitKeypressEvents(input);
112
+ input.setRawMode(true);
113
+ input.resume?.();
114
+ input.on('keypress', onKey);
115
+ output.write(HIDE);
116
+ draw();
117
+ });
118
+ }
@@ -0,0 +1,11 @@
1
+ // Where the worktree of an issue lives: next to the repository, never inside it. Shared by `go-local` and
2
+ // `go-worktree` so that the two cannot name it differently.
3
+
4
+ import { basename, dirname, join } from 'node:path';
5
+
6
+ export const WORKTREES_FOLDER = '.relevo-worktrees';
7
+
8
+ /** The worktree of an issue: `<parent of the repository>/.relevo-worktrees/<repository>-<issue>`. Pure. */
9
+ export function worktreePath(repoRoot, issue) {
10
+ return join(dirname(repoRoot), WORKTREES_FOLDER, `${basename(repoRoot)}-${issue}`);
11
+ }
@@ -27,10 +27,22 @@ Launch `relevo:dev` and `relevo:qa` in the foreground and wait for each report b
27
27
 
28
28
  ## Where it runs
29
29
 
30
- In the cloud the workflow starts this skill with the decision already made. A person can also start it on their machine: `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-local.mjs <issue>` runs it in a separate git worktree, with the same limits and tool lists as the workflow, and removes the worktree when it ends cleanly. Started by hand without that script, it needs a clean working tree (the preflight below stops otherwise).
30
+ In the cloud the workflow starts this skill with the decision already made. A person can also start it on their machine: `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-local.mjs <issue>` runs it in a separate git worktree, with the same limits and tool lists as the workflow, and removes the worktree when it ends cleanly.
31
31
 
32
32
  **When `$ARGUMENTS` has `--local`**, do not build in this session: run `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-local.mjs <issue>`, show what it prints, and stop. The build happens in the process that script starts, in its own worktree and with the closed tool lists, which a plain session does not have.
33
33
 
34
+ **In an interactive session** (started by hand without `--decision` and without `--local`), do not build in the folder you were started in: another session may be building another issue there, and the person may have changes of their own. Before step 0, make a worktree for the issue:
35
+
36
+ ```bash
37
+ node ${CLAUDE_PLUGIN_ROOT}/scripts/go-worktree.mjs create <issue>
38
+ ```
39
+
40
+ - Exit 0: it prints `{"worktree": "<path>", "base": "<base>"}`. Enter it with `EnterWorktree` and its `path` (never its `name`, which would branch from the default branch and not from the base of the project). From then on every step, command and agent works in that folder.
41
+ - Exit 4: the session is already in a linked worktree (a run of `go-local.mjs`, or one the person made). Build where you are, make no worktree, and skip the exit below.
42
+ - Any other exit (for example 3, the worktree of this issue already exists because another run is building it or a failed one left it): show its message and stop without changing anything.
43
+
44
+ At the end of the run, before the final report, leave the worktree: call `ExitWorktree` with `keep`. Then, from the folder you came from, remove it with `node ${CLAUDE_PLUGIN_ROOT}/scripts/go-worktree.mjs remove <issue>` when the outcome is `built` or a gate decision that stopped before step 2 (nothing was built, so nothing is lost). After the failure path, or if the run stopped for any other reason, leave the worktree on disk and say its path in the report.
45
+
34
46
  ## OpenSpec commands
35
47
 
36
48
  Use these OpenSpec commands and no others:
@@ -33,11 +33,12 @@ If `relevo.yml` already exists and is valid, skip this step. Otherwise ask, in s
33
33
  - who builds and reviews (GitHub usernames) and who writes the specs;
34
34
  - the review mode: `none` (a solo project), `peer` or `roles`;
35
35
  - the language of the hand-off messages (`en` or `es`);
36
- - the platforms: for each one a name, its stack (look at the project to propose one: the profiles in `${CLAUDE_PLUGIN_ROOT}/stacks/` are the stacks Relevo ships with: `flutter`, `vue` and `laravel`), the folder where it lives (`.` for a repository with one platform, `apps/web` in a monorepo) and, for a stack that has targets, which ones (for Flutter: `android`, `ios`, `web`; for Vue: `web`; Laravel has none). Several platforms in one repository are written down, but `go` builds only a repository with one for now;
36
+ - the platforms: for each one a name, its stack (look at the project to propose one: the profiles in `${CLAUDE_PLUGIN_ROOT}/stacks/` are the stacks Relevo ships with: `flutter`, `vue`, `laravel` and `supabase`), the folder where it lives (`.` for a repository with one platform, `apps/web` in a monorepo) and, for a stack that has targets, which ones (for Flutter: `android`, `ios`, `web`; for Vue: `web`; Laravel and Supabase have none). Several platforms in one repository are written down, but `go` builds only a repository with one for now;
37
37
  - where `go` builds: in the cloud, on each person's machine, or both (the default), and, for the cloud, with an API key from the Anthropic Console (the default) or with a subscription token. A subscription's quota is shared by every run and by the interactive use of the account, so `init` sets `limits.concurrent_agents` to 1 for it. Nothing about this goes in `relevo.yml` apart from that number: the installed workflows, the secret that exists and the repository variable `RELEVO_CLOUD_GO` (`off` pauses the cloud at once, with no pull request) decide it;
38
38
  - the repositories this one depends on, if any (`owner/repo`, for example the back end of a front end): they are written to `links`, the closed list that `Depends-on: owner/repo#N` is checked against;
39
- - whether to create a commented `.relevo/stacks/<stack>.md` for each stack, where the project can add its own hygiene to the plugin's profile (optional; it is not committed);
40
- - the cost limit per unattended run in US dollars: the template's 5 is a placeholder the person must accept or change.
39
+ - whether to create a commented `.relevo/stacks/<stack>.md` for each stack, where the project can add its own hygiene to the plugin's profile (optional; `init` creates it but does not commit it: it is left for the person to commit, because the cloud and teammates read it from git);
40
+ - which agent builds in the cloud, only when the person says they use Antigravity: `claude` (the default) or `antigravity`. With `antigravity` the model key is `GEMINI_API_KEY`, a key made only for Relevo in its own Google Cloud project with a quota, and the limit asked below is a number of tokens (`limits.run_max_tokens`) because that agent has no cost limit; `docs/security.md` says what else it cannot enforce;
41
+ - the cost limit per unattended run in US dollars: the template's 5 is a placeholder the person must accept or change (with `antigravity`, the token limit instead).
41
42
 
42
43
  Write the answers as JSON to a temporary file **outside the repository**, with the keys `members`, `specWriter`, `reviewMode`, `language`, `platforms` (a map of name to `{ stack, path, targets }`), `links` (a list of `owner/repo`, possibly none), `runs` (`cloud`, `local` or `both`), `credential` (`api_key` or `subscription`), `stackProfiles` (the stacks that get a profile file, possibly none) and, if given, `runMaxCost`. Never put a secret in it.
43
44
 
@@ -71,4 +72,4 @@ Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/doctor.mjs` and show its output.
71
72
 
72
73
  ## Report
73
74
 
74
- Finish with: what was created, what was left alone, what the person must still do by hand (always the model credential in the repository secrets, with all whitespace removed from a subscription token, and committing and pushing the new files to the default branch, because a workflow only runs from there), and the doctor result.
75
+ Finish with: what was created, what was left alone, what the person must still do by hand (always the model credential in the repository secrets, with all whitespace removed from a subscription token, and committing and pushing the new files to the default branch, because a workflow only runs from there; name each `.relevo/stacks/<stack>.md` it created among them), and the doctor result.
@@ -152,6 +152,16 @@ Only when `decide` exited 0.
152
152
  - `evidence.policy: local_only`: delete the `evidence/` folder now. Nothing is attached anywhere.
153
153
  - `evidence.policy: attach_to_pr`: GitHub has no API for attaching images, so you cannot upload them. List the files in `evidence/` and ask the person to drag them into the pull request description. Keep the folder until they say they did, then delete it.
154
154
 
155
+ ### 9. Return to base branch
156
+
157
+ Restore the working tree to the base branch so the workspace remains clean on the base branch for other tasks or subsequent reviews, but only where that is possible: in the main checkout, and when no other worktree holds the base branch.
158
+
159
+ ```bash
160
+ [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ] && ! git worktree list --porcelain | grep -qx "branch refs/heads/<base>" && git switch <base>
161
+ ```
162
+
163
+ If the condition does not hold (a separate worktree, or the base branch is checked out elsewhere), do not switch: say which branch the working tree stays on. If `git switch` fails, say so; it never changes the verdict or the state of the issue, because the pull request already exists.
164
+
155
165
  ## Report
156
166
 
157
167
  Finish with one short block: the issue, the review verdict, the checklist result, the pull request link (or why none was opened), and the issue's new state.
package/stacks/flutter.md CHANGED
@@ -4,7 +4,7 @@ commands:
4
4
  analyze: flutter analyze --no-pub
5
5
  test: flutter test --reporter=compact --no-pub
6
6
  format: dart format
7
- generated: ['*.g.dart', '*.freezed.dart']
7
+ generated: ['*.g.dart', '*.freezed.dart', '*.mocks.dart']
8
8
  tools: [flutter]
9
9
  requires_skills: []
10
10
  skills:
@@ -22,29 +22,67 @@ targets:
22
22
 
23
23
  ## Verification
24
24
 
25
- Prefer the Dart tooling server's tools when they are available in this session (analysis, running tests, formatting). Otherwise use the commands of this profile: `analyze`, `test` and `format`.
25
+ Run the commands of this profile from the folder of the platform: `analyze`, `test` and `format`. Run `format` on the files you changed, never on generated files.
26
+
27
+ When the Dart and Flutter MCP server is connected, you may use the tools that write nothing: `analyze_files` for analysis, `lsp` and `read_package_uris` to check an API or a symbol instead of guessing it, and `run_tests` when the server enables it (it is off by default). When a tool is missing, use the command of this profile. No agent uses `dart_format`, `dart_fix`, `pub`, `hot_reload`, `hot_restart` or `launch_app` during a run: they write files or drive an app, and `dart_format` formats the whole project, generated files included.
28
+
29
+ When annotated models or mocks changed, run `dart run build_runner build --delete-conflicting-outputs` before the tests.
26
30
 
27
31
  Do not claim the code compiles, analyses clean or passes tests unless you ran the tools and saw it. If neither the tooling server nor Flutter is available, say so plainly in your report.
28
32
 
33
+ ## Architecture
34
+
35
+ Follow the architecture that the project's rules declare (MVVM, bloc, riverpod or another). When they declare none, write new code with the structure of Flutter's architecture guide: UI and data in separate layers; data reached through repositories, which call stateless services that wrap each API or plugin; dependencies passed by constructor and kept private; no data logic in widgets. Command and Result objects, and any state-management package, are not required by this profile; use them when the project's rules do.
36
+
29
37
  ## Hygiene for the developer
30
38
 
39
+ Lifecycle and state:
40
+
31
41
  - Check `mounted` before using a `BuildContext` or calling `setState` after an `await`.
32
- - Dispose every controller, stream subscription and listener you create.
42
+ - Dispose every controller, stream subscription and listener you create. A `ChangeNotifierProvider(create:)` disposes its model itself.
43
+ - Never call an API, create a future or show a dialog inside `build()`; start them in `initState` or in an event handler.
33
44
  - Avoid the null-assertion operator unless a prior check makes it provably safe.
34
45
  - Give every screen loading, empty and error states.
35
46
  - Prevent double submission of actions.
36
- - Keep layouts from overflowing on small screens.
47
+
48
+ Layout:
49
+
50
+ - Keep layouts from overflowing on small screens: wrap a long `Text` or other unconstrained child of a `Row` or `Column` in `Expanded` or `Flexible`.
51
+ - Give a `ListView` or `GridView` inside a `Column` a bound (`Expanded` or a fixed height), and a `TextField` inside a `Row` a width (`Expanded` or `SizedBox`).
52
+ - Put `Expanded` and `Flexible` directly in a `Row`, `Column` or `Flex`, and `Positioned` directly in a `Stack`.
53
+ - Give each scroll view its own `ScrollController`.
54
+
55
+ Performance:
56
+
57
+ - Build long lists and grids with the builder constructors, not with a list of children.
58
+ - Keep `build()` cheap, and call `setState` as low in the tree as the change allows.
59
+ - Split large widgets into smaller widget classes rather than helper methods that return widgets.
60
+ - Keep `Opacity` and clipping out of animations; use `AnimatedOpacity` or a semi-transparent colour.
61
+ - Move heavy work, such as decoding a large JSON response, off the UI isolate with `Isolate.run`. On the web it still runs on the main thread.
62
+
63
+ Web:
64
+
65
+ - Use `package:web` and `dart:js_interop`; `dart:html` and `package:js` prevent the Wasm build.
66
+ - Do not use `dart:io` or `Platform` in code that runs on the web; check `kIsWeb` or use a conditional import.
67
+
68
+ Tests:
69
+
37
70
  - Write unit and widget tests for the scenarios the platform allows. A scenario that can only be seen on a device or in another app is covered by correct code, and is verified later in the on-device review.
71
+ - Make fakes of repositories and services for tests; a mock is also acceptable. A plugin throws `MissingPluginException` in unit and widget tests, so put it behind the project's own class and fake that class.
38
72
 
39
73
  ## What the auditor looks for in the diff
40
74
 
41
- - **Async and lifecycle**: a `BuildContext` use or `setState` after an `await` without a `mounted` check.
75
+ - **Async and lifecycle**: a `BuildContext` use or `setState` after an `await` without a `mounted` check; a network call, a new future, `setState` or `showDialog` inside `build()`.
42
76
  - **Leaks**: a controller, stream subscription or listener created and never disposed.
43
77
  - **Null safety**: an unchecked null-assertion operator that can throw at runtime.
44
- - **Layout**: unconstrained content that can overflow on a small screen.
78
+ - **Layout**: unconstrained content that can overflow on a small screen; a `ListView` or `GridView` in a `Column` without a bound; a `TextField` in a `Row` without a width; `Expanded`, `Flexible` or `Positioned` outside a compatible parent; one `ScrollController` attached to several scroll views.
45
79
  - **States**: missing loading, empty or error states; an action that can be triggered twice.
80
+ - **Performance**: a long list built from a list of children; costly work in `build()`; `setState` high in the tree for a local change; `Opacity` or clipping inside an animation; a large JSON decode on the UI isolate.
81
+ - **Web**: an import of `dart:html` or `package:js`; `dart:io` or `Platform` in code that runs on the web.
82
+ - **Tests**: a test that calls a real plugin, network or service instead of a fake or a mock.
83
+ - **Architecture**: report only what breaks a rule the project wrote down. Whatever the project declares, an HTTP or GraphQL client called from a widget is a finding.
46
84
  - **Android specifics**: an external-app launch with no handling for the app being missing, permission use without handling denial, behaviour on the back button.
47
- - **Generated code**: leave generated files (`*.g.dart`, `*.freezed.dart`) out of the review.
85
+ - **Generated code**: leave generated files (`*.g.dart`, `*.freezed.dart`, `*.mocks.dart`) out of the review.
48
86
 
49
87
  ## Targets
50
88
 
@@ -0,0 +1,63 @@
1
+ ---
2
+ stack: supabase
3
+ commands:
4
+ analyze: supabase db lint
5
+ test: supabase test db
6
+ format: npx prettier --write supabase/
7
+ generated: []
8
+ tools: [supabase, docker]
9
+ requires_skills:
10
+ - supabase
11
+ - supabase-postgres-best-practices
12
+ skills:
13
+ dev:
14
+ - supabase
15
+ - supabase-postgres-best-practices
16
+ qa:
17
+ - supabase
18
+ - supabase-postgres-best-practices
19
+ issue:
20
+ - supabase
21
+ - supabase-postgres-best-practices
22
+ review: []
23
+ ---
24
+
25
+ # Supabase
26
+
27
+ ## Verification
28
+
29
+ Run the verification commands from the platform directory: `analyze` (`supabase db lint`) and `test` (`supabase test db`). These commands require the Supabase CLI and a running Docker daemon.
30
+
31
+ Do not claim the database schema is valid, clean, or passes tests unless you ran the commands and saw the results. All tests run hermetically against local Docker containers; never point tests at a remote Supabase project.
32
+
33
+ ## Questions for the issue writer
34
+
35
+ - **Authorization & RLS**: Who is permitted to select, insert, update, and delete rows in these tables? Which Postgres roles (`anon`, `authenticated`) should have access?
36
+ - **Relationships & Deletes**: If a parent row or user is deleted, what is the expected cascade behavior (`CASCADE`, `SET NULL`, or `RESTRICT`)?
37
+ - **Storage & Files**: If files are uploaded, what are the allowed MIME types, max file sizes, and who can overwrite or delete objects?
38
+ - **Query Performance & Filters**: Which columns will be filtered in `WHERE` clauses, sorted, or referenced in RLS policies (`auth.uid() = user_id`)?
39
+
40
+ ## Hygiene for the developer
41
+
42
+ - Follow the official guidelines from `supabase` and `supabase-postgres-best-practices`.
43
+ - Enable Row Level Security on every public table: `ALTER TABLE <table_name> ENABLE ROW LEVEL SECURITY;`.
44
+ - Avoid using `auth.role()` in policies; use the `TO` clause instead (e.g. `TO authenticated` or `TO anon`).
45
+ - Combine `TO authenticated` with an explicit ownership predicate (e.g. `(select auth.uid()) = user_id`) to prevent broken object-level authorization (BOLA / IDOR).
46
+ - Always include both `USING` and `WITH CHECK` clauses on `UPDATE` policies so users cannot reassign ownership columns.
47
+ - Ensure all views on tables with RLS specify `WITH (security_invoker = true)` (Postgres 15+).
48
+ - Avoid `SECURITY DEFINER` functions in the `public` schema; if required, keep them in a private schema and validate caller identity explicitly.
49
+ - Create an index on every foreign key column and on every column evaluated in RLS policies.
50
+ - Structure all schema changes as versioned, timestamped migrations in `supabase/migrations/`.
51
+ - Maintain test and sample data in `supabase/seed.sql` using idempotent statements (`INSERT ... ON CONFLICT DO NOTHING;`).
52
+ - Write pgTAP test suites in `supabase/tests/` verifying that unauthorized or anonymous callers cannot read or modify protected rows.
53
+
54
+ ## What the auditor looks for in the diff
55
+
56
+ - **Missing RLS**: Any table created in an exposed schema without `ENABLE ROW LEVEL SECURITY`.
57
+ - **Incomplete Update Policies**: An `UPDATE` policy that defines `USING` but omits `WITH CHECK`.
58
+ - **Permissive Policies**: Policies using `USING (true)` or checking only `TO authenticated` without row-ownership verification on private user data.
59
+ - **Unprotected Views**: Views defined without `security_invoker = true`.
60
+ - **Privilege Escalation**: `SECURITY DEFINER` functions placed in `public` or callable without `auth.uid()` checks.
61
+ - **Missing Indexes**: Foreign key constraints or RLS filter columns without an index.
62
+ - **Destructive Migrations**: `DROP TABLE`, `DROP COLUMN`, or `TRUNCATE` operations without explicit migration plans or rollback safety.
63
+ - **Exposed Secrets**: Hardcoded `service_role` keys, API tokens, or service passwords in code, migrations, or seed files.