@elalfner/relevo 0.6.0 → 0.8.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/scripts/dev-server-guard.mjs +32 -0
  4. package/scripts/extra-server.mjs +78 -0
  5. package/scripts/init.mjs +3 -1
  6. package/scripts/lib/can-edit-issue.mjs +4 -1
  7. package/scripts/lib/config.mjs +19 -1
  8. package/scripts/lib/dev-server-guard.mjs +49 -0
  9. package/scripts/lib/doctor.mjs +136 -5
  10. package/scripts/lib/extra-server.mjs +84 -0
  11. package/scripts/lib/github.mjs +15 -5
  12. package/scripts/lib/init.mjs +63 -3
  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-adapters.mjs +15 -2
  19. package/scripts/lib/review-brief.mjs +12 -4
  20. package/scripts/lib/review-events.mjs +6 -3
  21. package/scripts/lib/review-state.mjs +30 -4
  22. package/scripts/lib/ship-github.mjs +87 -0
  23. package/scripts/lib/ship-unattended.mjs +271 -0
  24. package/scripts/lib/ship.mjs +15 -3
  25. package/scripts/lib/toolchain-check.mjs +6 -0
  26. package/scripts/lib/web-preflight.mjs +133 -0
  27. package/scripts/lib/web-review.mjs +82 -1
  28. package/scripts/review-adapter.mjs +5 -3
  29. package/scripts/review-brief.mjs +3 -2
  30. package/scripts/review-event.mjs +10 -1
  31. package/scripts/review-state.mjs +12 -5
  32. package/scripts/ship-check.mjs +6 -4
  33. package/scripts/ship-unattended.mjs +45 -0
  34. package/scripts/toolchain-check.mjs +3 -3
  35. package/scripts/web-hook.mjs +28 -0
  36. package/scripts/web-preflight.mjs +48 -0
  37. package/scripts/web-review.mjs +80 -29
  38. package/skills/doctor/SKILL.md +3 -2
  39. package/skills/go/SKILL.md +4 -2
  40. package/skills/init/SKILL.md +7 -2
  41. package/skills/review/SKILL.md +49 -10
  42. package/skills/ship/SKILL.md +3 -1
  43. package/stacks/vue.md +2 -2
  44. package/templates/agents/antigravity-step.yml +8 -5
  45. package/templates/relevo.yml +4 -0
  46. package/templates/workflows/relevo-digest.yml +10 -1
  47. package/templates/workflows/relevo-go.yml +25 -5
  48. package/templates/workflows/relevo-queue.yml +10 -1
  49. package/templates/workflows/relevo-review.yml +10 -1
  50. package/templates/workflows/relevo-ship.yml +135 -0
  51. package/templates/workflows/relevo-sla.yml +10 -1
@@ -45,16 +45,20 @@ function normalizeIssue(raw) {
45
45
  milestone: raw.milestone ? { number: raw.milestone.number, due_on: raw.milestone.due_on ?? null } : null,
46
46
  ready_at: null,
47
47
  ready_by: null,
48
+ urgent_by: null,
48
49
  };
49
50
  }
50
51
 
