@elalfner/relevo 0.8.0 → 0.9.1

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.8.0",
4
+ "version": "0.9.1",
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
@@ -74,6 +74,7 @@ Además, `/relevo:status` (quién espera a quién) y `/relevo:doctor` (comprueba
74
74
 
75
75
  - [Primeros pasos](docs/get-started.es.md): de cero a tu primer pull request fusionado, paso a paso.
76
76
  - [Instalación](docs/install.es.md): qué necesitas y los primeros pasos.
77
+ - [Actualizar](docs/upgrading.es.md): pasar un proyecto a una versión nueva, en el orden correcto.
77
78
  - [Cómo funciona](docs/how-it-works.es.md): la idea, y una corrida real de la idea al pull request mergeado.
78
79
  - [Comandos](docs/commands.es.md): los nueve comandos, y las skills que usa `think`.
79
80
  - [Configuración](docs/configuration.es.md): el archivo `relevo.yml`.
package/README.md CHANGED
@@ -74,6 +74,7 @@ Plus `/relevo:status` (who is waiting on what) and `/relevo:doctor` (checks your
74
74
 
75
75
  - [Get started](docs/get-started.md): from nothing to your first merged pull request, step by step.
76
76
  - [Install](docs/install.md): what you need and the first steps.
77
+ - [Upgrading](docs/upgrading.md): moving a project to a new version, in the right order.
77
78
  - [How it works](docs/how-it-works.md): the idea, and a real run from idea to merged pull request.
78
79
  - [Commands](docs/commands.md): the nine commands, and the skills that `think` uses.
79
80
  - [Configuration](docs/configuration.md): the `relevo.yml` file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elalfner/relevo",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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",
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // Checks that the machine, the repository and GitHub are ready for Relevo. Read-only: it changes nothing.
3
- // node scripts/doctor.mjs [--repo owner/name] [--json]
3
+ // node scripts/doctor.mjs [--repo owner/name] [--all] [--json]
4
+ // --all lists the checks that passed too; --json always lists every check
4
5
  // exit 0: no check failed (warnings allowed) exit 1: at least one check failed
5
6
 
6
7
  import { execFileSync } from 'node:child_process';
7
8
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
9
  import { homedir } from 'node:os';
9
10
  import { adapterFacts } from './lib/adapters/install.mjs';
10
- import { gatherFacts, readOnlyGh, renderReport, runChecks } from './lib/doctor.mjs';
11
+ import { exitCode, gatherFacts, readOnlyGh, renderReport, reportHeader, runChecks } from './lib/doctor.mjs';
11
12
 
12
13
  const arg = (name) => {
13
14
  const i = process.argv.indexOf(`--${name}`);
@@ -53,5 +54,5 @@ const facts = gatherFacts({
53
54
  repo,
54
55
  });
55
56
  const results = runChecks(facts);
56
- console.log(process.argv.includes('--json') ? JSON.stringify(results, null, 2) : renderReport(results));
57
- process.exit(results.some((x) => x.status === 'fail') ? 1 : 0);
57
+ console.log(process.argv.includes('--json') ? JSON.stringify(results, null, 2) : renderReport(results, { header: reportHeader(facts), all: process.argv.includes('--all') }));
58
+ process.exit(exitCode(results));
package/scripts/init.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // Plans and applies the repository setup. Usage:
3
- // node scripts/init.mjs plan [--answers answers.json] [--repo owner/name] [--json]
4
- // node scripts/init.mjs apply [--answers answers.json] [--repo owner/name] [--skip id,id] [--json]
3
+ // node scripts/init.mjs plan [--answers answers.json] [--repo owner/name] [--from version] [--json]
4
+ // node scripts/init.mjs apply [--answers answers.json] [--repo owner/name] [--from version] [--skip id,id] [--json]
5
+ // --from names the published version a workflow that differs came from, when none is close enough to tell
5
6
  // `plan` changes nothing. `apply` carries out the planned changes except installs (the skill asks first).
6
7
  // answers.json: { "members": ["a","b"], "specWriter": "a", "reviewMode": "peer", "language": "en", "platforms": { "app": { "stack": "flutter", "path": ".", "targets": ["android"] } }, "stackProfiles": [] }
7
8
  // It never reads or writes the model API key.
@@ -13,6 +14,7 @@ import { dirname, resolve } from 'node:path';
13
14
  import { homedir } from 'node:os';
14
15
  import { gatherFacts, readOnlyGh, vueWebFacts, WORKFLOW_FILES } from './lib/doctor.mjs';
15
16
  import { applyPlan, planInit, renderPlan, skipItems } from './lib/init.mjs';
17
+ import { planUpgrades, publishedTemplates, unifiedDiff } from './lib/workflow-upgrade.mjs';
16
18
 
17
19
  const arg = (name) => {
18
20
  const i = process.argv.indexOf(`--${name}`);
@@ -72,9 +74,26 @@ const current = {
72
74
  stackProfiles: Object.fromEntries((answers.stackProfiles ?? []).map((s) => [s, existsSync(`.relevo/stacks/${s}.md`)])),
73
75
  };
74
76
 
77
+ // A workflow that exists and differs may be updated by a merge with the published templates (asked for lazily: it uses npm).
78
+ const published = publishedTemplates({ exec: (cmd, args) => run(cmd, args) });
79
+ process.on('exit', () => published.cleanup());
80
+ let versions;
81
+ const source = { versions: () => (versions ??= published.versions()), read: published.read };
82
+ const upgrades = (file) => {
83
+ try {
84
+ const state = planUpgrades({
85
+ files: [file], ours: current.workflows, templates: templates.workflows, agentBlocks: templates.agentBlocks,
86
+ labels: facts.config?.runner?.labels ?? answers.runnerLabels, build: answers.agentBuild ?? 'claude', from: arg('from') ?? null, source,
87
+ })[file];
88
+ return state.status === 'update' ? { ...state, diff: unifiedDiff(current.workflows[file], state.text) } : state;
89
+ } catch (err) {
90
+ return { status: 'error', message: String(err.message ?? err).split('\n')[0] };
91
+ }
92
+ };
93
+
75
94
  let items;
76
95
  try {
77
- items = planInit({ facts, answers, templates, current, admin });
96
+ items = planInit({ facts, answers, templates, current, admin, upgrades });
78
97
  } catch (err) {
79
98
  fail(err.message);
80
99
  }
@@ -13,7 +13,7 @@ export const USAGE = [
13
13
  ' relevo init [--tools claude,antigravity] [--scope project|machine] [--yes] [--force]',
14
14
  ' install add the marketplace and install the plugin; asks where in a terminal (project by default)',
15
15
  ' update update the marketplace and the plugin (without --scope, the installation of this project), and render again the plugins that init made; --force overwrites files edited by hand',
16
- ' doctor check this repository (the same as /relevo:doctor)',
16
+ ' doctor check this repository (the same as /relevo:doctor); --all lists the checks that passed too',
17
17
  ' version print the version of this package',
18
18
  '',
19
19
  'user: every project of this machine. project: this repository, shared through .claude/settings.json. local: this repository, only you.'
@@ -26,14 +26,16 @@ export function parseArgs(argv) {
26
26
  if (!['install', 'update', 'doctor', 'version'].includes(command)) return { error: `unknown command "${command}"` };
27
27
  let scope = null;
28
28
  let force = false;
29
+ let all = false;
29
30
  for (let i = 0; i < rest.length; i += 1) {
30
31
  const arg = rest[i];
31
32
  if (arg === '--force' && command === 'update') force = true;
33
+ else if (arg === '--all' && command === 'doctor') all = true;
32
34
  else if (arg === '--scope') { scope = rest[i + 1]; i += 1; } else if (arg.startsWith('--scope=')) scope = arg.slice(8);
33
35
  else return { error: `unknown option "${arg}"` };
34
36
  }
35
37
  if (scope !== null && !SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
36
- return force ? { command, scope, force } : { command, scope };
38
+ return { command, scope, ...(force ? { force } : {}), ...(all ? { all } : {}) };
37
39
  }
38
40
 
39
41
  /** Whether the folder is inside a git work tree; `run` is the injected runner. A missing git counts as no. */
@@ -85,11 +87,11 @@ export function steps(command, scope) {
85
87
  * Runs a command. Returns { code, lines }: the lines to print and the exit code. Stops at the first failing step
86
88
  * and says which one, so the person knows what was already done.
87
89
  */
88
- export function execute({ command, scope }, { run, has, version, doctorScript, cwd = process.cwd() }) {
90
+ export function execute({ command, scope, all = false }, { run, has, version, doctorScript, cwd = process.cwd() }) {
89
91
  const lines = [];
90
92
  if (command === 'version') return { code: 0, lines: [version] };
91
93
  if (command === 'doctor') {
92
- const r = run(process.execPath, [doctorScript]);
94
+ const r = run(process.execPath, all ? [doctorScript, '--all'] : [doctorScript]);
93
95
  return { code: r.code, lines: [r.output.trimEnd()] };
94
96
  }
95
97
  if (!has('claude')) {
@@ -85,7 +85,7 @@ function checkType(setting, value, errors) {
85
85
  break;
86
86
  case 'label-list':
87
87
  if (!Array.isArray(value)) {
88
- errors.push({ path, message: `${path} must be a list of runner labels, for example [self-hosted, Front] (got ${JSON.stringify(value)})` });
88
+ errors.push({ path, message: `${path} must be a list of runner labels, for example [self-hosted, linux-build] (got ${JSON.stringify(value)})` });
89
89
  } else if (value.length === 0) {
90
90
  errors.push({ path, message: `${path} needs at least one label; leave the setting out to use ubuntu-latest` });
91
91
  } else if (value.some((v) => typeof v !== 'string' || v.trim() === '')) {
@@ -48,6 +48,8 @@ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
48
48
  const pass = (id, message) => ({ id, status: 'pass', message });
49
49
  const fail = (id, message, fix) => ({ id, status: 'fail', message, fix });
50
50
  const warn = (id, message, fix) => ({ id, status: 'warn', message, fix });
51
+ // Information that asks for nothing: neither a failure nor a warning, and it never changes the exit status.
52
+ const note = (id, message) => ({ id, status: 'note', message });
51
53
 
52
54
  /** True when `version` (like "22.1.0") is at least `min` (like "20.19"). */
53
55
  export function versionAtLeast(version, min) {
@@ -183,6 +185,12 @@ export function gatherFacts(p) {
183
185
  const installedRoot = p.pluginRoot ?? PLUGIN_ROOT;
184
186
  f.pluginFolders = ['scripts', 'skills', 'agents', 'stacks', 'templates'].filter((d) => tryRun(() => readdirSync(resolve(installedRoot, d)).length > 0, false) !== true);
185
187
  f.pluginVersion = tryRun(() => JSON.parse(readFileSync(resolve(installedRoot, '.claude-plugin', 'plugin.json'), 'utf8')).version ?? null, null);
188
+ // The installations Claude Code knows of: the user scope, and the project or local scope of this repository. null when
189
+ // `claude` cannot list them. Only the command is read, never Claude Code's own files.
190
+ const repoTop = tryRun(() => String(p.tool('git', ['rev-parse', '--show-toplevel'])).trim() || null, null);
191
+ f.installedPlugins = tryRun(() => JSON.parse(String(p.tool('claude', ['plugin', 'list', '--json'])))
192
+ .filter((e) => String(e.id).startsWith('relevo@') && (e.scope === 'user' || e.projectPath === repoTop))
193
+ .map((e) => ({ scope: e.scope, version: e.version ?? null })), null);
186
194
 
187
195
  f.configText = p.exists('relevo.yml') ? p.read('relevo.yml') : null;
188
196
  const loaded = f.configText === null ? null : loadConfig(f.configText, profileLookups({ pluginRoot: p.pluginRoot ?? PLUGIN_ROOT, read: readerFor(p, tryRun) }));
@@ -306,6 +314,11 @@ export function gatherFacts(p) {
306
314
  WORKFLOW_FILES.forEach((file) => {
307
315
  if (typeof texts[file] === 'string') f.pluginPins[file] = (/^\s*RELEVO_VERSION:\s*['"]?([\w.+-]+)['"]?\s*$/m.exec(texts[file]) ?? [])[1] ?? null;
308
316
  });
317
+ // The version of the text each workflow was copied from (null when it has no stamp).
318
+ f.pluginStamps = {};
319
+ WORKFLOW_FILES.forEach((file) => {
320
+ if (typeof texts[file] === 'string') f.pluginStamps[file] = (/^# relevo-template: (\d+\.\d+\.\d+)\s*$/m.exec(texts[file]) ?? [])[1] ?? null;
321
+ });
309
322
  f.hasCi = names.some((n) => /\.ya?ml$/.test(n) && !n.startsWith('relevo-'));
310
323
  }
311
324
  }
@@ -478,11 +491,11 @@ function executionChecks(f) {
478
491
 
479
492
  if (mode === 'both') r.push(warn('execution.credential', `both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN exist${used ? `; the workflow uses the ${used === 'api_key' ? 'API key' : 'subscription token'}` : ''}`, 'keep only the secret the workflow uses, so that it is clear which account pays'));
480
493
  else if (mode === 'api_key') r.push(pass('execution.credential', 'the cloud builds with an API key: run_max_cost is a real ceiling on spending'));
481
- else if (mode === 'subscription') r.push(pass('execution.credential', 'the cloud builds with a subscription token: run_max_cost is only a notional ceiling, and the quota is shared with the use of the account that owns the token'));
494
+ else if (mode === 'subscription') r.push(note('execution.credential', 'the cloud builds with a subscription token: run_max_cost is only a notional ceiling, and the quota is shared with the use of the account that owns the token'));
482
495
  else r.push(pass('execution.credential', installed ? 'no model credential: the cloud cannot build (see the warning about the secret)' : 'no model credential and no go workflow: go builds on machines only'));
483
496
 
484
497
  if (mode === 'subscription' || (mode === 'both' && used === 'subscription')) {
485
- r.push(pass('execution.policy', 'reminder: read the current Anthropic terms on automated use of a subscription token (and the README of claude-code-action) before relying on it for a team'));
498
+ r.push(note('execution.policy', 'reminder: read the current Anthropic terms on automated use of a subscription token (and the README of claude-code-action) before relying on it for a team'));
486
499
  if ((f.config?.limits?.concurrent_agents ?? 1) > 1) {
487
500
  r.push(warn('execution.concurrency', `limits.concurrent_agents is ${f.config.limits.concurrent_agents} with a subscription, whose quota is shared by every run and by the interactive use of the account`, 'set limits.concurrent_agents to 1'));
488
501
  }
@@ -564,7 +577,7 @@ function platformChecks(f) {
564
577
  e.targetTools.forEach(({ target, tool, ok }) => {
565
578
  const id = `tools.target.${e.name}.${target}`;
566
579
  if (tool === '') r.push(pass(id, `target ${target} of ${e.name} needs no extra tool`));
567
- else if (tool === 'xcrun' && !f.macos) r.push(pass(id, `target ${target} of ${e.name}: iOS can only be built and reviewed on macOS (a notice, not a problem)`));
580
+ else if (tool === 'xcrun' && !f.macos) r.push(note(id, `target ${target} of ${e.name}: iOS can only be built and reviewed on macOS`));
568
581
  else if (ok) r.push(pass(id, `target ${target} of ${e.name}: ${tool} is installed`));
569
582
  else r.push(warn(id, `target ${target} of ${e.name}: ${tool} is not installed; \`review\` needs it for this target`, TOOL_INSTALL[tool] ?? `install ${tool}`));
570
583
  });
@@ -622,7 +635,7 @@ export function runChecks(f) {
622
635
  // skills that think invokes
623
636
  REQUIRED_SKILLS.forEach((name) => {
624
637
  const manual = (f.manualOnlySkills ?? []).includes(name);
625
- r.push(f.skills[name] ? pass(`skills.${name}`, manual ? `skill ${name} is installed; it is reserved for you, so run /${name} by hand when \`think\` asks (a notice, not a problem)` : `skill ${name} is installed`) : fail(`skills.${name}`, `skill ${name} is not installed; \`think\` needs it`, SKILL_INSTALL[name]));
638
+ r.push(f.skills[name] ? (manual ? note(`skills.${name}`, `skill ${name} is installed; it is reserved for you, so run /${name} by hand when \`think\` asks`) : pass(`skills.${name}`, `skill ${name} is installed`)) : fail(`skills.${name}`, `skill ${name} is not installed; \`think\` needs it`, SKILL_INSTALL[name]));
626
639
  });
627
640
 
628
641
  // configuration
@@ -688,14 +701,27 @@ export function runChecks(f) {
688
701
  } else if (f.secrets.some((n) => ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(n))) r.push(pass('automation.secret', 'a model credential secret exists'));
689
702
  else r.push(warn('automation.secret', 'no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN secret, so the cloud cannot build: a ready label only leaves a comment that says to run /relevo:go on a machine', 'for the cloud, add one to the repository secrets (init never touches it; for a subscription token remove all whitespace before storing it); for local builds nothing is needed'));
690
703
 
704
+ // One result per cause: the workflows in the same state, with the same status and the same fix, are one result that names them.
705
+ const groups = new Map();
706
+ const put = (kind, status, file, what, fix) => {
707
+ const key = `${kind}|${status}`;
708
+ const g = groups.get(key) ?? { kind, status, files: [], what, fix };
709
+ g.files.push(file);
710
+ groups.set(key, g);
711
+ };
691
712
  Object.entries(f.workflows).forEach(([file, w]) => {
692
- const required = REQUIRED_WORKFLOWS.has(file);
693
- const bad = (m, fix) => r.push(required ? fail(`automation.workflow.${file}`, m, fix) : warn(`automation.workflow.${file}`, m, fix));
694
- if (!w.present && w.local && w.remote === false) bad(`${file} is only in your working copy, not on the default branch, and a workflow only runs from there`, 'commit it and push it to the default branch (through a pull request if the branch is protected)');
695
- else if (!w.present) bad(`${file} is not installed`, 'run /relevo:init to copy it into .github/workflows/');
696
- else if (w.enabled === false) bad(`${file} is disabled`, 'enable it in the Actions tab');
697
- else if (w.enabled === null) r.push(warn(`automation.workflow.${file}`, `${file} is on the default branch but GitHub does not list it yet`, 'check the Actions tab, or run doctor again in a minute'));
698
- else r.push(pass(`automation.workflow.${file}`, `${file} is installed and enabled`));
713
+ const level = REQUIRED_WORKFLOWS.has(file) ? 'fail' : 'warn';
714
+ if (!w.present && w.local && w.remote === false) put('local-only', level, file, 'only in your working copy, not on the default branch, and a workflow only runs from there', 'commit it and push it to the default branch (through a pull request if the branch is protected)');
715
+ else if (!w.present) put('missing', level, file, 'not installed', 'run /relevo:init to copy it into .github/workflows/');
716
+ else if (w.enabled === false) put('disabled', level, file, 'disabled', 'enable it in the Actions tab');
717
+ else if (w.enabled === null) put('unlisted', 'warn', file, 'on the default branch but GitHub does not list it yet', 'check the Actions tab, or run doctor again in a minute');
718
+ else put('installed', 'pass', file, 'installed and enabled');
719
+ });
720
+ groups.forEach(({ kind, status, files, what, fix }) => {
721
+ // The same state can be a failure for a required workflow and a warning for an optional one: two results, two ids.
722
+ const id = files.length === 1 ? `automation.workflow.${files[0]}` : `automation.workflow.${kind}${status === 'warn' && kind !== 'unlisted' ? '-optional' : ''}`;
723
+ const message = files.length === 1 ? `${files[0]} is ${what}` : `${files.join(', ')} are ${what}`;
724
+ r.push({ id, status, message, ...(fix ? { fix } : {}) });
699
725
  });
700
726
  r.push(f.hasCi ? pass('automation.ci', 'an objective CI check exists') : fail('automation.ci', 'no CI workflow other than Relevo\'s was found; QA needs an objective check', 'add a workflow that runs the project\'s analysis and tests on pull requests'));
701
727
 
@@ -708,7 +734,10 @@ export function runChecks(f) {
708
734
  r.push(...linkChecks(f));
709
735
  r.push(...executionChecks(f));
710
736
  r.push(...shipChecks(f));
711
- if (f.pluginRepoPrivate === true && Array.isArray(f.secrets)) {
737
+ // The token is for reading the plugin repository, which only a workflow that still loads the plugin through a marketplace
738
+ // (or clones it, the older layout) does; one that loads it from the pinned package needs none.
739
+ const readsPluginRepository = Boolean(f.pluginMarketplace) || (f.pluginCheckouts ?? []).length > 0;
740
+ if (readsPluginRepository && f.pluginRepoPrivate === true && Array.isArray(f.secrets)) {
712
741
  if (f.secrets.includes('RELEVO_PLUGIN_TOKEN')) r.push(pass('automation.plugin-secret', 'the RELEVO_PLUGIN_TOKEN secret exists for the private plugin repository'));
713
742
  else r.push(fail('automation.plugin-secret', 'the plugin repository is private and the repository has no RELEVO_PLUGIN_TOKEN secret, so the workflows fall back to the default token and fail at checkout', 'store a read-only fine-grained token that can read the plugin repository as the repository secret RELEVO_PLUGIN_TOKEN; a secret defined at organization level is not visible to this check'));
714
743
  }
@@ -759,7 +788,7 @@ function toolchainChecks(f) {
759
788
 
760
789
  const HOSTED_RUNNER = ['ubuntu-latest'];
761
790
 
762
- /** The labels written in a `runs-on` line: `[self-hosted, Front]` or `ubuntu-latest`; null for an expression we cannot judge. */
791
+ /** The labels written in a `runs-on` line: `[self-hosted, linux-build]` or `ubuntu-latest`; null for an expression we cannot judge. */
763
792
  function parseRunsOn(value) {
764
793
  if (value.includes('${{')) return null;
765
794
  const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
@@ -776,12 +805,21 @@ function runnerChecks(f) {
776
805
  const same = (a, b) => a.length === b.length && [...a].sort().every((l, i) => l === [...b].sort()[i]);
777
806
  const r = [];
778
807
  let judged = 0;
808
+ // One result per cause: the files that run on the same other runner are one failure that names all of them.
809
+ const differing = new Map();
779
810
  Object.entries(f.workflows ?? {}).forEach(([file, w]) => {
780
811
  if (!w.present || typeof w.text !== 'string') return;
781
812
  judged += 1;
782
813
  const found = [...w.text.matchAll(/^[ \t]+runs-on:[ \t]*(.+?)[ \t]*(?:#.*)?$/gm)].map((m) => parseRunsOn(m[1])).filter((l) => l !== null);
783
814
  const other = found.find((labels) => !same(labels, wanted));
784
- if (other) r.push(fail(`automation.runner.${file}`, `${file} runs on [${other.join(', ')}] and relevo.yml says [${wanted.join(', ')}]`, same(wanted, HOSTED_RUNNER) ? `set runner.labels: [${other.join(', ')}] in relevo.yml if that is the runner you want, or restore runs-on: ubuntu-latest in ${file}` : `change runs-on in ${file} to [${wanted.join(', ')}], or change runner.labels in relevo.yml`));
815
+ if (!other) return;
816
+ const key = other.join(', ');
817
+ differing.set(key, [...(differing.get(key) ?? []), file]);
818
+ });
819
+ [...differing.entries()].forEach(([labels, files]) => {
820
+ const names = files.join(', ');
821
+ const id = differing.size === 1 ? 'automation.runner' : `automation.runner.${labels}`;
822
+ r.push(fail(id, `${names} ${files.length === 1 ? 'runs' : 'run'} on [${labels}] and relevo.yml says [${wanted.join(', ')}]`, same(wanted, HOSTED_RUNNER) ? `set runner.labels: [${labels}] in relevo.yml if that is the runner you want, or restore runs-on: ubuntu-latest in ${names}` : `change runs-on in ${names} to [${wanted.join(', ')}], or change runner.labels in relevo.yml`));
785
823
  });
786
824
  if (r.length > 0) return r;
787
825
  if (judged > 0) r.push(pass('automation.runner', `the Relevo workflows run on [${wanted.join(', ')}]`));
@@ -825,7 +863,50 @@ function pluginInstallChecks(f) {
825
863
  const unpinned = Object.entries(pins).filter(([file, v]) => v === null && !cloners.includes(file)).map(([file]) => file);
826
864
  if (unpinned.length > 0) r.push(fail('automation.plugin-pin', `${unpinned.join(', ')} do not pin the version of the Relevo package`, 'copy the current templates, which set RELEVO_VERSION'));
827
865
  const old = f.pluginVersion ? Object.entries(pins).filter(([, v]) => v !== null && v !== f.pluginVersion).map(([file, v]) => `${file} (${v})`) : [];
828
- if (old.length > 0) r.push(warn('automation.plugin-version', `${old.join(', ')} pin a different version than the installed plugin (${f.pluginVersion})`, 'to move them, change RELEVO_VERSION to the installed version after reading the changelog'));
866
+ if (old.length > 0) r.push(warn('automation.plugin-version', `${old.join(', ')} pin a different version than the installed plugin (${f.pluginVersion})`, 'run /relevo:init: it updates a workflow by merging it with the new template and keeps your own changes; or change RELEVO_VERSION by hand after reading the changelog (that moves the scripts, not the text)'));
867
+ r.push(...versionChecks(f), ...workflowTextChecks(f));
868
+ return r;
869
+ }
870
+
871
+ const byVersion = (a, b) => a.split('.').map(Number).reduce((x, n, i) => x || n - Number(b.split('.')[i]), 0);
872
+
873
+ /**
874
+ * Which version each part is on, from what can be read here: the plugin in use, each installation `claude` lists and the
875
+ * pins of the workflows. It is a note, always visible; a warning only when the user and the project scope disagree, with
876
+ * the command that moves the lagging one. It asks the npm registry nothing.
877
+ */
878
+ function versionChecks(f) {
879
+ if (f.installedPlugins === undefined) return [];
880
+ if (f.installedPlugins === null) return [warn('plugin.versions', 'the installed versions of the plugin could not be read: `claude plugin list --json` failed', 'check that Claude Code is installed (claude --version) and run claude plugin list')];
881
+ const r = [];
882
+ const scopes = f.installedPlugins.filter((e) => e.version);
883
+ const pins = [...new Set(Object.values(f.pluginPins ?? {}).filter(Boolean))].sort(byVersion);
884
+ const parts = [
885
+ `plugin in use ${f.pluginVersion ?? 'unknown'}`,
886
+ scopes.length > 0 ? `installed ${scopes.map((e) => `${e.scope} ${e.version}`).join(', ')}` : 'not installed through Claude Code',
887
+ pins.length > 0 ? `workflows pin ${pins.join(', ')}` : null,
888
+ ].filter(Boolean);
889
+ r.push(note('plugin.versions', `versions: ${parts.join('; ')}`));
890
+ const distinct = [...new Set(scopes.map((e) => e.version))].sort(byVersion);
891
+ if (distinct.length > 1) {
892
+ const newest = distinct.at(-1);
893
+ const lagging = scopes.filter((e) => e.version !== newest).map((e) => e.scope);
894
+ r.push(warn('plugin.scopes', `the plugin is on different versions: ${scopes.map((e) => `${e.scope} ${e.version}`).join(', ')}`, `update the lagging scope: ${lagging.map((s) => `claude plugin update relevo@relevo --scope ${s}`).join('; ')}`));
895
+ }
896
+ return r;
897
+ }
898
+
899
+ /** Is the text of each workflow as current as the scripts it runs? The stamp is the version of the text; the pin is the scripts'. */
900
+ function workflowTextChecks(f) {
901
+ const stamps = f.pluginStamps;
902
+ if (!stamps) return [];
903
+ const pins = f.pluginPins ?? {};
904
+ const unknown = Object.keys(stamps).filter((file) => stamps[file] === null && f.workflows?.[file]?.present);
905
+ const older = Object.keys(stamps).filter((file) => stamps[file] !== null && pins[file] && byVersion(stamps[file], pins[file]) < 0);
906
+ const r = [];
907
+ if (older.length > 0) r.push(warn('automation.workflow-text', `${older.map((file) => `${file} (text ${stamps[file]}, scripts ${pins[file]})`).join(', ')}: the text of the workflow is older than the scripts it runs`, 'run /relevo:init: it updates the workflow by merging it with the new template and keeps your own changes'));
908
+ if (unknown.length > 0) r.push(warn('automation.workflow-text-unknown', `${unknown.join(', ')} have no # relevo-template: line, so the age of their text is unknown`, 'run /relevo:init: it updates the workflow by merging it with the new template and keeps your own changes'));
909
+ if (older.length === 0 && unknown.length === 0 && Object.keys(stamps).length > 0) r.push(pass('automation.workflow-text', 'the text of the workflows is as current as the scripts they run'));
829
910
  return r;
830
911
  }
831
912
 
@@ -846,14 +927,41 @@ function adapterChecks(f) {
846
927
  });
847
928
  }
848
929
 
849
- /** Text report. */
850
- export function renderReport(results) {
851
- const tag = { pass: 'pass', fail: 'FAIL', warn: 'warn' };
852
- const lines = results.flatMap((x) => [`[${tag[x.status]}] ${x.message}`, ...(x.fix && x.status !== 'pass' ? [` fix: ${x.fix}`] : [])]);
853
- const failed = results.filter((x) => x.status === 'fail').length;
854
- const warned = results.filter((x) => x.status === 'warn').length;
930
+ /** The exit status of `doctor`: 1 when a check failed, 0 otherwise. Warnings and notes never fail it. */
931
+ export function exitCode(results) {
932
+ return results.some((x) => x.status === 'fail') ? 1 : 0;
933
+ }
934
+
935
+ const TAG = { pass: 'pass', fail: 'FAIL', warn: 'warn', note: 'note' };
936
+ const lineOf = (x) => [`[${TAG[x.status]}] ${x.message}`, ...(x.fix && (x.status === 'fail' || x.status === 'warn') ? [` fix: ${x.fix}`] : [])];
937
+
938
+ /** The first line of the report: what was judged, and where the workflows were read from. */
939
+ export function reportHeader(facts) {
940
+ if (!facts?.repo || !facts.ghAuth) return 'Relevo doctor: only the local checks ran (no GitHub repository, or not signed in to GitHub)';
941
+ return `Relevo doctor: ${facts.repo}, workflows read from the ${facts.workflowSource ?? 'working copy'}`;
942
+ }
943
+
944
+ /**
945
+ * Text report. By default what needs action comes first (failures, warnings, notes, each with its count) and the checks
946
+ * that passed are only counted; `all` lists every check in the order it was evaluated. The closing line counts results.
947
+ */
948
+ export function renderReport(results, { header = null, all = false } = {}) {
949
+ const by = (status) => results.filter((x) => x.status === status);
950
+ const failed = by('fail').length;
951
+ const warned = by('warn').length;
952
+ const passed = by('pass').length;
953
+ const lines = header ? [header, ''] : [];
954
+ if (all) lines.push(...results.flatMap(lineOf));
955
+ else {
956
+ [['FAILURES', 'fail'], ['WARNINGS', 'warn'], ['NOTES', 'note']].forEach(([title, status]) => {
957
+ const group = by(status);
958
+ if (group.length === 0) return;
959
+ lines.push(`${title} (${group.length})`, ...group.flatMap(lineOf), '');
960
+ });
961
+ if (passed > 0) lines.push(`${passed} check(s) passed; run with --all to list them.`);
962
+ }
855
963
  lines.push('', failed === 0 ? (warned === 0 ? 'All green.' : `No failures, ${warned} warning(s).`) : `${failed} check(s) failed${warned ? `, ${warned} warning(s)` : ''}.`);
856
- return lines.join('\n');
964
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
857
965
  }
858
966
 
859
967
  /** Wrap a `gh` client so that it can only read: `doctor` must never change anything. */
@@ -210,10 +210,13 @@ const item = (id, status, description, extra = {}) => ({ id, status, description
210
210
  * templates { config: text, workflows: { file: text } }
211
211
  * current { workflows: { file: text|null }, gitignore: text|null, keepFiles: { path: boolean } }
212
212
  * admin true when the person has administrator permission on the repository
213
+ * upgrades { file: state } or a function file -> state (asked only for a workflow that exists and differs), from
214
+ * `planUpgrades`: a clean merge becomes an
215
+ * update to confirm, a conflict or a missing base stays `differs` and says why
213
216
  * Items have status: ok (nothing to do), todo (init will do it), differs (exists but differs: reported, never
214
217
  * overwritten), manual (the person must do it).
215
218
  */
216
- export function planInit({ facts, answers, templates, current, admin }) {
219
+ export function planInit({ facts, answers, templates, current, admin, upgrades = {} }) {
217
220
  const items = [];
218
221
  const base = facts.config?.branches?.base ?? answers.base ?? 'dev';
219
222
  const release = facts.config?.branches?.release ?? answers.release ?? 'main';
@@ -314,9 +317,22 @@ export function planInit({ facts, answers, templates, current, admin }) {
314
317
  const shared = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
315
318
  const wanted = runnerLine(agentWorkflow(shared, build, templates.agentBlocks), runnerLabels);
316
319
  const have = current.workflows[file] ?? null;
320
+ // The state of the update is asked for only when the file exists and differs: it may need the network.
321
+ const upgrade = () => (typeof upgrades === 'function' ? upgrades(file) : upgrades[file]);
322
+ const up = have !== null && have !== wanted ? upgrade() : undefined;
317
323
  if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
318
324
  else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
319
- else {
325
+ else if (up?.status === 'update') {
326
+ items.push(item(`workflow.${file}`, 'todo', `Update .github/workflows/${file} from ${up.from} to ${up.to}, keeping your own changes`, { path: `.github/workflows/${file}`, content: up.text, diff: up.diff }));
327
+ } else if (up?.status === 'current') items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date (with your own changes)`));
328
+ else if (up?.status === 'conflict') {
329
+ const where = up.conflicts.length > 0 ? `${up.conflicts.length} place(s) changed by you and by the new template` : up.message;
330
+ items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} differs from the template and cannot be updated without a conflict (${where}); it is left as it is`, { conflicts: up.conflicts }));
331
+ } else if (up?.status === 'nobase') {
332
+ items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} differs from the template and no published version is close to it; it is left as it is (name the version it came from with --from <version>)`));
333
+ } else if (up?.status === 'error') {
334
+ items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} differs from the template and could not be checked for an update (${up.message}); it is left as it is`));
335
+ } else {
320
336
  // The cloud build only passes QA when the build job installs the toolchain of the stack, which the template does.
321
337
  const toolchain = file === 'relevo-go.yml' ? '; check that its build job installs the toolchain of your stacks, as the template does for Flutter and Laravel' : '';
322
338
  items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} exists and differs from the template; it is left as it is (compare it by hand${toolchain})`));
@@ -358,7 +374,12 @@ export function planInit({ facts, answers, templates, current, admin }) {
358
374
  export function renderPlan(items) {
359
375
  const tag = { ok: 'ok ', todo: 'will do', differs: 'differs', manual: 'by hand' };
360
376
  const todo = items.filter((i) => i.status === 'todo').length;
361
- const lines = items.map((i) => `[${tag[i.status]}] ${i.description}${i.install ? ` (${i.command})` : ''}`);
377
+ const indent = (text) => text.split('\n').map((l) => ` ${l}`);
378
+ const lines = items.flatMap((i) => [
379
+ `[${tag[i.status]}] ${i.description}${i.install ? ` (${i.command})` : ''}`,
380
+ ...(i.diff ? indent(i.diff.trimEnd()) : []),
381
+ ...(i.conflicts ?? []).flatMap((c) => [indent('yours:'), indent(c.yours), indent('the new template:'), indent(c.theirs)].flat()),
382
+ ]);
362
383
  lines.push('', todo === 0 ? 'Nothing to do.' : `${todo} change(s) to make.`);
363
384
  return lines.join('\n');
364
385
  }
@@ -411,6 +432,8 @@ export function applyPlan(items, { repo, io }) {
411
432
  it.paths.forEach((p) => io.write(p, ''));
412
433
  break;
413
434
  default:
435
+ // A workflow is written only under .github/workflows/, whatever the item says.
436
+ if (it.id.startsWith('workflow.') && !/^\.github\/workflows\/[\w.-]+\.yml$/.test(it.path)) throw new Error(`refusing to write ${it.path}: a workflow goes under .github/workflows/`);
414
437
  if (it.id.startsWith('workflow.') || it.id.startsWith('stack-profile.')) io.write(it.path, it.content);
415
438
  else throw new Error(`no way to apply "${it.id}"`);
416
439
  }
@@ -0,0 +1,198 @@
1
+ // Updates a workflow of a repository to the current template by a three-way merge. Implements the requirements "Init
2
+ // updates a workflow by a three-way merge", "A merged workflow is written only when clean and confirmed" and "The pin and
3
+ // the stamp are set, not merged" of openspec/specs/init-doctor/spec.md.
4
+ //
5
+ // base the published template closest to the file (the pin is no guide: a person can raise it by hand)
6
+ // ours the file as it is
7
+ // theirs the current template, rendered with the runner and the agent of the repository, so that a person's runner
8
+ // or agent block is the same on both sides and is not reported as a change
9
+ //
10
+ // The pin and the stamp are not merged: both are set to the base's value in the file before the merge (otherwise the
11
+ // person's own pin conflicts with every release) and to the current value after it. Nothing here writes a workflow: the
12
+ // caller decides, and a conflict gives back text that must not be written.
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { agentWorkflow } from './agent-workflow.mjs';
19
+ import { runnerLine } from './init.mjs';
20
+
21
+ const PIN_RE = /^(\s*RELEVO_VERSION:\s*)(['"]?)([\w.+-]+)\2[ \t]*$/m;
22
+ const STAMP_RE = /^# relevo-template: (\d+\.\d+\.\d+)[ \t]*$/m;
23
+ const MARKERS = ['credential', 'agent-step'];
24
+
25
+ export const pinOf = (text) => PIN_RE.exec(text)?.[3] ?? null;
26
+ export const stampOf = (text) => STAMP_RE.exec(text)?.[1] ?? null;
27
+ const setPin = (text, version) => text.replace(PIN_RE, (_, head, quote) => `${head}${quote}${version}${quote}`);
28
+ const setStamp = (text, version) => text.replace(STAMP_RE, `# relevo-template: ${version}`);
29
+
30
+ /** How many lines two workflows do not share (as multisets), with the pin and the stamp set aside. */
31
+ export function distance(a, b) {
32
+ const lines = (t) => setStamp(setPin(t, 'X'), 'X').split('\n');
33
+ const count = new Map();
34
+ lines(a).forEach((l) => count.set(l, (count.get(l) ?? 0) + 1));
35
+ lines(b).forEach((l) => count.set(l, (count.get(l) ?? 0) - 1));
36
+ return [...count.values()].reduce((sum, n) => sum + Math.abs(n), 0);
37
+ }
38
+
39
+ /**
40
+ * The candidate whose text is closest to the file; the newest among equals. `from` names the base instead. null when no
41
+ * candidate is close enough: more than half of the lines of the file different is not a version of this template.
42
+ */
43
+ export function chooseBase(file, candidates, { from = null } = {}) {
44
+ if (from !== null) {
45
+ const named = candidates.find((c) => c.version === from);
46
+ return named ? { ...named, distance: distance(file, named.rendered) } : null;
47
+ }
48
+ const ranked = candidates.map((c) => ({ ...c, distance: distance(file, c.rendered) }))
49
+ .sort((a, b) => a.distance - b.distance || compareVersions(b.version, a.version));
50
+ const limit = Math.max(20, Math.floor(file.split('\n').length / 2));
51
+ return ranked.length > 0 && ranked[0].distance <= limit ? ranked[0] : null;
52
+ }
53
+
54
+ const compareVersions = (a, b) => a.split('.').map(Number).reduce((r, n, i) => r || n - Number(b.split('.')[i]), 0);
55
+
56
+ /** `git merge-file -p`: the exit status is the number of conflicts (1 to 127); anything else is an error. */
57
+ export function gitMergeFile(ours, base, theirs) {
58
+ const dir = mkdtempSync(join(tmpdir(), 'relevo-merge-'));
59
+ try {
60
+ const paths = [['ours', ours], ['base', base], ['theirs', theirs]].map(([name, text]) => {
61
+ const path = join(dir, name);
62
+ writeFileSync(path, text);
63
+ return path;
64
+ });
65
+ try {
66
+ return { status: 0, stdout: execFileSync('git', ['merge-file', '-p', ...paths], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) };
67
+ } catch (err) {
68
+ return { status: err.status ?? 255, stdout: String(err.stdout ?? '') };
69
+ }
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ /** The blocks of conflict of a merged text: [{ yours, theirs }]. */
76
+ export function conflictsOf(text) {
77
+ const out = [];
78
+ const re = /^<<<<<<<[^\n]*\n([\s\S]*?)^=======\n([\s\S]*?)^>>>>>>>[^\n]*\n/gm;
79
+ let m;
80
+ while ((m = re.exec(text)) !== null) out.push({ yours: m[1].trimEnd(), theirs: m[2].trimEnd() });
81
+ return out;
82
+ }
83
+
84
+ /** A merge that lost what other code depends on is not safe to write, whatever git says. */
85
+ function structureProblem(merged, current) {
86
+ if ((merged.match(/^\s*RELEVO_VERSION:/gm) ?? []).length !== 1) return 'it does not have exactly one RELEVO_VERSION';
87
+ if (stampOf(current) !== null && (merged.match(/^# relevo-template:/gm) ?? []).length !== 1) return 'it does not have exactly one stamp';
88
+ for (const name of MARKERS) {
89
+ const count = (t, kind) => (t.match(new RegExp(`^\\s*# relevo:${name} ${kind}\\s*$`, 'gm')) ?? []).length;
90
+ for (const kind of ['begin', 'end']) {
91
+ if (count(merged, kind) !== count(current, kind)) return `the block "${name}" (${kind}) is not as in the template`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * The state of one workflow against the current template.
99
+ * { status: 'current' } nothing to update
100
+ * { status: 'update', from, to, text } a clean merge; `text` is what to write
101
+ * { status: 'conflict', from, conflicts } lines both sides changed, or a merge that is not safe; write nothing
102
+ * { status: 'nobase' } no published template is close to the file
103
+ * { status: 'error', message } git failed
104
+ * `candidates` are [{ version, template, blocks }] (the raw template of a published version and its agent blocks, or
105
+ * null). `render` turns a raw template into what `init` would write for this repository.
106
+ */
107
+ export function upgradeWorkflow({ ours, current, candidates, from = null, render, merge = gitMergeFile }) {
108
+ const theirs = render(current.template, current.blocks);
109
+ const rendered = candidates.map((c) => ({ ...c, rendered: render(c.template, c.blocks) }));
110
+ const base = chooseBase(ours, rendered, { from });
111
+ if (!base) return { status: 'nobase' };
112
+
113
+ let prepared = ours;
114
+ if (pinOf(prepared) !== null && pinOf(base.rendered) !== null) prepared = setPin(prepared, pinOf(base.rendered));
115
+ if (stampOf(prepared) !== null && stampOf(base.rendered) !== null) prepared = setStamp(prepared, stampOf(base.rendered));
116
+
117
+ const result = merge(prepared, base.rendered, theirs);
118
+ if (result.status === 0) {
119
+ let text = result.stdout;
120
+ if (pinOf(theirs) !== null) text = setPin(text, pinOf(theirs));
121
+ if (stampOf(theirs) !== null && stampOf(text) !== null) text = setStamp(text, stampOf(theirs));
122
+ const problem = structureProblem(text, theirs);
123
+ if (problem) return { status: 'conflict', from: base.version, conflicts: [], message: `the merge is not safe: ${problem}` };
124
+ if (text === ours) return { status: 'current' };
125
+ return { status: 'update', from: base.version, to: stampOf(theirs) ?? pinOf(theirs), text };
126
+ }
127
+ if (result.status >= 1 && result.status <= 127) return { status: 'conflict', from: base.version, conflicts: conflictsOf(result.stdout) };
128
+ return { status: 'error', message: 'git merge-file failed' };
129
+ }
130
+
131
+ /** The published versions of the package and their templates, fetched with `npm`; `exec(cmd, args)` returns stdout. */
132
+ export function publishedTemplates({ exec, tmp = mkdtempSync(join(tmpdir(), 'relevo-published-')), pkg = '@elalfner/relevo' }) {
133
+ const unpacked = new Map();
134
+ const dirOf = (version) => {
135
+ if (!unpacked.has(version)) {
136
+ const dir = join(tmp, version);
137
+ mkdirSync(dir, { recursive: true });
138
+ try {
139
+ exec('npm', ['pack', `${pkg}@${version}`, '--silent', '--pack-destination', dir]);
140
+ const tarball = join(dir, `${pkg.replace(/^@/, '').replace('/', '-')}-${version}.tgz`);
141
+ exec('tar', ['-xzf', tarball, '-C', dir]);
142
+ unpacked.set(version, join(dir, 'package'));
143
+ } catch {
144
+ unpacked.set(version, null);
145
+ }
146
+ }
147
+ return unpacked.get(version);
148
+ };
149
+ return {
150
+ versions: () => JSON.parse(exec('npm', ['view', pkg, 'versions', '--json'])).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0'),
151
+ read: (version, path) => {
152
+ const dir = dirOf(version);
153
+ if (dir === null) return null;
154
+ try {
155
+ return readFileSync(join(dir, path), 'utf8');
156
+ } catch {
157
+ return null;
158
+ }
159
+ },
160
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The state of each workflow of the repository that exists and differs from what `init` would write. `ours` and
166
+ * `templates` map a file name to its text; `repo` brings `runnerLabels` and `build`; `source` is `publishedTemplates`.
167
+ */
168
+ export function planUpgrades({ files, ours, templates, agentBlocks, labels, build, from = null, source, merge }) {
169
+ const render = (template, blocks) => runnerLine(agentWorkflow(template, build, blocks), labels);
170
+ const versions = files.some((f) => ours[f] != null) ? source.versions() : [];
171
+ const out = {};
172
+ files.filter((f) => ours[f] != null).forEach((file) => {
173
+ const candidates = versions.map((version) => {
174
+ const template = source.read(version, `templates/workflows/${file}`);
175
+ return template === null ? null : { version, template, blocks: build === 'claude' ? null : source.read(version, `templates/agents/${build}-step.yml`) };
176
+ }).filter(Boolean);
177
+ out[file] = upgradeWorkflow({ ours: ours[file], current: { template: templates[file], blocks: agentBlocks }, candidates, from, render, merge });
178
+ });
179
+ return out;
180
+ }
181
+
182
+ /** A readable unified difference between the file and its update, for the plan. */
183
+ export function unifiedDiff(before, after) {
184
+ const dir = mkdtempSync(join(tmpdir(), 'relevo-diff-'));
185
+ try {
186
+ const a = join(dir, 'before');
187
+ const b = join(dir, 'after');
188
+ writeFileSync(a, before);
189
+ writeFileSync(b, after);
190
+ try {
191
+ return execFileSync('git', ['diff', '--no-index', '--no-color', '--unified=2', a, b], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
192
+ } catch (err) {
193
+ return String(err.stdout ?? '').split('\n').slice(4).join('\n');
194
+ }
195
+ } finally {
196
+ rmSync(dir, { recursive: true, force: true });
197
+ }
198
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: doctor
3
- description: Checks that this machine, the repository and GitHub are ready for Relevo and reports every check as pass, fail or warning, with the fix next to each failure. It covers the required tools (OpenSpec, git, the GitHub CLI, the toolchain and the targets of each platform's stack profile), the skills that /relevo:think invokes, the extra skills of each role, relevo.yml, labels and branches, branch protection and approvals, the automation secret and workflows, what the ship workflow needs, in a web session whether the repository installs OpenSpec at its start, and a CI check. Read-only: it never changes anything. Use when asked to check, diagnose or verify the Relevo setup.
4
- argument-hint: ""
3
+ description: Checks that this machine, the repository and GitHub are ready for Relevo and reports what needs action first (failures, then warnings, then notes, with the fix next to each failure and warning) and counts the checks that passed; --all lists them too. It covers the required tools (OpenSpec, git, the GitHub CLI, the toolchain and the targets of each platform's stack profile), the skills that /relevo:think invokes, the extra skills of each role, relevo.yml, labels and branches, branch protection and approvals, the automation secret and workflows, what the ship workflow needs, in a web session whether the repository installs OpenSpec at its start, and a CI check. Read-only: it never changes anything. Use when asked to check, diagnose or verify the Relevo setup.
4
+ argument-hint: "[--all]"
5
5
  ---
6
6
 
7
7
  # Relevo doctor
@@ -18,8 +18,8 @@ Scripts live in `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `node`. They hol
18
18
 
19
19
  ## Steps
20
20
 
21
- 1. Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/doctor.mjs` from the repository root. It finds the repository itself; pass `--repo owner/name` only when it cannot.
22
- 2. Show its output as it is: each check is `pass`, `FAIL` or `warn`, and each failure and warning has its fix on the next line.
21
+ 1. Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/doctor.mjs` from the repository root. It finds the repository itself; pass `--repo owner/name` only when it cannot. Add `--all` when the person asked for it, to list the checks that passed.
22
+ 2. Show its output as it is. The first line says what was judged. Then come the `FAILURES`, the `WARNINGS` and the `NOTES`, each with its count; every `[FAIL]` and `[warn]` line has its fix on the next line. A `[note]` asks for nothing: do not present it as a problem. The checks that passed are only counted, and `--all` lists them as `[pass]`.
23
23
  3. If there are failures, say which command fixes most of them: `/relevo:init` for labels, branches, protection, workflows and the ignore line; the install commands shown for tools and skills; adding the model credential to the repository secrets by hand.
24
24
  4. If a `ship.app` check fails, say that the `relevo:ship` label cannot ship in this repository until a GitHub App and its two secrets exist, because the base branch requires checks, and that `/relevo:ship` still works meanwhile.
25
25
  5. If the output says branch protection is not available, say plainly that approving and merging are then enforced only by the written confirmation `ship` asks for, not by the platform.
@@ -15,7 +15,8 @@ Scripts live in `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `node`. They hol
15
15
 
16
16
  - Never read, write, print or ask for the model API key or any token. Tell the person to add it to the repository secrets themselves.
17
17
  - Never apply a change before the person says yes to the plan, and never install anything before they confirm that exact command.
18
- - Never overwrite a workflow or `relevo.yml` that already exists and differs; report it. A `relevo.yml` with the older `platform: flutter-android` line stays as it is.
18
+ - Never overwrite `relevo.yml` that already exists and differs; report it. A `relevo.yml` with the older `platform: flutter-android` line stays as it is.
19
+ - A workflow that already exists and differs is reported, unless the plan offers to update it: that line (`Update .github/workflows/<file> from A to B, keeping your own changes`) means a three-way merge with the published templates came out clean, and it is written only with the person's yes to the plan. A workflow whose merge conflicts or has no close published version is left as it is; never edit it to resolve the conflict yourself.
19
20
  - Never create files under `openspec/` by hand. If there is no OpenSpec root, tell the person to run `openspec init`.
20
21
  - Never commit or push. The new files are left for the person to commit.
21
22
  - Never put credentials in `relevo.yml` or in the answers file.
@@ -35,7 +36,7 @@ If `relevo.yml` already exists and is valid, skip this step. Otherwise ask, in s
35
36
  - the language of the hand-off messages (`en` or `es`);
36
37
  - 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
38
  - 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. Whatever the answer, the ship workflow (`relevo-ship.yml`) is added: it ships an approved issue when a person applies the `relevo:ship` label, runs no model and needs no model credential, and the repository variable `RELEVO_CLOUD_SHIP` (`off`) pauses it;
38
- - whether the repository runs its GitHub Actions on a self-hosted runner. Ask nothing more when the answer is no: the workflows run on `ubuntu-latest` and `relevo.yml` gets no `runner` key. On a yes, ask for the runner's labels (for example `self-hosted` and `Front`): they become `runner.labels` in `relevo.yml` and the `runs-on` of every workflow `init` adds. Say that Relevo assumes a self-hosted runner keeps its files between jobs, whatever the person says about it, and that the agent's code runs with the permissions of the runner's user; do not ask whether the runner is ephemeral;
39
+ - whether the repository runs its GitHub Actions on a self-hosted runner. Ask nothing more when the answer is no: the workflows run on `ubuntu-latest` and `relevo.yml` gets no `runner` key. On a yes, ask for the runner's labels (for example `self-hosted` and `linux-build`): they become `runner.labels` in `relevo.yml` and the `runs-on` of every workflow `init` adds. Say that Relevo assumes a self-hosted runner keeps its files between jobs, whatever the person says about it, and that the agent's code runs with the permissions of the runner's user; do not ask whether the runner is ephemeral;
39
40
  - 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;
40
41
  - 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);
41
42
  - 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;
@@ -49,7 +50,7 @@ Write the answers as JSON to a temporary file **outside the repository**, with t
49
50
  node ${CLAUDE_PLUGIN_ROOT}/scripts/init.mjs plan --answers <answers file>
50
51
  ```
51
52
 
52
- Show it as it is. Each line says whether it is already `ok`, will be done, `differs` (exists, left alone) or must be done `by hand`. Then ask one direct yes/no question: apply the changes marked "will do"? Wait for the answer.
53
+ Show it as it is. Each line says whether it is already `ok`, will be done, `differs` (exists, left alone) or must be done `by hand`. A workflow update shows its difference under the line, and a conflict shows both versions. When a line says that no published version is close to a workflow, ask which version of Relevo it was copied from and run the plan again with `--from <version>`. Then ask one direct yes/no question: apply the changes marked "will do"? Wait for the answer.
53
54
 
54
55
  ### 4. Offer the installs, one at a time
55
56
 
@@ -39,7 +39,7 @@ limits:
39
39
  run_max_cost: 5 # REQUIRED: US dollars per unattended run; choose your own
40
40
 
41
41
  # runner: # where the Relevo workflows run; leave it out for GitHub-hosted (ubuntu-latest)
42
- # labels: [self-hosted, Front] # a self-hosted runner is assumed to persist between jobs (docs/execution.md)
42
+ # labels: [self-hosted, linux-build] # a self-hosted runner is assumed to persist between jobs (docs/execution.md)
43
43
 
44
44
  qa:
45
45
  retries: 1 # developer attempts after QA rejects
@@ -1,3 +1,4 @@
1
+ # relevo-template: 0.9.1
1
2
  # Relevo: posts one digest per working day, as a comment on the issue named by digest.issue in relevo.yml.
2
3
  # It lists who is waiting on what and the alerts, and posts nothing when nothing is waiting.
3
4
  # Subscribe your chat tool to that issue to receive it. Copy to .github/workflows/relevo-digest.yml.
@@ -13,7 +14,7 @@ name: relevo-digest
13
14
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
14
15
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
15
16
  env:
16
- RELEVO_VERSION: '0.8.0'
17
+ RELEVO_VERSION: '0.9.1'
17
18
 
18
19
  on:
19
20
  schedule:
@@ -1,3 +1,4 @@
1
+ # relevo-template: 0.9.1
1
2
  # Relevo: builds an issue when someone with write access labels it `relevo:ready`.
2
3
  # Copy to .github/workflows/relevo-go.yml. If you renamed the ready label in relevo.yml, change it below too.
3
4
  #
@@ -19,7 +20,7 @@ name: relevo-go
19
20
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
20
21
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
21
22
  env:
22
- RELEVO_VERSION: '0.8.0'
23
+ RELEVO_VERSION: '0.9.1'
23
24
  # The version of OpenSpec that the job installs for itself.
24
25
  OPENSPEC_VERSION: '1.14.3'
25
26
 
@@ -183,7 +184,8 @@ jobs:
183
184
  # Stops before the agent, without spending a model turn, when the runner lacks a tool that the profile of the
184
185
  # platform lists (the Flutter SDK, PHP and Composer, ...): otherwise the developer writes the code and QA only
185
186
  # finds out at the end, after a full run. The package is unpacked outside the workspace on purpose: the agent needs
186
- # a clean working tree. If you renamed the labels in relevo.yml, change the names below too.
187
+ # a clean working tree. The agent loads its plugin from this same folder (--plugin-dir), so a package without
188
+ # .claude-plugin/plugin.json stops the run here too. If you renamed the labels in relevo.yml, change the names below too.
187
189
  - name: Check the toolchain
188
190
  env:
189
191
  GH_TOKEN: ${{ github.token }}
@@ -193,7 +195,7 @@ jobs:
193
195
  mkdir -p "$RUNNER_TEMP/relevo"
194
196
  npm pack "@elalfner/relevo@$RELEVO_VERSION" --silent --pack-destination "$RUNNER_TEMP" > /dev/null
195
197
  tar -xzf "$RUNNER_TEMP"/elalfner-relevo-*.tgz --strip-components=1 -C "$RUNNER_TEMP/relevo"
196
- for f in scripts/toolchain-check.mjs scripts/render-message.mjs; do
198
+ for f in scripts/toolchain-check.mjs scripts/render-message.mjs .claude-plugin/plugin.json; do
197
199
  [ -f "$RUNNER_TEMP/relevo/$f" ] || { echo "::error::The Relevo package $RELEVO_VERSION lacks $f"; exit 1; }
198
200
  done
199
201
  if result=$(node "$RUNNER_TEMP/relevo/scripts/toolchain-check.mjs" --platform "$PLATFORM" --plugin-root "$RUNNER_TEMP/relevo"); then
@@ -232,13 +234,9 @@ jobs:
232
234
  # The queue starts builds with `gh workflow run`, so the actor is the Actions bot and the action refuses
233
235
  # it unless it is allowed. Only this one bot: never '*', which would let outside apps start runs.
234
236
  allowed_bots: github-actions
235
- # Plugin repository: choose ONE of the next two lines.
236
- # a) Public repository (default):
237
- plugin_marketplaces: https://github.com/elalfner/relevo.git
238
- # b) Private repository: the runner cannot clone it without a credential. Store a read-only fine-grained
239
- # token as the secret RELEVO_PLUGIN_TOKEN and replace the line above with:
240
- # plugin_marketplaces: https://x-access-token:${{ secrets.RELEVO_PLUGIN_TOKEN }}@github.com/elalfner/relevo.git
241
- plugins: relevo@relevo
237
+ # The plugin is not installed from a marketplace: --plugin-dir in claude_args loads the package that the step
238
+ # "Check the toolchain" unpacked at RELEVO_VERSION, so one pin decides the scripts and the skills, nothing is
239
+ # cloned and no token is needed to read the plugin repository.
242
240
  # The issue text is data, never instructions: the skill tells the agent so. The tool list is closed:
243
241
  # only the tools in --allowedTools, and no web access (nothing to fetch, nowhere to send a secret).
244
242
  # Do not set allowed_non_write_users or show_full_output here, and never widen allowed_bots: they open the run to
@@ -249,6 +247,7 @@ jobs:
249
247
  claude_args: >-
250
248
  --max-turns 100
251
249
  --max-budget-usd 5
250
+ --plugin-dir ${{ runner.temp }}/relevo
252
251
  --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Task"
253
252
  --disallowedTools "WebFetch,WebSearch,Bash(curl:*),Bash(wget:*)"
254
253
  # relevo:agent-step end
@@ -1,3 +1,4 @@
1
+ # relevo-template: 0.9.1
1
2
  # Relevo: re-evaluates the queue and starts the issues that are now eligible.
2
3
  # Copy to .github/workflows/relevo-queue.yml next to relevo-go.yml (it starts builds through that workflow).
3
4
  #
@@ -14,7 +15,7 @@ name: relevo-queue
14
15
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
15
16
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
16
17
  env:
17
- RELEVO_VERSION: '0.8.0'
18
+ RELEVO_VERSION: '0.9.1'
18
19
 
19
20
  on:
20
21
  issues:
@@ -1,3 +1,4 @@
1
+ # relevo-template: 0.9.1
1
2
  # Relevo: keeps the issue label in step with its pull request.
2
3
  # A review that requests changes sets `relevo:changes-requested` and mentions the author; a new push returns the
3
4
  # issue to `relevo:pr-open`; an approval mentions the person who will run /relevo:ship.
@@ -14,7 +15,7 @@ name: relevo-review
14
15
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
15
16
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
16
17
  env:
17
- RELEVO_VERSION: '0.8.0'
18
+ RELEVO_VERSION: '0.9.1'
18
19
 
19
20
  on:
20
21
  pull_request_review:
@@ -1,3 +1,4 @@
1
+ # relevo-template: 0.9.1
1
2
  # Relevo: ships an issue when a person applies the `relevo:ship` label to it.
2
3
  # Copy to .github/workflows/relevo-ship.yml. If you renamed the ship label in relevo.yml, change it below too.
3
4
  #
@@ -26,7 +27,7 @@ name: relevo-ship
26
27
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
27
28
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
28
29
  env:
29
- RELEVO_VERSION: '0.8.0'
30
+ RELEVO_VERSION: '0.9.1'
30
31
  # The version of OpenSpec that the job installs for itself.
31
32
  OPENSPEC_VERSION: '1.14.3'
32
33
 
@@ -1,3 +1,4 @@
1
+ # relevo-template: 0.9.1
1
2
  # Relevo: reminds the reviewer when a pull request waits longer than limits.review_sla_hours, and escalates
2
3
  # to the spec writer (adding the needs-reviewer label) after the same time again.
3
4
  # Copy to .github/workflows/relevo-sla.yml. Needs a relevo.yml at the repository root.
@@ -12,7 +13,7 @@ name: relevo-sla
12
13
  # The version of @elalfner/relevo whose scripts this workflow runs. The plugin's templates carry the version they
13
14
  # were released with, and `/relevo:init` reports this file as different when it is older; change it by hand to update.
14
15
  env:
15
- RELEVO_VERSION: '0.8.0'
16
+ RELEVO_VERSION: '0.9.1'
16
17
 
17
18
  on:
18
19
  schedule: