@elalfner/relevo 0.7.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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/scripts/init.mjs +3 -1
  4. package/scripts/lib/can-edit-issue.mjs +4 -1
  5. package/scripts/lib/config.mjs +19 -1
  6. package/scripts/lib/doctor.mjs +136 -5
  7. package/scripts/lib/github.mjs +15 -5
  8. package/scripts/lib/init.mjs +63 -3
  9. package/scripts/lib/labels.mjs +4 -0
  10. package/scripts/lib/messages.mjs +109 -0
  11. package/scripts/lib/next-step.mjs +12 -0
  12. package/scripts/lib/plan.mjs +29 -6
  13. package/scripts/lib/preflight.mjs +1 -1
  14. package/scripts/lib/review-events.mjs +6 -3
  15. package/scripts/lib/ship-github.mjs +87 -0
  16. package/scripts/lib/ship-unattended.mjs +271 -0
  17. package/scripts/lib/ship.mjs +15 -3
  18. package/scripts/lib/toolchain-check.mjs +6 -0
  19. package/scripts/lib/web-preflight.mjs +50 -4
  20. package/scripts/review-event.mjs +10 -1
  21. package/scripts/ship-check.mjs +6 -4
  22. package/scripts/ship-unattended.mjs +45 -0
  23. package/scripts/toolchain-check.mjs +3 -3
  24. package/scripts/web-hook.mjs +28 -0
  25. package/scripts/web-preflight.mjs +6 -5
  26. package/scripts/web-review.mjs +1 -1
  27. package/skills/doctor/SKILL.md +3 -2
  28. package/skills/go/SKILL.md +4 -2
  29. package/skills/init/SKILL.md +7 -2
  30. package/skills/review/SKILL.md +4 -3
  31. package/skills/ship/SKILL.md +3 -1
  32. package/stacks/vue.md +2 -2
  33. package/templates/agents/antigravity-step.yml +8 -5
  34. package/templates/relevo.yml +4 -0
  35. package/templates/workflows/relevo-digest.yml +10 -1
  36. package/templates/workflows/relevo-go.yml +25 -5
  37. package/templates/workflows/relevo-queue.yml +10 -1
  38. package/templates/workflows/relevo-review.yml +10 -1
  39. package/templates/workflows/relevo-ship.yml +135 -0
  40. package/templates/workflows/relevo-sla.yml +10 -1
