@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,28 @@
1
+ #!/usr/bin/env node
2
+ // Prints what a fix does to an issue's labels. Usage:
3
+ // node scripts/fix-state.mjs --current "relevo:qa-failed,bug" [--config relevo.yml]
4
+ // Output: {"allowed":true,"from":"qa-failed","to":"built","notify":"assignee","labels":{"add":[...],"remove":[...]}}
5
+ // exit 0: a fix may run exit 1: not allowed in this state exit 2: bad arguments
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { loadConfig } from './lib/config.mjs';
10
+ import { fixOutcome } from './lib/fix.mjs';
11
+
12
+ const arg = (name) => {
13
+ const i = process.argv.indexOf(`--${name}`);
14
+ return i === -1 ? undefined : process.argv[i + 1];
15
+ };
16
+ if (arg('current') === undefined) {
17
+ console.error('usage: fix-state.mjs --current "label,label" [--config relevo.yml]');
18
+ process.exit(2);
19
+ }
20
+ let overrides = {};
21
+ const path = resolve(arg('config') ?? 'relevo.yml');
22
+ if (existsSync(path)) {
23
+ const loaded = loadConfig(readFileSync(path, 'utf8'));
24
+ if (loaded.ok) overrides = loaded.config.labels;
25
+ }
26
+ const result = fixOutcome(arg('current').split(',').map((s) => s.trim()).filter(Boolean), overrides);
27
+ console.log(JSON.stringify(result));
28
+ process.exit(result.allowed ? 0 : 1);
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Checks the task list of a change around a fix. Usage:
3
+ // node scripts/fix-tasks.mjs --file openspec/changes/31-x/tasks.md --stage before|after
4
+ // before: run before the developer; after: run after QA passes.
5
+ // exit 0: ok exit 1: prints why not exit 2: bad arguments
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { checkFixTasks } from './lib/tasks-file.mjs';
9
+
10
+ const arg = (name) => {
11
+ const i = process.argv.indexOf(`--${name}`);
12
+ return i === -1 ? undefined : process.argv[i + 1];
13
+ };
14
+ const file = arg('file');
15
+ const stage = arg('stage');
16
+ if (!file || !['before', 'after'].includes(stage) || !existsSync(file)) {
17
+ console.error('usage: fix-tasks.mjs --file <tasks.md> --stage before|after');
18
+ process.exit(2);
19
+ }
20
+ const result = checkFixTasks(readFileSync(file, 'utf8'), stage);
21
+ if (!result.ok) result.reasons.forEach((r) => console.log(r));
22
+ process.exit(result.ok ? 0 : 1);
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Builds an issue on this machine, with your own Claude session, as the cloud would. Usage:
3
+ // node scripts/go-local.mjs <issue> [--config relevo.yml] [--dry-run]
4
+ // It runs `claude -p "/relevo:go <issue>"` in a separate git worktree (so your uncommitted changes do not matter),
5
+ // with the turn and cost limits of relevo.yml and the closed tool lists of the workflow. The worktree is removed when
6
+ // the run ends cleanly and kept otherwise.
7
+ // exit 0: done exit 2: bad usage or something missing other: the exit status of the run
8
+
9
+ import { execFileSync, spawnSync } from 'node:child_process';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { loadConfig } from './lib/config.mjs';
13
+ import { runLocal } from './lib/go-local.mjs';
14
+
15
+ const args = process.argv.slice(2);
16
+ const flag = (name) => args.includes(`--${name}`);
17
+ const opt = (name) => {
18
+ const i = args.indexOf(`--${name}`);
19
+ return i === -1 ? undefined : args[i + 1];
20
+ };
21
+ const issue = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--config');
22
+ const fail = (message, code = 2) => {
23
+ console.error(message);
24
+ process.exit(code);
25
+ };
26
+
27
+ const configPath = resolve(opt('config') ?? 'relevo.yml');
28
+ if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /relevo:init to create it.`);
29
+ const loaded = loadConfig(readFileSync(configPath, 'utf8'));
30
+ if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
31
+
32
+ const runner = {
33
+ capture: (cmd, cmdArgs, options) => execFileSync(cmd, cmdArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: options.cwd }),
34
+ stream: (cmd, cmdArgs, options) => {
35
+ const r = spawnSync(cmd, cmdArgs, { stdio: 'inherit', cwd: options.cwd, env: { ...process.env, ...options.env } });
36
+ if (r.error) throw r.error;
37
+ return r.status ?? 1;
38
+ },
39
+ };
40
+ const result = runLocal({
41
+ issue,
42
+ config: loaded.config,
43
+ runner,
44
+ dryRun: flag('dry-run'),
45
+ workflowPresent: existsSync('.github/workflows/relevo-go.yml'),
46
+ exists: existsSync,
47
+ });
48
+ result.messages.forEach((m) => console.log(m));
49
+ process.exit(result.status);
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // Plans what `go` should do. Reads GitHub (or a JSON file), prints the decisions as JSON.
3
+ //
4
+ // node scripts/go-plan.mjs --repo owner/name [--config relevo.yml] [--credential false] [--running 12,13] [--only 31] [--apply] [--claim] [--github-output file]
5
+ // node scripts/go-plan.mjs --input state.json [--config relevo.yml]
6
+ //
7
+ // --only N keeps only the decision for issue N (the whole queue is still planned, so limits stay correct).
8
+ // --apply carries out every decision except `start` (labels, assignees, comments).
9
+ // --claim also carries out the `start` decision of the issue given by --only (marks it as building).
10
+ // --github-output FILE appends `action=<decision action, or none>`, `decision_b64=<the decision as base64 JSON>` and, for a start,
11
+ // `platform=`, `stack=` and `path=` (empty otherwise)
12
+ // to FILE (the GITHUB_OUTPUT file of a workflow step), so a later job can build without planning again.
13
+ // Needs --only: it describes the decision of one issue.
14
+ // The `start` decisions are returned for the skill to build.
15
+ // exit 0: planned exit 2: invalid or missing configuration, or bad arguments
16
+
17
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import { loadConfig } from './lib/config.mjs';
20
+ import { applyDecisions, gather, realGh, realGhLinks } from './lib/github.mjs';
21
+ import { planGo, withoutCredential } from './lib/plan.mjs';
22
+
23
+ function arg(name) {
24
+ const i = process.argv.indexOf(`--${name}`);
25
+ return i === -1 ? undefined : process.argv[i + 1];
26
+ }
27
+ const flag = (name) => process.argv.includes(`--${name}`);
28
+ const fail = (message) => {
29
+ console.error(message);
30
+ process.exit(2);
31
+ };
32
+
33
+ const configPath = resolve(arg('config') ?? 'relevo.yml');
34
+ if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /relevo:init to create it.`);
35
+ const loaded = loadConfig(readFileSync(configPath, 'utf8'));
36
+ if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
37
+ const { config } = loaded;
38
+
39
+ const running = (arg('running') ?? '').split(',').filter(Boolean).map(Number);
40
+ let state;
41
+ let repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
42
+
43
+ if (arg('input')) {
44
+ state = JSON.parse(readFileSync(arg('input') === '-' ? 0 : arg('input'), 'utf8'));
45
+ state.running = [...(state.running ?? []), ...running];
46
+ } else {
47
+ if (!repo) fail('give --repo owner/name (or set GITHUB_REPOSITORY), or --input state.json');
48
+ state = gather({ gh: realGh, ghLinks: realGhLinks(), repo, config, running });
49
+ }
50
+
51
+ const result = planGo({ config, repo, ...state });
52
+ // `--credential false`: the workflow found no model credential, so nothing may be claimed for a cloud build.
53
+ if (arg('credential') === 'false') {
54
+ result.decisions = withoutCredential(result.decisions, state.issues);
55
+ result.summary = {};
56
+ result.decisions.forEach((d) => { result.summary[d.action] = (result.summary[d.action] ?? 0) + 1; });
57
+ }
58
+
59
+ const only = arg('only') ? Number(arg('only')) : null;
60
+ if (only !== null) {
61
+ if (!Number.isInteger(only)) fail('--only needs an issue number');
62
+ result.decisions = result.decisions.filter((d) => d.issue === only);
63
+ result.summary = {};
64
+ result.decisions.forEach((d) => {
65
+ result.summary[d.action] = (result.summary[d.action] ?? 0) + 1;
66
+ });
67
+ }
68
+ if (flag('claim') && only === null) fail('--claim needs --only <issue>: it marks one issue as building');
69
+
70
+ if (flag('apply') || flag('claim')) {
71
+ if (!repo) fail('--apply and --claim need --repo owner/name');
72
+ result.applied = applyDecisions(result.decisions, { gh: realGh, repo, config, includeStart: flag('claim') }).length;
73
+ }
74
+ if (arg('github-output')) {
75
+ if (only === null) fail('--github-output needs --only <issue>: it describes the decision of one issue');
76
+ const decision = result.decisions[0] ?? null;
77
+ const encoded = decision ? Buffer.from(JSON.stringify(decision), 'utf8').toString('base64') : '';
78
+ // The build job installs the toolchain of the stack of the platform; the values are empty unless the decision is `start`.
79
+ const start = decision?.action === 'start' ? decision : {};
80
+ appendFileSync(arg('github-output'), `action=${decision ? decision.action : 'none'}\ndecision_b64=${encoded}\nplatform=${start.platform ?? ''}\nstack=${start.stack ?? ''}\npath=${start.path ?? ''}\n`);
81
+ }
82
+ console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // Plans and applies the repository setup. Usage:
3
+ // node scripts/init.mjs plan [--answers answers.json] [--repo owner/name] [--json]
4
+ // node scripts/init.mjs apply [--answers answers.json] [--repo owner/name] [--skip id,id] [--json]
5
+ // `plan` changes nothing. `apply` carries out the planned changes except installs (the skill asks first).
6
+ // answers.json: { "members": ["a","b"], "specWriter": "a", "reviewMode": "peer", "language": "en", "platforms": { "app": { "stack": "flutter", "path": ".", "targets": ["android"] } }, "stackProfiles": [] }
7
+ // It never reads or writes the model API key.
8
+ // exit 0: done exit 2: bad arguments or a failed change
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { gatherFacts, readOnlyGh, WORKFLOW_FILES } from './lib/doctor.mjs';
15
+ import { applyPlan, planInit, renderPlan, skipItems } from './lib/init.mjs';
16
+
17
+ const arg = (name) => {
18
+ const i = process.argv.indexOf(`--${name}`);
19
+ return i === -1 ? undefined : process.argv[i + 1];
20
+ };
21
+ const fail = (m) => {
22
+ console.error(m);
23
+ process.exit(2);
24
+ };
25
+
26
+ const mode = process.argv[2];
27
+ if (!['plan', 'apply'].includes(mode)) fail('usage: init.mjs <plan|apply> [--answers file] [--repo owner/name] [--json]');
28
+
29
+ const root = resolve(dirname(new URL(import.meta.url).pathname), '..');
30
+ const run = (name, args, input) => execFileSync(name, args, { encoding: 'utf8', input, maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
31
+ const tool = (name, args) => run(name, args);
32
+
33
+ let repo = arg('repo') ?? process.env.GITHUB_REPOSITORY ?? null;
34
+ if (!repo) {
35
+ try {
36
+ repo = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']).trim();
37
+ } catch {
38
+ fail('could not find the GitHub repository: run inside one, or pass --repo owner/name (and sign in with `gh auth login`)');
39
+ }
40
+ }
41
+
42
+ const answers = arg('answers') ? JSON.parse(readFileSync(arg('answers'), 'utf8')) : {};
43
+ const facts = gatherFacts({
44
+ tool,
45
+ gh: readOnlyGh((args) => run('gh', args)),
46
+ exists: existsSync,
47
+ read: (p) => readFileSync(p, 'utf8'),
48
+ listDir: (p) => readdirSync(p),
49
+ skillDirs: ['.claude/skills', '.agents/skills', `${homedir()}/.claude/skills`],
50
+ repo,
51
+ });
52
+
53
+ let admin = false;
54
+ try {
55
+ admin = JSON.parse(run('gh', ['api', `repos/${repo}`])).permissions?.admin === true;
56
+ } catch {
57
+ admin = false;
58
+ }
59
+
60
+ const templates = {
61
+ config: readFileSync(`${root}/templates/relevo.yml`, 'utf8'),
62
+ workflows: Object.fromEntries(WORKFLOW_FILES.map((f) => [f, readFileSync(`${root}/templates/workflows/${f}`, 'utf8')])),
63
+ };
64
+ const keepPaths = ['openspec/specs/.gitkeep', 'openspec/changes/archive/.gitkeep'];
65
+ const current = {
66
+ workflows: Object.fromEntries(WORKFLOW_FILES.map((f) => [f, existsSync(`.github/workflows/${f}`) ? readFileSync(`.github/workflows/${f}`, 'utf8') : null])),
67
+ gitignore: existsSync('.gitignore') ? readFileSync('.gitignore', 'utf8') : null,
68
+ keepFiles: Object.fromEntries(keepPaths.map((p) => [p, existsSync(p)])),
69
+ stackProfiles: Object.fromEntries((answers.stackProfiles ?? []).map((s) => [s, existsSync(`.relevo/stacks/${s}.md`)])),
70
+ };
71
+
72
+ let items;
73
+ try {
74
+ items = planInit({ facts, answers, templates, current, admin });
75
+ } catch (err) {
76
+ fail(err.message);
77
+ }
78
+
79
+ if (mode === 'plan') {
80
+ console.log(process.argv.includes('--json') ? JSON.stringify(items, null, 2) : renderPlan(items));
81
+ process.exit(0);
82
+ }
83
+
84
+ const io = {
85
+ gh: (args, input) => run('gh', args, input),
86
+ write: (p, text) => {
87
+ mkdirSync(dirname(resolve(p)), { recursive: true });
88
+ writeFileSync(p, text);
89
+ },
90
+ append: (p, line) => {
91
+ const have = existsSync(p) ? readFileSync(p, 'utf8') : '';
92
+ appendFileSync(p, `${have === '' || have.endsWith('\n') ? '' : '\n'}${line}\n`);
93
+ },
94
+ };
95
+ const skipped = (arg('skip') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
96
+ const results = applyPlan(skipItems(items, skipped), { repo, io });
97
+ skipped.forEach((id) => console.error(`skipped ${id}: run init again to apply it`));
98
+ if (process.argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
99
+ else {
100
+ results.forEach((r) => {
101
+ console.log(`${r.ok ? 'done ' : 'FAILED'} ${r.id}: ${r.message}`);
102
+ (r.checklist ?? []).forEach((c) => console.log(` by hand: ${c}`));
103
+ });
104
+ items.filter((i) => i.status === 'manual').forEach((i) => {
105
+ console.log(`by hand ${i.id}: ${i.description}`);
106
+ (i.checklist ?? []).forEach((c) => console.log(` ${c}`));
107
+ });
108
+ }
109
+ process.exit(results.some((r) => !r.ok && !r.checklist) ? 2 : 0);
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Makes new files show up in `git diff <base>` without staging their content. Usage:
3
+ // node scripts/intent-to-add.mjs path [path ...]
4
+ // `go` runs it on the files the developer reported, before QA reviews the work. Files that git ignores and files that
5
+ // do not exist are skipped. Refuses "." and "-A": name the files.
6
+ // exit 0: done (prints what was marked and skipped) exit 2: bad arguments or git failed
7
+
8
+ import { execFileSync } from 'node:child_process';
9
+ import { existsSync } from 'node:fs';
10
+ import { intentToAdd } from './lib/intent-to-add.mjs';
11
+
12
+ const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
13
+
14
+ try {
15
+ const { marked, ignored, missing } = intentToAdd({ git, exists: existsSync, paths: process.argv.slice(2) });
16
+ console.log(`marked ${marked.length} file(s) for the diff${ignored.length ? `, skipped ${ignored.length} ignored by git` : ''}${missing.length ? `, skipped ${missing.length} that do not exist` : ''}`);
17
+ } catch (err) {
18
+ console.error(err.message);
19
+ process.exit(2);
20
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Checks that every id of the agreement went into one of the drafts. Usage:
3
+ // node scripts/issue-coverage.mjs --agreement agreement.md --issue 1.md [--issue 2.md ...] [--discarded D3,Q1]
4
+ // Prints a table (id, issue, section), the discarded ids and the ids without a destination.
5
+ // exit 0: every id has a destination or was discarded exit 1: some id has none exit 2: bad arguments
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { coverage } from './lib/issue-coverage.mjs';
9
+
10
+ const values = (name) => process.argv.flatMap((a, i, all) => (a === `--${name}` ? [all[i + 1]] : []));
11
+ const [agreementFile] = values('agreement');
12
+ const issueFiles = values('issue');
13
+ if (!agreementFile || issueFiles.length === 0) {
14
+ console.error('usage: issue-coverage.mjs --agreement <file> --issue <file> [--issue <file> ...] [--discarded D3,Q1]');
15
+ process.exit(2);
16
+ }
17
+ const read = (f) => {
18
+ try {
19
+ return readFileSync(f, 'utf8');
20
+ } catch (err) {
21
+ console.error(`cannot read ${f}: ${err.message}`);
22
+ process.exit(2);
23
+ }
24
+ };
25
+ const discarded = (values('discarded')[0] ?? '').split(',');
26
+ const r = coverage({ agreement: read(agreementFile), issues: issueFiles.map((f) => ({ name: f, body: read(f) })), discarded });
27
+ r.found.forEach((f) => console.log(`${f.id}\t${f.issue}\t${f.section}`));
28
+ r.discarded.forEach((id) => console.log(`${id}\t(discarded)`));
29
+ r.missing.forEach((id) => console.log(`${id}\tWITHOUT DESTINATION`));
30
+ process.exit(r.ok ? 0 : 1);
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // Checks that an issue still has the text it was claimed with. Usage:
3
+ // node scripts/issue-hash.mjs --repo owner/name --issue 42 --expect <hash> exit 0 same, 1 different
4
+ // node scripts/issue-hash.mjs --input issue.json --expect <hash> (a saved `gh issue view --json title,body`)
5
+ // node scripts/issue-hash.mjs --repo owner/name --issue 42 prints the hash
6
+ // exit 2: bad arguments or the issue could not be read.
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { realGh } from './lib/github.mjs';
10
+ import { checkIssueHash, issueHash, shortHash } from './lib/issue-hash.mjs';
11
+
12
+ const arg = (name) => {
13
+ const i = process.argv.indexOf(`--${name}`);
14
+ return i === -1 ? undefined : process.argv[i + 1];
15
+ };
16
+ const fail = (message) => {
17
+ console.error(message);
18
+ process.exit(2);
19
+ };
20
+
21
+ let issue;
22
+ try {
23
+ if (arg('input')) {
24
+ issue = JSON.parse(readFileSync(arg('input'), 'utf8'));
25
+ } else {
26
+ const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
27
+ const number = arg('issue');
28
+ if (!repo || !/^\d+$/.test(number ?? '')) fail('usage: issue-hash.mjs --repo owner/name --issue <number> [--expect <hash>] (or --input issue.json)');
29
+ issue = JSON.parse(realGh(['issue', 'view', number, '--repo', repo, '--json', 'title,body']));
30
+ }
31
+ } catch (err) {
32
+ fail(`could not read the issue: ${err.message}`);
33
+ }
34
+
35
+ if (!arg('expect')) {
36
+ console.log(issueHash(issue));
37
+ } else {
38
+ const { ok, actual } = checkIssueHash({ expected: arg('expect'), ...issue });
39
+ if (ok) console.log(`the issue text is unchanged (${shortHash(actual)})`);
40
+ else {
41
+ console.log(`the issue text changed after it was claimed (expected ${shortHash(arg('expect'))}, found ${shortHash(actual)})`);
42
+ process.exit(1);
43
+ }
44
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Reads the sections of an issue body. Usage:
3
+ // node scripts/issue-sections.mjs --body-file issue.md --list names of the sections that have text
4
+ // node scripts/issue-sections.mjs --body-file issue.md --section Decisions the text of one section
5
+ // node scripts/issue-sections.mjs --body-file issue.md --copy "Decisions,Constraints" the block go appends to design.md
6
+ // exit 0: done (a --copy with nothing to copy prints nothing) exit 1: --section names a section that is not there exit 2: bad arguments
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { copyBlock, section, sections } from './lib/issue-sections.mjs';
10
+
11
+ const arg = (name) => {
12
+ const i = process.argv.indexOf(`--${name}`);
13
+ return i === -1 ? undefined : process.argv[i + 1];
14
+ };
15
+ const file = arg('body-file');
16
+ const modes = ['list', 'section', 'copy'].filter((m) => process.argv.includes(`--${m}`));
17
+ if (!file || modes.length !== 1) {
18
+ console.error('usage: issue-sections.mjs --body-file <file> (--list | --section <Name> | --copy "<Name>,<Name>")');
19
+ process.exit(2);
20
+ }
21
+ let body;
22
+ try {
23
+ body = readFileSync(file, 'utf8');
24
+ } catch (err) {
25
+ console.error(`cannot read ${file}: ${err.message}`);
26
+ process.exit(2);
27
+ }
28
+ if (modes[0] === 'list') {
29
+ sections(body).filter((s) => s.text !== '').forEach((s) => console.log(s.name));
30
+ } else if (modes[0] === 'section') {
31
+ const s = section(body, arg('section') ?? '');
32
+ if (!s) process.exit(1);
33
+ console.log(s.text);
34
+ } else {
35
+ const names = (arg('copy') ?? '').split(',').map((n) => n.trim()).filter(Boolean);
36
+ if (names.length === 0) {
37
+ console.error('--copy needs at least one section name');
38
+ process.exit(2);
39
+ }
40
+ const block = copyBlock(body, names);
41
+ if (block) process.stdout.write(block);
42
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // Prints the label changes needed to move an issue to a state. Usage:
3
+ // node scripts/label-change.mjs --to built --current "relevo:building,bug" [--config relevo.yml]
4
+ // node scripts/label-change.mjs --clear --current "relevo:pr-open,bug" (removes every Relevo label)
5
+ // Output: {"add":[...],"remove":[...]} using the label names from relevo.yml.
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { loadConfig } from './lib/config.mjs';
10
+ import { clearAll, transition } from './lib/labels.mjs';
11
+
12
+ const arg = (name) => {
13
+ const i = process.argv.indexOf(`--${name}`);
14
+ return i === -1 ? undefined : process.argv[i + 1];
15
+ };
16
+
17
+ const to = arg('to');
18
+ const clear = process.argv.includes('--clear');
19
+ if (!to && !clear) {
20
+ console.error('usage: label-change.mjs (--to <label-key> | --clear) --current "a,b" [--config path]');
21
+ process.exit(2);
22
+ }
23
+ let overrides = {};
24
+ const path = resolve(arg('config') ?? 'relevo.yml');
25
+ if (existsSync(path)) {
26
+ const loaded = loadConfig(readFileSync(path, 'utf8'));
27
+ if (loaded.ok) overrides = loaded.config.labels;
28
+ }
29
+ try {
30
+ const current = (arg('current') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
31
+ console.log(JSON.stringify(clear ? clearAll(current, overrides) : transition(current, to, overrides)));
32
+ } catch (err) {
33
+ console.error(err.message);
34
+ process.exit(2);
35
+ }
@@ -0,0 +1,12 @@
1
+ // Finds the Android application id of the project under review, so the log audit can ignore other apps.
2
+
3
+ const GRADLE = [/applicationId\s*=\s*["']([\w.]+)["']/, /applicationId\s+["']([\w.]+)["']/];
4
+
5
+ /** Application id from the text of android/app/build.gradle or build.gradle.kts, or null. */
6
+ export function applicationId(gradleText) {
7
+ for (const re of GRADLE) {
8
+ const m = re.exec(String(gradleText));
9
+ if (m) return m[1];
10
+ }
11
+ return null;
12
+ }
@@ -0,0 +1,29 @@
1
+ // The spec archive is committed after approval, so it may touch nothing but the specifications folder.
2
+ // Implements "Archive the specification just before merging" of openspec/specs/ship/spec.md.
3
+
4
+ /**
5
+ * paths every path changed in the working tree by the archive (from `git status --porcelain`)
6
+ * Returns { ok, outside }.
7
+ */
8
+ export function checkArchiveChanges(paths, specsDir = 'openspec') {
9
+ const prefix = `${specsDir.replace(/\/+$/, '')}/`;
10
+ const outside = paths.filter((p) => !p.startsWith(prefix));
11
+ if (paths.length === 0) return { ok: false, outside: [], empty: true };
12
+ return { ok: outside.length === 0, outside };
13
+ }
14
+
15
+ /** Paths from `git status --porcelain` output, including both sides of a rename. */
16
+ export function pathsFromPorcelain(output) {
17
+ return String(output)
18
+ .split('\n')
19
+ .filter(Boolean)
20
+ .flatMap((line) => line.slice(3).split(' -> '))
21
+ .map((p) => p.replace(/^"|"$/g, ''));
22
+ }
23
+
24
+ /** Whether every conflicted path is inside the specifications folder. Returns { ok, paths, outside }. */
25
+ export function conflictScope(paths, specsDir = 'openspec') {
26
+ const prefix = `${specsDir.replace(/\/+$/, '')}/`;
27
+ const outside = paths.filter((p) => !p.startsWith(prefix));
28
+ return { ok: outside.length === 0, paths, outside };
29
+ }
@@ -0,0 +1,15 @@
1
+ // Tells whether a change is still to be archived, already archived, or unknown.
2
+ // Implements "Ship can be run again after a failed merge" of openspec/specs/ship/spec.md.
3
+
4
+ /**
5
+ * active names of the changes in `openspec list --json`
6
+ * archived names of the folders of openspec/changes/archive/ (YYYY-MM-DD-<change>)
7
+ * change the change folder name, e.g. "31-summary"
8
+ * Returns "active", "archived" or "missing".
9
+ */
10
+ export function archiveState({ active, archived, change }) {
11
+ if (active.includes(change)) return 'active';
12
+ const dated = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${change.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
13
+ if (archived.some((name) => dated.test(name))) return 'archived';
14
+ return 'missing';
15
+ }
@@ -0,0 +1,13 @@
1
+ // Tells a branch that holds implementation from one that holds only the specification.
2
+ // Implements "A branch without implementation is not corrected" of openspec/specs/fix/spec.md.
3
+
4
+ /**
5
+ * paths every path that differs from the base branch
6
+ * changeDir e.g. "openspec/changes/31-summary"
7
+ * Returns { implementation, files }: files are the paths outside the change folder.
8
+ */
9
+ export function hasImplementation(paths, changeDir) {
10
+ const prefix = `${changeDir.replace(/\/+$/, '')}/`;
11
+ const files = paths.filter((p) => !p.startsWith(prefix));
12
+ return { implementation: files.length > 0, files };
13
+ }
@@ -0,0 +1,43 @@
1
+ // Branch and change names derived from an issue.
2
+
3
+ const MAX_SLUG = 40;
4
+
5
+ /** kebab-case slug of a title: ASCII only, at most 40 characters, cut at a word boundary. */
6
+ export function slugify(title, max = MAX_SLUG) {
7
+ let slug = String(title ?? '')
8
+ .normalize('NFD')
9
+ .replace(/[̀-ͯ]/g, '')
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '');
13
+ if (slug.length > max) {
14
+ const midWord = slug[max] !== '-';
15
+ slug = slug.slice(0, max);
16
+ if (midWord && slug.includes('-')) slug = slug.replace(/-[^-]*$/, '');
17
+ slug = slug.replace(/-+$/g, '');
18
+ }
19
+ return slug || 'task';
20
+ }
21
+
22
+ export const changeId = (number, title) => `${number}-${slugify(title)}`;
23
+ export const branchName = (number, title) => `feature/${changeId(number, title)}`;
24
+
25
+ /** The issue number a feature branch was made for (`feature/12-add-total` -> 12), or null. */
26
+ export function issueFromBranch(branch) {
27
+ const m = /^feature\/(\d+)-/.exec(String(branch ?? ''));
28
+ return m ? Number(m[1]) : null;
29
+ }
30
+
31
+ /**
32
+ * What an existing feature branch holds, from the paths it changes against the base branch:
33
+ * `empty` (nothing), `spec-only` (only the change specification, under openspec/changes/<change_id>/) or `implementation`.
34
+ */
35
+ export function classifyBranchChanges(paths, id) {
36
+ const changed = (paths ?? []).map((p) => String(p).trim()).filter(Boolean);
37
+ if (changed.length === 0) return 'empty';
38
+ const prefix = `openspec/changes/${id}/`;
39
+ return changed.every((p) => p.startsWith(prefix)) ? 'spec-only' : 'implementation';
40
+ }
41
+
42
+ /** What to tell the person about a failed run on a branch that already exists: fix it, or start again. */
43
+ export const recoveryForBranch = (kind) => (kind === 'implementation' ? 'fix' : 'delete-branch');
@@ -0,0 +1,34 @@
1
+ // Decides whether the description of an issue may be rewritten.
2
+ // Implements "Only an issue Relevo has not committed to a build may be edited" of openspec/specs/issue/spec.md.
3
+ import { LABELS, resolveLabelNames } from './labels.mjs';
4
+
5
+ // Why each lifecycle label closes the issue to editing. `needs-spec` is the only one that leaves it open.
6
+ const REASON = {
7
+ ready: 'go starts it by itself, so the new text would be built without anyone rereading it',
8
+ queued: 'go starts it by itself when there is capacity',
9
+ blocked: 'go starts it by itself when its dependency merges',
10
+ building: 'the agent is building from the current text; wait for it to finish or fail',
11
+ built: 'a branch already exists and the text would no longer match what was built',
12
+ 'in-review': 'it is being reviewed against the current text',
13
+ 'pr-open': 'a pull request already exists for the current text',
14
+ 'qa-failed': 'a branch already exists and the text would no longer match what was built',
15
+ 'changes-requested': 'a branch already exists and the text would no longer match what was built',
16
+ 'needs-reviewer': 'it is built and waiting for a reviewer',
17
+ };
18
+ export const EDITABLE_LABELS = ['needs-spec'];
19
+
20
+ /**
21
+ * @param {{state?: string, labels?: Array<string|{name: string}>}} issue `gh issue view --json state,labels`
22
+ * @param {Record<string,string>} [overrides] the `labels` setting of relevo.yml
23
+ * @returns {{editable: true} | {editable: false, label?: string, reason: string}}
24
+ */
25
+ export function canEditIssue(issue, overrides = {}) {
26
+ if (String(issue.state ?? '').toUpperCase() !== 'OPEN') return { editable: false, reason: 'the issue is closed' };
27
+ const names = resolveLabelNames(overrides);
28
+ const have = new Set((issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)));
29
+ for (const { key } of LABELS) {
30
+ if (EDITABLE_LABELS.includes(key) || !have.has(names[key])) continue;
31
+ return { editable: false, label: names[key], reason: REASON[key] };
32
+ }
33
+ return { editable: true };
34
+ }