@elalfner/relevo 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.es.md +3 -1
  3. package/README.md +3 -1
  4. package/package.json +1 -1
  5. package/scripts/agent-guard.mjs +94 -0
  6. package/scripts/cli.mjs +67 -6
  7. package/scripts/doctor.mjs +10 -0
  8. package/scripts/init.mjs +1 -0
  9. package/scripts/lib/adapters/antigravity.mjs +99 -0
  10. package/scripts/lib/adapters/index.mjs +6 -0
  11. package/scripts/lib/adapters/install.mjs +127 -0
  12. package/scripts/lib/adapters/source.mjs +65 -0
  13. package/scripts/lib/adapters/tools.mjs +52 -0
  14. package/scripts/lib/agent-guard.mjs +188 -0
  15. package/scripts/lib/agent-workflow.mjs +46 -0
  16. package/scripts/lib/cli.mjs +44 -12
  17. package/scripts/lib/config.mjs +18 -7
  18. package/scripts/lib/doctor.mjs +92 -4
  19. package/scripts/lib/go-local.mjs +31 -4
  20. package/scripts/lib/init-tools.mjs +97 -0
  21. package/scripts/lib/init.mjs +38 -2
  22. package/scripts/lib/messages.mjs +16 -1
  23. package/scripts/lib/next-step.mjs +10 -1
  24. package/scripts/lib/protected-paths.mjs +10 -4
  25. package/scripts/lib/run-usage.mjs +1 -1
  26. package/scripts/lib/select.mjs +118 -0
  27. package/scripts/lib/status-data.mjs +16 -5
  28. package/scripts/lib/status.mjs +20 -5
  29. package/scripts/lib/toolchain-check.mjs +12 -0
  30. package/scripts/toolchain-check.mjs +49 -0
  31. package/skills/init/SKILL.md +5 -2
  32. package/skills/review/SKILL.md +10 -0
  33. package/stacks/supabase.md +63 -0
  34. package/templates/agents/antigravity-step.yml +70 -0
  35. package/templates/deploy-supabase.yml +75 -0
  36. package/templates/workflows/relevo-digest.yml +1 -1
  37. package/templates/workflows/relevo-go.yml +35 -1
  38. package/templates/workflows/relevo-queue.yml +3 -1
  39. package/templates/workflows/relevo-review.yml +1 -1
  40. package/templates/workflows/relevo-sla.yml +1 -1
@@ -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
+ }
@@ -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
 
@@ -22,6 +24,10 @@ const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE
22
24
  * subscription token that line is commented out and the token line is switched on. Throws when the template no
23
25
  * longer has the lines (a test runs this on the real template).
24
26
  */
27
+ // The two official commands that install the plugin at the project scope; the person confirms this exact line.
28
+ export const PLUGIN_PROJECT_COMMAND = 'claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin && claude plugin install relevo@relevo --scope project';
29
+ const PLUGIN_PROJECT_DETAIL = 'It writes the marketplace and the enabled plugin into .claude/settings.json of this repository. It works even if the plugin is already installed for your machine, and nothing is committed for you.';
30
+
25
31
  export function credentialLine(workflow, credential) {
26
32
  if (credential !== 'subscription') return workflow;
27
33
  if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
@@ -83,7 +89,12 @@ export function renderConfig(template, answers = {}) {
83
89
  if (answers.specWriter !== undefined) out = set(out, 'spec_writer', answers.specWriter ?? 'null');
84
90
  if (answers.reviewMode) out = set(out, 'mode', answers.reviewMode);
85
91
  if (answers.merger) out = set(out, 'merger', answers.merger);
86
- 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);
87
98
  if (answers.language) out = set(out, 'language', answers.language);
88
99
  const check = loadConfig(out);
89
100
  if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
@@ -174,6 +185,25 @@ export function planInit({ facts, answers, templates, current, admin }) {
174
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 }));
175
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] })));
176
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
+
202
+ // the plugin, declared in the project so that a teammate who clones the repository is offered it
203
+ if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
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'));
205
+ else if (facts.pluginDeclared) items.push(item('install.plugin-project', 'todo', 'Declare the Relevo plugin in the project, so that a teammate who clones the repository is offered it (the new .claude/settings.json is left for you to commit)', { install: true, command: PLUGIN_PROJECT_COMMAND, detail: PLUGIN_PROJECT_DETAIL }));
206
+
177
207
  // relevo.yml