51
- /** When and by whom the ready label was last applied, from the issue timeline. */
52
- export function readyEvent(timeline, readyName) {
53
- const events = timeline.filter((e) => e.event === 'labeled' && e.label?.name === readyName);
52
+ /** When and by whom a label was last applied, from the issue timeline. */
53
+ export function labelEvent(timeline, labelName) {
54
+ const events = timeline.filter((e) => e.event === 'labeled' && e.label?.name === labelName);
54
55
  const last = events[events.length - 1];
55
56
  return last ? { at: last.created_at, by: last.actor?.login ?? null } : { at: null, by: null };
56
57
  }
57
58
 
59
+ /** When and by whom the ready label was last applied. */
60
+ export const readyEvent = labelEvent;
61
+
58
62
  /**
59
63
  * Collect everything `planGo` needs. Returns { issues, completed, completedExternal, unreadable, running, permissions }.
60
64
  * `running` is supplied by the caller (workflow runs in progress). `ghLinks` reads issues of linked repositories
@@ -74,9 +78,11 @@ export function gather({ gh, ghLinks = gh, repo, config, running = [] }) {
74
78
  const candidates = issues.filter((i) => [names.ready, names.queued, names.blocked].some((n) => i.labels.includes(n)));
75
79
  candidates.forEach((issue) => {
76
80
  const timeline = apiJson(gh, `repos/${repo}/issues/${issue.number}/timeline?per_page=100`, { paginate: true });
77
- const { at, by } = readyEvent(timeline, names.ready);
81
+ const { at, by } = labelEvent(timeline, names.ready);
78
82
  issue.ready_at = at;
79
83
  issue.ready_by = by;
84
+ // The same timeline answers for the urgent label: who asked for the early start.
85
+ if (issue.labels.includes(names.urgent)) issue.urgent_by = labelEvent(timeline, names.urgent).by;
80
86
  });
81
87
 
82
88
  const wanted = new Set();
@@ -109,7 +115,7 @@ export function gather({ gh, ghLinks = gh, repo, config, running = [] }) {
109
115
  });
110
116
 
111
117
  const permissions = {};
112
- new Set(candidates.map((i) => i.ready_by).filter(Boolean)).forEach((login) => {
118
+ new Set(candidates.flatMap((i) => [i.ready_by, i.urgent_by]).filter(Boolean)).forEach((login) => {
113
119
  try {
114
120
  permissions[login] = apiJson(gh, `repos/${repo}/collaborators/${login}/permission`).permission ?? 'none';
115
121
  } catch {
@@ -132,6 +138,10 @@ export function commandsFor(decision, { repo, config }) {
132
138
  const body = renderMessage(decision.comment.kind, decision.comment.data, config.language);
133
139
  commands.push(['issue', 'comment', String(decision.issue), '--repo', repo, '--body', body]);
134
140
  }
141
+ if (decision.notice) {
142
+ const body = renderMessage(decision.notice.kind, decision.notice.data, config.language);
143
+ commands.push(['issue', 'comment', String(decision.issue), '--repo', repo, '--body', body]);
144
+ }
135
145
  return commands;
136
146
  }
137
147
 
@@ -9,7 +9,7 @@ import { loadConfig } from './config.mjs';
9
9
  import { LEGACY_PLATFORMS } from './platforms.mjs';
10
10
  import { labelDefinitions } from './labels.mjs';
11
11
  import { agentWorkflow } from './agent-workflow.mjs';
12
- import { OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WORKFLOW_FILES } from './doctor.mjs';
12
+ import { isVueWeb, OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WEB_HOOK_COMMAND, WORKFLOW_FILES } from './doctor.mjs';
13
13
 
14
14
  export const REVIEW_MODES = ['none', 'peer', 'roles'];
15
15
 
@@ -28,12 +28,42 @@ const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE
28
28
  export const PLUGIN_PROJECT_COMMAND = 'claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin && claude plugin install relevo@relevo --scope project';
29
29
  const PLUGIN_PROJECT_DETAIL = 'It writes the marketplace and the enabled plugin into .claude/settings.json of this repository. It works even if the plugin is already installed for your machine, and nothing is committed for you.';
30
30
 
31
+ const WEB_HOOK_DETAIL = 'It adds a SessionStart hook to .claude/settings.json of this repository, keeping what the file already has. In a session of Claude Code on the web it installs OpenSpec when it is missing (npm install --global @fission-ai/openspec); on a terminal it does nothing. The file is left for you to commit.';
32
+
33
+ /**
34
+ * `.claude/settings.json` with the hook that installs OpenSpec added to `SessionStart`, every other key and hook kept.
35
+ * Idempotent: text that already installs OpenSpec is returned as it is. Throws on text that is not a JSON object, so a
36
+ * broken file is never rewritten. `text` is null when the file does not exist.
37
+ */
38
+ export function mergeWebHook(text) {
39
+ const settings = text === null ? {} : JSON.parse(text);
40
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('.claude/settings.json is not a JSON object');
41
+ const current = settings.hooks?.SessionStart;
42
+ if (current !== undefined && !Array.isArray(current)) throw new Error('hooks.SessionStart in .claude/settings.json is not a list');
43
+ if ((current ?? []).some((e) => (e?.hooks ?? []).some((h) => typeof h?.command === 'string' && h.command.includes('@fission-ai/openspec')))) return text;
44
+ const hooks = { ...(settings.hooks ?? {}), SessionStart: [...(current ?? []), { hooks: [{ type: 'command', command: WEB_HOOK_COMMAND }] }] };
45
+ return `${JSON.stringify({ ...settings, hooks }, null, 2)}\n`;
46
+ }
47
+
31
48
  export function credentialLine(workflow, credential) {
32
49
  if (credential !== 'subscription') return workflow;
33
50
  if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
34
51
  return workflow.replace(API_KEY_LINE, API_KEY_LINE.replace('anthropic_api_key', '# anthropic_api_key')).replace(TOKEN_LINE, TOKEN_LINE.replace('# claude_code_oauth_token', 'claude_code_oauth_token'));
35
52
  }
36
53
 
54
+ export const DEFAULT_RUNNER = ['ubuntu-latest'];
55
+
56
+ /**
57
+ * The workflow with the runner of `runner.labels` in every `runs-on`. The templates run on `ubuntu-latest`, which is
58
+ * what an empty or default list leaves alone; a list of one label is written as that label.
59
+ */
60
+ export function runnerLine(workflow, labels) {
61
+ const list = labels ?? DEFAULT_RUNNER;
62
+ if (list.length === 1 && list[0] === DEFAULT_RUNNER[0]) return workflow;
63
+ if (!workflow.includes('runs-on: ubuntu-latest')) throw new Error('the workflow template has no runs-on line to switch');
64
+ return workflow.replaceAll('runs-on: ubuntu-latest', `runs-on: ${list.length === 1 ? list[0] : `[${list.join(', ')}]`}`);
65
+ }
66
+
37
67
  /** The text of a `platforms:` block for { name: { stack, path, targets } }. */
38
68
  export function platformsBlock(platforms) {
39
69
  const lines = ['platforms:'];
@@ -96,6 +126,15 @@ export function renderConfig(template, answers = {}) {
96
126
  if (answers.runMaxTokens !== undefined) out = set(out, 'run_max_tokens', answers.runMaxTokens);
97
127
  } else if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
98
128
  if (answers.language) out = set(out, 'language', answers.language);
129
+ // The commented example of the template becomes the setting; the default runner leaves the template as it is.
130
+ if (answers.runnerLabels && !(answers.runnerLabels.length === 1 && answers.runnerLabels[0] === DEFAULT_RUNNER[0])) {
131
+ const re = /^# runner:[^\n]*\n# labels:[^\n]*\n/m;
132
+ if (!re.test(out)) throw new Error('the template has no "runner" example to switch on');
133
+ out = out.replace(re, `runner: # where the Relevo workflows run (docs/execution.md)\n labels: [${answers.runnerLabels.join(', ')}]\n`);
134
+ }
135
+ // A browser review is always driven by the agent, so a Vue web platform cannot keep the autopilot off.
136
+ const autopilot = answers.autopilot ?? (Object.values(platforms ?? {}).some(isVueWeb) ? 'precheck' : null);
137
+ if (autopilot) out = set(out, 'autopilot', autopilot);
99
138
  const check = loadConfig(out);
100
139
  if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
101
140
  return out;
@@ -167,7 +206,7 @@ const item = (id, status, description, extra = {}) => ({ id, status, description
167
206
  /**
168
207
  * Plan every change. `ctx`:
169
208
  * facts from gatherFacts (doctor)
170
- * answers the person's answers (members, specWriter, reviewMode, language, platforms, links, runs, credential, stackProfiles, base, release, merger)
209
+ * answers the person's answers (members, specWriter, reviewMode, language, platforms, links, runs, credential, runnerLabels, stackProfiles, base, release, merger)
171
210
  * templates { config: text, workflows: { file: text } }
172
211
  * current { workflows: { file: text|null }, gitignore: text|null, keepFiles: { path: boolean } }
173
212
  * admin true when the person has administrator permission on the repository
@@ -199,11 +238,30 @@ export function planInit({ facts, answers, templates, current, admin }) {
199
238
  });
200
239
  }
201
240
 
241
+ // What a browser review of each Vue platform needs: Playwright in its development dependencies, and its Chromium.
242
+ Object.entries(configuredPlatforms).filter(([, p]) => isVueWeb(p)).forEach(([name, p]) => {
243
+ const found = facts.vueWeb?.[name];
244
+ if (!found) return;
245
+ const cd = p.path && p.path !== '.' ? `cd ${p.path} && ` : '';
246
+ if (found.playwright) items.push(item(`install.playwright.${name}`, 'ok', `Playwright resolves from platform ${name}`));
247
+ else items.push(item(`install.playwright.${name}`, 'todo', `Add Playwright to the development dependencies of platform ${name}, which its browser review needs (package.json and the lock file are left for you to commit)`, { install: true, command: `${cd}npm install -D playwright`, detail: 'It changes package.json and the lock file of the platform: commit them on the base branch, so that every review finds Playwright. If you decline, a browser review of this platform stops until it is installed.' }));
248
+ if (found.playwright && found.chromium) items.push(item(`install.chromium.${name}`, 'ok', `The Chromium of Playwright is installed for platform ${name}`));
249
+ else items.push(item(`install.chromium.${name}`, 'todo', `Install the Chromium of Playwright for platform ${name}`, { install: true, command: `${cd}npx playwright install chromium`, detail: 'It downloads the browser to the cache of your user, outside the repository. If you decline, a browser review of this platform stops until it is installed.' }));
250
+ });
251
+ if (facts.config && (facts.config.review?.autopilot ?? 'off') === 'off' && Object.values(configuredPlatforms).some(isVueWeb)) {
252
+ items.push(item('review.autopilot', 'manual', 'A browser review of a Vue platform is driven by the agent: set review.autopilot to precheck (or verify) in relevo.yml'));
253
+ }
254
+
202
255
  // the plugin, declared in the project so that a teammate who clones the repository is offered it
203
256
  if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
204
257
  else if (facts.pluginDeclared?.unreadable) items.push(item('install.plugin-project', 'manual', '.claude/settings.json is not valid JSON: fix it, then run init again to declare the Relevo plugin in the project'));
205
258
  else if (facts.pluginDeclared) items.push(item('install.plugin-project', 'todo', 'Declare the Relevo plugin in the project, so that a teammate who clones the repository is offered it (the new .claude/settings.json is left for you to commit)', { install: true, command: PLUGIN_PROJECT_COMMAND, detail: PLUGIN_PROJECT_DETAIL }));
206
259
 
260
+ // OpenSpec in the sessions of Claude Code on the web, where every new container starts without it
261
+ if (facts.webHook?.declared) items.push(item('install.web-hook', 'ok', 'The repository installs OpenSpec at the start of a web session (.claude/settings.json)'));
262
+ else if (facts.webHook?.unreadable) items.push(item('install.web-hook', 'manual', '.claude/settings.json is not valid JSON: fix it, then run init again to add the hook that installs OpenSpec in web sessions'));
263
+ else if (facts.webHook) items.push(item('install.web-hook', 'todo', 'Add a hook that installs OpenSpec at the start of a web session of Claude Code, where it is missing (the changed .claude/settings.json is left for you to commit)', { install: true, command: `node \${CLAUDE_PLUGIN_ROOT}/scripts/web-hook.mjs`, hook: WEB_HOOK_COMMAND, detail: WEB_HOOK_DETAIL }));
264
+
207
265
  // relevo.yml
208
266
  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('; ')}`));
209
267
  else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
@@ -248,11 +306,13 @@ export function planInit({ facts, answers, templates, current, admin }) {
248
306
  if (!CREDENTIALS.includes(credential)) throw new Error(`credential must be one of ${CREDENTIALS.join(', ')} (got "${credential}")`);
249
307
  const build = answers.agentBuild ?? 'claude';
250
308
  if (!AGENT_BUILDS.includes(build)) throw new Error(`agentBuild must be one of ${AGENT_BUILDS.join(', ')} (got "${build}")`);
309
+ // The runner of the workflows: the setting of the repository, else the person's answer, else GitHub's.
310
+ const runnerLabels = facts.config?.runner?.labels ?? answers.runnerLabels ?? DEFAULT_RUNNER;
251
311
  const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
252
312
  WORKFLOW_FILES.forEach((file) => {
253
313
  if (runs === 'local' && cloudFiles.includes(file)) return;
254
314
  const shared = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
255
- const wanted = agentWorkflow(shared, build, templates.agentBlocks);
315
+ const wanted = runnerLine(agentWorkflow(shared, build, templates.agentBlocks), runnerLabels);
256
316
  const have = current.workflows[file] ?? null;
257
317
  if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
258
318
  else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
@@ -17,6 +17,10 @@ export const LABELS = [
17
17
  { key: 'qa-failed', name: 'relevo:qa-failed', color: 'e11d21', kind: 'exit', description: 'QA still rejected after the retries; branch is pushed' },
18
18
  { key: 'changes-requested', name: 'relevo:changes-requested', color: 'f9d0c4', kind: 'exit', description: 'The reviewer asked for changes' },
19
19
  { key: 'needs-reviewer', name: 'relevo:needs-reviewer', color: 'fef2c0', kind: 'overlay', description: 'No reviewer available or the review is overdue' },
20
+ // A request, not a state: the ship workflow merges the pull request and always removes it.
21
+ { key: 'ship', name: 'relevo:ship', color: '2ea44f', kind: 'overlay', description: 'Ship it: merge the approved pull request on GitHub' },
22
+ // A request, not a state: start it before the others and without waiting for the assignee's review capacity.
23
+ { key: 'urgent', name: 'relevo:urgent', color: 'ff7b00', kind: 'overlay', description: 'Start it first, even if the assignee has built issues waiting for review' },
20
24
  ];
21
25
 
22
26
  export const LABEL_KEYS = LABELS.map((l) => l.key);
@@ -29,10 +29,18 @@ const REQUIRED = {
29
29
  no_assignee: ['actor', 'issue'],
30
30
  ready_denied: ['issue'],
31
31
  ready_denied_maintainer: ['issue'],
32
+ urgent_denied: ['issue'],
33
+ urgent_denied_maintainer: ['issue'],
32
34
  blocked_notice: ['issue', 'title', 'blockers'],
33
35
  queued_capacity: ['issue', 'title', 'assignee', 'built', 'limit'],
34
36
  queued_agents: ['issue', 'title'],
35
37
  fix_done: ['issue', 'title', 'summary', 'qa', 'commit', 'iteration'],
38
+ ship_stopped: ['actor', 'issue', 'reasons'],
39
+ ship_not_approver: ['actor', 'issue'],
40
+ ship_no_ci: ['actor', 'issue'],
41
+ ship_needs_app: ['actor', 'issue', 'base'],
42
+ ship_done: ['issue', 'pr', 'base'],
43
+ ship_failed: ['issue', 'run'],
36
44
  };
37
45
 
38
46
  // The lifecycle state whose next step each message shows (see next-step.mjs, the only place that knows the commands).
@@ -52,6 +60,7 @@ const CATALOG = {
52
60
  '',
53
61
  'What happened: {{tasks_done}} of {{tasks_total}} tasks done, QA passed.',
54
62
  'Branch: {{branch}}',
63
+ 'Started ahead of the queue (urgent): you now have {{built_waiting}} built issues waiting for your review.',
55
64
  'Built from the issue text: {{issue_hash_short}}',
56
65
  'What to test: {{scenarios}} scenarios (checklist below)',
57
66
  'Riskiest area, check it first: {{risk}}',
@@ -93,6 +102,7 @@ const CATALOG = {
93
102
  '',
94
103
  'Pull request: {{pr}}',
95
104
  'Your next step: {{command}}',
105
+ 'Or apply the label {{ship_label}} to this issue: it ships on GitHub, with no local copy.',
96
106
  ],
97
107
  needs_reviewer: [
98
108
  '@{{spec_writer}} #{{issue}} "{{title}}" has no available reviewer',
@@ -134,10 +144,19 @@ const CATALOG = {
134
144
  '#{{issue}} was not started: this repository is public, so only maintainers can mark an issue ready, and the label was removed',
135
145
  'Applied by: @{{actor}}',
136
146
  ],
147
+ urgent_denied: [
148
+ '#{{issue}}: the urgent label was removed because only people with write access can ask for an early start. The issue keeps its place in the queue.',
149
+ 'Applied by: @{{actor}}',
150
+ ],
151
+ urgent_denied_maintainer: [
152
+ '#{{issue}}: the urgent label was removed because this repository is public, so only maintainers can ask for an early start. The issue keeps its place in the queue.',
153
+ 'Applied by: @{{actor}}',
154
+ ],
137
155
  blocked_notice: [
138
156
  '#{{issue}} "{{title}}" is blocked',
139
157
  '',
140
158
  'Waiting for {{blockers}} to be merged. It starts automatically when they are.',
159
+ '{{urgent_note}}',
141
160
  ],
142
161
  queued_capacity: [
143
162
  '#{{issue}} "{{title}}" is queued',
@@ -157,6 +176,41 @@ const CATALOG = {
157
176
  'Commit: {{commit}}',
158
177
  '@{{recipient}} {{ask}}',
159
178
  ],
179
+ ship_stopped: [
180
+ '@{{actor}} #{{issue}} was not shipped',
181
+ '',
182
+ 'Why: {{reasons}}',
183
+ 'Nothing was merged and the issue keeps its state; the relevo:ship label was removed. Apply it again once this is solved, or run `/relevo:ship {{issue}}`.',
184
+ ],
185
+ ship_not_approver: [
186
+ '@{{actor}} #{{issue}} was not shipped: you did not approve this pull request',
187
+ '',
188
+ 'An eligible person who did not approve has to say so in writing, and a label cannot. Run `/relevo:ship {{issue}}`, which asks you to confirm it, or ask {{approvers}} to apply the label. The label was removed.',
189
+ ],
190
+ ship_no_ci: [
191
+ '@{{actor}} #{{issue}} was not shipped: no check ran on this pull request',
192
+ '',
193
+ 'Nothing but the review vouches for this code, and the label cannot confirm that. Run `/relevo:ship {{issue}}`, which asks you, or set `review.ship_without_ci: true` in relevo.yml to let the label merge without checks. The label was removed.',
194
+ ],
195
+ ship_needs_app: [
196
+ '@{{actor}} #{{issue}} was not shipped: {{base}} requires status checks',
197
+ '',
198
+ 'The archive commit needs its own checks, and a push with the workflow token starts none. Create a GitHub App for this repository and store its id and private key as the secrets RELEVO_SHIP_APP_ID and RELEVO_SHIP_APP_KEY (see docs/execution.md), or run `/relevo:ship {{issue}}`. Nothing was archived; the label was removed.',
199
+ ],
200
+ ship_done: [
201
+ '#{{issue}} shipped ✅',
202
+ '',
203
+ 'Pull request {{pr}} was squash-merged into {{base}} as {{commit}}.',
204
+ 'Applied by: @{{actor}}',
205
+ '{{checks_note}}',
206
+ 'The issue is closed and its labels removed. Promoting {{base}} to the release branch is a separate pull request.',
207
+ ],
208
+ ship_failed: [
209
+ '#{{issue}} was not shipped: the run ended without an outcome',
210
+ '',
211
+ '{{log_line}}',
212
+ 'When the cause is fixed, apply relevo:ship again: a specification already archived is not archived twice.',
213
+ ],
160
214
  },
161
215
  es: {
162
216
  built: [
@@ -164,6 +218,7 @@ const CATALOG = {
164
218
  '',
165
219
  'Qué pasó: {{tasks_done}} de {{tasks_total}} tareas hechas, QA aprobado.',
166
220
  'Rama: {{branch}}',
221
+ 'Empezó antes que la cola (urgente): ahora tienes {{built_waiting}} issues construidos esperando tu revisión.',
167
222
  'Construida con el texto del issue: {{issue_hash_short}}',
168
223
  'Qué probar: {{scenarios}} escenarios (checklist abajo)',
169
224
  'Zona más riesgosa, revísala primero: {{risk}}',
@@ -205,6 +260,7 @@ const CATALOG = {
205
260
  '',
206
261
  'Pull request: {{pr}}',
207
262
  'Tu siguiente paso: {{command}}',
263
+ 'O pon la etiqueta {{ship_label}} a este issue: se fusiona en GitHub, sin copia local.',
208
264
  ],
209
265
  needs_reviewer: [
210
266
  '@{{spec_writer}} #{{issue}} "{{title}}" no tiene revisor disponible',
@@ -246,10 +302,19 @@ const CATALOG = {
246
302
  '#{{issue}} no se inició: este repositorio es público, así que solo quienes mantienen el proyecto pueden marcar un issue como ready, y se quitó la etiqueta',
247
303
  'Aplicada por: @{{actor}}',
248
304
  ],
305
+ urgent_denied: [
306
+ '#{{issue}}: se quitó la etiqueta de urgente porque solo quienes tienen acceso de escritura pueden pedir un inicio anticipado. El issue conserva su lugar en la cola.',
307
+ 'Aplicada por: @{{actor}}',
308
+ ],
309
+ urgent_denied_maintainer: [
310
+ '#{{issue}}: se quitó la etiqueta de urgente porque este repositorio es público, así que solo quienes mantienen el proyecto pueden pedir un inicio anticipado. El issue conserva su lugar en la cola.',
311
+ 'Aplicada por: @{{actor}}',
312
+ ],
249
313
  blocked_notice: [
250
314
  '#{{issue}} "{{title}}" está bloqueado',
251
315
  '',
252
316
  'Espera a que se fusionen {{blockers}}. Empieza solo cuando lo estén.',
317
+ '{{urgent_note}}',
253
318
  ],
254
319
  queued_capacity: [
255
320
  '#{{issue}} "{{title}}" está en cola',
@@ -269,6 +334,41 @@ const CATALOG = {
269
334
  'Commit: {{commit}}',
270
335
  '@{{recipient}} {{ask}}',
271
336
  ],
337
+ ship_stopped: [
338
+ '@{{actor}} #{{issue}} no se fusionó',
339
+ '',
340
+ 'Por qué: {{reasons}}',
341
+ 'No se fusionó nada y el issue conserva su estado; se quitó la etiqueta relevo:ship. Vuelve a ponerla cuando esté resuelto, o ejecuta `/relevo:ship {{issue}}`.',
342
+ ],
343
+ ship_not_approver: [
344
+ '@{{actor}} #{{issue}} no se fusionó: tú no aprobaste este pull request',
345
+ '',
346
+ 'Quien es elegible pero no aprobó tiene que decirlo por escrito, y una etiqueta no puede. Ejecuta `/relevo:ship {{issue}}`, que te pide confirmarlo, o pide a {{approvers}} que ponga la etiqueta. Se quitó la etiqueta.',
347
+ ],
348
+ ship_no_ci: [
349
+ '@{{actor}} #{{issue}} no se fusionó: no corrió ningún check en este pull request',
350
+ '',
351
+ 'Solo la revisión respalda este código, y la etiqueta no puede confirmarlo. Ejecuta `/relevo:ship {{issue}}`, que te lo pregunta, o pon `review.ship_without_ci: true` en relevo.yml para que la etiqueta fusione sin checks. Se quitó la etiqueta.',
352
+ ],
353
+ ship_needs_app: [
354
+ '@{{actor}} #{{issue}} no se fusionó: {{base}} exige status checks',
355
+ '',
356
+ 'El commit del archivo necesita sus propios checks, y un push con el token del workflow no lanza ninguno. Crea una GitHub App para este repositorio y guarda su id y su clave privada como los secrets RELEVO_SHIP_APP_ID y RELEVO_SHIP_APP_KEY (ver docs/execution.es.md), o ejecuta `/relevo:ship {{issue}}`. No se archivó nada; se quitó la etiqueta.',
357
+ ],
358
+ ship_done: [
359
+ '#{{issue}} fusionada ✅',
360
+ '',
361
+ 'El pull request {{pr}} se fusionó con squash en {{base}} como {{commit}}.',
362
+ 'La pidió: @{{actor}}',
363
+ '{{checks_note}}',
364
+ 'El issue está cerrado y sin etiquetas. Promover {{base}} a la rama de release es un pull request aparte.',
365
+ ],
366
+ ship_failed: [
367
+ '#{{issue}} no se fusionó: la ejecución terminó sin resultado',
368
+ '',
369
+ '{{log_line}}',
370
+ 'Cuando la causa esté resuelta, vuelve a poner relevo:ship: una especificación ya archivada no se archiva dos veces.',
371
+ ],
272
372
  },
273
373
  };
274
374
 
@@ -297,7 +397,16 @@ export function renderMessage(kind, data = {}, language = 'en') {
297
397
  });
298
398
  const language_ = pickLanguage(language);
299
399
  const es = language_ === 'es';
400
+ // The person who applied the label is mentioned when the run knows who it was.
401
+ if (kind === 'ship_failed') {
402
+ const who = present(values.actor) ? `@${values.actor} ` : '';
403
+ values.log_line = es ? `${who}${who ? 'lee' : 'Lee'} el log para ver por qué: ${values.run}` : `${who}${who ? 'read' : 'Read'} the log to see why: ${values.run}`;
404
+ }
300
405
  if (kind === 'built' && present(values.issue_hash)) values.issue_hash_short = shortHash(values.issue_hash);
406
+ // Urgent does not skip dependencies: an urgent issue that is blocked says so.
407
+ if (kind === 'blocked_notice' && values.urgent === true) {
408
+ values.urgent_note = es ? 'La etiqueta de urgente no salta las dependencias: empieza primero en la cola cuando se fusionen.' : 'The urgent label does not skip dependencies: it goes first in the queue once they are merged.';
409
+ }
301
410
 
302
411
  // The next step comes from one table. needs-spec depends on why the issue came back, qa-failed on what the branch holds.
303
412
  if (STATE_OF[kind]) {
@@ -104,6 +104,18 @@ const TABLE = {
104
104
  who: 'spec_writer',
105
105
  hint: { en: 'assign a reviewer or ask someone to take it', es: 'asigna a alguien que revise o pide que alguien lo tome' },
106
106
  },
107
+ // The ship workflow is merging it; when it stops, its comment says why and the issue is back on pr-open.
108
+ ship: {
109
+ command: null,
110
+ who: 'nobody',
111
+ hint: { en: 'the ship workflow is merging it', es: 'el workflow de ship lo está fusionando' },
112
+ },
113
+ // A request next to the state, not a state: the state it travels with decides the next step.
114
+ urgent: {
115
+ command: null,
116
+ who: 'nobody',
117
+ hint: { en: 'it starts before the others; the state it is in decides the next step', es: 'empieza antes que las demás; el estado en que está decide el siguiente paso' },
118
+ },
107
119
  };
108
120
 
109
121
  const WHO = {
@@ -6,7 +6,8 @@
6
6
  // issues [{ number, title, body, state, labels: [names], assignees: [logins],
7
7
  // milestone: { number, due_on } | null,
8
8
  // ready_at: ISO | null, // when the ready label was last applied
9
- // ready_by: login | null // who applied it }]
9
+ // ready_by: login | null, // who applied it
10
+ // urgent_by: login | null // who applied the urgent label, when the issue carries it }]
10
11
  // completed issue numbers closed as completed (their pull request merged)
11
12
  // running issue numbers with an agent run in progress
12
13
  // permissions { login: 'admin' | 'maintain' | 'write' | 'triage' | 'read' | 'none' }
@@ -61,6 +62,12 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
61
62
  const linked = (config.links ?? []);
62
63
  const own = repo ? repo.toLowerCase() : null;
63
64
 
65
+ // The urgent label is a request that only the people who may start work can make. A label from anyone else is not
66
+ // valid: it is removed (with a notice) and the issue is evaluated as a normal one.
67
+ const mayTrigger = config.trust === 'public' ? MAY_TRIGGER_PUBLIC : MAY_TRIGGER;
68
+ const hasUrgentLabel = (issue) => has(issue, 'urgent');
69
+ const isUrgent = (issue) => hasUrgentLabel(issue) && Boolean(issue.urgent_by) && mayTrigger.has(permissions[issue.urgent_by]);
70
+
64
71
  const runningSet = new Set(running);
65
72
  open.filter((i) => has(i, 'building')).forEach((i) => runningSet.add(i.number));
66
73
  let freeSlots = Math.max(0, config.limits.concurrent_agents - runningSet.size);
@@ -71,14 +78,16 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
71
78
  if (owner) builtBy.set(owner, (builtBy.get(owner) ?? 0) + 1);
72
79
  });
73
80
 
81
+ // Valid urgent issues are considered first, so they take the free agent slots before the others; inside each group
82
+ // the order is the time `ready` was applied.
74
83
  const candidates = open
75
84
  .filter((i) => has(i, 'ready') || has(i, 'queued') || has(i, 'blocked'))
76
- .sort(byReadyAt);
85
+ .sort((a, b) => Number(isUrgent(b)) - Number(isUrgent(a)) || byReadyAt(a, b));
77
86
 
78
87
  const decisions = [];
79
88
 
80
89
  function decision(issue, action, reason, extra = {}) {
81
- const labels = extra.labels ?? { add: [], remove: [] };
90
+ let labels = extra.labels ?? { add: [], remove: [] };
82
91
  const out = {
83
92
  issue: issue.number,
84
93
  action,
@@ -87,6 +96,16 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
87
96
  comment: extra.comment ?? null,
88
97
  assign: extra.assign ?? null,
89
98
  };
99
+ // An urgent label that is not valid is removed in the decision of the issue, with its own notice. An issue whose
100
+ // run is in progress is left alone.
101
+ if (action !== 'skip' && hasUrgentLabel(issue) && !isUrgent(issue)) {
102
+ labels = { add: labels.add, remove: [...labels.remove, names.urgent] };
103
+ out.labels = labels;
104
+ out.notice = {
105
+ kind: config.trust === 'public' ? 'urgent_denied_maintainer' : 'urgent_denied',
106
+ data: { issue: issue.number, ...(issue.urgent_by ? { actor: issue.urgent_by } : {}) },
107
+ };
108
+ }
90
109
  if (action === 'start') Object.assign(out, extra.start);
91
110
  decisions.push(out);
92
111
  }
@@ -189,15 +208,17 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
189
208
  decision(issue, 'block', `waiting for ${blockers.join(', ')}`, {
190
209
  labels: moveTo(issue, 'blocked'),
191
210
  comment: entering
192
- ? { kind: 'blocked_notice', data: { issue: issue.number, title: issue.title, blockers: blockers.join(', ') } }
211
+ ? { kind: 'blocked_notice', data: { issue: issue.number, title: issue.title, blockers: blockers.join(', '), ...(isUrgent(issue) ? { urgent: true } : {}) } }
193
212
  : null,
194
213
  });
195
214
  return;
196
215
  }
197
216
 
198
- // Gate 3: capacity of the assignee.
217
+ // Gate 3: capacity of the assignee. A valid urgent label skips this gate and no other.
199
218
  const built = assignee ? builtBy.get(assignee) ?? 0 : 0;
200
- if (assignee && built >= config.limits.built_unreviewed_per_person) {
219
+ const overCapacity = Boolean(assignee) && built >= config.limits.built_unreviewed_per_person;
220
+ const urgent = isUrgent(issue);
221
+ if (overCapacity && !urgent) {
201
222
  decision(issue, 'queue', `${assignee} already has ${built} built issues waiting for review`, {
202
223
  labels: moveTo(issue, 'queued'),
203
224
  comment: has(issue, 'queued')
@@ -244,6 +265,8 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
244
265
  stack: platform.entry.stack,
245
266
  path: platform.entry.path,
246
267
  targets: platform.entry.targets,
268
+ // Only when the urgent label let the issue start over the assignee's capacity: the hand-off message says so.
269
+ ...(overCapacity && urgent ? { urgent: true, built_waiting: built + 1 } : {}),
247
270
  },
248
271
  });
249
272
  });
@@ -27,7 +27,7 @@ function checkConfig({ readConfig }) {
27
27
 
28
28
  function checkOpenspec({ run }) {
29
29
  const r = run('openspec', ['--version']);
30
- return r.status === 0 ? pass('openspec') : fail('openspec', 'the OpenSpec command line does not run', 'run /relevo:doctor');
30
+ return r.status === 0 ? pass('openspec') : fail('openspec', 'the OpenSpec command line does not run. Stop here: do not go on from memory and do not write anything under openspec/changes/ by hand', 'install it with `npm install --global @fission-ai/openspec` (Node.js 20.19 or newer), or run /relevo:doctor');
31
31
  }
32
32
 
33
33
  function checkOpenspecRoot({ run }) {
@@ -1,12 +1,14 @@
1
1
  // How `review` gets a log and captures for a platform and a target, and whether the agent may drive it.
2
2
  // Implements "The review picks its adapter from the platform and the target" of openspec/specs/review/spec.md.
3
3
 
4
+ import { pickLanguage } from './language.mjs';
5
+
4
6
  const DEFAULT_TARGET = { flutter: 'android', vue: 'web' };
5
7
 
6
8
  const ADAPTERS = {
7
9
  'flutter/android': { kind: 'device', log: 'adb logcat', capture: 'adb screencap', driver: 'flutter_driver', needs: ['adb'] },
8
- 'flutter/web': { kind: 'browser', log: 'the browser runner', capture: 'the browser runner', driver: 'flutter_driver', needs: ['a browser'] },
9
- 'vue/web': { kind: 'browser', log: 'the browser runner', capture: 'the browser runner', driver: 'playwright', needs: ['playwright'] },
10
+ 'flutter/web': { kind: 'browser', log: 'the browser runner', capture: 'capture(name, check) of the browser runner', driver: 'flutter_driver', needs: ['a browser'] },
11
+ 'vue/web': { kind: 'browser', log: 'the browser runner', capture: 'capture(name, check) of the browser runner', driver: 'playwright', needs: ['playwright'] },
10
12
  };
11
13
 
12
14
  const NOT_AVAILABLE = {
@@ -35,3 +37,14 @@ export function adapterFor({ stack, target }) {
35
37
  export function needsAutopilot(adapter) {
36
38
  return adapter.supported && adapter.kind === 'browser';
37
39
  }
40
+
41
+ const ADVICE = {
42
+ en: 'A browser has no manual review. Set review.autopilot to verify in relevo.yml (precheck is the cautious option) and commit it to the base branch. You still confirm every line, and lines about appearance are never marked automatically.',
43
+ es: 'Un navegador no tiene revisión manual. Pon review.autopilot en verify en relevo.yml (precheck es la opción prudente) y haz commit en la rama base. Tú sigues confirmando cada línea, y las líneas sobre apariencia nunca se marcan automáticamente.',
44
+ };
45
+
46
+ /** What to tell the person when a browser review cannot start because review.autopilot is off; null otherwise. */
47
+ export function autopilotAdvice(adapter, { autopilot = 'off', language = 'en' } = {}) {
48
+ if (!needsAutopilot(adapter) || autopilot !== 'off') return null;
49
+ return ADVICE[pickLanguage(language)];
50
+ }
@@ -8,8 +8,14 @@ export const MAX_SENTENCES = 2;
8
8
  export const MAX_LINE = 160;
9
9
 
10
10
  const LABELS = {
11
- en: { asked: 'Asked', outcome: 'Outcome', first: 'Check first', count: (s, e) => `To check: ${s} scenario(s), ${e} edge case(s)` },
12
- es: { asked: 'Se pidió', outcome: 'Resultado', first: 'Revisa primero', count: (s, e) => `Por revisar: ${s} escenario(s), ${e} caso(s) borde` },
11
+ en: {
12
+ asked: 'Asked', outcome: 'Outcome', first: 'Check first', count: (s, e) => `To check: ${s} scenario(s), ${e} edge case(s)`,
13
+ autopilot: 'The agent walks the lines below first; you confirm every line, and lines about appearance are never marked automatically.',
14
+ },
15
+ es: {
16
+ asked: 'Se pidió', outcome: 'Resultado', first: 'Revisa primero', count: (s, e) => `Por revisar: ${s} escenario(s), ${e} caso(s) borde`,
17
+ autopilot: 'El agente recorre primero las líneas de abajo; tú confirmas cada línea, y las líneas sobre apariencia nunca se marcan automáticamente.',
18
+ },
13
19
  };
14
20
  const RISK_RE = /(?:Riskiest area, check it first|Zona más riesgosa, revísala primero):\s*(.+)/;
15
21
 
@@ -26,9 +32,10 @@ export function firstSentences(text, max = MAX_SENTENCES) {
26
32
  /**
27
33
  * issue { number, title, body }
28
34
  * built the text of the `built` comment, when there is one (the risk line is taken from it)
35
+ * autopilot review.autopilot; with precheck or verify a fixed note says the person still confirms every line
29
36
  * Returns the brief as a string of about five lines.
30
37
  */
31
- export function reviewBrief({ issue, built = '', language = 'en' }) {
38
+ export function reviewBrief({ issue, built = '', language = 'en', autopilot = 'off' }) {
32
39
  const L = LABELS[language === 'es' ? 'es' : 'en'];
33
40
  const parsed = parseIssueBody(issue.body);
34
41
  const lines = [`#${issue.number} ${clean(issue.title)}`];
@@ -39,5 +46,6 @@ export function reviewBrief({ issue, built = '', language = 'en' }) {
39
46
  const risk = String(built).match(RISK_RE)?.[1];
40
47
  if (risk) lines.push(`${L.first}: ${clean(risk)}`);
41
48
  lines.push(L.count(parsed.scenarios, parsed.edgeCases));
42
- return lines.map(cap).join('\n');
49
+ const note = ['precheck', 'verify'].includes(autopilot) ? `\n${L.autopilot}` : '';
50
+ return `${lines.map(cap).join('\n')}${note}`;
43
51
  }
@@ -16,8 +16,11 @@ function summarise(body, es) {
16
16
  return text.length > SUMMARY_LENGTH ? `${text.slice(0, SUMMARY_LENGTH)}…` : text;
17
17
  }
18
18
 
19
- /** Returns { action, labels: { add, remove }, comment } or null when the event changes nothing. */
20
- export function planReviewEvent({ config, event, pr, issue }) {
19
+ /**
20
+ * Returns { action, labels: { add, remove }, comment } or null when the event changes nothing.
21
+ * shipWorkflow: the ship workflow is installed on the default branch, so the approval also names its label.
22
+ */
23
+ export function planReviewEvent({ config, event, pr, issue, shipWorkflow = false }) {
21
24
  if (config.review.mode === 'none') return null;
22
25
  const names = resolveLabelNames(config.labels);
23
26
  const has = (key) => issue.labels.includes(names[key]);
@@ -49,7 +52,7 @@ export function planReviewEvent({ config, event, pr, issue }) {
49
52
  return {
50
53
  action: 'approved',
51
54
  labels: { add: [], remove: [] },
52
- comment: { kind: 'approved', data: { recipient, issue: issue.number, reviewer: event.reviewer, pr: pr.url } },
55
+ comment: { kind: 'approved', data: { recipient, issue: issue.number, reviewer: event.reviewer, pr: pr.url, ...(shipWorkflow ? { ship_label: names.ship } : {}) } },
53
56
  };
54
57
  }
55
58
  return null;