@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.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.es.md +90 -0
- package/README.md +89 -2
- package/agents/dev.md +101 -0
- package/agents/qa.md +112 -0
- package/package.json +42 -4
- package/scripts/add-corrections.mjs +21 -0
- package/scripts/app-id.mjs +19 -0
- package/scripts/archive-state.mjs +31 -0
- package/scripts/branch-state.mjs +31 -0
- package/scripts/branch-work.mjs +25 -0
- package/scripts/can-edit-issue.mjs +45 -0
- package/scripts/capture.mjs +22 -0
- package/scripts/check-archive.mjs +19 -0
- package/scripts/check-issue.mjs +41 -0
- package/scripts/checklist.mjs +29 -0
- package/scripts/cli.mjs +28 -0
- package/scripts/commit-change.mjs +50 -0
- package/scripts/conflict-scope.mjs +19 -0
- package/scripts/design-fetch.mjs +103 -0
- package/scripts/device.mjs +12 -0
- package/scripts/digest.mjs +50 -0
- package/scripts/doctor.mjs +47 -0
- package/scripts/fix-guard.mjs +38 -0
- package/scripts/fix-stash.mjs +57 -0
- package/scripts/fix-state.mjs +28 -0
- package/scripts/fix-tasks.mjs +22 -0
- package/scripts/go-local.mjs +49 -0
- package/scripts/go-plan.mjs +82 -0
- package/scripts/init.mjs +109 -0
- package/scripts/intent-to-add.mjs +20 -0
- package/scripts/issue-coverage.mjs +30 -0
- package/scripts/issue-hash.mjs +44 -0
- package/scripts/issue-sections.mjs +42 -0
- package/scripts/label-change.mjs +35 -0
- package/scripts/lib/appid.mjs +12 -0
- package/scripts/lib/archive-check.mjs +29 -0
- package/scripts/lib/archive-state.mjs +15 -0
- package/scripts/lib/branch-work.mjs +13 -0
- package/scripts/lib/branch.mjs +43 -0
- package/scripts/lib/can-edit-issue.mjs +34 -0
- package/scripts/lib/checklist.mjs +68 -0
- package/scripts/lib/cli.mjs +82 -0
- package/scripts/lib/commit-change.mjs +57 -0
- package/scripts/lib/config.mjs +296 -0
- package/scripts/lib/design-fetch.mjs +156 -0
- package/scripts/lib/device.mjs +67 -0
- package/scripts/lib/doctor.mjs +614 -0
- package/scripts/lib/execution-mode.mjs +24 -0
- package/scripts/lib/fix-guard.mjs +23 -0
- package/scripts/lib/fix-stash.mjs +17 -0
- package/scripts/lib/fix.mjs +31 -0
- package/scripts/lib/github.mjs +161 -0
- package/scripts/lib/go-local.mjs +102 -0
- package/scripts/lib/init.mjs +324 -0
- package/scripts/lib/intent-to-add.mjs +32 -0
- package/scripts/lib/issue-check.mjs +63 -0
- package/scripts/lib/issue-coverage.mjs +47 -0
- package/scripts/lib/issue-hash.mjs +25 -0
- package/scripts/lib/issue-parse.mjs +194 -0
- package/scripts/lib/issue-sections.mjs +43 -0
- package/scripts/lib/labels.mjs +65 -0
- package/scripts/lib/language.mjs +9 -0
- package/scripts/lib/logaudit.mjs +123 -0
- package/scripts/lib/messages.mjs +328 -0
- package/scripts/lib/next-step.mjs +144 -0
- package/scripts/lib/openspec-commands.mjs +44 -0
- package/scripts/lib/openspec-status.mjs +16 -0
- package/scripts/lib/plan.mjs +256 -0
- package/scripts/lib/platform-of.mjs +32 -0
- package/scripts/lib/platform-scope.mjs +32 -0
- package/scripts/lib/platforms.mjs +90 -0
- package/scripts/lib/preflight.mjs +71 -0
- package/scripts/lib/protected-paths.mjs +102 -0
- package/scripts/lib/requeue.mjs +23 -0
- package/scripts/lib/review-adapters.mjs +37 -0
- package/scripts/lib/review-brief.mjs +43 -0
- package/scripts/lib/review-events.mjs +56 -0
- package/scripts/lib/review-state.mjs +157 -0
- package/scripts/lib/reviewers.mjs +39 -0
- package/scripts/lib/role-skills.mjs +112 -0
- package/scripts/lib/run-flags.mjs +16 -0
- package/scripts/lib/run-usage.mjs +65 -0
- package/scripts/lib/scan.mjs +73 -0
- package/scripts/lib/ship.mjs +161 -0
- package/scripts/lib/sla.mjs +90 -0
- package/scripts/lib/stack-profile.mjs +169 -0
- package/scripts/lib/staging.mjs +21 -0
- package/scripts/lib/status-data.mjs +109 -0
- package/scripts/lib/status.mjs +199 -0
- package/scripts/lib/tasks-file.mjs +81 -0
- package/scripts/lib/web-review.mjs +98 -0
- package/scripts/lib/yaml-lite.mjs +157 -0
- package/scripts/needs-reviewer.mjs +41 -0
- package/scripts/next-step.mjs +39 -0
- package/scripts/pick-reviewer.mjs +42 -0
- package/scripts/platform-of.mjs +49 -0
- package/scripts/preflight.mjs +32 -0
- package/scripts/render-message.mjs +35 -0
- package/scripts/requeue.mjs +53 -0
- package/scripts/review-adapter.mjs +32 -0
- package/scripts/review-brief.mjs +29 -0
- package/scripts/review-event.mjs +70 -0
- package/scripts/review-sla.mjs +41 -0
- package/scripts/review-state.mjs +171 -0
- package/scripts/review-who.mjs +21 -0
- package/scripts/role-skills.mjs +53 -0
- package/scripts/run-usage.mjs +22 -0
- package/scripts/scan-diff.mjs +16 -0
- package/scripts/ship-check.mjs +54 -0
- package/scripts/spec-ready.mjs +40 -0
- package/scripts/stack-profile.mjs +67 -0
- package/scripts/stage.mjs +14 -0
- package/scripts/status.mjs +47 -0
- package/scripts/usage-limit.mjs +17 -0
- package/scripts/validate-config.mjs +44 -0
- package/scripts/web-review.mjs +134 -0
- package/skills/doctor/SKILL.md +28 -0
- package/skills/fix/SKILL.md +128 -0
- package/skills/go/SKILL.md +242 -0
- package/skills/init/SKILL.md +72 -0
- package/skills/issue/SKILL.md +128 -0
- package/skills/issue/references/update.md +15 -0
- package/skills/review/SKILL.md +157 -0
- package/skills/ship/SKILL.md +128 -0
- package/skills/status/SKILL.md +27 -0
- package/skills/think/SKILL.md +76 -0
- package/stacks/flutter.md +63 -0
- package/stacks/laravel.md +65 -0
- package/stacks/vue.md +70 -0
- package/templates/issue.md +99 -0
- package/templates/relevo.yml +59 -0
- package/templates/workflows/relevo-digest.yml +54 -0
- package/templates/workflows/relevo-go.yml +263 -0
- package/templates/workflows/relevo-queue.yml +72 -0
- package/templates/workflows/relevo-review.yml +60 -0
- package/templates/workflows/relevo-sla.yml +53 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// `init`: builds the plan of everything that has to change, then applies it once the person confirms.
|
|
2
|
+
// Implements openspec/specs/init-doctor/spec.md.
|
|
3
|
+
//
|
|
4
|
+
// planInit is pure: it compares the facts `doctor` gathers with what a configured repository looks like, so
|
|
5
|
+
// running it again only lists what differs (idempotent). applyPlan carries items out through injected I/O.
|
|
6
|
+
// init never reads or writes the model API key.
|
|
7
|
+
|
|
8
|
+
import { loadConfig } from './config.mjs';
|
|
9
|
+
import { LEGACY_PLATFORMS } from './platforms.mjs';
|
|
10
|
+
import { labelDefinitions } from './labels.mjs';
|
|
11
|
+
import { OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WORKFLOW_FILES } from './doctor.mjs';
|
|
12
|
+
|
|
13
|
+
export const REVIEW_MODES = ['none', 'peer', 'roles'];
|
|
14
|
+
|
|
15
|
+
export const RUNS = ['cloud', 'local', 'both'];
|
|
16
|
+
export const CREDENTIALS = ['api_key', 'subscription'];
|
|
17
|
+
const API_KEY_LINE = ' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}';
|
|
18
|
+
const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The go workflow with the credential line of the chosen mode: the API key is the template's default; for a
|
|
22
|
+
* subscription token that line is commented out and the token line is switched on. Throws when the template no
|
|
23
|
+
* longer has the lines (a test runs this on the real template).
|
|
24
|
+
*/
|
|
25
|
+
export function credentialLine(workflow, credential) {
|
|
26
|
+
if (credential !== 'subscription') return workflow;
|
|
27
|
+
if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
|
|
28
|
+
return workflow.replace(API_KEY_LINE, API_KEY_LINE.replace('anthropic_api_key', '# anthropic_api_key')).replace(TOKEN_LINE, TOKEN_LINE.replace('# claude_code_oauth_token', 'claude_code_oauth_token'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The text of a `platforms:` block for { name: { stack, path, targets } }. */
|
|
32
|
+
export function platformsBlock(platforms) {
|
|
33
|
+
const lines = ['platforms:'];
|
|
34
|
+
Object.entries(platforms).forEach(([name, p]) => {
|
|
35
|
+
if (!p) throw new Error(`unknown platform "${name}"`);
|
|
36
|
+
lines.push(` ${name}:`, ` stack: ${p.stack}`, ` path: ${p.path ?? '.'}`);
|
|
37
|
+
if (p.targets?.length) lines.push(` targets: [${p.targets.join(', ')}]`);
|
|
38
|
+
});
|
|
39
|
+
return `${lines.join('\n')}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Replace the `platforms:` block of the template (up to the next blank line) with the person's platforms. */
|
|
43
|
+
function setPlatforms(text, platforms) {
|
|
44
|
+
const re = /^platforms:[^\n]*\n(?:[ \t]+[^\n]*\n|[ \t]*#[^\n]*\n)*/m;
|
|
45
|
+
if (!re.test(text)) throw new Error('the template has no "platforms" block');
|
|
46
|
+
return text.replace(re, platformsBlock(platforms));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A commented `.relevo/stacks/<stack>.md` that adds to the plugin's profile. It adds; it never replaces. */
|
|
50
|
+
export function stackProfileTemplate(stack) {
|
|
51
|
+
return `---
|
|
52
|
+
stack: ${stack}
|
|
53
|
+
# Lists are added to the plugin's profile; a command that the plugin sets cannot be replaced here (use
|
|
54
|
+
# platforms.<name>.checks in relevo.yml for that).
|
|
55
|
+
# generated: ['coverage/**']
|
|
56
|
+
# tools: []
|
|
57
|
+
# skills:
|
|
58
|
+
# dev: []
|
|
59
|
+
# qa: []
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<!-- Hygiene of this project for ${stack}, in plain sentences. The developer follows it and QA audits against it.
|
|
63
|
+
It is added after the plugin's text for the stack. -->
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** relevo.yml from the template and the person's answers. Throws when the result does not validate. */
|
|
68
|
+
export function renderConfig(template, answers = {}) {
|
|
69
|
+
const set = (text, key, value) => {
|
|
70
|
+
const re = new RegExp(`^(\\s*${key}:)\\s*[^#\\n]*?(\\s*(#.*)?)$`, 'm');
|
|
71
|
+
if (!re.test(text)) throw new Error(`the template has no "${key}" line`);
|
|
72
|
+
return text.replace(re, (_, head, tail) => `${head} ${value}${tail.startsWith(' ') || tail === '' ? tail : ` ${tail}`}`);
|
|
73
|
+
};
|
|
74
|
+
let out = template;
|
|
75
|
+
if (answers.base) out = set(out, 'base', answers.base);
|
|
76
|
+
if (answers.release) out = set(out, 'release', answers.release);
|
|
77
|
+
const platforms = answers.platforms ?? (answers.platform ? { app: LEGACY_PLATFORMS[answers.platform] } : null);
|
|
78
|
+
if (platforms) out = setPlatforms(out, platforms);
|
|
79
|
+
if (answers.links) out = set(out, 'links', `[${answers.links.join(', ')}]`);
|
|
80
|
+
// A subscription's quota is shared by every run and by the interactive use of the account: one agent at a time.
|
|
81
|
+
if (answers.credential === 'subscription') out = set(out, 'concurrent_agents', answers.concurrentAgents ?? 1);
|
|
82
|
+
if (answers.members) out = set(out, 'members', `[${answers.members.join(', ')}]`);
|
|
83
|
+
if (answers.specWriter !== undefined) out = set(out, 'spec_writer', answers.specWriter ?? 'null');
|
|
84
|
+
if (answers.reviewMode) out = set(out, 'mode', answers.reviewMode);
|
|
85
|
+
if (answers.merger) out = set(out, 'merger', answers.merger);
|
|
86
|
+
if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
|
|
87
|
+
if (answers.language) out = set(out, 'language', answers.language);
|
|
88
|
+
const check = loadConfig(out);
|
|
89
|
+
if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The repository ruleset that makes the invariants platform-enforced. */
|
|
94
|
+
export function rulesetBody({ base, release, reviewMode }) {
|
|
95
|
+
return {
|
|
96
|
+
name: 'relevo-protected-branches',
|
|
97
|
+
target: 'branch',
|
|
98
|
+
enforcement: 'active',
|
|
99
|
+
conditions: { ref_name: { include: [`refs/heads/${base}`, `refs/heads/${release}`], exclude: [] } },
|
|
100
|
+
bypass_actors: [],
|
|
101
|
+
rules: [
|
|
102
|
+
{ type: 'deletion' },
|
|
103
|
+
{ type: 'non_fast_forward' },
|
|
104
|
+
{
|
|
105
|
+
type: 'pull_request',
|
|
106
|
+
parameters: {
|
|
107
|
+
required_approving_review_count: reviewMode === 'none' ? 0 : 1,
|
|
108
|
+
// The archive commit is pushed after approval; dismissing approvals on push would undo it.
|
|
109
|
+
dismiss_stale_reviews_on_push: false,
|
|
110
|
+
require_code_owner_review: false,
|
|
111
|
+
require_last_push_approval: false,
|
|
112
|
+
required_review_thread_resolution: false,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The ruleset that keeps the unattended agent on `feature/*`: nobody but a maintainer or an administrator may
|
|
121
|
+
* create any other branch. Updates to the base and release branches are already blocked by the other ruleset
|
|
122
|
+
* (pull request required); existing branches are not touched, so merging and normal work keep working.
|
|
123
|
+
*/
|
|
124
|
+
export function pushScopeBody({ base, release }) {
|
|
125
|
+
return {
|
|
126
|
+
name: 'relevo-push-scope',
|
|
127
|
+
target: 'branch',
|
|
128
|
+
enforcement: 'active',
|
|
129
|
+
conditions: { ref_name: { include: ['~ALL'], exclude: ['refs/heads/feature/*', `refs/heads/${base}`, `refs/heads/${release}`] } },
|
|
130
|
+
// Repository role ids: 5 = admin, 2 = maintain.
|
|
131
|
+
bypass_actors: [
|
|
132
|
+
{ actor_id: 5, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
133
|
+
{ actor_id: 2, actor_type: 'RepositoryRole', bypass_mode: 'always' },
|
|
134
|
+
],
|
|
135
|
+
rules: [{ type: 'creation' }],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What to change by hand when init cannot (not an administrator, or the feature needs a paid plan). */
|
|
140
|
+
export function manualChecklist({ base, release, reviewMode }) {
|
|
141
|
+
return [
|
|
142
|
+
`Settings > Rules > Rulesets: add a ruleset that targets \`${base}\` and \`${release}\`, with no bypass actors, that blocks deletion and force pushes and requires a pull request${reviewMode === 'none' ? '' : ' with at least 1 approval'}.`,
|
|
143
|
+
'In that ruleset, leave "Dismiss stale pull request approvals when new commits are pushed" OFF (the spec archive commit is pushed after approval).',
|
|
144
|
+
'Settings > Actions > General > Workflow permissions: choose "Read repository contents" and turn OFF "Allow GitHub Actions to create and approve pull requests".',
|
|
145
|
+
'Settings > Code security: turn ON secret scanning and push protection.',
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The extra setting that keeps the agent on feature branches, for the hand-made route. */
|
|
150
|
+
export function pushScopeChecklist({ base, release }) {
|
|
151
|
+
return [`Settings > Rules > Rulesets: add a second ruleset on all branches except \`feature/*\`, \`${base}\` and \`${release}\` that restricts creation, with the Maintain and Admin roles allowed to bypass, so the agent can only create \`feature/*\` branches.`];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const item = (id, status, description, extra = {}) => ({ id, status, description, ...extra });
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Plan every change. `ctx`:
|
|
158
|
+
* facts from gatherFacts (doctor)
|
|
159
|
+
* answers the person's answers (members, specWriter, reviewMode, language, platforms, links, runs, credential, stackProfiles, base, release, merger)
|
|
160
|
+
* templates { config: text, workflows: { file: text } }
|
|
161
|
+
* current { workflows: { file: text|null }, gitignore: text|null, keepFiles: { path: boolean } }
|
|
162
|
+
* admin true when the person has administrator permission on the repository
|
|
163
|
+
* Items have status: ok (nothing to do), todo (init will do it), differs (exists but differs: reported, never
|
|
164
|
+
* overwritten), manual (the person must do it).
|
|
165
|
+
*/
|
|
166
|
+
export function planInit({ facts, answers, templates, current, admin }) {
|
|
167
|
+
const items = [];
|
|
168
|
+
const base = facts.config?.branches?.base ?? answers.base ?? 'dev';
|
|
169
|
+
const release = facts.config?.branches?.release ?? answers.release ?? 'main';
|
|
170
|
+
const reviewMode = facts.config?.review?.mode ?? answers.reviewMode ?? 'peer';
|
|
171
|
+
const names = labelDefinitions(facts.config?.labels ?? {});
|
|
172
|
+
|
|
173
|
+
// dependencies: offered, never installed without confirmation
|
|
174
|
+
if (!facts.openspec) items.push(item('install.openspec', 'todo', 'Install the OpenSpec command-line tool', { install: true, command: 'npm install --global @fission-ai/openspec', detail: OPENSPEC_INSTALL }));
|
|
175
|
+
REQUIRED_SKILLS.filter((n) => !facts.skills[n]).forEach((n) => items.push(item(`install.skill.${n}`, 'todo', `Install the ${n} skill`, { install: true, command: n === 'openspec-explore' ? 'openspec init' : 'npx skills add mattpocock/skills', detail: SKILL_INSTALL[n] })));
|
|
176
|
+
|
|
177
|
+
// relevo.yml
|
|
178
|
+
if (facts.configText !== null) items.push(item('config', facts.config ? 'ok' : 'differs', facts.config ? 'relevo.yml exists and is valid' : `relevo.yml exists but is invalid: ${facts.configErrors.join('; ')}`));
|
|
179
|
+
else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
|
|
180
|
+
|
|
181
|
+
// optional stack profiles of the repository (created only when asked for, and never committed by init)
|
|
182
|
+
(answers.stackProfiles ?? []).forEach((stack) => {
|
|
183
|
+
const path = `.relevo/stacks/${stack}.md`;
|
|
184
|
+
if ((current.stackProfiles ?? {})[stack]) items.push(item(`stack-profile.${stack}`, 'ok', `${path} exists`));
|
|
185
|
+
else items.push(item(`stack-profile.${stack}`, 'todo', `Create ${path}, a commented profile that adds to the plugin's (not committed)`, { path, content: stackProfileTemplate(stack) }));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// labels
|
|
189
|
+
const missing = (facts.labels ?? null) === null ? names : names.filter((l) => !facts.labels.includes(l.name));
|
|
190
|
+
items.push(missing.length === 0 ? item('labels', 'ok', 'All lifecycle labels exist') : item('labels', 'todo', `Create ${missing.length} label(s): ${missing.map((l) => l.name).join(', ')}`, { labels: missing }));
|
|
191
|
+
|
|
192
|
+
// base branch
|
|
193
|
+
items.push(facts.branches?.base ? item('branch.base', 'ok', `Base branch ${base} exists`) : item('branch.base', 'todo', `Create branch ${base} from ${release}`, { base, release }));
|
|
194
|
+
|
|
195
|
+
// repository protection
|
|
196
|
+
const protectionOk = ['base', 'release'].every((k) => facts.protection?.[k]?.pullRequestRequired && !facts.protection[k].dismissStale);
|
|
197
|
+
const checklist = manualChecklist({ base, release, reviewMode });
|
|
198
|
+
if (protectionOk) items.push(item('protection', 'ok', 'Branch protection is in place'));
|
|
199
|
+
else if (!admin || facts.protectionUnavailable) items.push(item('protection', 'manual', admin ? 'Branch protection is not available on this repository; set it by hand when you can' : 'You are not an administrator: ask one to set the branch protection', { checklist }));
|
|
200
|
+
else items.push(item('protection', 'todo', `Add a ruleset: pull request required on ${base} and ${release}, no deletion, no force push, approvals not dismissed on push`, { ruleset: rulesetBody({ base, release, reviewMode }), checklist }));
|
|
201
|
+
|
|
202
|
+
if (facts.pushScope === true) items.push(item('push-scope', 'ok', 'The agent can only create feature/* branches'));
|
|
203
|
+
else if (!admin || facts.protectionUnavailable) items.push(item('push-scope', 'manual', admin ? 'Rulesets are not available on this repository: keep the agent on feature/* branches by hand when you can' : 'Ask an administrator to keep the agent on feature/* branches', { checklist: pushScopeChecklist({ base, release }) }));
|
|
204
|
+
else items.push(item('push-scope', 'todo', 'Add a ruleset so that only maintainers and administrators can create branches other than feature/* (the agent pushes only feature/*)', { ruleset: pushScopeBody({ base, release }), checklist: pushScopeChecklist({ base, release }) }));
|
|
205
|
+
|
|
206
|
+
items.push(facts.canApprove === false ? item('workflow-permissions', 'ok', 'Workflows cannot approve pull requests')
|
|
207
|
+
: admin ? item('workflow-permissions', 'todo', 'Stop workflows from approving pull requests and make their token read-only by default')
|
|
208
|
+
: item('workflow-permissions', 'manual', 'Ask an administrator to stop workflows from approving pull requests', { checklist: [checklist[2]] }));
|
|
209
|
+
const scanOn = facts.secretScanning?.scanning === true && facts.secretScanning?.pushProtection === true;
|
|
210
|
+
if (scanOn) items.push(item('secret-scanning', 'ok', 'Secret scanning and push protection are on'));
|
|
211
|
+
else items.push(admin ? item('secret-scanning', 'todo', 'Turn on secret scanning and push protection (may need a public repository or a paid plan; if GitHub refuses, it is reported)', { checklist: [checklist[3]] })
|
|
212
|
+
: item('secret-scanning', 'manual', 'Ask an administrator to turn on secret scanning and push protection', { checklist: [checklist[3]] }));
|
|
213
|
+
|
|
214
|
+
// workflows: where go builds decides which ones are added (default: both the cloud and machines)
|
|
215
|
+
const runs = answers.runs ?? 'both';
|
|
216
|
+
const credential = answers.credential ?? 'api_key';
|
|
217
|
+
if (!RUNS.includes(runs)) throw new Error(`runs must be one of ${RUNS.join(', ')} (got "${runs}")`);
|
|
218
|
+
if (!CREDENTIALS.includes(credential)) throw new Error(`credential must be one of ${CREDENTIALS.join(', ')} (got "${credential}")`);
|
|
219
|
+
const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
|
|
220
|
+
WORKFLOW_FILES.forEach((file) => {
|
|
221
|
+
if (runs === 'local' && cloudFiles.includes(file)) return;
|
|
222
|
+
const wanted = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
|
|
223
|
+
const have = current.workflows[file] ?? null;
|
|
224
|
+
if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
|
|
225
|
+
else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
|
|
226
|
+
else items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} exists and differs from the template; it is left as it is (compare it by hand)`));
|
|
227
|
+
});
|
|
228
|
+
if (runs === 'local') items.push(item('execution', 'ok', 'go builds on each person\'s machine: the go and queue workflows are not added. Run /relevo:go <n>, or node scripts/go-local.mjs <n> to build in a separate worktree'));
|
|
229
|
+
|
|
230
|
+
// local files
|
|
231
|
+
const ignored = current.gitignore !== null && /^\/?evidence\/?\s*$/m.test(current.gitignore);
|
|
232
|
+
items.push(ignored ? item('gitignore', 'ok', 'evidence/ is ignored') : item('gitignore', 'todo', 'Add evidence/ to .gitignore', { path: '.gitignore', line: 'evidence/' }));
|
|
233
|
+
|
|
234
|
+
if (!facts.openspecRoot) items.push(item('openspec-root', 'manual', 'There is no OpenSpec root: run `openspec init` (init never creates openspec/ files by hand), then run init again'));
|
|
235
|
+
else {
|
|
236
|
+
const keeps = Object.entries(current.keepFiles).filter(([, present]) => !present).map(([path]) => path);
|
|
237
|
+
items.push(keeps.length === 0 ? item('openspec-root', 'ok', 'The OpenSpec folders are tracked-ready') : item('openspec-root', 'todo', `Add ${keeps.join(', ')} so git keeps the empty OpenSpec folders (a clean checkout would otherwise have no root)`, { paths: keeps }));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// always manual
|
|
241
|
+
const hasCredential = (facts.secrets ?? []).some((n) => ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(n));
|
|
242
|
+
const secretName = credential === 'subscription' ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'ANTHROPIC_API_KEY';
|
|
243
|
+
if (runs === 'local') items.push(item('manual.api-key', 'ok', 'No model secret is needed in the repository: each person builds with their own Claude session'));
|
|
244
|
+
else if (hasCredential) items.push(item('manual.api-key', 'ok', 'A model credential secret exists (init never reads it)'));
|
|
245
|
+
else if (credential === 'subscription') items.push(item('manual.api-key', 'manual', `Create a token with \`claude setup-token\` (it prints it over two lines: remove all whitespace) and store it yourself as the secret ${secretName}; init never reads or writes it. Read the current Anthropic terms on automated use of a subscription first; the quota is shared with the interactive use of that account, which is why concurrent_agents is 1`));
|
|
246
|
+
else items.push(item('manual.api-key', 'manual', `Create an API key in the Anthropic Console (a spending limit on its workspace is advisable) and store it yourself as the secret ${secretName}; init never reads or writes it`));
|
|
247
|
+
const links = facts.config?.links ?? answers.links ?? [];
|
|
248
|
+
if (links.length > 0) {
|
|
249
|
+
if ((facts.secrets ?? []).includes('RELEVO_LINKS_TOKEN')) items.push(item('manual.links-token', 'ok', 'The RELEVO_LINKS_TOKEN secret exists (init never reads it)'));
|
|
250
|
+
else items.push(item('manual.links-token', 'manual', `If ${links.join(', ')} ${links.length > 1 ? 'are' : 'is'} private, create a fine-grained token with read access to issues and pull requests and store it yourself as the secret RELEVO_LINKS_TOKEN (init never reads or writes it; Relevo only reads with it, and public repositories need none)`));
|
|
251
|
+
}
|
|
252
|
+
items.push(item('manual.push', 'manual', 'Commit and push the new files to the default branch: a workflow only runs from there'));
|
|
253
|
+
|
|
254
|
+
return items;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Plain-text plan, shown before anything is changed. */
|
|
258
|
+
export function renderPlan(items) {
|
|
259
|
+
const tag = { ok: 'ok ', todo: 'will do', differs: 'differs', manual: 'by hand' };
|
|
260
|
+
const todo = items.filter((i) => i.status === 'todo').length;
|
|
261
|
+
const lines = items.map((i) => `[${tag[i.status]}] ${i.description}${i.install ? ` (${i.command})` : ''}`);
|
|
262
|
+
lines.push('', todo === 0 ? 'Nothing to do.' : `${todo} change(s) to make.`);
|
|
263
|
+
return lines.join('\n');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Mark the items named in `ids` as skipped, so `applyPlan` leaves them for a later run. */
|
|
267
|
+
export function skipItems(items, ids) {
|
|
268
|
+
const skip = new Set(ids);
|
|
269
|
+
return items.map((i) => (skip.has(i.id) && i.status === 'todo' ? { ...i, status: 'skipped' } : i));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Carry out the `todo` items that are not installs. `io`: gh(args, input?) -> stdout, write(path, text),
|
|
274
|
+
* append(path, line), mkdir(path). Returns [{ id, ok, message, checklist? }]; a refused GitHub change becomes a
|
|
275
|
+
* manual checklist instead of a silent failure.
|
|
276
|
+
*/
|
|
277
|
+
export function applyPlan(items, { repo, io }) {
|
|
278
|
+
const results = [];
|
|
279
|
+
const refused = (it, err) => {
|
|
280
|
+
const m = String(err.stderr ?? err.message ?? err).trim().split('\n')[0];
|
|
281
|
+
results.push({ id: it.id, ok: false, message: `GitHub refused: ${m}`, checklist: it.checklist ?? [] });
|
|
282
|
+
};
|
|
283
|
+
items.filter((i) => i.status === 'todo' && !i.install).forEach((it) => {
|
|
284
|
+
try {
|
|
285
|
+
switch (it.id) {
|
|
286
|
+
case 'config':
|
|
287
|
+
io.write('relevo.yml', it.content);
|
|
288
|
+
break;
|
|
289
|
+
case 'labels':
|
|
290
|
+
it.labels.forEach((l) => io.gh(['label', 'create', l.name, '--repo', repo, '--color', l.color, '--description', l.description, '--force']));
|
|
291
|
+
break;
|
|
292
|
+
case 'branch.base': {
|
|
293
|
+
const sha = JSON.parse(io.gh(['api', `repos/${repo}/branches/${it.release}`])).commit.sha;
|
|
294
|
+
io.gh(['api', '-X', 'POST', `repos/${repo}/git/refs`, '-f', `ref=refs/heads/${it.base}`, '-f', `sha=${sha}`]);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
case 'protection':
|
|
298
|
+
case 'push-scope':
|
|
299
|
+
io.gh(['api', '-X', 'POST', `repos/${repo}/rulesets`, '--input', '-'], JSON.stringify(it.ruleset));
|
|
300
|
+
break;
|
|
301
|
+
case 'workflow-permissions':
|
|
302
|
+
io.gh(['api', '-X', 'PUT', `repos/${repo}/actions/permissions/workflow`, '-f', 'default_workflow_permissions=read', '-F', 'can_approve_pull_request_reviews=false']);
|
|
303
|
+
break;
|
|
304
|
+
case 'secret-scanning':
|
|
305
|
+
io.gh(['api', '-X', 'PATCH', `repos/${repo}`, '--input', '-'], JSON.stringify({ security_and_analysis: { secret_scanning: { status: 'enabled' }, secret_scanning_push_protection: { status: 'enabled' } } }));
|
|
306
|
+
break;
|
|
307
|
+
case 'gitignore':
|
|
308
|
+
io.append(it.path, it.line);
|
|
309
|
+
break;
|
|
310
|
+
case 'openspec-root':
|
|
311
|
+
it.paths.forEach((p) => io.write(p, ''));
|
|
312
|
+
break;
|
|
313
|
+
default:
|
|
314
|
+
if (it.id.startsWith('workflow.') || it.id.startsWith('stack-profile.')) io.write(it.path, it.content);
|
|
315
|
+
else throw new Error(`no way to apply "${it.id}"`);
|
|
316
|
+
}
|
|
317
|
+
results.push({ id: it.id, ok: true, message: it.description });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (['protection', 'push-scope', 'workflow-permissions', 'secret-scanning'].includes(it.id)) refused(it, err);
|
|
320
|
+
else results.push({ id: it.id, ok: false, message: String(err.message ?? err) });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return results;
|
|
324
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Makes the new files of the developer visible to `git diff` without staging their content, so QA can review the
|
|
2
|
+
// work in progress against the base branch. Implements "QA reviews the work in progress" of the go specification.
|
|
3
|
+
|
|
4
|
+
import { explicitStageArgs } from './staging.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mark `paths` with `git add --intent-to-add`. Files that git ignores and files that do not exist are skipped, not errors.
|
|
8
|
+
* git(args) -> stdout; throws when git exits with an error
|
|
9
|
+
* exists(path) -> boolean
|
|
10
|
+
* Returns { marked, ignored, missing }.
|
|
11
|
+
*/
|
|
12
|
+
export function intentToAdd({ git, exists, paths }) {
|
|
13
|
+
explicitStageArgs(paths); // refuses "." and "-A", and an empty list
|
|
14
|
+
const ignoredByGit = (path) => {
|
|
15
|
+
try {
|
|
16
|
+
git(['check-ignore', '-q', '--', path]);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false; // exit 1 means "not ignored"
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const marked = [];
|
|
23
|
+
const ignored = [];
|
|
24
|
+
const missing = [];
|
|
25
|
+
paths.forEach((path) => {
|
|
26
|
+
if (!exists(path)) missing.push(path);
|
|
27
|
+
else if (ignoredByGit(path)) ignored.push(path);
|
|
28
|
+
else marked.push(path);
|
|
29
|
+
});
|
|
30
|
+
if (marked.length > 0) git(['add', '--intent-to-add', '--', ...marked]);
|
|
31
|
+
return { marked, ignored, missing };
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Checks a drafted issue before it is created, with the same rules `go` applies when it starts.
|
|
2
|
+
// Implements "Issues are checked before they are created" of openspec/specs/issue/spec.md.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { MAX_REFERENCES, parseIssueBody } from './issue-parse.mjs';
|
|
7
|
+
import { resolvePlatform } from './platform-of.mjs';
|
|
8
|
+
import { hardPathsInAllow } from './protected-paths.mjs';
|
|
9
|
+
import { findCredentials } from './scan.mjs';
|
|
10
|
+
|
|
11
|
+
export const TITLE_MAX = 72;
|
|
12
|
+
export const BODY_WARN = 8000;
|
|
13
|
+
// Text that only exists in templates/issue.md; finding it means the template was not filled in.
|
|
14
|
+
export const PLACEHOLDERS = ['<the situation>', '<the result a person can observe>', '<unusual input or state>', '<what must happen>', '<what is deliberately left out', '<dependency files the change may touch', '<name of a platform in relevo.yml>', '#<number>', '**<term>**', '<definition>', '<words to avoid>', '<decision title>', '<what was decided>', '<the reason>', '<the alternative', '<a concrete limit', '<path/to/file>', '<why it matters>', '<what only a person can check', '<an assumption', '<path/to/reference>', '<what must match>'];
|
|
15
|
+
const PICTOGRAPH_RE = /\p{Extended_Pictographic}/u;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns { ok, problems: string[], warnings: string[], scenarios, edgeCases, dependsOn, allowPaths } for a drafted title and body.
|
|
19
|
+
* `rulesFile` is the `qa.rules_from` file (or files) of the project, which can never be listed in an `Allow-paths:` line.
|
|
20
|
+
* `platforms` is the registry of relevo.yml ({ name: { ... } }); when given, the platform of the draft is checked too.
|
|
21
|
+
* `links` is the `links` list of relevo.yml: a `Depends-on:` that names a repository outside it is a problem.
|
|
22
|
+
* `repoRoot` is the folder of the repository: a design reference to a file of the repository must exist under it
|
|
23
|
+
* (when it is null the existence is not checked, as in the light planning job that has no checkout).
|
|
24
|
+
*/
|
|
25
|
+
export function checkIssueDraft({ title, body, rulesFile = null, platforms = null, links = [], repoRoot = null }) {
|
|
26
|
+
const problems = [];
|
|
27
|
+
const t = String(title ?? '').trim();
|
|
28
|
+
if (!t) problems.push('the title is empty');
|
|
29
|
+
if (PICTOGRAPH_RE.test(t)) problems.push('the title contains an emoji; titles are truncated in lists and boards, so keep them plain');
|
|
30
|
+
if (t.length > TITLE_MAX) problems.push(`the title is ${t.length} characters; keep it at ${TITLE_MAX} or fewer`);
|
|
31
|
+
|
|
32
|
+
const parsed = parseIssueBody(body);
|
|
33
|
+
parsed.missing.forEach((m) => problems.push(`the body has no ${m}`));
|
|
34
|
+
if (parsed.openQuestions.length > 0) problems.push(`the body has open questions (${parsed.openQuestions.length}): answer them or remove the "Open questions" section; the first is "${parsed.openQuestions[0].replace(/^[-*+]\s+/, '').slice(0, 80)}"`);
|
|
35
|
+
if (parsed.needsClarification) problems.push('the body contains NEEDS CLARIFICATION: resolve it before the issue can be built');
|
|
36
|
+
parsed.idProblems.forEach((p) => problems.push(p));
|
|
37
|
+
parsed.design.problems.forEach((p) => problems.push(`Design reference: ${p}`));
|
|
38
|
+
if (parsed.design.references.length > MAX_REFERENCES) problems.push(`Design reference has ${parsed.design.references.length} references; the most Relevo takes is ${MAX_REFERENCES}, so split the issue or keep the ones that matter`);
|
|
39
|
+
parsed.design.references.forEach((ref) => {
|
|
40
|
+
if (ref.kind === 'path' && repoRoot && !existsSync(join(repoRoot, ref.path))) problems.push(`Design reference: "${ref.target}" does not exist in the repository`);
|
|
41
|
+
if (ref.kind === 'repo-file' && !links.includes(ref.repo)) problems.push(`Design reference: "${ref.target}" names ${ref.repo}, which is not in links of relevo.yml (${links.length ? links.join(', ') : 'no repository is linked'})`);
|
|
42
|
+
if (ref.kind === 'attachment' && !ref.allowed) problems.push(`Design reference: ${ref.target} is not a GitHub attachment (github.com/user-attachments, user-images.githubusercontent.com)`);
|
|
43
|
+
});
|
|
44
|
+
const warnings = [];
|
|
45
|
+
const size = String(body ?? '').length;
|
|
46
|
+
if (size > BODY_WARN) warnings.push(`the body is ${size} characters; about ${BODY_WARN} or fewer reads better for an unattended build`);
|
|
47
|
+
const left = PLACEHOLDERS.filter((p) => String(body ?? '').includes(p));
|
|
48
|
+
if (left.length) problems.push(`the body still contains template text: ${left.join(', ')}`);
|
|
49
|
+
findCredentials(String(body ?? '')).forEach(({ name }) => problems.push(`the body contains something that looks like a credential (${name}); issues are readable by everyone with access`));
|
|
50
|
+
hardPathsInAllow(parsed.allowPaths, { rulesFile }).forEach((entry) => problems.push(`Allow-paths lists "${entry}", which can never be allowed: workflows, the Relevo configuration, skills and the project rules are not changed by an unattended run`));
|
|
51
|
+
|
|
52
|
+
parsed.dependsOnInvalid.forEach((token) => problems.push(`Depends-on has "${token}", which is not a reference; write #12 for an issue of this repository or owner/repo#12 for one of a linked repository`));
|
|
53
|
+
[...new Set(parsed.dependsOnExternal.map((d) => d.repo))].filter((repo) => !links.includes(repo)).forEach((repo) => {
|
|
54
|
+
problems.push(`Depends-on names ${repo}, which is not in links of relevo.yml (${links.length ? links.join(', ') : 'no repository is linked'})`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (platforms) {
|
|
58
|
+
const r = resolvePlatform({ platforms, body });
|
|
59
|
+
if (!r.ok) problems.push(r.reason === 'missing' ? `the body has no ${r.message}` : r.message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ok: problems.length === 0, problems, warnings, scenarios: parsed.scenarios, edgeCases: parsed.edgeCases, dependsOn: parsed.dependsOn, dependsOnExternal: parsed.dependsOnExternal, allowPaths: parsed.allowPaths };
|
|
63
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Checks that every id of the agreement (D decisions, C constraints, A assumptions, Q open questions) went somewhere.
|
|
2
|
+
// Implements "Nothing from the agreement is lost on the way to the issue" of openspec/specs/issue/spec.md.
|
|
3
|
+
// It checks presence, not meaning: the table it feeds is what the person approves.
|
|
4
|
+
|
|
5
|
+
import { sections } from './issue-sections.mjs';
|
|
6
|
+
|
|
7
|
+
const ID_AT_START_RE = /^\s*(?:[-*+]\s+)?(?:\*\*|__)?(?:#{1,6}\s+)?([DCAQ]\d+)\b/;
|
|
8
|
+
|
|
9
|
+
/** Ids listed in the agreement, in order, without repeats. */
|
|
10
|
+
export function agreementIds(text) {
|
|
11
|
+
const ids = [];
|
|
12
|
+
String(text ?? '').split('\n').forEach((line) => {
|
|
13
|
+
const m = line.match(ID_AT_START_RE);
|
|
14
|
+
if (m && !ids.includes(m[1])) ids.push(m[1]);
|
|
15
|
+
});
|
|
16
|
+
return ids;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* agreement text of the agreement
|
|
21
|
+
* issues [{ name, body }]
|
|
22
|
+
* discarded ids the person marked as discarded
|
|
23
|
+
* Returns { ok, found: [{ id, issue, section }], discarded: [id], missing: [id] }.
|
|
24
|
+
*/
|
|
25
|
+
export function coverage({ agreement, issues, discarded = [] }) {
|
|
26
|
+
const ids = agreementIds(agreement);
|
|
27
|
+
const skip = new Set(discarded.map((d) => d.trim()).filter(Boolean));
|
|
28
|
+
const found = [];
|
|
29
|
+
const dropped = [];
|
|
30
|
+
const missing = [];
|
|
31
|
+
ids.forEach((id) => {
|
|
32
|
+
if (skip.has(id)) {
|
|
33
|
+
dropped.push(id);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const re = new RegExp(`(?<![A-Za-z0-9])${id}(?![0-9])`);
|
|
37
|
+
let place = null;
|
|
38
|
+
issues.forEach((issue) => {
|
|
39
|
+
if (place) return;
|
|
40
|
+
const hit = sections(issue.body).find((s) => re.test(`${s.name}\n${s.text}`));
|
|
41
|
+
if (hit) place = { id, issue: issue.name, section: hit.name };
|
|
42
|
+
});
|
|
43
|
+
if (place) found.push(place);
|
|
44
|
+
else missing.push(id);
|
|
45
|
+
});
|
|
46
|
+
return { ok: missing.length === 0, found, discarded: dropped, missing };
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A hash of the text of an issue, to know that what is built is what a person approved.
|
|
2
|
+
// Implements "The issue that is built is the issue that was approved" of the security specification.
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const normalize = (text) => String(text ?? '')
|
|
7
|
+
.replace(/\r\n?/g, '\n')
|
|
8
|
+
.split('\n')
|
|
9
|
+
.map((line) => line.replace(/[ \t]+$/, ''))
|
|
10
|
+
.join('\n')
|
|
11
|
+
.trim();
|
|
12
|
+
|
|
13
|
+
/** SHA-256 (hex) of the title and the body. Line endings and trailing spaces do not change it. */
|
|
14
|
+
export function issueHash({ title, body }) {
|
|
15
|
+
return createHash('sha256').update(`${normalize(title)}\n\n${normalize(body)}`, 'utf8').digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first characters of a hash, for messages. */
|
|
19
|
+
export const shortHash = (hash, length = 8) => String(hash ?? '').slice(0, length);
|
|
20
|
+
|
|
21
|
+
/** Compare the text of an issue with the hash it was claimed with. Returns { ok, actual }. */
|
|
22
|
+
export function checkIssueHash({ expected, title, body }) {
|
|
23
|
+
const actual = issueHash({ title, body });
|
|
24
|
+
return { ok: actual === String(expected ?? '').toLowerCase(), actual };
|
|
25
|
+
}
|