178
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('; ')}`));
179
209
  else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
@@ -216,10 +246,13 @@ export function planInit({ facts, answers, templates, current, admin }) {
216
246
  const credential = answers.credential ?? 'api_key';
217
247
  if (!RUNS.includes(runs)) throw new Error(`runs must be one of ${RUNS.join(', ')} (got "${runs}")`);
218
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}")`);
219
251
  const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
220
252
  WORKFLOW_FILES.forEach((file) => {
221
253
  if (runs === 'local' && cloudFiles.includes(file)) return;
222
- 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);
223
256
  const have = current.workflows[file] ?? null;
224
257
  if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
225
258
  else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
@@ -253,6 +286,9 @@ export function planInit({ facts, answers, templates, current, admin }) {
253
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)'));
254
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)`));
255
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
+ }
256
292
  items.push(item('manual.push', 'manual', 'Commit and push the new files to the default branch: a workflow only runs from there'));
257
293
 
258
294
  return items;
@@ -17,6 +17,7 @@ const MAX_VALUE_LENGTH = 400;
17
17
  const REQUIRED = {
18
18
  built: ['assignee', 'issue', 'title', 'tasks_done', 'tasks_total', 'branch', 'scenarios'],
19
19
  qa_failed: ['assignee', 'issue', 'title', 'finding', 'branch'],
20
+ toolchain_missing: ['assignee', 'issue', 'title', 'tools', 'platform'],
20
21
  needs_spec: ['spec_writer', 'issue', 'title', 'missing'],
21
22
  changes_requested: ['author', 'issue', 'reviewer', 'summary'],
22
23
  needs_reviewer: ['spec_writer', 'issue', 'title', 'pr'],
@@ -38,6 +39,7 @@ const REQUIRED = {
38
39
  const STATE_OF = {
39
40
  built: 'built',
40
41
  qa_failed: 'qa-failed',
42
+ toolchain_missing: 'qa-failed',
41
43
  needs_spec: 'needs-spec',
42
44
  changes_requested: 'changes-requested',
43
45
  approved: 'pr-open',
@@ -67,6 +69,12 @@ const CATALOG = {
67
69
  'Full QA report: {{log_url}}',
68
70
  '{{next_line}}',
69
71
  ],
72
+ toolchain_missing: [
73
+ '@{{assignee}} #{{issue}} "{{title}}" was not built: the runner has no {{tools}} ❌',
74
+ '',
75
+ 'What happened: the build job checked the toolchain of platform {{platform}} before the agent started, and {{tools}} is not on the path of the runner. Nothing was built and no model turns were spent.',
76
+ '{{next_line}}',
77
+ ],
70
78
  needs_spec: [
71
79
  '@{{spec_writer}} #{{issue}} "{{title}}" is back with you: the spec needs work',
72
80
  '',
@@ -173,6 +181,12 @@ const CATALOG = {
173
181
  'Reporte completo de QA: {{log_url}}',
174
182
  '{{next_line}}',
175
183
  ],
184
+ toolchain_missing: [
185
+ '@{{assignee}} #{{issue}} "{{title}}" no se construyó: el runner no tiene {{tools}} ❌',
186
+ '',
187
+ 'Qué pasó: el job de build comprobó el toolchain de la plataforma {{platform}} antes de que empezara el agente, y {{tools}} no está en el path del runner. No se construyó nada y no se gastó ningún turno del modelo.',
188
+ '{{next_line}}',
189
+ ],
176
190
  needs_spec: [
177
191
  '@{{spec_writer}} #{{issue}} "{{title}}" regresa contigo: la spec necesita trabajo',
178
192
  '',
@@ -287,9 +301,10 @@ export function renderMessage(kind, data = {}, language = 'en') {
287
301
 
288
302
  // The next step comes from one table. needs-spec depends on why the issue came back, qa-failed on what the branch holds.
289
303
  if (STATE_OF[kind]) {
290
- const reason = kind === 'needs_spec' ? values.reason ?? 'missing' : kind === 'qa_failed' ? values.recovery ?? (values.branch_state === 'kept' ? 'kept' : 'fix') : null;
304
+ const reason = kind === 'needs_spec' ? values.reason ?? 'missing' : kind === 'qa_failed' ? values.recovery ?? (values.branch_state === 'kept' ? 'kept' : 'fix') : kind === 'toolchain_missing' ? 'toolchain' : null;
291
305
  const step = nextStep({ state: STATE_OF[kind], reason, issue: values.issue, language: language_ });
292
306
  if (!present(values.command) && step.command) values.command = step.command;
307
+ if (kind === 'toolchain_missing') values.next_line = `${nextStepPrefix(language_)}: ${step.hint}.`;
293
308
  if (kind === 'needs_spec') values.next_line = `${nextStepPrefix(language_)}: ${step.command}: ${step.hint}.`;
294
309
  if (kind === 'qa_failed') {
295
310
  // For kept work the next step says what the command does and the other way forward; the others are the bare command.
@@ -76,6 +76,15 @@ const TABLE = {
76
76
  es: 'verifica y corrige el trabajo que se guardó; para repensar el issue, usa /relevo:think y luego /relevo:issue {n}',
77
77
  },
78
78
  },
79
+ // Nothing was built: the runner lacks a tool of the profile, and the fix is in the workflow, not a command.
80
+ toolchain: {
81
+ command: null,
82
+ who: 'assignee',
83
+ hint: {
84
+ en: 'install the toolchain in the build job of relevo-go.yml (the template has the step for Flutter and Laravel, and /relevo:doctor checks it), then remove relevo:qa-failed and label the issue relevo:ready again',
85
+ es: 'instala el toolchain en el job de build de relevo-go.yml (la plantilla trae el paso para Flutter y Laravel, y /relevo:doctor lo comprueba), luego quita relevo:qa-failed y vuelve a poner relevo:ready al issue',
86
+ },
87
+ },
79
88
  'delete-branch': {
80
89
  command: null,
81
90
  who: 'assignee',
@@ -113,7 +122,7 @@ export const NEXT_STEP_STATES = Object.keys(TABLE);
113
122
  /** Reasons for the states whose next step depends on one. */
114
123
  export const NEXT_STEP_REASONS = {
115
124
  'needs-spec': ['missing', 'ambiguous'],
116
- 'qa-failed': ['fix', 'kept', 'delete-branch'],
125
+ 'qa-failed': ['fix', 'kept', 'toolchain', 'delete-branch'],
117
126
  };
118
127
 
119
128
  const fill = (text, issue) => String(text).replaceAll('{n}', String(issue ?? ''));
@@ -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) {
@@ -0,0 +1,118 @@
1
+ // A list in the terminal that is chosen with the arrows, and nothing else: no dependency, so that the package stays
2
+ // small. The input and the output are injected, so a test drives it with a simulated keyboard. The terminal is put
3
+ // back (raw mode off, cursor shown) on every way out, including a cancel and an error.
4
+ import { emitKeypressEvents } from 'node:readline';
5
+
6
+ const HIDE = '\u001b[?25l';
7
+ const SHOW = '\u001b[?25h';
8
+ const UP = (n) => `\u001b[${n}A`;
9
+ const CLEAR = '\u001b[0J';
10
+
11
+ /**
12
+ * choices: [{ value, label }]; defaultIndex: the one selected at the start.
13
+ * Resolves the chosen value, or null when the person cancels (Esc, Ctrl-C, Ctrl-D, q).
14
+ * Keys: up/down (or k/j), Enter, the digits 1 to 9 as a shortcut that chooses at once.
15
+ */
16
+ export function select({ title, choices, defaultIndex = 0, input, output, hint = 'Arrows and Enter to choose, a number to pick one, Esc to cancel' }) {
17
+ if (!input?.isTTY || typeof input.setRawMode !== 'function') return Promise.reject(new Error('the list needs a terminal'));
18
+ if (!Array.isArray(choices) || choices.length === 0) return Promise.reject(new Error('the list has no choices'));
19
+ return new Promise((resolve) => {
20
+ let index = Math.min(Math.max(defaultIndex, 0), choices.length - 1);
21
+ let drawn = 0;
22
+ const draw = () => {
23
+ const lines = [title, ...choices.map((c, i) => `${i === index ? '>' : ' '} ${i + 1}. ${c.label}`), hint];
24
+ output.write(`${drawn ? UP(drawn) : ''}\r${CLEAR}${lines.join('\n')}\n`);
25
+ drawn = lines.length;
26
+ };
27
+ const finish = (value) => {
28
+ input.removeListener('keypress', onKey);
29
+ try {
30
+ input.setRawMode(false);
31
+ } finally {
32
+ input.pause?.();
33
+ output.write(SHOW);
34
+ }
35
+ resolve(value);
36
+ };
37
+ function onKey(str, key = {}) {
38
+ try {
39
+ if (key.ctrl && (key.name === 'c' || key.name === 'd')) return finish(null);
40
+ if (key.name === 'escape' || str === 'q') return finish(null);
41
+ if (key.name === 'return' || key.name === 'enter') return finish(choices[index].value);
42
+ if (key.name === 'up' || str === 'k') index = (index + choices.length - 1) % choices.length;
43
+ else if (key.name === 'down' || str === 'j') index = (index + 1) % choices.length;
44
+ else if (/^[1-9]$/.test(str ?? '') && Number(str) <= choices.length) return finish(choices[Number(str) - 1].value);
45
+ else return undefined;
46
+ draw();
47
+ } catch (err) {
48
+ finish(null);
49
+ throw err;
50
+ }
51
+ return undefined;
52
+ }
53
+ emitKeypressEvents(input);
54
+ input.setRawMode(true);
55
+ input.resume?.();
56
+ input.on('keypress', onKey);
57
+ output.write(HIDE);
58
+ draw();
59
+ });
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
+ }
@@ -8,7 +8,13 @@ import { cloudBuilds, CLOUD_VARIABLE } from './execution-mode.mjs';
8
8
 
9
9
  const paged = (gh, path) => JSON.parse(gh(['api', '--paginate', '--slurp', path])).flat();
10
10
 
11
+ // The first line of an error, which is the reason a read failed.
12
+ const reasonOf = (err) => String(err?.stderr ?? err?.message ?? err).trim().split('\n')[0];
13
+ const notFound = (err) => /404|Not Found/i.test(String(err?.stderr ?? err?.message ?? err));
14
+
11
15
  export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
16
+ // The reads that fail and are not an answer; the report lists them so that a partial report is not taken for a complete one.
17
+ const unread = [];
12
18
  const names = resolveLabelNames(config.labels);
13
19
  const byNumber = new Map();
14
20
  LABELS.forEach((l) => {
@@ -44,8 +50,9 @@ export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
44
50
  let behind = 0;
45
51
  try {
46
52
  behind = JSON.parse(gh(['api', `repos/${repo}/compare/${pr.head.sha}...${pr.base.ref}`])).ahead_by ?? 0;
47
- } catch {
53
+ } catch (err) {
48
54
  behind = 0;
55
+ unread.push({ what: 'compare', pr: pr.number, reason: reasonOf(err) });
49
56
  }
50
57
  prs.push({
51
58
  number: pr.number, issue, url: pr.html_url, author: pr.user.login,
@@ -54,7 +61,7 @@ export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
54
61
  approved: approvedBy.length > 0, behind_by: behind,
55
62
  });
56
63
  });
57
- return { issues: issues.map(({ body, ...rest }) => rest), prs, milestones, externalBlocks: readExternalBlocks({ ghLinks, issues, repo, config, names }), cloudBuilds: readCloudBuilds({ gh, repo }) };
64
+ return { issues: issues.map(({ body, ...rest }) => rest), prs, milestones, externalBlocks: readExternalBlocks({ ghLinks, issues, repo, config, names }), cloudBuilds: readCloudBuilds({ gh, repo, unread }), unread };
58
65
  }
59
66
 
60
67
  /**
@@ -90,20 +97,24 @@ function readExternalBlocks({ ghLinks, issues, repo, config, names }) {
90
97
  * is not `off`. When the workflow cannot be read the answer is `true` (the cautious one: no extra rows, no claim
91
98
  * that a person must build by hand).
92
99
  */
93
- function readCloudBuilds({ gh, repo }) {
100
+ function readCloudBuilds({ gh, repo, unread = [] }) {
94
101
  let workflowPresent;
95
102
  try {
96
103
  gh(['api', `repos/${repo}/contents/.github/workflows/relevo-go.yml`]);
97
104
  workflowPresent = true;
98
105
  } catch (err) {
99
- if (!/404|Not Found/i.test(String(err.stderr ?? err.message ?? err))) return true;
106
+ if (!notFound(err)) {
107
+ unread.push({ what: 'workflow', reason: reasonOf(err) });
108
+ return true;
109
+ }
100
110
  workflowPresent = false;
101
111
  }
102
112
  let variable = null;
103
113
  try {
104
114
  variable = JSON.parse(gh(['api', `repos/${repo}/actions/variables/${CLOUD_VARIABLE}`])).value ?? null;
105
- } catch {
115
+ } catch (err) {
106
116
  variable = null;
117
+ if (!notFound(err)) unread.push({ what: 'variable', reason: reasonOf(err) });
107
118
  }
108
119
  return cloudBuilds({ workflowPresent, variable });
109
120
  }
@@ -28,7 +28,7 @@ function stateOf(issue, names) {
28
28
  return order.find((k) => issue.labels.includes(names[k])) ?? null;
29
29
  }
30
30
 
31
- export function buildStatus({ config, issues, prs = [], milestones = [], externalBlocks = {}, cloudBuilds = true, me = null, now = new Date() }) {
31
+ export function buildStatus({ config, issues, prs = [], milestones = [], externalBlocks = {}, cloudBuilds = true, unread = [], me = null, now = new Date() }) {
32
32
  const names = resolveLabelNames(config.labels);
33
33
  const entries = issues.map((i) => ({
34
34
  number: i.number, title: i.title, state: stateOf(i, names), assignee: i.assignees[0] ?? null, milestone: i.milestone ?? null,
@@ -116,14 +116,19 @@ export function buildStatus({ config, issues, prs = [], milestones = [], externa
116
116
  return {
117
117
  me, view: isSpecWriter ? 'spec-writer' : 'person',
118
118
  onMe, toReview, toShip, readyForMe, waitingOnOthers, specWriter: specWriterView,
119
- milestones: summaries, alerts, waiting,
119
+ milestones: summaries, alerts, waiting, unread,
120
120
  readOnly: true,
121
121
  };
122
122
  }
123
123
 
124
124
  const T = {
125
125
  en: {
126
- nothing: 'Nothing is waiting on you.', onMe: 'Waiting on you', review: 'Pull requests to review', ship: 'Approved, ready to ship',
126
+ nothing: 'Nothing is waiting on you.', fromRead: '(from what could be read)', unreadTitle: 'Could not read, so this report may be incomplete',
127
+ unread: {
128
+ variable: (u) => `the variable RELEVO_CLOUD_GO (${u.reason}): the issues you could build by hand may be missing`,
129
+ workflow: (u) => `the workflow relevo-go.yml (${u.reason}): the cloud is assumed to build, so the issues you could build by hand may be missing`,
130
+ compare: (u) => `the comparison of the branch of pull request #${u.pr} with the base (${u.reason}): a branch that is behind may not be reported`,
131
+ }, onMe: 'Waiting on you', review: 'Pull requests to review', ship: 'Approved, ready to ship',
127
132
  others: 'Waiting on others', spec: 'Back with the spec writer', blocked: 'Blocked', needsReviewer: 'Need a reviewer', milestone: 'Milestone',
