@elalfner/relevo 0.3.1 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "relevo",
3
3
  "displayName": "Relevo",
4
- "version": "0.3.1",
4
+ "version": "0.4.0",
5
5
  "description": "An automatic issue-to-merge workflow where an agent builds and people verify.",
6
6
  "author": {
7
7
  "name": "Elias Alfaro"
package/README.es.md CHANGED
@@ -26,7 +26,7 @@ claude plugin install relevo@relevo
26
26
 
27
27
  Eso lo instala para todos los proyectos de la máquina. Para instalarlo solo en un repositorio, añade `--scope project` al segundo comando (compartido con el equipo en `.claude/settings.json`) o `--scope local` (solo para ti). Solo se instala el plugin (scripts, skills, agents, perfiles de stack y plantillas); la documentación está en línea.
28
28
 
29
- Un atajo, si prefieres un solo comando: `npm install -g @elalfner/relevo` te da `relevo install` (o `relevo install --scope project`), `relevo update` y `relevo doctor`.
29
+ Un atajo, si prefieres un solo comando: `npm install -g @elalfner/relevo` te da `relevo install`, que pregunta dónde instalarlo (el proyecto viene preseleccionado; `relevo install --scope project` se salta la pregunta), `relevo update` y `relevo doctor`.
30
30
 
31
31
  ```bash
32
32
  npm install -g @elalfner/relevo
package/README.md CHANGED
@@ -26,7 +26,7 @@ claude plugin install relevo@relevo
26
26
 
27
27
  That installs it for every project of the machine. To install it for one repository only, add `--scope project` to the second command (shared with the team through `.claude/settings.json`) or `--scope local` (only you). Only the plugin is installed (scripts, skills, agents, stack profiles and templates); the documentation is online.
28
28
 
29
- A shortcut, if you prefer one command: `npm install -g @elalfner/relevo` gives you `relevo install` (or `relevo install --scope project`), `relevo update` and `relevo doctor`.
29
+ A shortcut, if you prefer one command: `npm install -g @elalfner/relevo` gives you `relevo install`, which asks where to install it (the project is preselected; `relevo install --scope project` skips the question), `relevo update` and `relevo doctor`.
30
30
 
31
31
  ```bash
32
32
  npm install -g @elalfner/relevo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elalfner/relevo",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Relevo: an automatic issue-to-merge workflow where an agent builds and people verify.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/scripts/cli.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // relevo install | update | doctor | version. See scripts/lib/cli.mjs.
2
+ // relevo install | update | doctor | version. See scripts/lib/cli.mjs. `install` without --scope asks where, in a
3
+ // terminal, with the project preselected.
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import { readFileSync } from 'node:fs';
5
6
  import { fileURLToPath } from 'node:url';
6
- import { USAGE, execute, parseArgs } from './lib/cli.mjs';
7
+ import { USAGE, execute, isInsideGitRepo, parseArgs, resolveScope, scopeChoices } from './lib/cli.mjs';
8
+ import { select } from './lib/select.mjs';
7
9
 
8
10
  const here = (p) => fileURLToPath(new URL(p, import.meta.url));
9
11
  const parsed = parseArgs(process.argv.slice(2));
@@ -23,6 +25,29 @@ const run = (cmd, args) => {
23
25
  const has = (cmd) => spawnSync(cmd, ['--version'], { encoding: 'utf8' }).error === undefined;
24
26
  const version = JSON.parse(readFileSync(here('../package.json'), 'utf8')).version;
25
27
 
26
- const { code, lines } = execute(parsed, { run, has, version, doctorScript: here('./doctor.mjs') });
28
+ let { scope } = parsed;
29
+ if (parsed.command === 'install' && has('claude')) {
30
+ const inRepo = isInsideGitRepo(run);
31
+ const resolved = resolveScope({ given: scope, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), inRepo });
32
+ if (resolved.ask) {
33
+ const choices = scopeChoices({ cwd: process.cwd(), inRepo });
34
+ scope = await select({
35
+ title: 'Where should Relevo be installed?',
36
+ choices,
37
+ defaultIndex: Math.max(0, choices.findIndex((c) => c.value === resolved.preferred)),
38
+ input: process.stdin,
39
+ output: process.stdout
40
+ });
41
+ if (scope === null) {
42
+ process.stdout.write('Cancelled: nothing was installed.\n');
43
+ process.exit(130);
44
+ }
45
+ } else {
46
+ scope = resolved.scope;
47
+ if (resolved.reason) process.stdout.write(`relevo: ${resolved.reason}.\n`);
48
+ }
49
+ }
50
+
51
+ const { code, lines } = execute({ command: parsed.command, scope }, { run, has, version, doctorScript: here('./doctor.mjs'), cwd: process.cwd() });
27
52
  process.stdout.write(`${lines.join('\n')}\n`);
28
53
  process.exit(code);
@@ -9,8 +9,8 @@ export const SCOPES = ['user', 'project', 'local'];
9
9
  export const USAGE = [
10
10
  'usage: relevo <command> [--scope user|project|local]',
11
11
  '',
12
- ' install add the marketplace and install the plugin (default scope: user)',
13
- ' update update the marketplace and the plugin',
12
+ ' 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)',
14
14
  ' doctor check this repository (the same as /relevo:doctor)',
15
15
  ' version print the version of this package',
16
16
  '',
@@ -22,16 +22,44 @@ export function parseArgs(argv) {
22
22
  const [command, ...rest] = argv;
23
23
  if (!command || command === '--help' || command === '-h' || command === 'help') return { command: 'help' };
24
24
  if (!['install', 'update', 'doctor', 'version'].includes(command)) return { error: `unknown command "${command}"` };
25
- let scope = 'user';
25
+ let scope = null;
26
26
  for (let i = 0; i < rest.length; i += 1) {
27
27
  const arg = rest[i];
28
28
  if (arg === '--scope') { scope = rest[i + 1]; i += 1; } else if (arg.startsWith('--scope=')) scope = arg.slice(8);
29
29
  else return { error: `unknown option "${arg}"` };
30
30
  }
31
- if (!SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
31
+ if (scope !== null && !SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
32
32
  return { command, scope };
33
33
  }
34
34
 
35
+ /** Whether the folder is inside a git work tree; `run` is the injected runner. A missing git counts as no. */
36
+ export function isInsideGitRepo(run) {
37
+ const r = run('git', ['rev-parse', '--is-inside-work-tree']);
38
+ return r.code === 0 && r.output.trim() === 'true';
39
+ }
40
+
41
+ /**
42
+ * Which scope an install uses. A given scope wins. In a terminal the person is asked, with the project preselected
43
+ * inside a git repository and the machine outside one; without a terminal nothing waits: the same default is used and
44
+ * `reason` says why, to be printed.
45
+ */
46
+ export function resolveScope({ given = null, interactive = false, inRepo = false }) {
47
+ if (given) return { scope: given };
48
+ const preferred = inRepo ? 'project' : 'user';
49
+ if (interactive) return { ask: true, preferred };
50
+ const why = inRepo ? 'this folder is inside a git repository, so the project scope is used' : 'this folder is not inside a git repository, so the machine scope is used';
51
+ return { scope: preferred, reason: `no --scope given and no terminal: ${why}` };
52
+ }
53
+
54
+ /** The choices of the list; the project says which folder gets the settings file. */
55
+ export function scopeChoices({ cwd, inRepo }) {
56
+ return [
57
+ { value: 'project', label: `Project (${cwd})${inRepo ? '' : ' [not a git repository]'}: declared in .claude/settings.json, shared with your team` },
58
+ { value: 'user', label: 'Machine: every project of this machine' },
59
+ { value: 'local', label: 'Only me, in this project: not shared' }
60
+ ];
61
+ }
62
+
35
63
  /** The `claude` invocations of a command, in order. */
36
64
  export function steps(command, scope) {
37
65
  if (command === 'install') {
@@ -43,7 +71,7 @@ export function steps(command, scope) {
43
71
  if (command === 'update') {
44
72
  return [
45
73
  { label: 'update the marketplace', args: ['plugin', 'marketplace', 'update', MARKETPLACE_NAME] },
46
- { label: 'update the plugin', args: ['plugin', 'update', PLUGIN_ID, '--scope', scope] }
74
+ { label: 'update the plugin', args: ['plugin', 'update', PLUGIN_ID, ...(scope ? ['--scope', scope] : [])] }
47
75
  ];
48
76
  }
49
77
  return [];
@@ -53,7 +81,7 @@ export function steps(command, scope) {
53
81
  * Runs a command. Returns { code, lines }: the lines to print and the exit code. Stops at the first failing step
54
82
  * and says which one, so the person knows what was already done.
55
83
  */
56
- export function execute({ command, scope }, { run, has, version, doctorScript }) {
84
+ export function execute({ command, scope }, { run, has, version, doctorScript, cwd = process.cwd() }) {
57
85
  const lines = [];
58
86
  if (command === 'version') return { code: 0, lines: [version] };
59
87
  if (command === 'doctor') {
@@ -74,9 +102,9 @@ export function execute({ command, scope }, { run, has, version, doctorScript })
74
102
  return { code: r.code || 1, lines };
75
103
  }
76
104
  }
77
- const where = { user: 'for this machine', project: 'for this repository (declared in .claude/settings.json, so commit it to share it)', local: 'for this repository, only for you' }[scope];
78
- lines.push(command === 'install'
79
- ? `Relevo is installed ${where}. Restart Claude Code and run /relevo:doctor.`
80
- : 'Relevo is up to date. Restart Claude Code to apply it.');
105
+ const where = { user: 'for this machine', project: 'for this repository', local: 'for this repository, only for you' }[scope];
106
+ const wrote = { user: 'your user settings', project: `.claude/settings.json of ${cwd}, so commit it to share it`, local: `.claude/settings.local.json of ${cwd}` }[scope];
107
+ if (command === 'install') lines.push(`Scope: ${scope} (${wrote}).`, `Relevo is installed ${where}. Restart Claude Code and run /relevo:doctor.`);
108
+ else lines.push('Relevo is up to date. Restart Claude Code to apply it.');
81
109
  return { code: 0, lines };
82
110
  }
@@ -153,6 +153,7 @@ export function gatherFacts(p) {
153
153
  f.openspecRoot = p.exists('openspec/config.yaml') || p.exists('openspec/config.yml');
154
154
  f.openspecRootTracked = tryRun(() => String(p.tool('git', ['ls-files', 'openspec'])).trim().length > 0, false);
155
155
  f.evidenceIgnored = p.exists('.gitignore') && /^\/?evidence\/?\s*$/m.test(p.read('.gitignore'));
156
+ f.pluginDeclared = pluginDeclaration(p);
156
157
 
157
158
  // Workflows: what runs on GitHub is what is on the default branch, not what is in the working copy, so once the
158
159
  // repository is known they are read from there. `present` says whether the file is where it has to be; `local`
@@ -263,6 +264,22 @@ export function gatherFacts(p) {
263
264
  }
264
265
  }
265
266
 
267
+ /**
268
+ * Whether the repository declares the Relevo plugin in the settings file that is committed, so that a teammate who
269
+ * clones it is offered the plugin: { declared } or, when the file is not valid JSON, { unreadable: true }. Only the
270
+ * shared file counts; the user's and the local settings do not reach anyone else.
271
+ */
272
+ export function pluginDeclaration(p) {
273
+ const path = '.claude/settings.json';
274
+ if (!p.exists(path)) return { declared: false };
275
+ try {
276
+ const settings = JSON.parse(p.read(path));
277
+ return { declared: Boolean(settings?.extraKnownMarketplaces?.relevo) && settings?.enabledPlugins?.['relevo@relevo'] === true };
278
+ } catch {
279
+ return { unreadable: true };
280
+ }
281
+ }
282
+
266
283
  /** Reads a file: the plugin's own files from disk, the repository's through the probes. Null when absent. */
267
284
  function readerFor(p, tryRun) {
268
285
  const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
@@ -504,6 +521,7 @@ export function runChecks(f) {
504
521
  if (f.openspecRoot && f.openspecRootTracked) r.push(pass('openspec.root', 'the OpenSpec root is tracked by git'));
505
522
  else r.push(fail('openspec.root', 'the OpenSpec root is missing or not tracked by git, so a clean checkout (and the runner) has no `openspec/`', 'run /relevo:init, then commit openspec/config.yaml and the .gitkeep files'));
506
523
  r.push(f.evidenceIgnored ? pass('evidence.ignored', 'evidence/ is ignored by git') : fail('evidence.ignored', 'evidence/ is not in .gitignore; `review` would put screenshots in git', 'add the line `evidence/` to .gitignore (or run /relevo:init)'));
524
+ r.push(...pluginDeclaredChecks(f));
507
525
 
508
526
  // GitHub side
509
527
  if (!f.repo || !f.ghAuth) {
@@ -595,6 +613,15 @@ function toolchainChecks(f) {
595
613
  });
596
614
  }
597
615
 
616
+ /** Does the committed settings file declare the plugin? It is a local fact, so it needs no GitHub sign-in. */
617
+ function pluginDeclaredChecks(f) {
618
+ const r = [];
619
+ if (f.pluginDeclared?.unreadable) r.push(warn('plugin.declared', '.claude/settings.json is not valid JSON, so it cannot be checked whether the repository declares the Relevo plugin', 'fix the file; /relevo:init then offers to declare the plugin'));
620
+ else if (f.pluginDeclared?.declared) r.push(pass('plugin.declared', 'the repository declares the Relevo plugin in .claude/settings.json, so a teammate who opens it is offered it'));
621
+ else if (f.pluginDeclared) r.push(warn('plugin.declared', 'the repository does not declare the Relevo plugin in .claude/settings.json: a teammate who clones it is not offered the plugin and finds no /relevo: commands', 'run /relevo:init, or claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin and claude plugin install relevo@relevo --scope project, and commit .claude/settings.json'));
622
+ return r;
623
+ }
624
+
598
625
  /**
599
626
  * The installed plugin and what the workflows pin. A package that lost a folder cannot run; a workflow that clones
600
627
  * the plugin repository for its scripts (the layout before the package) or has no pin is told to be replaced; a pin
@@ -22,6 +22,10 @@ const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE
22
22
  * subscription token that line is commented out and the token line is switched on. Throws when the template no
23
23
  * longer has the lines (a test runs this on the real template).
24
24
  */
25
+ // The two official commands that install the plugin at the project scope; the person confirms this exact line.
26
+ export const PLUGIN_PROJECT_COMMAND = 'claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin && claude plugin install relevo@relevo --scope project';
27
+ 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.';
28
+
25
29
  export function credentialLine(workflow, credential) {
26
30
  if (credential !== 'subscription') return workflow;
27
31
  if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
@@ -174,6 +178,11 @@ export function planInit({ facts, answers, templates, current, admin }) {
174
178
  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
179
  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
180
 
181
+ // the plugin, declared in the project so that a teammate who clones the repository is offered it
182
+ if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
183
+ 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'));
184
+ 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 }));
185
+
177
186
  // relevo.yml
178
187
  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
188
  else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
@@ -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 ?? ''));
@@ -0,0 +1,60 @@
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
+ }
@@ -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
  }
@@ -0,0 +1,12 @@
1
+ // Which of the tools that a profile lists are not on the path. `probe(tool)` says whether one answers; it is injected
2
+ // so that a test does not need the tools. An empty list checks nothing.
3
+
4
+ /** The tools that are missing, in the order of the profile and without repeats. */
5
+ export function missingTools(tools, probe) {
6
+ const seen = new Set();
7
+ return (tools ?? []).filter((tool) => {
8
+ if (seen.has(tool)) return false;
9
+ seen.add(tool);
10
+ return !probe(tool);
11
+ });
12
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Checks that the tools which the profile of a platform lists are on the path of this machine. Usage:
3
+ // node scripts/toolchain-check.mjs --platform <name> [--config relevo.yml] [--plugin-root <dir>]
4
+ // It prints { platform, stack, tools, missing } as JSON. A tool is there when `<tool> --version` starts. The cloud build
5
+ // runs it before the agent, so that a runner without the SDK stops in seconds and not after a full run.
6
+ // exit 0: every tool is there (or the profile lists none) exit 1: a tool is missing, or the profile cannot be resolved
7
+ // exit 2: bad usage or an invalid relevo.yml
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { dirname, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { loadConfig } from './lib/config.mjs';
14
+ import { resolveProfile } from './lib/stack-profile.mjs';
15
+ import { missingTools } from './lib/toolchain-check.mjs';
16
+
17
+ const arg = (name) => {
18
+ const i = process.argv.indexOf(`--${name}`);
19
+ return i === -1 ? undefined : process.argv[i + 1];
20
+ };
21
+ const fail = (message, code) => {
22
+ console.error(message);
23
+ process.exit(code);
24
+ };
25
+
26
+ const pluginRoot = resolve(arg('plugin-root') ?? resolve(dirname(fileURLToPath(import.meta.url)), '..'));
27
+ const configPath = resolve(arg('config') ?? 'relevo.yml');
28
+ if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /relevo:init to create it.`, 2);
29
+ const loaded = loadConfig(readFileSync(configPath, 'utf8'));
30
+ if (!loaded.ok) fail(`relevo.yml is invalid:\n${loaded.errors.map((e) => ` ${e.message}`).join('\n')}`, 2);
31
+
32
+ const names = Object.keys(loaded.config.platforms);
33
+ const name = arg('platform') ?? (names.length === 1 ? names[0] : undefined);
34
+ if (!name) fail(`usage: toolchain-check.mjs --platform <${names.join('|')}> [--config relevo.yml] [--plugin-root dir]`, 2);
35
+ const platform = loaded.config.platforms[name];
36
+ if (!platform) fail(`unknown platform "${name}"; the platforms of relevo.yml are: ${names.join(', ')}`, 2);
37
+
38
+ const base = dirname(configPath);
39
+ const read = (path) => {
40
+ const file = path.startsWith('/') ? path : resolve(base, path);
41
+ return existsSync(file) ? readFileSync(file, 'utf8') : null;
42
+ };
43
+ const resolved = resolveProfile({ platform, pluginRoot, read });
44
+ if (!resolved.ok) fail(resolved.errors.join('\n'), 1);
45
+
46
+ const probe = (tool) => spawnSync(tool, ['--version'], { stdio: 'ignore' }).error === undefined;
47
+ const missing = missingTools(resolved.profile.tools, probe);
48
+ console.log(JSON.stringify({ platform: name, stack: resolved.profile.stack, tools: resolved.profile.tools, missing }));
49
+ process.exit(missing.length > 0 ? 1 : 0);
@@ -53,6 +53,8 @@ Show it as it is. Each line says whether it is already `ok`, will be done, `diff
53
53
 
54
54
  For each line in the plan that is an install, show its exact command and ask for a yes to that command alone. Run it only on a yes. If the person declines, continue, say that dependency is still missing, and that `go` and `ship` will not run until the OpenSpec tool is installed.
55
55
 
56
+ One install is not a dependency: `install.plugin-project` declares the Relevo plugin in the project, so that a teammate who clones the repository is offered it, even when the plugin is already installed for the machine. Show its exact command (the two `claude plugin` commands at the project scope) and run it only on a yes. If it ran, `.claude/settings.json` is new or changed: say that it is left for the person to commit. If the person declines, nothing else changes, and `/relevo:doctor` keeps warning about it. When the plan says that file is not valid JSON, do not touch it: tell the person to fix it.
57
+
56
58
  ### 5. Apply
57
59
 
58
60
  ```bash
@@ -9,7 +9,7 @@ name: relevo-digest
9
9
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
10
10
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
11
11
  env:
12
- RELEVO_VERSION: '0.3.1'
12
+ RELEVO_VERSION: '0.4.0'
13
13
 
14
14
  on:
15
15
  schedule:
@@ -15,7 +15,7 @@ name: relevo-go
15
15
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
16
16
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
17
17
  env:
18
- RELEVO_VERSION: '0.3.1'
18
+ RELEVO_VERSION: '0.4.0'
19
19
 
20
20
  on:
21
21
  issues:
@@ -164,6 +164,36 @@ jobs:
164
164
  env:
165
165
  OPENSPEC_TELEMETRY: '0'
166
166
 
167
+ # Stops before the agent, without spending a model turn, when the runner lacks a tool that the profile of the
168
+ # platform lists (the Flutter SDK, PHP and Composer, ...): otherwise the developer writes the code and QA only
169
+ # finds out at the end, after a full run. The package is unpacked outside the workspace on purpose: the agent needs
170
+ # a clean working tree. If you renamed the labels in relevo.yml, change the names below too.
171
+ - name: Check the toolchain
172
+ env:
173
+ GH_TOKEN: ${{ github.token }}
174
+ ISSUE: ${{ github.event.issue.number || inputs.issue }}
175
+ PLATFORM: ${{ needs.decide.outputs.platform }}
176
+ run: |
177
+ mkdir -p "$RUNNER_TEMP/relevo"
178
+ npm pack "@elalfner/relevo@$RELEVO_VERSION" --silent --pack-destination "$RUNNER_TEMP" > /dev/null
179
+ tar -xzf "$RUNNER_TEMP"/elalfner-relevo-*.tgz --strip-components=1 -C "$RUNNER_TEMP/relevo"
180
+ for f in scripts/toolchain-check.mjs scripts/render-message.mjs; do
181
+ [ -f "$RUNNER_TEMP/relevo/$f" ] || { echo "::error::The Relevo package $RELEVO_VERSION lacks $f"; exit 1; }
182
+ done
183
+ if result=$(node "$RUNNER_TEMP/relevo/scripts/toolchain-check.mjs" --platform "$PLATFORM" --plugin-root "$RUNNER_TEMP/relevo"); then
184
+ exit 0
185
+ fi
186
+ missing=$(printf '%s' "$result" | jq -r '.missing | join(", ")')
187
+ [ -n "$missing" ] || { echo "::error::The toolchain check failed: $result"; exit 1; }
188
+ title=$(gh issue view "$ISSUE" --json title --jq .title)
189
+ assignee=$(gh issue view "$ISSUE" --json assignees --jq '.assignees[0].login // empty')
190
+ body=$(jq -n --arg assignee "$assignee" --arg issue "$ISSUE" --arg title "$title" --arg tools "$missing" --arg platform "$PLATFORM" \
191
+ '{assignee: $assignee, issue: $issue, title: $title, tools: $tools, platform: $platform}' | node "$RUNNER_TEMP/relevo/scripts/render-message.mjs" toolchain_missing --config relevo.yml)
192
+ gh issue edit "$ISSUE" --remove-label relevo:ready --remove-label relevo:queued --remove-label relevo:building --add-label relevo:qa-failed
193
+ gh issue comment "$ISSUE" --body "$body"
194
+ echo "::error::The runner has no $missing, so nothing was built"
195
+ exit 1
196
+
167
197
  - id: agent
168
198
  uses: anthropics/claude-code-action@v1
169
199
  env:
@@ -10,7 +10,7 @@ name: relevo-queue
10
10
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
11
11
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
12
12
  env:
13
- RELEVO_VERSION: '0.3.1'
13
+ RELEVO_VERSION: '0.4.0'
14
14
 
15
15
  on:
16
16
  issues:
@@ -10,7 +10,7 @@ name: relevo-review
10
10
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
11
11
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
12
12
  env:
13
- RELEVO_VERSION: '0.3.1'
13
+ RELEVO_VERSION: '0.4.0'
14
14
 
15
15
  on:
16
16
  pull_request_review:
@@ -8,7 +8,7 @@ name: relevo-sla
8
8
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
9
9
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
10
10
  env:
11
- RELEVO_VERSION: '0.3.1'
11
+ RELEVO_VERSION: '0.4.0'
12
12
 
13
13
  on:
14
14
  schedule: