@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,256 @@
1
+ // Decides what `go` does with every candidate issue. Pure function: data in, decisions out.
2
+ // Implements openspec/specs/go/spec.md (trigger, gates, queue, limits).
3
+ //
4
+ // Input:
5
+ // config effective configuration (see config.mjs)
6
+ // issues [{ number, title, body, state, labels: [names], assignees: [logins],
7
+ // milestone: { number, due_on } | null,
8
+ // ready_at: ISO | null, // when the ready label was last applied
9
+ // ready_by: login | null // who applied it }]
10
+ // completed issue numbers closed as completed (their pull request merged)
11
+ // running issue numbers with an agent run in progress
12
+ // permissions { login: 'admin' | 'maintain' | 'write' | 'triage' | 'read' | 'none' }
13
+ //
14
+ // Output: { decisions: [...], summary }, in the order the issues were processed.
15
+
16
+ import { parseIssueBody } from './issue-parse.mjs';
17
+ import { resolveLabelNames, transition } from './labels.mjs';
18
+ import { branchName, changeId } from './branch.mjs';
19
+ import { issueHash } from './issue-hash.mjs';
20
+ import { resolvePlatform } from './platform-of.mjs';
21
+
22
+ const MAY_TRIGGER = new Set(['admin', 'maintain', 'write']);
23
+ // In a public repository only maintainers may start work: anyone with write access could otherwise start a
24
+ // run from an issue written by a stranger.
25
+ const MAY_TRIGGER_PUBLIC = new Set(['admin', 'maintain']);
26
+
27
+ function byReadyAt(a, b) {
28
+ const ta = a.ready_at ? Date.parse(a.ready_at) : Number.POSITIVE_INFINITY;
29
+ const tb = b.ready_at ? Date.parse(b.ready_at) : Number.POSITIVE_INFINITY;
30
+ if (ta !== tb) return ta - tb;
31
+ return a.number - b.number;
32
+ }
33
+
34
+ /**
35
+ * Without a model credential in the repository the cloud cannot build: a `start` decision becomes `local`, which
36
+ * claims nothing and moves no label, and says to run `go` on a machine or to add the secret.
37
+ * Every other decision is returned as it is.
38
+ */
39
+ export function withoutCredential(decisions, issues = []) {
40
+ return decisions.map((d) => {
41
+ if (d.action !== 'start') return d;
42
+ const issue = issues.find((i) => i.number === d.issue);
43
+ return {
44
+ issue: d.issue,
45
+ action: 'local',
46
+ reason: 'the repository has no model credential, so the cloud cannot build it',
47
+ labels: { add: [], remove: [] },
48
+ comment: { kind: 'cloud_not_configured', data: { issue: d.issue, actor: issue?.ready_by ?? d.assignee } },
49
+ assign: null,
50
+ };
51
+ });
52
+ }
53
+
54
+ export function planGo({ config, issues, completed = [], completedExternal = [], unreadable = [], repo = null, running = [], permissions = {} }) {
55
+ const names = resolveLabelNames(config.labels);
56
+ const has = (issue, key) => issue.labels.includes(names[key]);
57
+ const open = issues.filter((i) => i.state === 'open');
58
+ const completedSet = new Set(completed);
59
+ const completedExternalSet = new Set(completedExternal.map((r) => r.toLowerCase()));
60
+ const unreadableSet = new Set(unreadable.map((r) => r.toLowerCase()));
61
+ const linked = (config.links ?? []);
62
+ const own = repo ? repo.toLowerCase() : null;
63
+
64
+ const runningSet = new Set(running);
65
+ open.filter((i) => has(i, 'building')).forEach((i) => runningSet.add(i.number));
66
+ let freeSlots = Math.max(0, config.limits.concurrent_agents - runningSet.size);
67
+
68
+ const builtBy = new Map();
69
+ open.filter((i) => has(i, 'built')).forEach((i) => {
70
+ const owner = i.assignees[0];
71
+ if (owner) builtBy.set(owner, (builtBy.get(owner) ?? 0) + 1);
72
+ });
73
+
74
+ const candidates = open
75
+ .filter((i) => has(i, 'ready') || has(i, 'queued') || has(i, 'blocked'))
76
+ .sort(byReadyAt);
77
+
78
+ const decisions = [];
79
+
80
+ function decision(issue, action, reason, extra = {}) {
81
+ const labels = extra.labels ?? { add: [], remove: [] };
82
+ const out = {
83
+ issue: issue.number,
84
+ action,
85
+ reason,
86
+ labels,
87
+ comment: extra.comment ?? null,
88
+ assign: extra.assign ?? null,
89
+ };
90
+ if (action === 'start') Object.assign(out, extra.start);
91
+ decisions.push(out);
92
+ }
93
+
94
+ const moveTo = (issue, key) => transition(issue.labels, key, config.labels);
95
+ const dropReady = (issue) => ({ add: [], remove: issue.labels.filter((l) => l === names.ready) });
96
+
97
+ candidates.forEach((issue) => {
98
+ if (runningSet.has(issue.number)) {
99
+ decision(issue, 'skip', 'an agent is already running for this issue');
100
+ return;
101
+ }
102
+
103
+ const parsed = parseIssueBody(issue.body);
104
+ const assignee = issue.assignees[0] ?? null;
105
+ // The platform the issue is for. When it cannot be resolved that is a missing part of the specification.
106
+ const platform = resolvePlatform({ platforms: config.platforms, body: issue.body });
107
+ // Dependencies: the own repository in full is a local reference; a repository outside `links` is a missing part
108
+ // of the specification (nothing is read from it); a token that is not a reference is too.
109
+ const localDeps = [...parsed.dependsOn];
110
+ const externalDeps = [];
111
+ parsed.dependsOnExternal.forEach((d) => {
112
+ if (own && d.repo.toLowerCase() === own) {
113
+ if (!localDeps.includes(d.number)) localDeps.push(d.number);
114
+ } else externalDeps.push(d);
115
+ });
116
+ const linkProblems = [
117
+ ...parsed.dependsOnInvalid.map((t) => `a valid Depends-on reference instead of "${t}" (write #12, or owner/repo#12 for a linked repository)`),
118
+ ...[...new Set(externalDeps.map((d) => d.repo))].filter((r) => !linked.some((l) => l.toLowerCase() === r.toLowerCase()))
119
+ .map((r) => `a Depends-on that names ${r}, which is not in links of relevo.yml (${linked.length ? linked.join(', ') : 'no repository is linked'})`),
120
+ ];
121
+ // A question left open in the issue is an ambiguity, not a missing part: the writer has to decide, not just add text.
122
+ const openQuestion = parsed.openQuestions[0]?.replace(/^[-*+]\s+/, '').slice(0, 120);
123
+ const ambiguity = [
124
+ ...(openQuestion ? [`an answer to the open question "${openQuestion}"`] : []),
125
+ ...(parsed.needsClarification ? ['the text NEEDS CLARIFICATION resolved'] : []),
126
+ ];
127
+ const missing = [...parsed.missing, ...(platform.ok ? [] : [platform.message]), ...linkProblems, ...ambiguity];
128
+ const needsSpec = () => {
129
+ const specWriter = config.team.spec_writer ?? issue.ready_by;
130
+ decision(issue, 'needs-spec', `the spec is incomplete: ${missing.join('; ')}`, {
131
+ labels: moveTo(issue, 'needs-spec'),
132
+ assign: [specWriter],
133
+ comment: {
134
+ kind: 'needs_spec',
135
+ data: { spec_writer: specWriter, issue: issue.number, title: issue.title, missing: missing.join('; '), reason: ambiguity.length > 0 ? 'ambiguous' : 'missing' },
136
+ },
137
+ });
138
+ };
139
+
140
+ if (has(issue, 'ready')) {
141
+ // Trigger: only people with write access may start work.
142
+ const permission = issue.ready_by ? permissions[issue.ready_by] : undefined;
143
+ const isPublic = config.trust === 'public';
144
+ if (!issue.ready_by || !(isPublic ? MAY_TRIGGER_PUBLIC : MAY_TRIGGER).has(permission)) {
145
+ decision(issue, 'denied', isPublic ? 'the ready label was not applied by a maintainer (trust is public)' : 'the ready label was not applied by someone with write access', {
146
+ labels: dropReady(issue),
147
+ comment: { kind: isPublic ? 'ready_denied_maintainer' : 'ready_denied', data: { issue: issue.number, actor: issue.ready_by ?? undefined } },
148
+ });
149
+ return;
150
+ }
151
+ if (!issue.milestone) {
152
+ decision(issue, 'no-milestone', 'the issue has no milestone', {
153
+ labels: dropReady(issue),
154
+ comment: { kind: 'no_milestone', data: { issue: issue.number, actor: issue.ready_by } },
155
+ });
156
+ return;
157
+ }
158
+ if (!assignee) {
159
+ decision(issue, 'no-assignee', 'the issue has no assignee', {
160
+ labels: dropReady(issue),
161
+ comment: { kind: 'no_assignee', data: { issue: issue.number, actor: issue.ready_by } },
162
+ });
163
+ return;
164
+ }
165
+ // Gate 1: the spec. The agent never invents scenarios.
166
+ if (missing.length > 0) {
167
+ needsSpec();
168
+ return;
169
+ }
170
+ }
171
+
172
+ // An issue that was edited after it passed the gate can still name a repository that is not linked.
173
+ if (linkProblems.length > 0) {
174
+ needsSpec();
175
+ return;
176
+ }
177
+
178
+ // Gate 2: dependencies must be merged, in this repository or in a linked one.
179
+ const blockers = [
180
+ ...localDeps.filter((n) => !completedSet.has(n)).map((n) => `#${n}`),
181
+ ...externalDeps.filter((d) => !completedExternalSet.has(`${d.repo}#${d.number}`.toLowerCase()))
182
+ .map((d) => {
183
+ const ref = `${d.repo}#${d.number}`;
184
+ return unreadableSet.has(ref.toLowerCase()) ? `${ref} (could not be read: check the RELEVO_LINKS_TOKEN secret and that ${d.repo} is in links)` : ref;
185
+ }),
186
+ ];
187
+ if (blockers.length > 0) {
188
+ const entering = !has(issue, 'blocked');
189
+ decision(issue, 'block', `waiting for ${blockers.join(', ')}`, {
190
+ labels: moveTo(issue, 'blocked'),
191
+ comment: entering
192
+ ? { kind: 'blocked_notice', data: { issue: issue.number, title: issue.title, blockers: blockers.join(', ') } }
193
+ : null,
194
+ });
195
+ return;
196
+ }
197
+
198
+ // Gate 3: capacity of the assignee.
199
+ const built = assignee ? builtBy.get(assignee) ?? 0 : 0;
200
+ if (assignee && built >= config.limits.built_unreviewed_per_person) {
201
+ decision(issue, 'queue', `${assignee} already has ${built} built issues waiting for review`, {
202
+ labels: moveTo(issue, 'queued'),
203
+ comment: has(issue, 'queued')
204
+ ? null
205
+ : { kind: 'queued_capacity', data: { issue: issue.number, title: issue.title, assignee, built, limit: config.limits.built_unreviewed_per_person } },
206
+ });
207
+ return;
208
+ }
209
+
210
+ // Global limit on concurrent agents.
211
+ if (freeSlots <= 0) {
212
+ decision(issue, 'queue', 'the limit of concurrent agents is reached', {
213
+ labels: moveTo(issue, 'queued'),
214
+ comment: has(issue, 'queued') ? null : { kind: 'queued_agents', data: { issue: issue.number, title: issue.title } },
215
+ });
216
+ return;
217
+ }
218
+
219
+ // A queued or blocked issue was edited after it passed the gate: it goes back for the platform, not to a build.
220
+ if (!platform.ok) {
221
+ needsSpec();
222
+ return;
223
+ }
224
+
225
+ freeSlots -= 1;
226
+ runningSet.add(issue.number);
227
+ decision(issue, 'start', 'all gates passed', {
228
+ labels: moveTo(issue, 'building'),
229
+ start: {
230
+ branch: branchName(issue.number, issue.title),
231
+ change_id: changeId(issue.number, issue.title),
232
+ base: config.branches.base,
233
+ assignee,
234
+ title: issue.title,
235
+ qa_retries: config.qa.retries,
236
+ run_max_turns: config.limits.run_max_turns,
237
+ run_max_cost: config.limits.run_max_cost,
238
+ // The text the issue had when it was claimed: `go` checks it before it writes the specification.
239
+ issue_hash: issueHash({ title: issue.title, body: issue.body }),
240
+ // Dependency files the issue lets the change touch (see "Protected paths").
241
+ allow_paths: parsed.allowPaths,
242
+ // Which platform the build is for: the agents use its profile, its folder and its targets.
243
+ platform: platform.name,
244
+ stack: platform.entry.stack,
245
+ path: platform.entry.path,
246
+ targets: platform.entry.targets,
247
+ },
248
+ });
249
+ });
250
+
251
+ const summary = {};
252
+ decisions.forEach((d) => {
253
+ summary[d.action] = (summary[d.action] ?? 0) + 1;
254
+ });
255
+ return { decisions, summary };
256
+ }
@@ -0,0 +1,32 @@
1
+ // Which platform an issue belongs to. Implements "An issue belongs to one platform" of the platforms specification.
2
+ //
3
+ // One pure function, used by the planner, the issue check, `platform-of.mjs` and the tests, so that they cannot
4
+ // disagree about what an issue says.
5
+
6
+ import { parseIssueBody } from './issue-parse.mjs';
7
+
8
+ const names = (platforms) => Object.keys(platforms).join(', ');
9
+
10
+ /**
11
+ * `platforms` is the validated registry of relevo.yml ({ name: { stack, path, targets } }); `body` is the issue text.
12
+ * Returns { ok: true, name, entry } or { ok: false, reason: 'missing' | 'unknown' | 'conflict', message }.
13
+ * With one platform the line is optional; with several it is required.
14
+ */
15
+ export function resolvePlatform({ platforms, body }) {
16
+ const registry = platforms ?? {};
17
+ const known = Object.keys(registry);
18
+ const written = [...new Set(parseIssueBody(body).platform)];
19
+
20
+ if (written.length > 1) {
21
+ return { ok: false, reason: 'conflict', message: `the issue names two platforms (${written.join(', ')}); an issue belongs to one, so split it into one issue per platform with Depends-on` };
22
+ }
23
+ if (written.length === 1) {
24
+ const name = written[0];
25
+ if (!Object.hasOwn(registry, name)) {
26
+ return { ok: false, reason: 'unknown', message: `the issue says Platform: ${name}, which is not a platform of relevo.yml (${names(registry)})` };
27
+ }
28
+ return { ok: true, name, entry: registry[name] };
29
+ }
30
+ if (known.length === 1) return { ok: true, name: known[0], entry: registry[known[0]] };
31
+ return { ok: false, reason: 'missing', message: `Platform: line naming one of the platforms of relevo.yml (${names(registry)})` };
32
+ }
@@ -0,0 +1,32 @@
1
+ // An issue changes only its own platform. Implements "An issue changes only its platform" of the go specification.
2
+ //
3
+ // A commit of an unattended run may touch the folder of the platform of the issue, the specification of the change
4
+ // itself, GLOSSARY.md and docs/adr/ (the documentation the issue carries), and any path the issue lists in its
5
+ // `Allow-paths:` line. A platform whose folder is `.` is not restricted.
6
+
7
+ import { matchesGlob } from './protected-paths.mjs';
8
+
9
+ const clean = (path) => String(path ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
10
+
11
+ /** Paths that every platform may change, whatever its folder. */
12
+ function isShared(path, change) {
13
+ if (path === 'GLOSSARY.md' || path.startsWith('docs/adr/')) return true;
14
+ return Boolean(change) && (path === `openspec/changes/${change}` || path.startsWith(`openspec/changes/${change}/`));
15
+ }
16
+
17
+ /**
18
+ * The paths outside the platform. `within` is the platform folder (`.` or empty: no restriction), `change` is the
19
+ * change id (its specification folder is always allowed), `allow` the globs of the issue's `Allow-paths:` line.
20
+ * Returns violations like those of `checkProtected`: [{ path, tier: 'scope', rule: within }].
21
+ */
22
+ export function checkScope(paths, { within = '.', change = null, allow = [] } = {}) {
23
+ const folder = clean(within);
24
+ if (folder === '' || folder === '.') return { ok: true, violations: [] };
25
+ const violations = [];
26
+ [...new Set((paths ?? []).map(clean).filter(Boolean))].forEach((path) => {
27
+ const inside = path === folder || path.startsWith(`${folder}/`);
28
+ if (inside || isShared(path, change) || allow.some((pattern) => matchesGlob(path, pattern))) return;
29
+ violations.push({ path, tier: 'scope', rule: folder });
30
+ });
31
+ return { ok: violations.length === 0, violations };
32
+ }
@@ -0,0 +1,90 @@
1
+ // The platform registry of relevo.yml. Implements "Platforms are declared in a registry" of the platforms specification.
2
+ //
3
+ // `platforms` is a map of name to { stack, path, targets, checks }. The legacy `platform: flutter-android` is the
4
+ // same as one platform with stack `flutter`, path `.` and targets `[android]`.
5
+
6
+ export const LEGACY_PLATFORMS = {
7
+ 'flutter-android': { stack: 'flutter', path: '.', targets: ['android'] },
8
+ };
9
+
10
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
11
+ const STACK_RE = /^[a-z][a-z0-9-]*$/;
12
+ const ENTRY_KEYS = new Set(['stack', 'path', 'targets', 'checks']);
13
+
14
+ const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
15
+
16
+ /** A relative path inside the repository: not absolute, no `..` segment. */
17
+ export function isRepoRelativePath(value) {
18
+ if (typeof value !== 'string' || value.trim() === '') return false;
19
+ const p = value.replace(/\\/g, '/');
20
+ return !p.startsWith('/') && !/^[A-Za-z]:/.test(p) && !p.split('/').includes('..');
21
+ }
22
+
23
+ /**
24
+ * Turn the `platform` / `platforms` settings of a parsed relevo.yml into one map.
25
+ * raw { platform, platforms } as written (either may be undefined)
26
+ * knownTargets optional function (stack) -> list of targets the stack defines, or null when unknown;
27
+ * when given, targets are checked against it
28
+ * Returns { platforms, errors }; `platforms` is always a map (the legacy default when nothing is set).
29
+ */
30
+ export function normalizePlatforms({ platform, platforms } = {}, { knownTargets = null } = {}) {
31
+ const errors = [];
32
+ const has = (v) => v !== undefined && v !== null;
33
+
34
+ if (has(platform) && has(platforms)) {
35
+ errors.push({ path: 'platforms', message: 'set either platform (the legacy shortcut) or platforms, not both: keep only platforms' });
36
+ return { platforms: {}, errors };
37
+ }
38
+ if (!has(platforms)) {
39
+ const key = has(platform) ? platform : 'flutter-android';
40
+ const legacy = LEGACY_PLATFORMS[key];
41
+ if (!legacy) {
42
+ errors.push({ path: 'platform', message: `platform has invalid value ${JSON.stringify(key)}; allowed values: ${Object.keys(LEGACY_PLATFORMS).map((k) => `"${k}"`).join(', ')}, or use platforms` });
43
+ return { platforms: {}, errors };
44
+ }
45
+ return { platforms: { app: structuredClone(legacy) }, errors };
46
+ }
47
+ if (!isMap(platforms) || Object.keys(platforms).length === 0) {
48
+ errors.push({ path: 'platforms', message: 'platforms must be a map of platform name to its settings, with at least one entry' });
49
+ return { platforms: {}, errors };
50
+ }
51
+
52
+ const out = {};
53
+ Object.entries(platforms).forEach(([name, entry]) => {
54
+ const at = `platforms.${name}`;
55
+ if (!NAME_RE.test(name)) errors.push({ path: at, message: `${at}: the platform name must be lowercase letters, digits and hyphens` });
56
+ if (!isMap(entry)) {
57
+ errors.push({ path: at, message: `${at} must be a map with at least a stack` });
58
+ return;
59
+ }
60
+ Object.keys(entry).forEach((key) => {
61
+ if (!ENTRY_KEYS.has(key)) errors.push({ path: `${at}.${key}`, message: `unknown setting "${at}.${key}"; allowed: ${[...ENTRY_KEYS].join(', ')}` });
62
+ });
63
+ if (typeof entry.stack !== 'string' || !STACK_RE.test(entry.stack)) {
64
+ errors.push({ path: `${at}.stack`, message: `${at}.stack must name a stack, for example "flutter" (got ${JSON.stringify(entry.stack)})` });
65
+ }
66
+ const path = has(entry.path) ? entry.path : '.';
67
+ if (!isRepoRelativePath(path)) errors.push({ path: `${at}.path`, message: `${at}.path must be a folder inside the repository, without a leading "/" or ".." (got ${JSON.stringify(path)})` });
68
+
69
+ let targets = [];
70
+ if (has(entry.targets)) {
71
+ if (!Array.isArray(entry.targets) || entry.targets.some((t) => typeof t !== 'string' || t.trim() === '')) {
72
+ errors.push({ path: `${at}.targets`, message: `${at}.targets must be a list of target names` });
73
+ } else {
74
+ targets = [...new Set(entry.targets)];
75
+ const known = typeof entry.stack === 'string' && knownTargets ? knownTargets(entry.stack) : null;
76
+ if (known) {
77
+ targets.filter((t) => !known.includes(t)).forEach((t) => {
78
+ errors.push({ path: `${at}.targets`, message: `${at}.targets has unknown target "${t}"; the stack "${entry.stack}" defines: ${known.length ? known.join(', ') : 'no targets'}` });
79
+ });
80
+ }
81
+ }
82
+ }
83
+ const checks = has(entry.checks) ? entry.checks : {};
84
+ if (!isMap(checks) || Object.values(checks).some((v) => typeof v !== 'string' || v.trim() === '')) {
85
+ errors.push({ path: `${at}.checks`, message: `${at}.checks must map a command name to the command that replaces it` });
86
+ }
87
+ out[name] = { stack: entry.stack, path: typeof path === 'string' ? path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') || '.' : '.', targets, ...(isMap(checks) && Object.keys(checks).length ? { checks } : {}) };
88
+ });
89
+ return { platforms: out, errors };
90
+ }
@@ -0,0 +1,71 @@
1
+ // The checks `go` makes before it claims work or creates a branch, in one place.
2
+ // Implements "Go checks its environment before changing anything" of the go specification.
3
+ //
4
+ // Everything outside the process is injected, so the checks can be tested without OpenSpec, git or GitHub:
5
+ // readConfig() -> the text of relevo.yml (throws when it cannot be read)
6
+ // run(command, args) -> { status, stdout, stderr }
7
+
8
+ import { loadConfig } from './config.mjs';
9
+
10
+ export const CHECKS = ['config', 'openspec', 'openspec-root', 'clean-tree', 'gh'];
11
+ export const DEFAULT_CHECKS = ['config', 'openspec', 'openspec-root', 'clean-tree'];
12
+
13
+ const pass = (check) => ({ check, ok: true });
14
+ const fail = (check, message, fix) => ({ check, ok: false, message, fix });
15
+
16
+ function checkConfig({ readConfig }) {
17
+ let text;
18
+ try {
19
+ text = readConfig();
20
+ } catch {
21
+ return fail('config', 'relevo.yml was not found', 'run /relevo:init to create it');
22
+ }
23
+ const loaded = loadConfig(text);
24
+ if (loaded.ok) return pass('config');
25
+ return fail('config', `relevo.yml is not valid:\n${loaded.errors.map((e) => ` - ${e.message}`).join('\n')}`, 'fix it, and check it with node scripts/validate-config.mjs relevo.yml');
26
+ }
27
+
28
+ function checkOpenspec({ run }) {
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');
31
+ }
32
+
33
+ function checkOpenspecRoot({ run }) {
34
+ const r = run('openspec', ['list', '--json']);
35
+ let root = null;
36
+ try {
37
+ root = JSON.parse(r.stdout || '{}').root ?? null;
38
+ } catch {
39
+ root = null;
40
+ }
41
+ // `root: null` is OpenSpec's own answer for "no openspec/ directory here"; it also exits with an error.
42
+ return root && typeof root === 'object' ? pass('openspec-root') : fail('openspec-root', 'OpenSpec finds no root for this repository', 'run /relevo:doctor (and openspec init if the project is not set up)');
43
+ }
44
+
45
+ function checkCleanTree({ run }) {
46
+ const r = run('git', ['status', '--porcelain']);
47
+ if (r.status !== 0) return fail('clean-tree', 'git could not read the working tree', 'run it inside the repository');
48
+ const lines = r.stdout.split('\n').filter(Boolean);
49
+ if (lines.length === 0) return pass('clean-tree');
50
+ const shown = lines.slice(0, 5).map((l) => ` ${l}`).join('\n');
51
+ return fail('clean-tree', `the working tree has uncommitted changes:\n${shown}${lines.length > 5 ? `\n ... and ${lines.length - 5} more` : ''}`, 'commit or stash them first');
52
+ }
53
+
54
+ function checkGh({ run }) {
55
+ return run('gh', ['auth', 'status']).status === 0 ? pass('gh') : fail('gh', 'the GitHub command line is not signed in', 'run gh auth login');
56
+ }
57
+
58
+ const IMPLEMENTATION = { config: checkConfig, openspec: checkOpenspec, 'openspec-root': checkOpenspecRoot, 'clean-tree': checkCleanTree, gh: checkGh };
59
+
60
+ /** Run the named checks, in order. Returns { ok, results: [{ check, ok, message?, fix? }] }. */
61
+ export function runPreflight({ checks = DEFAULT_CHECKS, readConfig, run }) {
62
+ const unknown = checks.filter((c) => !IMPLEMENTATION[c]);
63
+ if (unknown.length > 0) throw new Error(`unknown check "${unknown[0]}": expected one of ${CHECKS.join(', ')}`);
64
+ const results = checks.map((check) => IMPLEMENTATION[check]({ readConfig, run }));
65
+ return { ok: results.every((r) => r.ok), results };
66
+ }
67
+
68
+ export function formatPreflight({ ok, results }) {
69
+ if (ok) return `preflight ok (${results.map((r) => r.check).join(', ')})`;
70
+ return results.filter((r) => !r.ok).map((r) => `${r.check}: ${r.message}\n fix: ${r.fix}`).join('\n');
71
+ }
@@ -0,0 +1,102 @@
1
+ // Paths the unattended agent must not change. Implements "Protected paths" of the security specification.
2
+ //
3
+ // Two tiers:
4
+ // hard never allowed, whatever the issue says: workflows, the Relevo configuration, the folders of skills and
5
+ // the file with the project rules (a defect is fixed in the code, not by rewriting the rule).
6
+ // soft dependency manifests and lock files: allowed only when the issue lists them in an `Allow-paths:` line.
7
+
8
+ export const HARD_PATTERNS = ['.github/', 'relevo.yml', '.relevo/', '.claude/skills/', '.agents/skills/', 'skills-lock.json'];
9
+
10
+ export const SOFT_FILES = [
11
+ 'pubspec.yaml', 'pubspec.lock',
12
+ 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock',
13
+ 'composer.json', 'composer.lock',
14
+ 'Gemfile', 'Gemfile.lock',
15
+ ];
16
+
17
+ const clean = (path) => String(path ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
18
+
19
+ const escapeRe = (s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
20
+
21
+ /** Convert a glob (`*`, `**`, `?`) to a regular expression. A pattern without `/` matches at any depth, like .gitignore. */
22
+ function globToRegExp(pattern) {
23
+ let p = clean(pattern);
24
+ if (p.endsWith('/')) p += '**';
25
+ const anyDepth = !p.includes('/');
26
+ let re = '';
27
+ for (let i = 0; i < p.length; i += 1) {
28
+ const c = p[i];
29
+ if (c === '*' && p[i + 1] === '*') {
30
+ i += 1;
31
+ if (p[i + 1] === '/') {
32
+ i += 1;
33
+ re += '(?:.*/)?';
34
+ } else re += '.*';
35
+ } else if (c === '*') re += '[^/]*';
36
+ else if (c === '?') re += '[^/]';
37
+ else re += escapeRe(c);
38
+ }
39
+ return new RegExp(`^${anyDepth ? '(?:.*/)?' : ''}${re}$`);
40
+ }
41
+
42
+ /** Does `path` match `pattern`? */
43
+ export function matchesGlob(path, pattern) {
44
+ return globToRegExp(pattern).test(clean(path));
45
+ }
46
+
47
+ /** The rules file setting is one name or a list (`qa.rules_from`); both give a list of cleaned names. */
48
+ const ruleFiles = (rulesFile) => [].concat(rulesFile ?? []).filter(Boolean).map(clean);
49
+
50
+ function hardPatterns(rulesFile) {
51
+ return [...HARD_PATTERNS, ...ruleFiles(rulesFile)];
52
+ }
53
+
54
+ // Hard patterns are anchored at the root of the repository; `relevo.yml` and the rules file are exact files.
55
+ const matchesHard = (path, pattern) => {
56
+ const p = clean(path);
57
+ const rule = clean(pattern);
58
+ return rule.endsWith('/') ? p.startsWith(rule) : p === rule;
59
+ };
60
+
61
+ /** The hard rule a path breaks, or null. */
62
+ export function hardRule(path, { rulesFile = null } = {}) {
63
+ return hardPatterns(rulesFile).find((pattern) => matchesHard(path, pattern)) ?? null;
64
+ }
65
+
66
+ /** Is the file a dependency manifest or lock file (soft tier)? */
67
+ export const isSoftProtected = (path) => SOFT_FILES.includes(clean(path).split('/').pop());
68
+
69
+ /**
70
+ * Check the paths of a diff. `allow` is the list of globs from the issue's `Allow-paths:` line.
71
+ * Returns { ok, violations: [{ path, tier: 'hard' | 'soft', rule }] }.
72
+ */
73
+ export function checkProtected(paths, { allow = [], rulesFile = null } = {}) {
74
+ const violations = [];
75
+ [...new Set((paths ?? []).map(clean).filter(Boolean))].forEach((path) => {
76
+ const hard = hardRule(path, { rulesFile });
77
+ if (hard) {
78
+ violations.push({ path, tier: 'hard', rule: hard });
79
+ return;
80
+ }
81
+ if (isSoftProtected(path) && !allow.some((pattern) => matchesGlob(path, pattern))) {
82
+ violations.push({ path, tier: 'soft', rule: clean(path).split('/').pop() });
83
+ }
84
+ });
85
+ return { ok: violations.length === 0, violations };
86
+ }
87
+
88
+ /** Entries of an `Allow-paths:` list that name (or would cover) a hard-protected path: they can never be allowed. */
89
+ export function hardPathsInAllow(allow, { rulesFile = null } = {}) {
90
+ const probes = [...HARD_PATTERNS.map((pattern) => (pattern.endsWith('/') ? `${pattern}x` : pattern)), ...ruleFiles(rulesFile)].filter(Boolean);
91
+ return (allow ?? []).filter((entry) => hardRule(entry, { rulesFile }) || probes.some((probe) => matchesGlob(probe, entry)));
92
+ }
93
+
94
+ export function formatViolations(violations) {
95
+ return violations
96
+ .map((v) => {
97
+ if (v.tier === 'hard') return `${v.path} protected path (${v.rule}): it is never changed by an unattended run`;
98
+ if (v.tier === 'scope') return `${v.path} outside the platform (${v.rule}): an issue changes only its platform; list the path in an Allow-paths: line to allow it`;
99
+ return `${v.path} dependency file: the issue must list it in an Allow-paths: line`;
100
+ })
101
+ .join('\n');
102
+ }
@@ -0,0 +1,23 @@
1
+ // Queue re-evaluation. Implements "Queue re-evaluation" of openspec/specs/go/spec.md.
2
+ // The planner decides; this module only says which issues may be handed to a build run now.
3
+
4
+ /**
5
+ * The decisions the queue is responsible for: those about issues that are already waiting (`queued` or `blocked`).
6
+ * An issue that has just been marked `ready` belongs to the `go` workflow that the label itself started; if the
7
+ * queue acted on it too, both would apply the same decision (a duplicated comment) or dispatch the same build.
8
+ */
9
+ export function ownedByQueue(decisions, issues, names) {
10
+ const waiting = new Set(issues.filter((i) => i.labels.includes(names.queued) || i.labels.includes(names.blocked)).map((i) => i.number));
11
+ return decisions.filter((d) => waiting.has(d.issue));
12
+ }
13
+
14
+ /** Issue numbers whose decision is `start`, oldest first (the planner already sorted them). */
15
+ export function startable(decisions) {
16
+ return decisions.filter((d) => d.action === 'start').map((d) => d.issue);
17
+ }
18
+
19
+ /** The unattended runs to dispatch: one per startable issue, never more than the free slots. */
20
+ export function toDispatch(decisions, { alreadyRunning = 0, limit }) {
21
+ const free = Math.max(0, limit - alreadyRunning);
22
+ return startable(decisions).slice(0, free);
23
+ }
@@ -0,0 +1,37 @@
1
+ // How `review` gets a log and captures for a platform and a target, and whether the agent may drive it.
2
+ // Implements "The review picks its adapter from the platform and the target" of openspec/specs/review/spec.md.
3
+
4
+ const DEFAULT_TARGET = { flutter: 'android', vue: 'web' };
5
+
6
+ const ADAPTERS = {
7
+ '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
+ };
11
+
12
+ const NOT_AVAILABLE = {
13
+ 'flutter/ios': 'the review of iOS is not available yet: it needs a macOS machine with a simulator (xcrun), and nothing in Relevo has been run there',
14
+ };
15
+
16
+ /** The target a platform is reviewed on when none is given: its first target, or the usual one of its stack. */
17
+ export function targetOf({ stack, targets = [] }) {
18
+ return targets[0] ?? DEFAULT_TARGET[stack] ?? null;
19
+ }
20
+
21
+ /**
22
+ * Returns { supported, stack, target, kind, log, capture, driver, needs, reason }.
23
+ * `reason` says why a combination is not supported.
24
+ */
25
+ export function adapterFor({ stack, target }) {
26
+ const key = `${stack}/${target}`;
27
+ if (ADAPTERS[key]) return { supported: true, stack, target, ...ADAPTERS[key], reason: null };
28
+ return {
29
+ supported: false, stack, target, kind: null, log: null, capture: null, driver: null, needs: [],
30
+ reason: NOT_AVAILABLE[key] ?? (target ? `there is no review adapter for the stack "${stack}" on the target "${target}"` : `the platform of stack "${stack}" names no target to review`),
31
+ };
32
+ }
33
+
34
+ /** Whether a review of this adapter can run at all with the given autopilot mode: a browser has no manual way. */
35
+ export function needsAutopilot(adapter) {
36
+ return adapter.supported && adapter.kind === 'browser';
37
+ }