@elalfner/relevo 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.es.md +1 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/scripts/doctor.mjs +5 -4
  6. package/scripts/init.mjs +25 -4
  7. package/scripts/lib/can-edit-issue.mjs +4 -1
  8. package/scripts/lib/cli.mjs +6 -4
  9. package/scripts/lib/config.mjs +19 -1
  10. package/scripts/lib/doctor.mjs +264 -25
  11. package/scripts/lib/github.mjs +15 -5
  12. package/scripts/lib/init.mjs +89 -6
  13. package/scripts/lib/labels.mjs +4 -0
  14. package/scripts/lib/messages.mjs +109 -0
  15. package/scripts/lib/next-step.mjs +12 -0
  16. package/scripts/lib/plan.mjs +29 -6
  17. package/scripts/lib/preflight.mjs +1 -1
  18. package/scripts/lib/review-events.mjs +6 -3
  19. package/scripts/lib/ship-github.mjs +87 -0
  20. package/scripts/lib/ship-unattended.mjs +271 -0
  21. package/scripts/lib/ship.mjs +15 -3
  22. package/scripts/lib/toolchain-check.mjs +6 -0
  23. package/scripts/lib/web-preflight.mjs +50 -4
  24. package/scripts/lib/workflow-upgrade.mjs +198 -0
  25. package/scripts/review-event.mjs +10 -1
  26. package/scripts/ship-check.mjs +6 -4
  27. package/scripts/ship-unattended.mjs +45 -0
  28. package/scripts/toolchain-check.mjs +3 -3
  29. package/scripts/web-hook.mjs +28 -0
  30. package/scripts/web-preflight.mjs +6 -5
  31. package/scripts/web-review.mjs +1 -1
  32. package/skills/doctor/SKILL.md +6 -5
  33. package/skills/go/SKILL.md +4 -2
  34. package/skills/init/SKILL.md +10 -4
  35. package/skills/review/SKILL.md +4 -3
  36. package/skills/ship/SKILL.md +3 -1
  37. package/stacks/vue.md +2 -2
  38. package/templates/agents/antigravity-step.yml +8 -5
  39. package/templates/relevo.yml +4 -0
  40. package/templates/workflows/relevo-digest.yml +11 -1
  41. package/templates/workflows/relevo-go.yml +33 -14
  42. package/templates/workflows/relevo-queue.yml +11 -1
  43. package/templates/workflows/relevo-review.yml +11 -1
  44. package/templates/workflows/relevo-ship.yml +136 -0
  45. package/templates/workflows/relevo-sla.yml +11 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "relevo",
3
3
  "displayName": "Relevo",
4
- "version": "0.7.0",
4
+ "version": "0.9.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
@@ -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.7.0",
3
+ "version": "0.9.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",
@@ -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.
@@ -11,8 +12,9 @@ import { execFileSync } from 'node:child_process';
11
12
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
12
13
  import { dirname, resolve } from 'node:path';
13
14
  import { homedir } from 'node:os';