@@ -0,0 +1,271 @@
1
+ // The ship that the `relevo:ship` label starts on GitHub: the steps of /relevo:ship, run by a workflow with no model.
2
+ // Implements "A label starts an unattended ship" and the requirements after it in openspec/specs/ship/spec.md.
3
+ //
4
+ // Everything that touches the outside is injected, so the whole sequence is testable without a network:
5
+ // sh(cmd, args, { cwd, input }) -> { status, stdout, stderr } git, openspec and the plugin's scripts
6
+ // gh(args) -> stdout reads, comments and labels (workflow token)
7
+ // pushGh(args) -> stdout merge and close (App token, or the workflow token)
8
+ // sleep(ms), now() waiting for the checks of the archive commit
9
+ // Nothing from the feature branch is executed: git runs without hooks, and the scripts come from `pluginRoot`.
10
+
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { loadConfig } from './config.mjs';
14
+ import { clearAll, resolveLabelNames } from './labels.mjs';
15
+ import { renderMessage } from './messages.mjs';
16
+ import { shipDecision } from './ship.mjs';
17
+ import { baselineChecks, loadPr, requiredChecks } from './ship-github.mjs';
18
+
19
+ const ARCHIVE_SUBJECT = 'chore(openspec): archive ';
20
+ const CONFLICT_REASON = /has conflicts/;
21
+
22
+ const NOTES = {
23
+ baseline: {
24
+ en: (sha) => `The archive commit changes only openspec/ and had no checks of its own, so the checks of the approved head ${sha} were used.`,
25
+ es: (sha) => `El commit del archivo solo cambia openspec/ y no tuvo checks propios, así que se usaron los checks del head aprobado ${sha}.`,
26
+ },
27
+ none: {
28
+ en: () => 'No check ran on this code: only the review vouched for it (review.ship_without_ci is true).',
29
+ es: () => 'No corrió ningún check sobre este código: solo la revisión lo respalda (review.ship_without_ci es true).',
30
+ },
31
+ };
32
+
33
+ /**
34
+ * What a label cannot do that a person in a session can. Returns null when the label may ship, or the message to post.
35
+ * - a decision that stops, stops;
36
+ * - an eligible person who did not approve must say so in writing, which a label cannot;
37
+ * - no checks at all need the person's word, unless review.ship_without_ci says the label is enough.
38
+ */
39
+ export function labelRules({ decision, config }) {
40
+ if (!decision.allowed) return { kind: 'ship_stopped', data: { reasons: decision.reasons.join('; ') } };
41
+ if (decision.confirm.includes('not-approver')) {
42
+ return { kind: 'ship_not_approver', data: { approvers: decision.summary.approvedBy.map((l) => `@${l}`).join(', ') } };
43
+ }
44
+ if (decision.confirm.includes('no-ci') && !config.review.ship_without_ci) return { kind: 'ship_no_ci', data: {} };
45
+ return null;
46
+ }
47
+
48
+ class Stop {
49
+ constructor(kind, data = {}) {
50
+ this.kind = kind;
51
+ this.data = data;
52
+ }
53
+ }
54
+ const stopWith = (kind, data) => {
55
+ throw new Stop(kind, data);
56
+ };
57
+ const stopReasons = (...reasons) => stopWith('ship_stopped', { reasons: reasons.join('; ') });
58
+ const firstLines = (text, n = 6) => String(text ?? '').trim().split('\n').slice(0, n).join(' / ');
59
+
60
+ /** The change folder of the issue on the branch, and whether it is still to be archived. */
61
+ export function findChange(workDir, issue) {
62
+ const changes = join(workDir, 'openspec', 'changes');
63
+ const prefix = `${issue}-`;
64
+ const active = existsSync(changes) ? readdirSync(changes).filter((n) => n.startsWith(prefix) && n !== 'archive') : [];
65
+ if (active.length === 1) return { name: active[0], state: 'active' };
66
+ if (active.length > 1) return { name: null, state: 'ambiguous', found: active };
67
+ const archiveDir = join(changes, 'archive');
68
+ const dated = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-(${issue}-.+)$`);
69
+ const archived = existsSync(archiveDir) ? readdirSync(archiveDir).map((n) => n.match(dated)?.[1]).filter(Boolean) : [];
70
+ if (archived.length === 1) return { name: archived[0], state: 'archived' };
71
+ return { name: null, state: archived.length > 1 ? 'ambiguous' : 'missing', found: archived };
72
+ }
73
+
74
+ export async function runUnattendedShip(ctx) {
75
+ const { repo, issue, actor, actorType, hasApp, workDir, pluginRoot, sh, gh, pushGh } = ctx;
76
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
77
+ const now = ctx.now ?? (() => Date.now());
78
+ const waitLimitMs = ctx.waitLimitMs ?? 30 * 60 * 1000;
79
+ const settleMs = ctx.settleMs ?? 3 * 60 * 1000;
80
+ const pollMs = ctx.pollMs ?? 20 * 1000;
81
+ const headPollMs = ctx.headPollMs ?? 10 * 1000;
82
+ const n = String(issue);
83
+
84
+ const run = (cmd, args, opts = {}) => sh(cmd, args, { cwd: workDir, ...opts });
85
+ const git = (args, opts = {}) => {
86
+ const r = run('git', ['-c', 'core.hooksPath=/dev/null', ...args], opts);
87
+ if (r.status !== 0 && !opts.allowFail) stopReasons(`git ${args[0]} failed: ${firstLines(r.stderr || r.stdout)}`);
88
+ return r;
89
+ };
90
+ const script = (name, args, opts = {}) => run(process.execPath, [join(pluginRoot, 'scripts', name), ...args], opts);
91
+ const openspec = (args) => run('openspec', args, { env: { OPENSPEC_TELEMETRY: '0' } });
92
+
93
+ // The configuration of the default branch only names the base branch; the one that applies is the base branch's.
94
+ const readConfig = (text, where) => {
95
+ const loaded = loadConfig(text);
96
+ if (!loaded.ok) stopReasons(`relevo.yml on ${where} is not valid: ${loaded.errors.map((e) => e.message).join('; ')}`);
97
+ return loaded.config;
98
+ };
99
+
100
+ let config = loadConfig('limits:\n run_max_cost: 1\n').config;
101
+ let names = resolveLabelNames({});
102
+ const removeShipLabel = () => {
103
+ try {
104
+ gh(['issue', 'edit', n, '--repo', repo, '--remove-label', names.ship]);
105
+ } catch {
106
+ // The label may already be gone; the comment still says what happened.
107
+ }
108
+ };
109
+
110
+ if (actorType !== 'User') {
111
+ removeShipLabel();
112
+ return { outcome: 'ignored', reason: 'the label was not applied by a person' };
113
+ }
114
+
115
+ try {
116
+ const fromDefault = run('git', ['show', 'HEAD:relevo.yml']);
117
+ if (fromDefault.status !== 0) stopReasons('relevo.yml not found on the default branch: run /relevo:init');
118
+ const base = readConfig(fromDefault.stdout, 'the default branch').branches.base;
119
+ git(['fetch', '--no-tags', 'origin', base]);
120
+ const fromBase = run('git', ['show', `origin/${base}:relevo.yml`]);
121
+ if (fromBase.status !== 0) stopReasons(`relevo.yml not found on ${base}`);
122
+ config = readConfig(fromBase.stdout, base);
123
+ names = resolveLabelNames(config.labels);
124
+ const language = config.language;
125
+
126
+ const issueData = JSON.parse(gh(['issue', 'view', n, '--repo', repo, '--json', 'state,labels']));
127
+ const labels = issueData.labels.map((l) => l.name);
128
+ if (issueData.state !== 'OPEN') stopReasons('the issue is closed');
129
+
130
+ // The issue's branch, looked up on the remote: never a branch named by the event.
131
+ const heads = git(['ls-remote', '--heads', 'origin']).stdout.split('\n')
132
+ .map((l) => l.split('\t')[1]?.replace('refs/heads/', ''))
133
+ .filter((b) => b && b.startsWith(`feature/${n}-`));
134
+ if (heads.length === 0) stopReasons(`there is no branch feature/${n}-* on the remote`);
135
+ if (heads.length > 1) stopReasons(`more than one branch matches feature/${n}-*: ${heads.join(', ')}`);
136
+ const branch = heads[0];
137
+ git(['fetch', '--no-tags', 'origin', branch]);
138
+ git(['switch', '--force-create', branch, `origin/${branch}`]);
139
+ const head = () => git(['rev-parse', 'HEAD']).stdout.trim();
140
+
141
+ const change = findChange(workDir, n);
142
+ if (change.state === 'missing') stopReasons(`no change folder openspec/changes/${n}-* on ${branch}`);
143
+ if (change.state === 'ambiguous') stopReasons(`more than one change folder for #${n}: ${change.found.join(', ')}`);
144
+
145
+ // The head the approval was given on: the current one, or the one before the archive when a previous run archived.
146
+ const subject = (ref) => git(['log', '-1', '--format=%s', ref]).stdout.trim();
147
+ let baseline = head();
148
+ if (change.state === 'archived') baseline = subject('HEAD').startsWith(ARCHIVE_SUBJECT) ? git(['rev-parse', 'HEAD^']).stdout.trim() : undefined;
149
+
150
+ const decide = (pr, withBaseline) => shipDecision({
151
+ config, pr, invoker: actor, labels, issue: n,
152
+ baseline: withBaseline,
153
+ baselineChecks: withBaseline ? baselineChecks(gh, repo, withBaseline, pr?.headRefOid) : undefined,
154
+ });
155
+ // GitHub takes a moment to see a push on the pull request; a head that never matches means someone else pushed.
156
+ const check = async (withBaseline) => {
157
+ const local = head();
158
+ let pr = loadPr(gh, repo, branch);
159
+ for (let tries = 0; pr && pr.headRefOid !== local && tries < 6; tries += 1) {
160
+ await sleep(headPollMs);
161
+ pr = loadPr(gh, repo, branch);
162
+ }
163
+ if (pr && pr.headRefOid !== local) stopReasons('the branch moved while shipping: apply the label again');
164
+ return { pr, decision: decide(pr, withBaseline) };
165
+ };
166
+
167
+ let { pr, decision } = await check(baseline);
168
+ const rule = labelRules({ decision, config });
169
+ if (rule) stopWith(rule.kind, rule.data);
170
+
171
+ const required = requiredChecks(gh, repo, base);
172
+ if (required.required && !hasApp) stopWith('ship_needs_app', { base });
173
+
174
+ const diff = git(['diff', `origin/${base}...HEAD`]).stdout;
175
+ const scan = script('scan-diff.mjs', [], { input: diff });
176
+ if (scan.status !== 0) stopReasons(`the diff has findings: ${firstLines(scan.stdout || scan.stderr)}`);
177
+
178
+ const archive = () => {
179
+ const steps = [
180
+ () => openspec(['validate', change.name]),
181
+ () => script('fix-tasks.mjs', ['--file', `openspec/changes/${change.name}/tasks.md`, '--stage', 'after']),
182
+ () => openspec(['archive', change.name, '-y']),
183
+ () => script('check-archive.mjs', []),
184
+ () => script('stage.mjs', ['openspec/changes', 'openspec/specs']),
185
+ ];
186
+ steps.forEach((step) => {
187
+ const r = step();
188
+ if (r.status !== 0) stopReasons(`the archive stopped: ${firstLines(r.stdout || r.stderr)}`);
189
+ });
190
+ git(['commit', '-m', `${ARCHIVE_SUBJECT}${change.name} (#${n})`]);
191
+ git(['push', 'origin', `HEAD:refs/heads/${branch}`]);
192
+ };
193
+
194
+ let pushed = false;
195
+ if (change.state === 'active') {
196
+ archive();
197
+ pushed = true;
198
+ }
199
+
200
+ // With the App, the push started the project's checks: wait for them, for a bounded time.
201
+ if (pushed && hasApp) {
202
+ const started = now();
203
+ for (;;) {
204
+ const current = loadPr(gh, repo, branch);
205
+ const rollup = current?.statusCheckRollup ?? [];
206
+ const running = rollup.some((c) => (c.__typename === 'StatusContext' ? !['SUCCESS', 'FAILURE', 'ERROR'].includes(c.state) : c.status !== 'COMPLETED'));
207
+ if (rollup.length > 0 && !running) break;
208
+ if (rollup.length === 0 && now() - started >= settleMs) break;
209
+ if (now() - started >= waitLimitMs) {
210
+ stopReasons(`the checks of the archive commit did not finish within ${Math.round(waitLimitMs / 60000)} minutes; apply the label again: the specification is already archived, so it resumes from the merge`);
211
+ }
212
+ await sleep(pollMs);
213
+ }
214
+ }
215
+
216
+ ({ pr, decision } = await check(baseline));
217
+ if (!decision.allowed && decision.reasons.length === 1 && CONFLICT_REASON.test(decision.reasons[0]) && subject('HEAD').startsWith(ARCHIVE_SUBJECT)) {
218
+ // The base branch changed the specifications the archive wrote: undo the archive, bring in the base, archive again.
219
+ git(['revert', '--no-edit', 'HEAD']);
220
+ const merge = git(['merge', '--no-edit', `origin/${base}`], { allowFail: true });
221
+ if (merge.status !== 0) {
222
+ const scope = script('conflict-scope.mjs', []);
223
+ git(['merge', '--abort'], { allowFail: true });
224
+ if (scope.status !== 0) stopReasons(`bringing in ${base} conflicts in code, which is for the author: ${firstLines(scope.stdout)}`);
225
+ stopReasons(`bringing in ${base} conflicts inside openspec/: run /relevo:ship ${n} to resolve it in a session`);
226
+ }
227
+ archive();
228
+ // The head now holds more than the archive, so the approval must be on it.
229
+ ({ pr, decision } = await check(undefined));
230
+ }
231
+ const again = labelRules({ decision, config });
232
+ if (again) stopWith(again.kind, again.data);
233
+
234
+ const merged = head();
235
+ try {
236
+ pushGh(['pr', 'merge', String(pr.number), '--repo', repo, '--squash', '--delete-branch', '--subject', pr.title, '--body', `Issue #${n}`, '--match-head-commit', merged]);
237
+ } catch (err) {
238
+ stopReasons(`GitHub refused the merge: ${firstLines(err.stderr || err.message)}`);
239
+ }
240
+
241
+ const after = JSON.parse(gh(['issue', 'view', n, '--repo', repo, '--json', 'state,labels']));
242
+ if (after.state === 'OPEN') pushGh(['issue', 'close', n, '--repo', repo, '--reason', 'completed']);
243
+ const { remove } = clearAll(after.labels.map((l) => l.name), config.labels);
244
+ if (remove.length > 0) pushGh(['issue', 'edit', n, '--repo', repo, ...remove.flatMap((l) => ['--remove-label', l])]);
245
+
246
+ let commit = merged.slice(0, 7);
247
+ try {
248
+ commit = (gh(['pr', 'view', String(pr.number), '--repo', repo, '--json', 'mergeCommit', '--jq', '.mergeCommit.oid']).trim() || merged).slice(0, 7);
249
+ } catch {
250
+ // The short head is close enough for the report.
251
+ }
252
+ const lang = language === 'es' ? 'es' : 'en';
253
+ const note = decision.summary.checksFrom === 'baseline' ? NOTES.baseline[lang](String(baseline).slice(0, 7)) : decision.summary.checks === 'none' ? NOTES.none[lang]() : '';
254
+ gh(['issue', 'comment', n, '--repo', repo, '--body', renderMessage('ship_done', { issue: n, pr: `#${pr.number}`, base, commit, actor, checks_note: note }, language)]);
255
+
256
+ // A close made with the workflow token starts no workflow: tell the queue, so dependent issues are unblocked now.
257
+ if (!hasApp) {
258
+ try {
259
+ gh(['workflow', 'run', 'relevo-queue.yml', '--repo', repo]);
260
+ } catch {
261
+ // No queue workflow: nothing waits on it.
262
+ }
263
+ }
264
+ return { outcome: 'merged', pr: pr.number, commit };
265
+ } catch (err) {
266
+ if (!(err instanceof Stop)) throw err;
267
+ removeShipLabel();
268
+ gh(['issue', 'comment', n, '--repo', repo, '--body', renderMessage(err.kind, { actor, issue: n, ...err.data }, config.language)]);
269
+ return { outcome: 'stopped', kind: err.kind, ...err.data };
270
+ }
271
+ }
@@ -67,11 +67,13 @@ export function checkRollup(rollup = []) {
67
67
  * baseline optional: the head commit recorded when ship started; an approval given on it still counts
68
68
  * labels optional: the names of the issue's labels; when given, the issue must be pr-open
69
69
  * issue optional: the issue number, used in the messages about the state
70
+ * baselineChecks optional: { archiveOnly, rollup } of the baseline. When the head differs from it only under
71
+ * `openspec/` (archiveOnly) and the head has no checks of its own, the baseline's checks are judged.
70
72
  * Returns { allowed, reasons, confirm, summary }.
71
73
  * reasons why it must stop (empty when allowed)
72
74
  * confirm what the person must still confirm in writing, always including "merge"
73
75
  */
74
- export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
76
+ export function shipDecision({ config, pr, invoker, baseline, labels, issue, baselineChecks }) {
75
77
  const reasons = [];
76
78
  const warnings = [];
77
79
  const confirm = ['merge'];
@@ -95,7 +97,17 @@ export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
95
97
  if (pr.mergeable === 'CONFLICTING') reasons.push('the pull request has conflicts: the author must synchronise the branch with the base branch');
96
98
  else if (pr.mergeable !== 'MERGEABLE') reasons.push('GitHub has not finished checking whether the pull request can be merged: try again in a minute');
97
99
 
98
- const checks = checkRollup(pr.statusCheckRollup);
100
+ // A push with the workflow token starts no workflows, so the archive commit may have no checks: the checks of the
101
+ // approved head still vouch for the code when only the specifications changed since.
102
+ let checks = checkRollup(pr.statusCheckRollup);
103
+ let checksFrom = 'head';
104
+ if (checks.state === 'none' && baselineChecks?.archiveOnly) {
105
+ const carried = checkRollup(baselineChecks.rollup);
106
+ if (carried.state !== 'none') {
107
+ checks = carried;
108
+ checksFrom = 'baseline';
109
+ }
110
+ }
99
111
  if (checks.state === 'failing') reasons.push(`checks are failing: ${checks.failing.join(', ')}`);
100
112
  else if (checks.state === 'pending') reasons.push(`checks are still running: ${checks.pending.join(', ')}`);
101
113
  else if (checks.state === 'none') confirm.push('no-ci');
@@ -156,6 +168,6 @@ export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
156
168
  allowed: reasons.length === 0,
157
169
  reasons,
158
170
  confirm,
159
- summary: { pr: pr.number, title: pr.title, author, branch: pr.headRefName, base: pr.baseRefName, mode, approvedBy, checks: checks.state, ...(warnings.length > 0 ? { warnings } : {}) },
171
+ summary: { pr: pr.number, title: pr.title, author, branch: pr.headRefName, base: pr.baseRefName, head: pr.headRefOid ?? null, mode, approvedBy, checks: checks.state, checksFrom, ...(warnings.length > 0 ? { warnings } : {}) },
160
172
  };
161
173
  }
