@elalfner/relevo 0.0.0-stage → 0.3.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 (139) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +51 -0
  4. package/README.es.md +90 -0
  5. package/README.md +89 -2
  6. package/agents/dev.md +101 -0
  7. package/agents/qa.md +112 -0
  8. package/package.json +42 -4
  9. package/scripts/add-corrections.mjs +21 -0
  10. package/scripts/app-id.mjs +19 -0
  11. package/scripts/archive-state.mjs +31 -0
  12. package/scripts/branch-state.mjs +31 -0
  13. package/scripts/branch-work.mjs +25 -0
  14. package/scripts/can-edit-issue.mjs +45 -0
  15. package/scripts/capture.mjs +22 -0
  16. package/scripts/check-archive.mjs +19 -0
  17. package/scripts/check-issue.mjs +41 -0
  18. package/scripts/checklist.mjs +29 -0
  19. package/scripts/cli.mjs +28 -0
  20. package/scripts/commit-change.mjs +50 -0
  21. package/scripts/conflict-scope.mjs +19 -0
  22. package/scripts/design-fetch.mjs +103 -0
  23. package/scripts/device.mjs +12 -0
  24. package/scripts/digest.mjs +50 -0
  25. package/scripts/doctor.mjs +47 -0
  26. package/scripts/fix-guard.mjs +38 -0
  27. package/scripts/fix-stash.mjs +57 -0
  28. package/scripts/fix-state.mjs +28 -0
  29. package/scripts/fix-tasks.mjs +22 -0
  30. package/scripts/go-local.mjs +49 -0
  31. package/scripts/go-plan.mjs +82 -0
  32. package/scripts/init.mjs +109 -0
  33. package/scripts/intent-to-add.mjs +20 -0
  34. package/scripts/issue-coverage.mjs +30 -0
  35. package/scripts/issue-hash.mjs +44 -0
  36. package/scripts/issue-sections.mjs +42 -0
  37. package/scripts/label-change.mjs +35 -0
  38. package/scripts/lib/appid.mjs +12 -0
  39. package/scripts/lib/archive-check.mjs +29 -0
  40. package/scripts/lib/archive-state.mjs +15 -0
  41. package/scripts/lib/branch-work.mjs +13 -0
  42. package/scripts/lib/branch.mjs +43 -0
  43. package/scripts/lib/can-edit-issue.mjs +34 -0
  44. package/scripts/lib/checklist.mjs +68 -0
  45. package/scripts/lib/cli.mjs +82 -0
  46. package/scripts/lib/commit-change.mjs +57 -0
  47. package/scripts/lib/config.mjs +296 -0
  48. package/scripts/lib/design-fetch.mjs +156 -0
  49. package/scripts/lib/device.mjs +67 -0
  50. package/scripts/lib/doctor.mjs +614 -0
  51. package/scripts/lib/execution-mode.mjs +24 -0
  52. package/scripts/lib/fix-guard.mjs +23 -0
  53. package/scripts/lib/fix-stash.mjs +17 -0
  54. package/scripts/lib/fix.mjs +31 -0
  55. package/scripts/lib/github.mjs +161 -0
  56. package/scripts/lib/go-local.mjs +102 -0
  57. package/scripts/lib/init.mjs +324 -0
  58. package/scripts/lib/intent-to-add.mjs +32 -0
  59. package/scripts/lib/issue-check.mjs +63 -0
  60. package/scripts/lib/issue-coverage.mjs +47 -0
  61. package/scripts/lib/issue-hash.mjs +25 -0
  62. package/scripts/lib/issue-parse.mjs +194 -0
  63. package/scripts/lib/issue-sections.mjs +43 -0
  64. package/scripts/lib/labels.mjs +65 -0
  65. package/scripts/lib/language.mjs +9 -0
  66. package/scripts/lib/logaudit.mjs +123 -0
  67. package/scripts/lib/messages.mjs +328 -0
  68. package/scripts/lib/next-step.mjs +144 -0
  69. package/scripts/lib/openspec-commands.mjs +44 -0
  70. package/scripts/lib/openspec-status.mjs +16 -0
  71. package/scripts/lib/plan.mjs +256 -0
  72. package/scripts/lib/platform-of.mjs +32 -0
  73. package/scripts/lib/platform-scope.mjs +32 -0
  74. package/scripts/lib/platforms.mjs +90 -0
  75. package/scripts/lib/preflight.mjs +71 -0
  76. package/scripts/lib/protected-paths.mjs +102 -0
  77. package/scripts/lib/requeue.mjs +23 -0
  78. package/scripts/lib/review-adapters.mjs +37 -0
  79. package/scripts/lib/review-brief.mjs +43 -0
  80. package/scripts/lib/review-events.mjs +56 -0
  81. package/scripts/lib/review-state.mjs +157 -0
  82. package/scripts/lib/reviewers.mjs +39 -0
  83. package/scripts/lib/role-skills.mjs +112 -0
  84. package/scripts/lib/run-flags.mjs +16 -0
  85. package/scripts/lib/run-usage.mjs +65 -0
  86. package/scripts/lib/scan.mjs +73 -0
  87. package/scripts/lib/ship.mjs +161 -0
  88. package/scripts/lib/sla.mjs +90 -0
  89. package/scripts/lib/stack-profile.mjs +169 -0
  90. package/scripts/lib/staging.mjs +21 -0
  91. package/scripts/lib/status-data.mjs +109 -0
  92. package/scripts/lib/status.mjs +199 -0
  93. package/scripts/lib/tasks-file.mjs +81 -0
  94. package/scripts/lib/web-review.mjs +98 -0
  95. package/scripts/lib/yaml-lite.mjs +157 -0
  96. package/scripts/needs-reviewer.mjs +41 -0
  97. package/scripts/next-step.mjs +39 -0
  98. package/scripts/pick-reviewer.mjs +42 -0
  99. package/scripts/platform-of.mjs +49 -0
  100. package/scripts/preflight.mjs +32 -0
  101. package/scripts/render-message.mjs +35 -0
  102. package/scripts/requeue.mjs +53 -0
  103. package/scripts/review-adapter.mjs +32 -0
  104. package/scripts/review-brief.mjs +29 -0
  105. package/scripts/review-event.mjs +70 -0
  106. package/scripts/review-sla.mjs +41 -0
  107. package/scripts/review-state.mjs +171 -0
  108. package/scripts/review-who.mjs +21 -0
  109. package/scripts/role-skills.mjs +53 -0
  110. package/scripts/run-usage.mjs +22 -0
  111. package/scripts/scan-diff.mjs +16 -0
  112. package/scripts/ship-check.mjs +54 -0
  113. package/scripts/spec-ready.mjs +40 -0
  114. package/scripts/stack-profile.mjs +67 -0
  115. package/scripts/stage.mjs +14 -0
  116. package/scripts/status.mjs +47 -0
  117. package/scripts/usage-limit.mjs +17 -0
  118. package/scripts/validate-config.mjs +44 -0
  119. package/scripts/web-review.mjs +134 -0
  120. package/skills/doctor/SKILL.md +28 -0
  121. package/skills/fix/SKILL.md +128 -0
  122. package/skills/go/SKILL.md +242 -0
  123. package/skills/init/SKILL.md +72 -0
  124. package/skills/issue/SKILL.md +128 -0
  125. package/skills/issue/references/update.md +15 -0
  126. package/skills/review/SKILL.md +157 -0
  127. package/skills/ship/SKILL.md +128 -0
  128. package/skills/status/SKILL.md +27 -0
  129. package/skills/think/SKILL.md +76 -0
  130. package/stacks/flutter.md +63 -0
  131. package/stacks/laravel.md +65 -0
  132. package/stacks/vue.md +70 -0
  133. package/templates/issue.md +99 -0
  134. package/templates/relevo.yml +59 -0
  135. package/templates/workflows/relevo-digest.yml +54 -0
  136. package/templates/workflows/relevo-go.yml +263 -0
  137. package/templates/workflows/relevo-queue.yml +72 -0
  138. package/templates/workflows/relevo-review.yml +60 -0
  139. package/templates/workflows/relevo-sla.yml +53 -0