128
133
  noDue: 'has no due date, so no capacity warning is computed', merged: 'merged', inReview: 'in review', queued: 'queued', warn: 'capacity warning: more work than the team can review before the due date',
129
134
  alerts: 'Alerts', staleReview: 'review waiting', staleBranch: 'branch behind the base by',
@@ -131,7 +136,12 @@ const T = {
131
136
  readyForMe: 'Ready, for you to build (the cloud is not building)', stuck: 'build with no activity for', restart: 'remove relevo:building and apply relevo:ready again, or run',
132
137
  },
133
138
  es: {
134
- nothing: 'Nada espera por ti.', onMe: 'Esperando por ti', review: 'Pull requests por revisar', ship: 'Aprobadas, listas para entregar',
139
+ nothing: 'Nada espera por ti.', fromRead: '(con lo que se pudo leer)', unreadTitle: 'No se pudo leer, así que este informe puede estar incompleto',
140
+ unread: {
141
+ variable: (u) => `la variable RELEVO_CLOUD_GO (${u.reason}): pueden faltar los issues que podrías construir a mano`,
142
+ workflow: (u) => `el workflow relevo-go.yml (${u.reason}): se supone que la nube construye, así que pueden faltar los issues que podrías construir a mano`,
143
+ compare: (u) => `la comparación de la rama del pull request #${u.pr} con la base (${u.reason}): puede que no se avise de una rama atrasada`,
144
+ }, onMe: 'Esperando por ti', review: 'Pull requests por revisar', ship: 'Aprobadas, listas para entregar',
135
145
  others: 'Esperando a otros', spec: 'De vuelta con quien escribe las specs', blocked: 'Bloqueadas', needsReviewer: 'Sin revisor', milestone: 'Milestone',
136
146
  noDue: 'no tiene fecha límite, así que no se calcula la advertencia de capacidad', merged: 'fusionadas', inReview: 'en revisión', queued: 'en cola', warn: 'advertencia de capacidad: hay más trabajo del que el equipo puede revisar antes de la fecha límite',
137
147
  alerts: 'Alertas', staleReview: 'revisión esperando', staleBranch: 'rama atrasada respecto a la base por',
@@ -160,7 +170,8 @@ export function renderStatus(report, language = 'en') {
160
170
  section(t.review, report.toReview.map((r) => `#${r.number} ${r.pr}`));
161
171
  section(t.ship, report.toShip.map((r) => `#${r.number} → ${r.command}`));
162
172
  section(t.others, report.waitingOnOthers.map((e) => withWaits(e, `#${e.number} ${e.title} (${e.state})`)));
163
- if (!sw && !report.onMe.length && !report.toReview.length && !report.toShip.length) out.unshift(t.nothing, '');
173
+ const unread = report.unread ?? [];
174
+ if (!sw && !report.onMe.length && !report.toReview.length && !report.toShip.length) out.unshift(unread.length ? `${t.nothing} ${t.fromRead}` : t.nothing, '');
164
175
  report.milestones.forEach((m) => {
165
176
  out.push(`${t.milestone} "${m.title}": ${m.merged} ${t.merged}, ${m.inReview} ${t.inReview}, ${m.queued} ${t.queued}`);
166
177
  if (!m.dueOn) out.push(` ${t.milestone} ${t.noDue}`);
@@ -173,6 +184,10 @@ export function renderStatus(report, language = 'en') {
173
184
  ? `#${a.issue} ${t.staleReview} ${a.hours}h (@${a.reviewer}) ${a.pr}`
174
185
  : `#${a.issue} ${t.staleBranch} ${a.behind} ${a.pr}`;
175
186
  }));
187
+ if (unread.length) {
188
+ if (out.length && out[out.length - 1] !== '') out.push('');
189
+ section(t.unreadTitle, unread.map((u) => t.unread[u.what](u)));
190
+ }
176
191
  while (out.length && out[out.length - 1] === '') out.pop();
177
192
  return out.join('\n');
178
193
  }