14
- import { gatherFacts, readOnlyGh, WORKFLOW_FILES } from './lib/doctor.mjs';
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}`);
@@ -50,6 +52,8 @@ const facts = gatherFacts({
50
52
  repo,
51
53
  });
52
54
 
55
+ facts.vueWeb = vueWebFacts(facts.config?.platforms ?? answers.platforms ?? {}, { tool, exists: existsSync });
56
+
53
57
  let admin = false;
54
58
  try {
55
59
  admin = JSON.parse(run('gh', ['api', `repos/${repo}`])).permissions?.admin === true;
@@ -70,9 +74,26 @@ const current = {
70
74
  stackProfiles: Object.fromEntries((answers.stackProfiles ?? []).map((s) => [s, existsSync(`.relevo/stacks/${s}.md`)])),
71
75
  };
72
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
+
73
94
  let items;
74
95
  try {
75
- items = planInit({ facts, answers, templates, current, admin });
96
+ items = planInit({ facts, answers, templates, current, admin, upgrades });
76
97
  } catch (err) {
77
98
  fail(err.message);
78
99
  }
@@ -14,8 +14,11 @@ const REASON = {
14
14
  'qa-failed': 'a branch already exists and the text would no longer match what was built',
15
15
  'changes-requested': 'a branch already exists and the text would no longer match what was built',
16
16
  'needs-reviewer': 'it is built and waiting for a reviewer',
17
+ ship: 'its pull request is being merged',
18
+ // Not a state: it travels next to one, so the state decides whether the text may change.
19
+ urgent: 'the label asks for an early start and does not commit the issue to a build',
17
20
  };
18
- export const EDITABLE_LABELS = ['needs-spec'];
21
+ export const EDITABLE_LABELS = ['needs-spec', 'urgent'];
19
22
 
20
23
  /**
21
24
  * @param {{state?: string, labels?: Array<string|{name: string}>}} issue `gh issue view --json state,labels`
@@ -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')) {
@@ -20,6 +20,8 @@ export const SETTINGS = [
20
20
  { path: 'review.merger', type: 'enum', allowed: ['author', 'approver'], default: 'approver' },
21
21
  { path: 'review.autopilot', type: 'enum', allowed: ['off', 'precheck', 'verify'], default: 'off' },
22
22
  { path: 'review.allowed_origins', type: 'origin-list', default: [] },
23
+ // Whether the ship workflow may merge when no check ran on any commit; /relevo:ship asks the person instead.
24
+ { path: 'review.ship_without_ci', type: 'bool', default: false },
23
25
  { path: 'limits.built_unreviewed_per_person', type: 'int', min: 1, default: 2 },
24
26
  { path: 'limits.concurrent_agents', type: 'int', min: 1, default: 3 },
25
27
  { path: 'limits.review_sla_hours', type: 'int', min: 1, default: 8 },
@@ -29,6 +31,8 @@ export const SETTINGS = [
29
31
  { path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true, default: null, requiredFor: 'claude', missing: 'unattended runs must always have a cost limit (amount in US dollars per run)', rejectedFor: 'antigravity', rejection: 'the agent has no cost limit; set limits.run_max_tokens instead, and note that the quota of the Google Cloud project that owns the key is the ceiling that really holds' },
30
32
  { path: 'limits.run_max_tokens', type: 'int', min: 1000, default: null, required: true, requiredFor: 'antigravity', missing: 'unattended runs must always have a limit, and for this agent it is a number of tokens per run', rejectedFor: 'claude', rejection: 'the run is limited by limits.run_max_cost' },
31
33
  { path: 'agent.build', type: 'enum', allowed: ['claude', 'antigravity'], default: 'claude' },
34
+ // The runner of every Relevo workflow: runs-on is read before any step, so init writes these labels into the files.
35
+ { path: 'runner.labels', type: 'label-list', default: ['ubuntu-latest'] },
32
36
  { path: 'qa.retries', type: 'int', min: 0, default: 1 },
33
37
  { path: 'qa.rules_from', type: 'path-list', default: [] },
34
38
  { path: 'skills.allow_scripts', type: 'string[]', default: [] },
@@ -79,6 +83,15 @@ function checkType(setting, value, errors) {
79
83
  errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
80
84
  }
81
85
  break;
86
+ case 'label-list':
87
+ if (!Array.isArray(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
+ } else if (value.length === 0) {
90
+ errors.push({ path, message: `${path} needs at least one label; leave the setting out to use ubuntu-latest` });
91
+ } else if (value.some((v) => typeof v !== 'string' || v.trim() === '')) {
92
+ errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
93
+ }
94
+ break;
82
95
  case 'path-list':
83
96
  if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
84
97
  errors.push({ path, message: `${path} must be a file name or a list of file names (got ${JSON.stringify(value)})` });
@@ -161,7 +174,12 @@ export function validateConfig(raw, { knownTargets = null, knownChecks = null }
161
174
  }
162
175
  const known = new Set(SETTINGS.filter((s) => s.path.startsWith(`${group}.`)).map((s) => s.path.split('.')[1]));
163
176
  Object.keys(value).forEach((k) => {
164
- if (!known.has(k)) errors.push({ path: `${group}.${k}`, message: `unknown setting "${group}.${k}"` });
177
+ if (known.has(k)) return;
178
+ // Relevo cannot verify what a runner is, so it offers no such key: a runner is assumed to persist.
179
+ const message = `${group}.${k}` === 'runner.ephemeral'
180
+ ? 'unknown setting "runner.ephemeral": Relevo does not ask whether a runner is ephemeral, it assumes every runner persists between jobs'
181
+ : `unknown setting "${group}.${k}"`;
182
+ errors.push({ path: `${group}.${k}`, message });
165
183
  });
166
184
  });
167
185