@@ -0,0 +1,614 @@
1
+ // `doctor`: reports every check as pass, fail or warn, with a fix next to each failure.
2
+ // Implements openspec/specs/init-doctor/spec.md.
3
+ //
4
+ // Two layers, both read-only:
5
+ // gatherFacts(probes) asks the machine and GitHub (through injected probes) and returns plain facts
6
+ // runChecks(facts) pure: turns the facts into a list of results
7
+ // The probes are injected so everything here is testable without a network.
8
+
9
+ import { readFileSync, readdirSync } from 'node:fs';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { loadConfig } from './config.mjs';
13
+ import { labelDefinitions } from './labels.mjs';
14
+ import { credentialMode, cloudBuilds, CLOUD_VARIABLE } from './execution-mode.mjs';
15
+ import { SKILL_DIRS, SIZE_BUDGET_BYTES, folderHash, lockDifferences, resolveRoleSkills, skillBudget } from './role-skills.mjs';
16
+ import { ROLES, profileLocations, profileLookups, resolveProfile } from './stack-profile.mjs';
17
+
18
+ export const REQUIRED_SKILLS = ['openspec-explore', 'grill-with-docs', 'grilling', 'domain-modeling'];
19
+ export const SKILL_INSTALL = {
20
+ 'openspec-explore': 'run `openspec init` (or `openspec update`) in the project',
21
+ 'grill-with-docs': 'run `npx skills add mattpocock/skills` and choose it',
22
+ grilling: 'run `npx skills add mattpocock/skills` and choose it',
23
+ 'domain-modeling': 'run `npx skills add mattpocock/skills` and choose it',
24
+ };
25
+ export const OPENSPEC_INSTALL = 'npm install --global @fission-ai/openspec (needs Node.js 20.19 or newer)';
26
+ export const WORKFLOW_FILES = ['relevo-go.yml', 'relevo-queue.yml', 'relevo-review.yml', 'relevo-sla.yml', 'relevo-digest.yml'];
27
+ const REQUIRED_WORKFLOWS = new Set(['relevo-go.yml']);
28
+ // How to ask a tool that it is there (most answer --version), and how to install the ones Relevo knows.
29
+ const TOOL_PROBE = { adb: ['version'] };
30
+ const TOOL_INSTALL = {
31
+ flutter: 'install Flutter: https://docs.flutter.dev/get-started/install',
32
+ adb: 'install the Android platform tools',
33
+ xcrun: 'install the Xcode command line tools (macOS only)',
34
+ node: 'install Node.js 20.19 or newer',
35
+ php: 'install PHP 8.2 or newer: https://www.php.net/downloads',
36
+ composer: 'install Composer: https://getcomposer.org/download/',
37
+ npm: 'install Node.js, which includes npm: https://nodejs.org/',
38
+ };
39
+ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
40
+
41
+ const pass = (id, message) => ({ id, status: 'pass', message });
42
+ const fail = (id, message, fix) => ({ id, status: 'fail', message, fix });
43
+ const warn = (id, message, fix) => ({ id, status: 'warn', message, fix });
44
+
45
+ /** True when `version` (like "22.1.0") is at least `min` (like "20.19"). */
46
+ export function versionAtLeast(version, min) {
47
+ const a = String(version).replace(/^v/, '').split('.').map(Number);
48
+ const b = String(min).split('.').map(Number);
49
+ for (let i = 0; i < b.length; i += 1) {
50
+ if ((a[i] ?? 0) > b[i]) return true;
51
+ if ((a[i] ?? 0) < b[i]) return false;
52
+ }
53
+ return true;
54
+ }
55
+
56
+ // ---------- branch protection
57
+
58
+ const globToRegExp = (g) => new RegExp(`^${g.split('*').map((p) => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
59
+
60
+ /** Does a ruleset ref pattern ("refs/heads/dev", "~DEFAULT_BRANCH", "~ALL", globs) cover this branch? */
61
+ export function refMatches(pattern, branch, defaultBranch) {
62
+ if (pattern === '~ALL') return true;
63
+ if (pattern === '~DEFAULT_BRANCH') return branch === defaultBranch;
64
+ return globToRegExp(pattern).test(`refs/heads/${branch}`);
65
+ }
66
+
67
+ /**
68
+ * What the repository enforces on a branch, from its rulesets and its classic protection.
69
+ * Returns { protected, pullRequestRequired, approvals, dismissStale, blocksDeletion, blocksForcePush }.
70
+ */
71
+ export function summarizeProtection({ rulesets = [], classic = null, branch, defaultBranch }) {
72
+ const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, blocksDeletion: false, blocksForcePush: false };
73
+ rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active').forEach((r) => {
74
+ const include = r.conditions?.ref_name?.include ?? [];
75
+ const exclude = r.conditions?.ref_name?.exclude ?? [];
76
+ if (!include.some((p) => refMatches(p, branch, defaultBranch)) || exclude.some((p) => refMatches(p, branch, defaultBranch))) return;
77
+ out.protected = true;
78
+ (r.rules ?? []).forEach((rule) => {
79
+ if (rule.type === 'pull_request') {
80
+ out.pullRequestRequired = true;
81
+ out.approvals = Math.max(out.approvals, rule.parameters?.required_approving_review_count ?? 0);
82
+ if (rule.parameters?.dismiss_stale_reviews_on_push) out.dismissStale = true;
83
+ }
84
+ if (rule.type === 'deletion') out.blocksDeletion = true;
85
+ if (rule.type === 'non_fast_forward') out.blocksForcePush = true;
86
+ });
87
+ });
88
+ if (classic) {
89
+ out.protected = true;
90
+ const pr = classic.required_pull_request_reviews;
91
+ if (pr) {
92
+ out.pullRequestRequired = true;
93
+ out.approvals = Math.max(out.approvals, pr.required_approving_review_count ?? 0);
94
+ if (pr.dismiss_stale_reviews) out.dismissStale = true;
95
+ }
96
+ if (classic.allow_deletions?.enabled === false) out.blocksDeletion = true;
97
+ if (classic.allow_force_pushes?.enabled === false) out.blocksForcePush = true;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // ---------- gather
103
+
104
+ /**
105
+ * Collect facts. `probes`:
106
+ * tool(name, args) -> stdout string, or throws when the tool is missing or fails
107
+ * gh(args) -> stdout (read-only GitHub calls)
108
+ * exists(path) -> boolean read(path) -> string
109
+ * skillDirs -> directories that may hold skills
110
+ * repo
111
+ * git(args) -> stdout (optional) readFolder(dir) -> [{ path, content }] (optional) os, pluginRoot (optional)
112
+ */
113
+ export function gatherFacts(p) {
114
+ const f = { repo: p.repo ?? null };
115
+ const tryRun = (fn, fallback = null) => {
116
+ try {
117
+ return fn();
118
+ } catch {
119
+ return fallback;
120
+ }
121
+ };
122
+
123
+ f.node = process.versions.node;
124
+ f.openspec = tryRun(() => String(p.tool('openspec', ['--version'])).trim());
125
+ f.git = tryRun(() => Boolean(p.tool('git', ['--version'])), false);
126
+ f.gh = tryRun(() => Boolean(p.tool('gh', ['--version'])), false);
127
+ f.ghAuth = f.gh ? tryRun(() => { p.tool('gh', ['auth', 'status']); return true; }, false) : false;
128
+ f.skills = {};
129
+ f.manualOnlySkills = [];
130
+ REQUIRED_SKILLS.forEach((name) => {
131
+ const dir = p.skillDirs.find((d) => p.exists(`${d}/${name}`));
132
+ f.skills[name] = dir !== undefined;
133
+ // A skill that sets `disable-model-invocation` can only be launched by the person; an unreadable file counts as invocable.
134
+ const head = dir === undefined ? '' : tryRun(() => String(p.read(`${dir}/${name}/SKILL.md`)).split(/\n---\s*\n/)[0], '');
135
+ if (/^disable-model-invocation:\s*true\s*$/m.test(head)) f.manualOnlySkills.push(name);
136
+ });
137
+
138
+ // What was installed with the plugin: the folders it runs from and its version. A package that lost one of them
139
+ // cannot run its commands, and the version is what the workflows should pin.
140
+ const installedRoot = p.pluginRoot ?? PLUGIN_ROOT;
141
+ f.pluginFolders = ['scripts', 'skills', 'agents', 'stacks', 'templates'].filter((d) => tryRun(() => readdirSync(resolve(installedRoot, d)).length > 0, false) !== true);
142
+ f.pluginVersion = tryRun(() => JSON.parse(readFileSync(resolve(installedRoot, '.claude-plugin', 'plugin.json'), 'utf8')).version ?? null, null);
143
+
144
+ f.configText = p.exists('relevo.yml') ? p.read('relevo.yml') : null;
145
+ const loaded = f.configText === null ? null : loadConfig(f.configText, profileLookups({ pluginRoot: p.pluginRoot ?? PLUGIN_ROOT, read: readerFor(p, tryRun) }));
146
+ f.config = loaded?.ok ? loaded.config : null;
147
+ f.configErrors = loaded && !loaded.ok ? loaded.errors.map((e) => e.message) : [];
148
+ f.rulesFilesMissing = (f.config?.qa?.rules_from ?? []).filter((file) => !p.exists(file));
149
+ f.rulesFileMissing = f.rulesFilesMissing.length > 0;
150
+
151
+ gatherPlatforms(f, p, tryRun);
152
+
153
+ f.openspecRoot = p.exists('openspec/config.yaml') || p.exists('openspec/config.yml');
154
+ f.openspecRootTracked = tryRun(() => String(p.tool('git', ['ls-files', 'openspec'])).trim().length > 0, false);
155
+ f.evidenceIgnored = p.exists('.gitignore') && /^\/?evidence\/?\s*$/m.test(p.read('.gitignore'));
156
+
157
+ // Workflows: what runs on GitHub is what is on the default branch, not what is in the working copy, so once the
158
+ // repository is known they are read from there. `present` says whether the file is where it has to be; `local`
159
+ // and `remote` say where it is.
160
+ f.workflows = {};
161
+ let texts = {};
162
+ WORKFLOW_FILES.forEach((file) => {
163
+ const local = p.exists(`.github/workflows/${file}`);
164
+ f.workflows[file] = { present: local, local, remote: null };
165
+ if (local) texts[file] = p.read(`.github/workflows/${file}`);
166
+ });
167
+ let names = tryRun(() => p.listDir('.github/workflows'), []);
168
+ f.workflowSource = 'working copy';
169
+
170
+ if (f.repo && f.ghAuth) {
171
+ const api = (path, paginate = false) => JSON.parse(p.gh(paginate ? ['api', '--paginate', '--slurp', path] : ['api', path]));
172
+ const flat = (data) => (Array.isArray(data) && Array.isArray(data[0]) ? data.flat() : data);
173
+ const base = f.config?.branches?.base ?? 'dev';
174
+ const release = f.config?.branches?.release ?? 'main';
175
+
176
+ const repoInfo = tryRun(() => api(`repos/${f.repo}`), {});
177
+ f.defaultBranch = repoInfo.default_branch ?? release;
178
+ const sa = repoInfo.security_and_analysis;
179
+ f.secretScanning = sa ? { scanning: sa.secret_scanning?.status === 'enabled', pushProtection: sa.secret_scanning_push_protection?.status === 'enabled' } : null;
180
+ f.labels = tryRun(() => flat(api(`repos/${f.repo}/labels?per_page=100`, true)).map((l) => l.name));
181
+ f.branches = {
182
+ base: tryRun(() => { api(`repos/${f.repo}/branches/${encodeURIComponent(base)}`); return true; }, false),
183
+ release: tryRun(() => { api(`repos/${f.repo}/branches/${encodeURIComponent(release)}`); return true; }, false),
184
+ };
185
+
186
+ let rulesets = [];
187
+ f.protectionUnavailable = null;
188
+ try {
189
+ const list = flat(api(`repos/${f.repo}/rulesets`));
190
+ rulesets = list.map((r) => api(`repos/${f.repo}/rulesets/${r.id}`));
191
+ } catch (err) {
192
+ const text = String(err.stderr ?? err.message ?? err);
193
+ if (/Upgrade to GitHub|not available/i.test(text)) f.protectionUnavailable = text.trim().split('\n')[0];
194
+ else f.protectionError = text.trim().split('\n')[0];
195
+ }
196
+ f.pushScope = rulesets.some((r) => r.name === 'relevo-push-scope' && r.enforcement === 'active');
197
+ f.protection = {};
198
+ [[base, 'base'], [release, 'release']].forEach(([branch, key]) => {
199
+ const classic = f.branches[key] ? tryRun(() => api(`repos/${f.repo}/branches/${encodeURIComponent(branch)}/protection`)) : null;
200
+ f.protection[key] = summarizeProtection({ rulesets, classic, branch, defaultBranch: f.defaultBranch });
201
+ });
202
+
203
+ f.canApprove = tryRun(() => api(`repos/${f.repo}/actions/permissions/workflow`).can_approve_pull_request_reviews, null);
204
+ f.secrets = tryRun(() => api(`repos/${f.repo}/actions/secrets`).secrets.map((s) => s.name));
205
+ f.cloudVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${CLOUD_VARIABLE}`).value, null);
206
+ // The repositories this one may depend on: can they be read, and are they private (then a token is needed)?
207
+ f.links = (f.config?.links ?? []).map((link) => {
208
+ const info = tryRun(() => api(`repos/${link}`), null);
209
+ return { repo: link, reachable: info !== null, private: info ? info.private === true : null };
210
+ });
211
+ const wf = tryRun(() => api(`repos/${f.repo}/actions/workflows`).workflows, []);
212
+ WORKFLOW_FILES.forEach((file) => {
213
+ const found = wf.find((w) => w.path === `.github/workflows/${file}`);
214
+ f.workflows[file].enabled = found ? found.state === 'active' : null;
215
+ });
216
+ const ref = encodeURIComponent(f.defaultBranch);
217
+ let remoteNames = null;
218
+ try {
219
+ remoteNames = api(`repos/${f.repo}/contents/.github/workflows?ref=${ref}`).map((e) => e.name);
220
+ } catch (err) {
221
+ if (/404|Not Found/i.test(String(err.stderr ?? err.message ?? err))) remoteNames = [];
222
+ }
223
+ if (remoteNames !== null) {
224
+ names = remoteNames;
225
+ texts = {};
226
+ WORKFLOW_FILES.forEach((file) => {
227
+ const remote = remoteNames.includes(file);
228
+ f.workflows[file].remote = remote;
229
+ f.workflows[file].present = remote;
230
+ if (remote) texts[file] = tryRun(() => Buffer.from(api(`repos/${f.repo}/contents/.github/workflows/${file}?ref=${ref}`).content, 'base64').toString('utf8'), '');
231
+ });
232
+ f.workflowSource = `default branch (${f.defaultBranch})`;
233
+ }
234
+ derivePluginFacts();
235
+ const plugin = f.pluginMarketplace && /^https:\/\/(?:.*@)?github\.com\/([^/@\s]+\/[^/@\s]+?)(?:\.git)?$/.exec(f.pluginMarketplace);
236
+ const slug = plugin ? plugin[1] : f.pluginCheckouts[0]?.repo;
237
+ f.pluginRepoPrivate = slug ? tryRun(() => api(`repos/${slug}`).private, null) : null;
238
+ } else {
239
+ derivePluginFacts();
240
+ }
241
+ return f;
242
+
243
+ // What the workflows say about the plugin repository, and whether a CI workflow exists (closes over texts, names).
244
+ function derivePluginFacts() {
245
+ const goText = texts['relevo-go.yml'] ?? '';
246
+ const marketplace = (/^\s*plugin_marketplaces:\s*(.+?)\s*$/m.exec(goText) ?? [])[1] ?? null;
247
+ // A URL that carries a credential (x-access-token:...@) can already be cloned by the runner.
248
+ f.pluginMarketplaceHasToken = marketplace !== null && /^https:\/\/.*@github\.com\//.test(marketplace);
249
+ f.pluginMarketplace = marketplace;
250
+ // Workflows that check out the plugin repository for its scripts: do they pass a token that can read it?
251
+ WORKFLOW_FILES.forEach((file) => { if (typeof texts[file] === 'string') f.workflows[file].text = texts[file]; });
252
+ f.pluginCheckouts = [];
253
+ Object.entries(texts).forEach(([file, text]) => {
254
+ const m = /repository:\s*([\w.-]+\/[\w.-]+)\s*\n([\s\S]*?)path:\s*\.relevo-plugin/.exec(text);
255
+ if (m) f.pluginCheckouts.push({ file, repo: m[1], hasToken: /\n\s*token:/.test(`\n${m[2]}`) });
256
+ });
257
+ // The version of the package each workflow runs its scripts from (null when it has no pin).
258
+ f.pluginPins = {};
259
+ WORKFLOW_FILES.forEach((file) => {
260
+ if (typeof texts[file] === 'string') f.pluginPins[file] = (/^\s*RELEVO_VERSION:\s*['"]?([\w.+-]+)['"]?\s*$/m.exec(texts[file]) ?? [])[1] ?? null;
261
+ });
262
+ f.hasCi = names.some((n) => /\.ya?ml$/.test(n) && !n.startsWith('relevo-'));
263
+ }
264
+ }
265
+
266
+ /** Reads a file: the plugin's own files from disk, the repository's through the probes. Null when absent. */
267
+ function readerFor(p, tryRun) {
268
+ const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
269
+ return (path) => {
270
+ if (path.startsWith(`${pluginRoot}/`)) return tryRun(() => readFileSync(path, 'utf8'), null);
271
+ return p.exists(path) ? p.read(path) : null;
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The platforms of relevo.yml: the profile each one resolves to, the tools and target tools it needs, the extra
277
+ * skills of each role and the state of the skills lock. The plugin's own files are read from disk, the
278
+ * repository's through the probes.
279
+ */
280
+ function gatherPlatforms(f, p, tryRun) {
281
+ f.platformFacts = [];
282
+ f.macos = (p.os ?? process.platform) === 'darwin';
283
+ f.autopilot = null;
284
+ f.roleSkills = null;
285
+ f.skillLockDifferences = [];
286
+ if (!f.config) return;
287
+
288
+ const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
289
+ const read = readerFor(p, tryRun);
290
+ const probed = new Map();
291
+ const available = (tool) => {
292
+ if (!probed.has(tool)) probed.set(tool, tryRun(() => Boolean(p.tool(tool, TOOL_PROBE[tool] ?? ['--version'])), false));
293
+ return probed.get(tool);
294
+ };
295
+
296
+ Object.entries(f.config.platforms).forEach(([name, platform]) => {
297
+ const resolved = resolveProfile({ platform, pluginRoot, read });
298
+ const entry = { name, stack: platform.stack, path: platform.path, targets: platform.targets, profile: resolved.ok ? resolved.profile : null, errors: resolved.ok ? [] : resolved.errors, tools: [], targetTools: [] };
299
+ if (resolved.ok) {
300
+ entry.tools = resolved.profile.tools.map((tool) => ({ tool, ok: available(tool) }));
301
+ entry.targetTools = platform.targets.map((target) => {
302
+ const tool = resolved.profile.targets[target] ?? '';
303
+ return { target, tool, ok: tool === '' ? true : available(tool) };
304
+ });
305
+ }
306
+ f.platformFacts.push(entry);
307
+ });
308
+
309
+ // What the Autopilot of `review` needs, only when the repository asks for it.
310
+ const mode = f.config.review?.autopilot ?? 'off';
311
+ if (mode !== 'off') {
312
+ const join = (dir, file) => (dir === '.' || dir === '' ? file : `${dir}/${file}`);
313
+ f.autopilot = {
314
+ mode,
315
+ platforms: Object.entries(f.config.platforms).map(([name, platform]) => {
316
+ const entry = { name, stack: platform.stack, path: platform.path };
317
+ if (platform.stack === 'flutter') {
318
+ entry.driverEntry = p.exists(join(platform.path, 'lib/main_driver.dart'));
319
+ const pubspec = p.exists(join(platform.path, 'pubspec.yaml')) ? tryRun(() => p.read(join(platform.path, 'pubspec.yaml')), '') : '';
320
+ entry.driverDependency = /^\s*flutter_driver\s*:/m.test(pubspec ?? '');
321
+ } else if (platform.stack === 'vue') {
322
+ entry.playwright = tryRun(() => Boolean(p.tool('node', ['-e', "require.resolve('playwright')"])), false);
323
+ }
324
+ return entry;
325
+ }),
326
+ };
327
+ }
328
+
329
+ // Extra skills per role, read from the base branch the way `go` reads them.
330
+ if (p.git) {
331
+ const baseRef = `origin/${f.config.branches.base}`;
332
+ if (tryRun(() => { p.git(['rev-parse', '--verify', '--quiet', baseRef]); return true; }, false)) {
333
+ f.roleSkills = {};
334
+ f.platformFacts.filter((e) => e.profile).forEach((e) => {
335
+ f.roleSkills[e.name] = {};
336
+ ROLES.forEach((role) => {
337
+ const skills = resolveRoleSkills({ role, profile: e.profile, git: p.git, baseRef, allowScripts: f.config.skills.allow_scripts });
338
+ skills.forEach((s) => { if (s.path) s.bytes = tryRun(() => Buffer.byteLength(String(read(s.path) ?? '')), 0); });
339
+ f.roleSkills[e.name][role] = skills;
340
+ });
341
+ });
342
+ }
343
+ }
344
+
345
+ // The skills lock: each skill folder must still have the hash it was installed with.
346
+ if (p.exists('skills-lock.json') && p.readFolder) {
347
+ const lock = tryRun(() => JSON.parse(p.read('skills-lock.json')), null);
348
+ const hashes = {};
349
+ Object.keys(lock?.skills ?? {}).forEach((name) => {
350
+ const dir = SKILL_DIRS.find((d) => p.exists(`${d}/${name}`));
351
+ hashes[name] = dir ? tryRun(() => folderHash(p.readFolder(`${dir}/${name}`)), null) : null;
352
+ });
353
+ f.skillLockDifferences = lockDifferences(lock, hashes);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Where `go` builds: the credential mode from the secret names, the repository variable that pauses the cloud, a
359
+ * reminder about the policy for a subscription, and the quota warning for a subscription with many agents.
360
+ */
361
+ function executionChecks(f) {
362
+ if (!Array.isArray(f.secrets)) return [];
363
+ const r = [];
364
+ const { mode } = credentialMode(f.secrets);
365
+ const goWorkflow = f.workflows?.['relevo-go.yml'];
366
+ const installed = Boolean(goWorkflow?.present);
367
+ // The credential line the workflow uses, when its text is known (the one that is not commented out).
368
+ const text = goWorkflow?.text ?? '';
369
+ const used = /^\s*claude_code_oauth_token:/m.test(text) ? 'subscription' : /^\s*anthropic_api_key:/m.test(text) ? 'api_key' : null;
370
+
371
+ if (mode === 'both') r.push(warn('execution.credential', `both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN exist${used ? `; the workflow uses the ${used === 'api_key' ? 'API key' : 'subscription token'}` : ''}`, 'keep only the secret the workflow uses, so that it is clear which account pays'));
372
+ else if (mode === 'api_key') r.push(pass('execution.credential', 'the cloud builds with an API key: run_max_cost is a real ceiling on spending'));
373
+ else if (mode === 'subscription') r.push(pass('execution.credential', 'the cloud builds with a subscription token: run_max_cost is only a notional ceiling, and the quota is shared with the use of the account that owns the token'));
374
+ else r.push(pass('execution.credential', installed ? 'no model credential: the cloud cannot build (see the warning about the secret)' : 'no model credential and no go workflow: go builds on machines only'));
375
+
376
+ if (mode === 'subscription' || (mode === 'both' && used === 'subscription')) {
377
+ r.push(pass('execution.policy', 'reminder: read the current Anthropic terms on automated use of a subscription token (and the README of claude-code-action) before relying on it for a team'));
378
+ if ((f.config?.limits?.concurrent_agents ?? 1) > 1) {
379
+ r.push(warn('execution.concurrency', `limits.concurrent_agents is ${f.config.limits.concurrent_agents} with a subscription, whose quota is shared by every run and by the interactive use of the account`, 'set limits.concurrent_agents to 1'));
380
+ }
381
+ }
382
+
383
+ const variable = f.cloudVariable ?? null;
384
+ if (String(variable ?? '').trim().toLowerCase() === 'off') r.push(pass('execution.cloud', `the cloud is paused (${CLOUD_VARIABLE} is off): ready issues are built on machines with /relevo:go`));
385
+ else if (cloudBuilds({ workflowPresent: installed, variable })) r.push(pass('execution.cloud', `the cloud builds ready issues (set the repository variable ${CLOUD_VARIABLE} to off to pause it)`));
386
+ else r.push(pass('execution.cloud', 'the go workflow is not installed: ready issues are built on machines with /relevo:go'));
387
+ return r;
388
+ }
389
+
390
+ /**
391
+ * The linked repositories (`links`): each one readable, and the read-only secret for private ones. Nothing is said
392
+ * when `links` is empty. The secret is checked by name only; doctor never reads its value.
393
+ */
394
+ function linkChecks(f) {
395
+ const links = f.links ?? [];
396
+ if (links.length === 0) return [];
397
+ const r = [];
398
+ links.forEach((l) => {
399
+ if (l.reachable) r.push(pass(`links.${l.repo}`, `${l.repo} can be read (${l.private ? 'private' : 'public'})`));
400
+ else r.push(fail(`links.${l.repo}`, `${l.repo} in links cannot be read with your sign-in`, 'check the name in links, and that you have access to the repository'));
401
+ });
402
+ const anyPrivate = links.some((l) => l.private === true);
403
+ if (!Array.isArray(f.secrets)) {
404
+ r.push(warn('links.secret', 'could not list the repository secrets, so RELEVO_LINKS_TOKEN was not checked', 'check that you may read them, or look in Settings > Secrets'));
405
+ } else if (f.secrets.includes('RELEVO_LINKS_TOKEN')) {
406
+ r.push(pass('links.secret', 'the RELEVO_LINKS_TOKEN secret exists for reading linked repositories'));
407
+ } else if (anyPrivate) {
408
+ r.push(fail('links.secret', `${links.filter((l) => l.private).map((l) => l.repo).join(', ')} is private and there is no RELEVO_LINKS_TOKEN secret, so a dependency on it can never be read`, 'create a fine-grained token with read access to issues and pull requests of that repository and store it as the secret RELEVO_LINKS_TOKEN (Relevo only reads with it)'));
409
+ } else {
410
+ r.push(warn('links.secret', 'every linked repository is public, so no RELEVO_LINKS_TOKEN is needed now, but a private one added later would not be readable', 'create the secret RELEVO_LINKS_TOKEN (read-only) before linking a private repository'));
411
+ }
412
+ return r;
413
+ }
414
+
415
+ /** What the profile of each platform asks for: tools, targets, layers, skills. */
416
+ function platformChecks(f) {
417
+ const r = [];
418
+ const seen = new Set();
419
+ (f.platformFacts ?? []).forEach((e) => {
420
+ if (!e.profile) {
421
+ r.push(fail(`platform.${e.name}.profile`, `platform ${e.name}: ${e.errors.join('; ')}`, `define the profile of "${e.stack}": ${profileLocations(e.stack)}`));
422
+ return;
423
+ }
424
+ const layers = e.profile.layers.map((l) => (l === 'plugin' ? 'default of the plugin' : `.relevo/stacks/${e.stack}.md`)).join(' + ');
425
+ r.push(pass(`platform.${e.name}.profile`, `platform ${e.name} (${e.stack}, ${e.path}): profile = ${layers}`));
426
+ (e.profile.ignored ?? []).forEach((x) => {
427
+ r.push(warn(`platform.${e.name}.ignored.${x.key}`, `platform ${e.name}: the repository profile sets ${x.key} = "${x.value}", which is ignored (the plugin keeps "${x.kept}")`, `a command is replaced with platforms.${e.name}.checks in relevo.yml, not in the profile`));
428
+ });
429
+ e.tools.forEach(({ tool, ok }) => {
430
+ if (seen.has(`tool:${tool}`)) return;
431
+ seen.add(`tool:${tool}`);
432
+ r.push(ok ? pass(`tools.${tool}`, `${tool} is installed`) : fail(`tools.${tool}`, `${tool} is not installed (platform ${e.name}, stack ${e.stack})`, TOOL_INSTALL[tool] ?? `install ${tool}`));
433
+ });
434
+ e.targetTools.forEach(({ target, tool, ok }) => {
435
+ const id = `tools.target.${e.name}.${target}`;
436
+ if (tool === '') r.push(pass(id, `target ${target} of ${e.name} needs no extra tool`));
437
+ else if (tool === 'xcrun' && !f.macos) r.push(pass(id, `target ${target} of ${e.name}: iOS can only be built and reviewed on macOS (a notice, not a problem)`));
438
+ else if (ok) r.push(pass(id, `target ${target} of ${e.name}: ${tool} is installed`));
439
+ else r.push(warn(id, `target ${target} of ${e.name}: ${tool} is not installed; \`review\` needs it for this target`, TOOL_INSTALL[tool] ?? `install ${tool}`));
440
+ });
441
+ e.profile.requires_skills.forEach((name) => {
442
+ if (f.skills[name] === undefined) r.push(warn(`skills.${name}`, `platform ${e.name} requires the skill ${name}; it was not checked`, SKILL_INSTALL[name] ?? `install the skill ${name}`));
443
+ });
444
+ });
445
+
446
+ Object.entries(f.roleSkills ?? {}).forEach(([platform, roles]) => {
447
+ Object.entries(roles).forEach(([role, skills]) => {
448
+ skills.filter((s) => s.left_out).forEach((s) => {
449
+ r.push(warn(`skills.role.${platform}.${role}.${s.name}`, `skill ${s.name} (${role}, ${platform}) is left out: ${s.reason}`, 'fix the reason, or remove the skill from the profile'));
450
+ });
451
+ skills.filter((s) => s.path && s.scripts).forEach((s) => {
452
+ r.push(pass(`skills.scripts.${platform}.${role}.${s.name}`, `skill ${s.name} has scripts and is allowed by skills.allow_scripts (a notice)`));
453
+ });
454
+ const budget = skillBudget(skills.filter((s) => s.path).map((s) => s.bytes ?? 0));
455
+ if (budget.over) r.push(warn(`skills.size.${platform}.${role}`, `the skills of ${role} (${platform}) add up to ${Math.round(budget.bytes / 1024)} KB, over the ${SIZE_BUDGET_BYTES / 1024} KB budget; the agent reads them at every run`, 'keep the skills of a role small, or list fewer'));
456
+ });
457
+ });
458
+ (f.autopilot?.platforms ?? []).forEach((e) => {
459
+ if (e.stack === 'flutter') {
460
+ if (!e.driverEntry) r.push(warn(`autopilot.${e.name}.entrypoint`, `review.autopilot is ${f.autopilot.mode} but platform ${e.name} has no lib/main_driver.dart, so the agent cannot drive the app`, 'add lib/main_driver.dart that calls enableFlutterDriverExtension() and then runs the app'));
461
+ else r.push(pass(`autopilot.${e.name}.entrypoint`, `platform ${e.name}: lib/main_driver.dart exists`));
462
+ if (!e.driverDependency) r.push(warn(`autopilot.${e.name}.dependency`, `platform ${e.name} does not depend on flutter_driver (dev_dependencies), which the driven review needs`, 'add flutter_driver: { sdk: flutter } under dev_dependencies in pubspec.yaml'));
463
+ else r.push(pass(`autopilot.${e.name}.dependency`, `platform ${e.name} depends on flutter_driver`));
464
+ } else if (e.stack === 'vue') {
465
+ if (!e.playwright) r.push(warn(`autopilot.${e.name}.playwright`, `review.autopilot is ${f.autopilot.mode} but Playwright cannot be resolved from this project (platform ${e.name})`, 'npm install -D playwright && npx playwright install chromium'));
466
+ else r.push(pass(`autopilot.${e.name}.playwright`, `Playwright is available for platform ${e.name}`));
467
+ } else {
468
+ r.push(warn(`autopilot.${e.name}.adapter`, `review.autopilot is ${f.autopilot.mode}, but a driven review of the stack ${e.stack} (platform ${e.name}) is not available`, 'set review.autopilot to off, or review that platform by hand'));
469
+ }
470
+ });
471
+ (f.skillLockDifferences ?? []).forEach((name) => {
472
+ r.push(warn(`skills.lock.${name}`, `the skill ${name} differs from the hash in skills-lock.json`, 'review the change to the skill, then run `npx skills` to refresh the lock, or restore the skill'));
473
+ });
474
+ return r;
475
+ }
476
+
477
+ /** Turn facts into results. Pure. */
478
+ export function runChecks(f) {
479
+ const r = [];
480
+
481
+ // tools
482
+ r.push(versionAtLeast(f.node, '20.19') ? pass('tools.node', `Node.js ${f.node}`) : fail('tools.node', `Node.js ${f.node} is older than 20.19`, 'install Node.js 20.19 or newer'));
483
+ r.push(f.openspec ? pass('tools.openspec', `OpenSpec ${f.openspec}`) : fail('tools.openspec', 'the OpenSpec command-line tool is not installed', `install it: ${OPENSPEC_INSTALL}`));
484
+ r.push(f.git ? pass('tools.git', 'git is installed') : fail('tools.git', 'git is not installed', 'install git'));
485
+ r.push(f.gh ? pass('tools.gh', 'the GitHub CLI is installed') : fail('tools.gh', 'the GitHub CLI (gh) is not installed', 'install it from https://cli.github.com'));
486
+ if (f.gh) r.push(f.ghAuth ? pass('tools.gh-auth', 'signed in to GitHub') : fail('tools.gh-auth', 'the GitHub CLI is not signed in', 'run `gh auth login`'));
487
+ r.push(...platformChecks(f));
488
+
489
+ // skills that think invokes
490
+ REQUIRED_SKILLS.forEach((name) => {
491
+ const manual = (f.manualOnlySkills ?? []).includes(name);
492
+ r.push(f.skills[name] ? pass(`skills.${name}`, manual ? `skill ${name} is installed; it is reserved for you, so run /${name} by hand when \`think\` asks (a notice, not a problem)` : `skill ${name} is installed`) : fail(`skills.${name}`, `skill ${name} is not installed; \`think\` needs it`, SKILL_INSTALL[name]));
493
+ });
494
+
495
+ // configuration
496
+ if (f.configText === null) r.push(fail('config.valid', 'relevo.yml not found', 'run /relevo:init'));
497
+ else if (!f.config) r.push(fail('config.valid', `relevo.yml is invalid: ${f.configErrors.join('; ')}`, 'fix the settings named above and run `node scripts/validate-config.mjs`'));
498
+ else {
499
+ r.push(pass('config.valid', 'relevo.yml is valid'));
500
+ if (f.rulesFileMissing) r.push(warn('config.rules-file', `qa.rules_from points to ${f.rulesFilesMissing.map((x) => `"${x}"`).join(', ')}, which does not exist`, 'create that file or change qa.rules_from'));
501
+ }
502
+
503
+ // OpenSpec in the repository
504
+ if (f.openspecRoot && f.openspecRootTracked) r.push(pass('openspec.root', 'the OpenSpec root is tracked by git'));
505
+ else r.push(fail('openspec.root', 'the OpenSpec root is missing or not tracked by git, so a clean checkout (and the runner) has no `openspec/`', 'run /relevo:init, then commit openspec/config.yaml and the .gitkeep files'));
506
+ r.push(f.evidenceIgnored ? pass('evidence.ignored', 'evidence/ is ignored by git') : fail('evidence.ignored', 'evidence/ is not in .gitignore; `review` would put screenshots in git', 'add the line `evidence/` to .gitignore (or run /relevo:init)'));
507
+
508
+ // GitHub side
509
+ if (!f.repo || !f.ghAuth) {
510
+ r.push(warn('repo.checks', 'the repository checks were skipped: no repository or no GitHub sign-in', 'run inside a GitHub repository after `gh auth login`'));
511
+ return r;
512
+ }
513
+ if (f.config) {
514
+ const wanted = labelDefinitions(f.config.labels).map((l) => l.name);
515
+ const missing = f.labels ? wanted.filter((n) => !f.labels.includes(n)) : null;
516
+ if (missing === null) r.push(warn('repo.labels', 'could not read the labels', 'check your access to the repository'));
517
+ else if (missing.length === 0) r.push(pass('repo.labels', 'all lifecycle labels exist'));
518
+ else r.push(fail('repo.labels', `missing labels: ${missing.join(', ')}`, 'run /relevo:init to create them'));
519
+ r.push(f.branches.base ? pass('repo.base-branch', `base branch ${f.config.branches.base} exists`) : fail('repo.base-branch', `base branch ${f.config.branches.base} does not exist`, `create it from ${f.config.branches.release}, or run /relevo:init`));
520
+ }
521
+
522
+ if (f.protectionError) {
523
+ r.push(warn('repo.protection', `could not read the branch rules (${f.protectionError}), so they were not checked`, 'run doctor again; if it keeps failing, check your access to the repository'));
524
+ } else if (f.protectionUnavailable) {
525
+ r.push(warn('repo.protection', `branch protection is not available on this repository (${f.protectionUnavailable})`, 'make the repository public or upgrade the plan; until then approval and merge are only enforced by the written confirmation in `ship`'));
526
+ } else {
527
+ [['base', f.config?.branches?.base ?? 'base'], ['release', f.config?.branches?.release ?? 'release']].forEach(([key, name]) => {
528
+ const p = f.protection?.[key];
529
+ if (!p || !p.pullRequestRequired) r.push(fail(`repo.protection.${key}`, `${name} does not require a pull request`, `protect ${name}: require a pull request with at least one approval (/relevo:init sets this up)`));
530
+ else if (f.config?.review?.mode !== 'none' && p.approvals < 1) r.push(fail(`repo.protection.${key}`, `${name} requires a pull request but no approval`, `require at least 1 approving review on ${name}`));
531
+ else r.push(pass(`repo.protection.${key}`, `${name} requires a pull request${p.approvals ? ` with ${p.approvals} approval(s)` : ''}`));
532
+ if (p?.dismissStale) r.push(fail(`repo.dismiss-stale.${key}`, `${name} dismisses approvals when new commits are pushed; the spec archive commit pushed by \`ship\` would dismiss the approval`, `turn off "dismiss stale pull request approvals when new commits are pushed" on ${name}`));
533
+ });
534
+ }
535
+ if (!f.protectionUnavailable && !f.protectionError) {
536
+ r.push(f.pushScope ? pass('repo.push-scope', 'the agent can only create feature/* branches') : warn('repo.push-scope', 'nothing stops the unattended agent from creating branches other than feature/*', 'run /relevo:init to add the relevo-push-scope ruleset'));
537
+ }
538
+ if (f.secretScanning) {
539
+ const { scanning, pushProtection } = f.secretScanning;
540
+ r.push(scanning && pushProtection ? pass('repo.secret-scanning', 'secret scanning and push protection are on')
541
+ : warn('repo.secret-scanning', `secret scanning is ${scanning ? 'on' : 'off'} and push protection is ${pushProtection ? 'on' : 'off'}`, 'turn both on in Settings > Code security (init tries to; GitHub may need a public repository or a paid plan)'));
542
+ }
543
+ if (f.canApprove === true) r.push(fail('repo.automation-approve', 'workflows are allowed to approve pull requests', 'in Settings > Actions > General, turn off "Allow GitHub Actions to create and approve pull requests"'));
544
+ else if (f.canApprove === false) r.push(pass('repo.automation-approve', 'workflows cannot approve pull requests'));
545
+ else r.push(warn('repo.automation-approve', 'could not read whether workflows may approve pull requests', 'check Settings > Actions > General by hand'));
546
+
547
+ // automation readiness
548
+ if (f.secrets === null) r.push(warn('automation.secret', 'could not list the repository secrets', 'check that you may read them, or look in Settings > Secrets'));
549
+ else if (f.secrets.some((n) => ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(n))) r.push(pass('automation.secret', 'a model credential secret exists'));
550
+ else r.push(warn('automation.secret', 'no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN secret, so the cloud cannot build: a ready label only leaves a comment that says to run /relevo:go on a machine', 'for the cloud, add one to the repository secrets (init never touches it; for a subscription token remove all whitespace before storing it); for local builds nothing is needed'));
551
+
552
+ Object.entries(f.workflows).forEach(([file, w]) => {
553
+ const required = REQUIRED_WORKFLOWS.has(file);
554
+ const bad = (m, fix) => r.push(required ? fail(`automation.workflow.${file}`, m, fix) : warn(`automation.workflow.${file}`, m, fix));
555
+ if (!w.present && w.local && w.remote === false) bad(`${file} is only in your working copy, not on the default branch, and a workflow only runs from there`, 'commit it and push it to the default branch (through a pull request if the branch is protected)');
556
+ else if (!w.present) bad(`${file} is not installed`, 'run /relevo:init to copy it into .github/workflows/');
557
+ else if (w.enabled === false) bad(`${file} is disabled`, 'enable it in the Actions tab');
558
+ else if (w.enabled === null) r.push(warn(`automation.workflow.${file}`, `${file} is on the default branch but GitHub does not list it yet`, 'check the Actions tab, or run doctor again in a minute'));
559
+ else r.push(pass(`automation.workflow.${file}`, `${file} is installed and enabled`));
560
+ });
561
+ r.push(f.hasCi ? pass('automation.ci', 'an objective CI check exists') : fail('automation.ci', 'no CI workflow other than Relevo\'s was found; QA needs an objective check', 'add a workflow that runs the project\'s analysis and tests on pull requests'));
562
+
563
+ r.push(...pluginInstallChecks(f));
564
+ if (f.pluginMarketplace && f.pluginRepoPrivate === true && !f.pluginMarketplaceHasToken) {
565
+ r.push(fail('automation.plugin-clone', `the plugin repository (${f.pluginMarketplace}) is private, so the runner cannot clone it without a credential`, 'store a read-only token as RELEVO_PLUGIN_TOKEN and use the private-repository line in relevo-go.yml'));
566
+ }
567
+ r.push(...linkChecks(f));
568
+ r.push(...executionChecks(f));
569
+ if (f.pluginRepoPrivate === true && Array.isArray(f.secrets)) {
570
+ if (f.secrets.includes('RELEVO_PLUGIN_TOKEN')) r.push(pass('automation.plugin-secret', 'the RELEVO_PLUGIN_TOKEN secret exists for the private plugin repository'));
571
+ else r.push(fail('automation.plugin-secret', 'the plugin repository is private and the repository has no RELEVO_PLUGIN_TOKEN secret, so the workflows fall back to the default token and fail at checkout', 'store a read-only fine-grained token that can read the plugin repository as the repository secret RELEVO_PLUGIN_TOKEN; a secret defined at organization level is not visible to this check'));
572
+ }
573
+ return r;
574
+ }
575
+
576
+ /**
577
+ * The installed plugin and what the workflows pin. A package that lost a folder cannot run; a workflow that clones
578
+ * the plugin repository for its scripts (the layout before the package) or has no pin is told to be replaced; a pin
579
+ * that differs from the installed version is only a warning, because a project chooses when it moves.
580
+ */
581
+ function pluginInstallChecks(f) {
582
+ const r = [];
583
+ if ((f.pluginFolders ?? []).length > 0) r.push(fail('plugin.install', `the installed plugin lacks ${f.pluginFolders.join(', ')}`, 'reinstall it: relevo install, or claude plugin install relevo@relevo'));
584
+ else r.push(pass('plugin.install', `the plugin has the folders it runs from${f.pluginVersion ? ` (version ${f.pluginVersion})` : ''}`));
585
+ const cloners = (f.pluginCheckouts ?? []).map((c) => c.file);
586
+ if (cloners.length > 0) r.push(fail('automation.plugin-layout', `${cloners.join(', ')} clone the plugin repository for their scripts, which downloads the documentation and the tests too`, 'copy the current templates: they install the published package at a pinned version (/relevo:init lists the difference)'));
587
+ const pins = f.pluginPins ?? {};
588
+ const unpinned = Object.entries(pins).filter(([file, v]) => v === null && !cloners.includes(file)).map(([file]) => file);
589
+ if (unpinned.length > 0) r.push(fail('automation.plugin-pin', `${unpinned.join(', ')} do not pin the version of the Relevo package`, 'copy the current templates, which set RELEVO_VERSION'));
590
+ const old = f.pluginVersion ? Object.entries(pins).filter(([, v]) => v !== null && v !== f.pluginVersion).map(([file, v]) => `${file} (${v})`) : [];
591
+ if (old.length > 0) r.push(warn('automation.plugin-version', `${old.join(', ')} pin a different version than the installed plugin (${f.pluginVersion})`, 'to move them, change RELEVO_VERSION to the installed version after reading the changelog'));
592
+ return r;
593
+ }
594
+
595
+ /** Text report. */
596
+ export function renderReport(results) {
597
+ const tag = { pass: 'pass', fail: 'FAIL', warn: 'warn' };
598
+ const lines = results.flatMap((x) => [`[${tag[x.status]}] ${x.message}`, ...(x.fix && x.status !== 'pass' ? [` fix: ${x.fix}`] : [])]);
599
+ const failed = results.filter((x) => x.status === 'fail').length;
600
+ const warned = results.filter((x) => x.status === 'warn').length;
601
+ lines.push('', failed === 0 ? (warned === 0 ? 'All green.' : `No failures, ${warned} warning(s).`) : `${failed} check(s) failed${warned ? `, ${warned} warning(s)` : ''}.`);
602
+ return lines.join('\n');
603
+ }
604
+
605
+ /** Wrap a `gh` client so that it can only read: `doctor` must never change anything. */
606
+ export function readOnlyGh(gh) {
607
+ return (args) => {
608
+ const isApi = args[0] === 'api';
609
+ const writes = args.some((a, i) => /^(-X|--method)$/.test(a) && String(args[i + 1]).toUpperCase() !== 'GET')
610
+ || args.some((a) => /^(-f|-F|--field|--raw-field|--input)$/.test(a));
611
+ if (!isApi || writes) throw new Error(`doctor is read-only: refusing "gh ${args.join(' ')}"`);
612
+ return gh(args);
613
+ };
614
+ }
@@ -0,0 +1,24 @@
1
+ // Where `go` builds. Implements the "execution-modes" specification: nothing is stored in relevo.yml, the mode is
2
+ // derived from what is installed, the secrets that exist and a repository variable.
3
+
4
+ export const API_KEY_SECRET = 'ANTHROPIC_API_KEY';
5
+ export const SUBSCRIPTION_SECRET = 'CLAUDE_CODE_OAUTH_TOKEN';
6
+ export const CLOUD_VARIABLE = 'RELEVO_CLOUD_GO';
7
+
8
+ /**
9
+ * The credential mode from the names of the repository secrets:
10
+ * { mode: 'api_key' | 'subscription' | 'both' | 'none', usable: boolean }.
11
+ */
12
+ export function credentialMode(secretNames = []) {
13
+ const key = secretNames.includes(API_KEY_SECRET);
14
+ const token = secretNames.includes(SUBSCRIPTION_SECRET);
15
+ if (key && token) return { mode: 'both', usable: true };
16
+ if (key) return { mode: 'api_key', usable: true };
17
+ if (token) return { mode: 'subscription', usable: true };
18
+ return { mode: 'none', usable: false };
19
+ }
20
+
21
+ /** Does the cloud build? The go workflow is on the default branch and the variable is not `off`. */
22
+ export function cloudBuilds({ workflowPresent, variable = null }) {
23
+ return Boolean(workflowPresent) && String(variable ?? '').trim().toLowerCase() !== 'off';
24
+ }