@@ -1,6 +1,12 @@
1
1
  // Which of the tools that a profile lists are not on the path. `probe(tool)` says whether one answers; it is injected
2
2
  // so that a test does not need the tools. An empty list checks nothing.
3
3
 
4
+ /**
5
+ * What every Relevo job needs on the runner whatever the stack: a GitHub-hosted runner has them, a self-hosted one may not.
6
+ * They are checked with the tools of the profile, so a runner without one stops before the agent, like one without Flutter.
7
+ */
8
+ export const RUNNER_TOOLS = ['git', 'gh', 'jq', 'node'];
9
+
4
10
  /** The tools that are missing, in the order of the profile and without repeats. */
5
11
  export function missingTools(tools, probe) {
6
12
  const seen = new Set();
@@ -2,7 +2,14 @@
2
2
  // address under review with the module scripts its page loads. Everything that touches the machine is injected.
3
3
  // Implements "A browser review checks its tools and the app before driving" of openspec/specs/review/spec.md.
4
4
 
5
- export const EXIT = { node_modules: 3, playwright: 4, browser: 5, url: 6 };
5
+ export const EXIT = { node_modules: 3, playwright: 4, browser: 5, url: 6, deps_stale: 7 };
6
+
7
+ // The frozen install of each lock file, first match wins. None runs package scripts where the tool can skip them.
8
+ const LOCKS = [
9
+ { file: 'package-lock.json', install: 'npm ci --ignore-scripts' },
10
+ { file: 'pnpm-lock.yaml', install: 'pnpm install --frozen-lockfile --ignore-scripts' },
11
+ { file: 'yarn.lock', install: 'yarn install --immutable' },
12
+ ];
6
13
 
7
14
  const START_SCRIPTS = ['dev', 'serve', 'start'];
8
15
 
@@ -34,6 +41,38 @@ export function moduleScripts(html, pageUrl) {
34
41
  .map((u) => u.href);
35
42
  }
36
43
 
44
+ /** The lock file of the folder and its frozen install command; null when the folder has none. */
45
+ export function lockOf(cwd, exists) {
46
+ return LOCKS.find((l) => exists(`${cwd}/${l.file}`)) ?? null;
47
+ }
48
+
49
+ const parse = (text) => {
50
+ try {
51
+ return JSON.parse(text);
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ /**
58
+ * Do the installed dependencies differ from package-lock.json? Compares each package of the lock file with the one
59
+ * npm recorded in node_modules/.package-lock.json. An optional package that is not installed (a binary for another
60
+ * platform) is not a difference. Returns true, false, or null when it cannot tell (never treated as stale).
61
+ */
62
+ export function npmStale(lockText, hiddenText) {
63
+ const lock = parse(lockText)?.packages;
64
+ if (!lock || typeof lock !== 'object') return null;
65
+ if (hiddenText === null) return true;
66
+ const hidden = parse(hiddenText)?.packages;
67
+ if (!hidden || typeof hidden !== 'object') return null;
68
+ return Object.entries(lock).some(([key, wanted]) => {
69
+ if (key === '') return false;
70
+ const installed = hidden[key];
71
+ if (!installed) return !(wanted?.optional || wanted?.devOptional);
72
+ return wanted?.version !== undefined && installed.version !== wanted.version;
73
+ });
74
+ }
75
+
37
76
  /** Does the page answer below 400 after redirects, and so do its module scripts? Returns { ok, detail }. */
38
77
  export async function checkUrl(url, { fetch, timeout = 5000 }) {
39
78
  const get = async (address) => {
@@ -56,7 +95,8 @@ export async function checkUrl(url, { fetch, timeout = 5000 }) {
56
95
  }
57
96
 
58
97
  /**
59
- * Runs the checks in order and stops at the first failure. Returns { ok, check, detail, start, env, code }.
98
+ * Runs the checks in order and stops at the first failure. Returns { ok, check, detail, start, env, install, code }:
99
+ * `install` is the frozen install command of the folder's lock file, or null without one.
60
100
  * deps: exists(path), readFile(path) (null when missing), loadPlaywright(), fetch, sleep(ms), now().
61
101
  * Only `.env.example` is read for variable names; the real environment files never are.
62
102
  * With `wait`, the address is retried every 500 ms until it answers or the time is over.
@@ -66,9 +106,15 @@ export async function webPreflight({ cwd, url, timeout = 5000, wait = 0 }, deps)
66
106
  const at = (name) => `${cwd}/${name}`;
67
107
  const start = startCommand(readFile(at('package.json')));
68
108
  const env = envNames(readFile(at('.env.example')));
69
- const result = (check, detail) => ({ ok: check === null, check, detail, start, env, code: check === null ? 0 : EXIT[check] });
109
+ const lock = lockOf(cwd, exists);
110
+ const install = lock?.install ?? null;
111
+ const result = (check, detail) => ({ ok: check === null, check, detail, start, env, install, code: check === null ? 0 : EXIT[check] });
70
112
 
71
- if (!exists(at('node_modules'))) return result('node_modules', 'the project has no node_modules: run npm install first');
113
+ if (!exists(at('node_modules'))) return result('node_modules', lock ? 'the project has no node_modules' : 'the project has no node_modules and no lock file to install from');
114
+ // Only npm records what it installed in a form comparable with its lock file; pnpm and yarn are checked for presence.
115
+ if (lock?.file === 'package-lock.json' && npmStale(readFile(at('package-lock.json')), readFile(at('node_modules/.package-lock.json'))) === true) {
116
+ return result('deps_stale', 'the installed dependencies do not match package-lock.json');
117
+ }
72
118
  const playwright = loadPlaywright();
73
119
  if (!playwright) return result('playwright', 'Playwright does not resolve from the project');
74
120
  let executable = null;
@@ -52,7 +52,16 @@ if (eventName === 'pull_request_review') {
52
52
 
53
53
  const pr = { number: prData.number, url: prData.html_url, author: prData.user.login };
54
54
  const labels = JSON.parse(realGh(['api', `repos/${repo}/issues/${number}`])).labels.map((l) => (typeof l === 'string' ? l : l.name));
55
- const decision = planReviewEvent({ config, event, pr, issue: { number, labels } });
55
+ // The approval names the ship label only where the ship workflow runs: it runs from the default branch.
56
+ const shipWorkflow = event.kind === 'review' && event.state === 'approved' && (() => {
57
+ try {
58
+ realGh(['api', `repos/${repo}/contents/.github/workflows/relevo-ship.yml`, '--silent']);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ })();
64
+ const decision = planReviewEvent({ config, event, pr, issue: { number, labels }, shipWorkflow });
56
65
  if (!decision) {
57
66
  console.log(JSON.stringify({ action: 'none' }));
58
67
  process.exit(0);
@@ -11,6 +11,7 @@ import { resolve } from 'node:path';
11
11
  import { loadConfig } from './lib/config.mjs';
12
12
  import { realGh } from './lib/github.mjs';
13
13
  import { shipDecision } from './lib/ship.mjs';
14
+ import { baselineChecks, loadPr } from './lib/ship-github.mjs';
14
15
 
15
16
  const arg = (name) => {
16
17
  const i = process.argv.indexOf(`--${name}`);
@@ -26,9 +27,9 @@ if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /r
26
27
  const loaded = loadConfig(readFileSync(configPath, 'utf8'));
27
28
  if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
28
29
 
29
- const FIELDS = 'number,state,isDraft,mergeable,title,author,reviews,statusCheckRollup,headRefName,headRefOid,baseRefName';
30
30
  let pr;
31
31
  let invoker = arg('invoker');
32
+ let carried;
32
33
 
33
34
  if (arg('input')) {
34
35
  pr = JSON.parse(readFileSync(arg('input'), 'utf8'));
@@ -36,10 +37,10 @@ if (arg('input')) {
36
37
  const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
37
38
  const branch = arg('branch');
38
39
  if (!repo || !branch) fail('usage: ship-check.mjs --repo owner/name --branch <branch> [--invoker login] [--config relevo.yml]');
39
- const found = JSON.parse(realGh(['pr', 'list', '--repo', repo, '--head', branch, '--state', 'all', '--json', 'number,state', '--limit', '5']));
40
- const open = found.find((p) => p.state === 'OPEN') ?? found[0];
41
- pr = open ? JSON.parse(realGh(['pr', 'view', String(open.number), '--repo', repo, '--json', FIELDS])) : null;
40
+ pr = loadPr(realGh, repo, branch);
42
41
  invoker ??= realGh(['api', 'user', '--jq', '.login']).trim();
42
+ // The archive commit may have no checks of its own; the approved head's count when only openspec/ changed since.
43
+ if (pr && arg('baseline')) carried = baselineChecks(realGh, repo, arg('baseline'), pr.headRefOid);
43
44
  }
44
45
 
45
46
  const decision = shipDecision({
@@ -49,6 +50,7 @@ const decision = shipDecision({
49
50
  baseline: arg('baseline'),
50
51
  labels: arg('labels') === undefined ? undefined : arg('labels').split(',').map((l) => l.trim()).filter(Boolean),
51
52
  issue: arg('issue'),
53
+ baselineChecks: carried,
52
54
  });
53
55
  console.log(JSON.stringify(decision, null, 2));
54
56
  process.exit(decision.allowed ? 0 : 1);
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // Ships an issue on GitHub, without a model, when a person applied the `relevo:ship` label. Usage (in the workflow):
3
+ // node scripts/ship-unattended.mjs --repo owner/name --issue 31 --actor login --actor-type User --work <checkout>
4
+ // Environment: GH_TOKEN (the workflow token), GH_PUSH_TOKEN (the App token, or the workflow token), HAS_APP (true|false).
5
+ // Prints the outcome as JSON. exit 0: merged, stopped with a comment, or ignored exit 2: bad arguments
6
+ // Any other failure exits non-zero, and the workflow's last step reports it on the issue.
7
+
8
+ import { execFileSync, spawnSync } from 'node:child_process';
9
+ import { dirname, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { runUnattendedShip } from './lib/ship-unattended.mjs';
12
+
13
+ const arg = (name) => {
14
+ const i = process.argv.indexOf(`--${name}`);
15
+ return i === -1 ? undefined : process.argv[i + 1];
16
+ };
17
+ const [repo, issue, actor, actorType, work] = ['repo', 'issue', 'actor', 'actor-type', 'work'].map(arg);
18
+ if (!repo || !/^\d+$/.test(issue ?? '') || !actor || !actorType || !work) {
19
+ console.error('usage: ship-unattended.mjs --repo owner/name --issue <n> --actor <login> --actor-type <User|Bot> --work <checkout>');
20
+ process.exit(2);
21
+ }
22
+
23
+ const ghWith = (token) => (args) => execFileSync('gh', args, {
24
+ encoding: 'utf8',
25
+ maxBuffer: 64 * 1024 * 1024,
26
+ env: { ...process.env, GH_TOKEN: token ?? process.env.GH_TOKEN },
27
+ });
28
+ const sh = (cmd, args, { cwd, input, env } = {}) => {
29
+ const r = spawnSync(cmd, args, { cwd, input, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...env } });
30
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? String(r.error ?? '') };
31
+ };
32
+
33
+ const outcome = await runUnattendedShip({
34
+ repo,
35
+ issue,
36
+ actor,
37
+ actorType,
38
+ hasApp: process.env.HAS_APP === 'true',
39
+ workDir: resolve(work),
40
+ pluginRoot: resolve(dirname(fileURLToPath(import.meta.url)), '..'),
41
+ sh,
42
+ gh: ghWith(process.env.GH_TOKEN),
43
+ pushGh: ghWith(process.env.GH_PUSH_TOKEN || process.env.GH_TOKEN),
44
+ });
45
+ console.log(JSON.stringify(outcome, null, 2));
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Checks that the tools which the profile of a platform lists are on the path of this machine. Usage:
3
3
  // node scripts/toolchain-check.mjs --platform <name> [--config relevo.yml] [--plugin-root <dir>]
4
- // It prints { platform, stack, tools, missing } as JSON. A tool is there when `<tool> --version` starts. The cloud build
4
+ // It prints { platform, stack, tools, missing } as JSON; `missing` also covers git, gh, jq and node, which every job needs. A tool is there when `<tool> --version` starts. The cloud build
5
5
  // runs it before the agent, so that a runner without the SDK stops in seconds and not after a full run.
6
6
  // exit 0: every tool is there (or the profile lists none) exit 1: a tool is missing, or the profile cannot be resolved
7
7
  // exit 2: bad usage or an invalid relevo.yml
@@ -12,7 +12,7 @@ import { dirname, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { loadConfig } from './lib/config.mjs';
14
14
  import { resolveProfile } from './lib/stack-profile.mjs';
15
- import { missingTools } from './lib/toolchain-check.mjs';
15
+ import { missingTools, RUNNER_TOOLS } from './lib/toolchain-check.mjs';
16
16
 
17
17
  const arg = (name) => {
18
18
  const i = process.argv.indexOf(`--${name}`);
@@ -44,6 +44,6 @@ const resolved = resolveProfile({ platform, pluginRoot, read });
44
44
  if (!resolved.ok) fail(resolved.errors.join('\n'), 1);
45
45
 
46
46
  const probe = (tool) => spawnSync(tool, ['--version'], { stdio: 'ignore' }).error === undefined;
47
- const missing = missingTools(resolved.profile.tools, probe);
47
+ const missing = missingTools([...RUNNER_TOOLS, ...(resolved.profile.tools ?? [])], probe);
48
48
  console.log(JSON.stringify({ platform: name, stack: resolved.profile.stack, tools: resolved.profile.tools, missing }));
49
49
  process.exit(missing.length > 0 ? 1 : 0);
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Adds the SessionStart hook that installs OpenSpec in web sessions to .claude/settings.json. Usage:
3
+ // node scripts/web-hook.mjs [--settings .claude/settings.json]
4
+ // Run by /relevo:init only after the person agreed to the hook. Every other key and hook of the file is kept, and a
5
+ // file that is not a JSON object is left as it is.
6
+ // exit 0: written, or already there exit 2: the file could not be merged
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import { mergeWebHook } from './lib/init.mjs';
11
+
12
+ const i = process.argv.indexOf('--settings');
13
+ const path = i === -1 ? '.claude/settings.json' : process.argv[i + 1];
14
+
15
+ try {
16
+ const before = existsSync(path) ? readFileSync(path, 'utf8') : null;
17
+ const after = mergeWebHook(before);
18
+ if (after === before) {
19
+ console.log(`${path} already installs OpenSpec at the start of a web session; nothing changed`);
20
+ } else {
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ writeFileSync(path, after);
23
+ console.log(`${path} now installs OpenSpec at the start of a web session; the file is left for you to commit`);
24
+ }
25
+ } catch (err) {
26
+ console.error(`could not add the hook to ${path}: ${err.message}`);
27
+ process.exit(2);
28
+ }
@@ -3,11 +3,12 @@
3
3
  // under review with the module scripts its page loads. Usage:
4
4
  // node scripts/web-preflight.mjs --url http://localhost:5173 [--timeout 5000] [--wait 30000]
5
5
  // With --wait it is also the readiness probe of a server the agent started: the address is retried until it answers.
6
- // Prints { ok, check, detail, start, env } as JSON: `start` is the command that starts the app, `env` the variable
7
- // names of .env.example (the real environment files are never read).
8
- // exit 0: ready exit 3: no node_modules (run npm install) exit 4: Playwright does not resolve
9
- // exit 5: the Chromium of Playwright is missing exit 6: the address or one of its modules does not answer below 400
10
- // exit 2: bad arguments
6
+ // Prints { ok, check, detail, start, env, install } as JSON: `start` is the command that starts the app, `env` the
7
+ // variable names of .env.example (the real environment files are never read), `install` the frozen install command of
8
+ // the lock file (null without one).
9
+ // exit 0: ready exit 3: no node_modules exit 7: node_modules does not match package-lock.json
10
+ // exit 4: Playwright does not resolve from the project exit 5: the Chromium of Playwright is missing
11
+ // exit 6: the address or one of its modules does not answer below 400 exit 2: bad arguments
11
12
 
12
13
  import { existsSync, readFileSync } from 'node:fs';
13
14
  import { loadPlaywright } from './lib/web-review.mjs';
@@ -73,7 +73,7 @@ const secrets = Object.fromEntries(secretNames.filter((n) => process.env[n]).map
73
73
  const hide = (text) => redact(text, secrets);
74
74
 
75
75
  const playwright = loadPlaywright();
76
- if (!playwright) stop(1, 'Playwright cannot be resolved from this project: with the person\'s permission, install it with "npm install --no-save playwright" and "npx playwright install chromium", then restore package-lock.json if npm changed it');
76
+ if (!playwright) stop(1, 'Playwright cannot be resolved from this project: it belongs in its development dependencies; run /relevo:init, or "npm install -D playwright" and "npx playwright install chromium" in the folder of the platform, and commit the change on the base branch');
77
77
 
78
78
  if (arg('out') === undefined && existsSync(join(out, 'web-log.txt'))) {
79
79
  tell(`warning: ${join(out, 'web-log.txt')} is replaced; a run against another server uses an --out of its own (evidence/run-<port>)`);
@@ -1,6 +1,6 @@
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, and a CI check. Read-only: it never changes anything. Use when asked to check, diagnose or verify the Relevo setup.
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
4
  argument-hint: ""
5
5
  ---
6
6
 
@@ -21,7 +21,8 @@ Scripts live in `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `node`. They hol
21
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
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.
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
- 4. 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.
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
+ 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.
25
26
 
26
27
  ## Exit status
27
28