@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,31 @@
1
+ #!/usr/bin/env node
2
+ // Says what a feature branch on the remote holds. Usage:
3
+ // node scripts/branch-state.mjs <base> <branch> <change_id>
4
+ // Prints one word: absent (no such branch), empty, spec-only or implementation.
5
+ // spec-only means every changed path is under openspec/changes/<change_id>/. exit 0: printed exit 2: bad arguments or git failed
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { classifyBranchChanges } from './lib/branch.mjs';
9
+
10
+ const [base, branch, id] = process.argv.slice(2);
11
+ if (!base || !branch || !id) {
12
+ console.error('usage: branch-state.mjs <base> <branch> <change_id>');
13
+ process.exit(2);
14
+ }
15
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
16
+
17
+ try {
18
+ // The branch may not exist: fetching it then fails, which is the answer.
19
+ try {
20
+ git('fetch', '--quiet', 'origin', base, branch);
21
+ } catch {
22
+ git('fetch', '--quiet', 'origin', base);
23
+ console.log('absent');
24
+ process.exit(0);
25
+ }
26
+ const paths = git('diff', '--name-only', `origin/${base}...origin/${branch}`).split('\n');
27
+ console.log(classifyBranchChanges(paths, id));
28
+ } catch (err) {
29
+ console.error(`could not read the branch: ${(err.stderr || err.message).toString().trim()}`);
30
+ process.exit(2);
31
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Says whether the current branch holds implementation beyond the change's specification. Usage:
3
+ // node scripts/branch-work.mjs --base dev --change 31-summary
4
+ // Prints {"implementation":true|false,"files":[...]}. exit 0: there is code to correct exit 1: only the specification exit 2: bad arguments
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { hasImplementation } from './lib/branch-work.mjs';
8
+
9
+ const arg = (name) => {
10
+ const i = process.argv.indexOf(`--${name}`);
11
+ return i === -1 ? undefined : process.argv[i + 1];
12
+ };
13
+ const base = arg('base');
14
+ const change = arg('change');
15
+ if (!base || !change) {
16
+ console.error('usage: branch-work.mjs --base <branch> --change <change folder name>');
17
+ process.exit(2);
18
+ }
19
+ const paths = execFileSync('git', ['diff', '--name-only', `origin/${base}...HEAD`], { encoding: 'utf8' }).split('\n').filter(Boolean);
20
+ const result = hasImplementation(paths, `openspec/changes/${change}`);
21
+ console.log(JSON.stringify(result));
22
+ if (!result.implementation) {
23
+ console.error('the branch holds only the specification: delete the remote branch and apply relevo:ready again');
24
+ process.exit(1);
25
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // Tells whether an issue's description may be rewritten. Usage:
3
+ // gh issue view 42 --json number,title,body,state,labels | node scripts/can-edit-issue.mjs [--input issue.json] [--config relevo.yml]
4
+ // Prints `editable`, or `refused: <label> - <reason>` (the label is left out when the issue is closed).
5
+ // exit 0: editable exit 1: refused 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 { canEditIssue } from './lib/can-edit-issue.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 = (m) => {
17
+ console.error(m);
18
+ process.exit(2);
19
+ };
20
+
21
+ let issue;
22
+ try {
23
+ issue = JSON.parse(readFileSync(arg('input') ?? 0, 'utf8'));
24
+ } catch (err) {
25
+ fail(`cannot read the issue JSON: ${err.message}`);
26
+ }
27
+ if (!issue || typeof issue !== 'object' || !Array.isArray(issue.labels) || typeof issue.state !== 'string') {
28
+ fail('the JSON must come from `gh issue view --json number,title,body,state,labels`');
29
+ }
30
+
31
+ let overrides = {};
32
+ const configPath = resolve(arg('config') ?? 'relevo.yml');
33
+ if (existsSync(configPath)) {
34
+ const loaded = loadConfig(readFileSync(configPath, 'utf8'));
35
+ if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
36
+ overrides = loaded.config.labels ?? {};
37
+ }
38
+
39
+ const r = canEditIssue(issue, overrides);
40
+ if (r.editable) {
41
+ console.log('editable');
42
+ process.exit(0);
43
+ }
44
+ console.log(`refused: ${r.label ? `${r.label} - ` : ''}${r.reason}`);
45
+ process.exit(1);
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // Takes a screenshot of the device and saves it as evidence/<name>.png. Never opens the image.
3
+ // node scripts/capture.mjs <name> [--dir evidence]
4
+ // Names are plain file names (letters, digits, "_", "-", "."), never paths.
5
+
6
+ import { capture, listDevices, pickDevice, safeCaptureName } from './lib/device.mjs';
7
+
8
+ const name = process.argv[2];
9
+ const di = process.argv.indexOf('--dir');
10
+ const dir = di === -1 ? 'evidence' : process.argv[di + 1];
11
+ if (!name || name.startsWith('--')) {
12
+ console.error('usage: capture.mjs <name> [--dir evidence]');
13
+ process.exit(2);
14
+ }
15
+ try {
16
+ safeCaptureName(name); // a bad name is refused before adb is run
17
+ const serial = pickDevice(listDevices(), process.env.ANDROID_SERIAL ?? null);
18
+ console.log(capture({ name, dir, serial }));
19
+ } catch (err) {
20
+ console.error(err.code === 'ENOENT' ? 'adb is not installed: install the Android platform tools' : err.message);
21
+ process.exit(2);
22
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // After `openspec archive`, checks that the archive changed nothing outside openspec/.
3
+ // node scripts/check-archive.mjs [--dir openspec]
4
+ // exit 0: only the specifications folder changed exit 1: something else changed, or nothing changed
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { checkArchiveChanges, pathsFromPorcelain } from './lib/archive-check.mjs';
8
+
9
+ const di = process.argv.indexOf('--dir');
10
+ const dir = di === -1 ? 'openspec' : process.argv[di + 1];
11
+ const paths = pathsFromPorcelain(execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' }));
12
+ const result = checkArchiveChanges(paths, dir);
13
+ if (result.ok) {
14
+ console.log(`the archive changed ${paths.length} path(s), all under ${dir}/`);
15
+ process.exit(0);
16
+ }
17
+ if (result.empty) console.log('the archive changed nothing: was the change already archived?');
18
+ else console.log(`the archive changed files outside ${dir}/: ${result.outside.join(', ')}`);
19
+ process.exit(1);
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // Checks a drafted issue. Usage:
3
+ // (--repo-root <folder>, default the current folder, is where the files of a Design reference must exist)
4
+ // node scripts/check-issue.mjs --title "Short plain title" --body-file issue.md [--rules-file CLAUDE.md] [--platforms web,api] [--links acme/api,acme/design]
5
+ // --platforms lists the names of the platforms of relevo.yml: with more than one the draft needs a `Platform:` line.
6
+ // --rules-file is the project rules file or files, comma separated (`qa.rules_from`), which an `Allow-paths:` line can never list.
7
+ // Prints the counts and every problem. exit 0: ready to create exit 1: problems exit 2: bad arguments
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { checkIssueDraft } from './lib/issue-check.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 title = arg('title');
17
+ const file = arg('body-file');
18
+ if (title === undefined || !file) {
19
+ console.error('usage: check-issue.mjs --title "<title>" --body-file <file>');
20
+ process.exit(2);
21
+ }
22
+ let body;
23
+ try {
24
+ body = readFileSync(file, 'utf8');
25
+ } catch (err) {
26
+ console.error(`cannot read ${file}: ${err.message}`);
27
+ process.exit(2);
28
+ }
29
+ const platformNames = (arg('platforms') ?? '').split(',').map((f) => f.trim()).filter(Boolean);
30
+ const platforms = platformNames.length ? Object.fromEntries(platformNames.map((n) => [n, {}])) : null;
31
+ const links = (arg('links') ?? '').split(',').map((f) => f.trim()).filter(Boolean);
32
+ const r = checkIssueDraft({ title, body, platforms, links, repoRoot: arg('repo-root') ?? process.cwd(), rulesFile: (arg('rules-file') ?? '').split(',').map((f) => f.trim()).filter(Boolean) });
33
+ const allows = r.allowPaths.length ? `, allows changes to ${r.allowPaths.join(', ')}` : '';
34
+ console.log(`${r.scenarios} scenario(s), ${r.edgeCases} edge case(s), depends on ${[...r.dependsOn.map((n) => `#${n}`), ...r.dependsOnExternal.map((d) => `${d.repo}#${d.number}`)].join(', ') || 'nothing'}${allows}`);
35
+ r.warnings.forEach((w) => console.error(`warning: ${w}`));
36
+ if (r.ok) {
37
+ console.log('ready to create');
38
+ process.exit(0);
39
+ }
40
+ r.problems.forEach((p) => console.error(`problem: ${p}`));
41
+ process.exit(1);
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Prints the review checklist of a change. Usage:
3
+ // node scripts/checklist.mjs openspec/changes/<id> [--json | --count]
4
+ // Default output is a Markdown checklist, one line per scenario of the change's specs.
5
+ // --state <file> ticks the lines the person confirmed in an on-device review (see review-state.mjs).
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { checklistFromChange, checklistMarkdown } from './lib/checklist.mjs';
9
+
10
+ const dir = process.argv[2];
11
+ if (!dir || dir.startsWith('--') || !existsSync(dir)) {
12
+ console.error('usage: checklist.mjs <change-directory> [--json | --count]');
13
+ process.exit(2);
14
+ }
15
+ const items = checklistFromChange(dir);
16
+ if (process.argv.includes('--count')) console.log(items.length);
17
+ else if (process.argv.includes('--json')) console.log(JSON.stringify(items, null, 2));
18
+ else {
19
+ const si = process.argv.indexOf('--state');
20
+ const statuses = {};
21
+ const autos = {};
22
+ if (si !== -1 && existsSync(process.argv[si + 1])) {
23
+ JSON.parse(readFileSync(process.argv[si + 1], 'utf8')).items.forEach((i) => {
24
+ statuses[i.id] = i.status;
25
+ if (i.auto) autos[i.id] = i.auto;
26
+ });
27
+ }
28
+ console.log(checklistMarkdown(items, statuses, autos));
29
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // relevo install | update | doctor | version. See scripts/lib/cli.mjs.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { USAGE, execute, parseArgs } from './lib/cli.mjs';
7
+
8
+ const here = (p) => fileURLToPath(new URL(p, import.meta.url));
9
+ const parsed = parseArgs(process.argv.slice(2));
10
+ if (parsed.error) {
11
+ process.stderr.write(`${parsed.error}\n${USAGE}\n`);
12
+ process.exit(2);
13
+ }
14
+ if (parsed.command === 'help') {
15
+ process.stdout.write(`${USAGE}\n`);
16
+ process.exit(0);
17
+ }
18
+
19
+ const run = (cmd, args) => {
20
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
21
+ return { code: r.status ?? 1, output: `${r.stdout ?? ''}${r.stderr ?? ''}` };
22
+ };
23
+ const has = (cmd) => spawnSync(cmd, ['--version'], { encoding: 'utf8' }).error === undefined;
24
+ const version = JSON.parse(readFileSync(here('../package.json'), 'utf8')).version;
25
+
26
+ const { code, lines } = execute(parsed, { run, has, version, doctorScript: here('./doctor.mjs') });
27
+ process.stdout.write(`${lines.join('\n')}\n`);
28
+ process.exit(code);
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // Commits named files, and nothing else, after checking them. Usage:
3
+ // node scripts/commit-change.mjs --message "feat: title (#42)" [--allow pubspec.yaml,pubspec.lock] [--rules-file CLAUDE.md,docs/rules.md]
4
+ // [--within apps/web --change 42-title] -- path [path ...]
5
+ // --within is the folder of the platform of the issue: files outside it are refused, except the specification of --change, GLOSSARY.md and docs/adr/.
6
+ // It stages the paths, refuses protected paths, scans the staged diff for temporary markers and credentials, and
7
+ // commits only when nothing was found. With no --rules-file it reads qa.rules_from from relevo.yml, if there is one.
8
+ // exit 0: committed (prints the short hash) exit 1: refused, nothing committed (prints what and where)
9
+ // exit 2: bad arguments, "." or "-A" as a path, or git failed
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { loadConfig } from './lib/config.mjs';
14
+ import { commitChange, refusalText } from './lib/commit-change.mjs';
15
+
16
+ const argv = process.argv.slice(2);
17
+ const sep = argv.indexOf('--');
18
+ const options = sep === -1 ? argv : argv.slice(0, sep);
19
+ const paths = sep === -1 ? [] : argv.slice(sep + 1);
20
+ const opt = (name) => {
21
+ const i = options.indexOf(`--${name}`);
22
+ return i === -1 ? undefined : options[i + 1];
23
+ };
24
+ const fail = (message) => {
25
+ console.error(message);
26
+ process.exit(2);
27
+ };
28
+
29
+ if (!opt('message') || paths.length === 0) fail('usage: commit-change.mjs --message "<message>" [--allow a,b] [--rules-file path] -- <path> [path ...]');
30
+
31
+ let rulesFile = opt('rules-file') ? opt('rules-file').split(',').map((f) => f.trim()).filter(Boolean) : null;
32
+ if (!rulesFile && existsSync('relevo.yml')) {
33
+ const loaded = loadConfig(readFileSync('relevo.yml', 'utf8'));
34
+ if (loaded.ok) rulesFile = loaded.config.qa.rules_from;
35
+ }
36
+ const allow = (opt('allow') ?? '').split(',').map((p) => p.trim()).filter(Boolean);
37
+ const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
38
+
39
+ try {
40
+ const result = commitChange({ git, message: opt('message'), paths, allow, rulesFile, within: opt('within') ?? null, change: opt('change') ?? null });
41
+ if (result.ok) {
42
+ console.log(result.commit);
43
+ process.exit(0);
44
+ }
45
+ console.error('not committed:');
46
+ console.error(refusalText(result));
47
+ process.exit(1);
48
+ } catch (err) {
49
+ fail(`${err.message}${err.stderr ? `\n${String(err.stderr).trim()}` : ''}`);
50
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Says whether every conflicted file is inside openspec/. Usage:
3
+ // node scripts/conflict-scope.mjs reads `git diff --name-only --diff-filter=U`
4
+ // node scripts/conflict-scope.mjs --paths reads the paths from stdin, one per line
5
+ // exit 0: all inside (or none) exit 1: prints the files outside exit 2: bad arguments
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { conflictScope } from './lib/archive-check.mjs';
10
+
11
+ const fromStdin = process.argv.includes('--paths');
12
+ const raw = fromStdin ? readFileSync(0, 'utf8') : execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { encoding: 'utf8' });
13
+ const result = conflictScope(raw.split('\n').filter(Boolean));
14
+ if (result.ok) {
15
+ console.log(result.paths.length === 0 ? 'no conflicts' : `all ${result.paths.length} conflicted file(s) are under openspec/`);
16
+ process.exit(0);
17
+ }
18
+ console.log(`conflicts outside openspec/: ${result.outside.join(', ')}`);
19
+ process.exit(1);
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // Fetches the design references of an issue so the builder can read them. Usage:
3
+ // node scripts/design-fetch.mjs --issue 42 [--repo owner/name] [--out .relevo-design] [--config relevo.yml] [--repo-root .]
4
+ // node scripts/design-fetch.mjs --body-file issue.md ... (the body from a file instead of GitHub, for tests)
5
+ // The references come only from the issue. Pasted attachments are downloaded only when design.images is true; files of
6
+ // linked repositories are read through `gh api`. Downloads go to <out> (default .relevo-design), which is added to
7
+ // .git/info/exclude so it is never committed, with a manifest.json. The files of this repository are not copied.
8
+ // exit 0: every reference is available exit 1: some reference was refused (the reasons are printed) exit 2: bad arguments
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { loadConfig } from './lib/config.mjs';
14
+ import { fetchReferences } from './lib/design-fetch.mjs';
15
+ import { parseDesign } from './lib/issue-parse.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 = (message, code) => {
22
+ console.error(message);
23
+ process.exit(code);
24
+ };
25
+
26
+ const issue = arg('issue');
27
+ const bodyFile = arg('body-file');
28
+ if (!issue && !bodyFile) fail('usage: design-fetch.mjs --issue <n> [--repo owner/name] [--out .relevo-design] [--config relevo.yml] [--repo-root .]', 2);
29
+
30
+ const configPath = resolve(arg('config') ?? 'relevo.yml');
31
+ let config = { design: { images: false }, links: [] };
32
+ if (existsSync(configPath)) {
33
+ const loaded = loadConfig(readFileSync(configPath, 'utf8'));
34
+ if (!loaded.ok) fail(`relevo.yml is invalid:\n${loaded.errors.map((e) => ` ${e.message}`).join('\n')}`, 2);
35
+ config = loaded.config;
36
+ }
37
+ const repoRoot = resolve(arg('repo-root') ?? '.');
38
+ const out = resolve(arg('out') ?? '.relevo-design');
39
+
40
+ let body;
41
+ if (bodyFile) {
42
+ body = readFileSync(bodyFile, 'utf8');
43
+ } else {
44
+ const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
45
+ const args = ['issue', 'view', String(issue), '--json', 'body', '-q', '.body', ...(repo ? ['--repo', repo] : [])];
46
+ try {
47
+ body = execFileSync('gh', args, { encoding: 'utf8' });
48
+ } catch (error) {
49
+ fail(`the issue could not be read: ${String(error.message).split('\n')[0]}`, 2);
50
+ }
51
+ }
52
+
53
+ const design = parseDesign(body);
54
+ if (!design.present) {
55
+ console.log('the issue has no Design reference');
56
+ process.exit(0);
57
+ }
58
+
59
+ const linksToken = process.env.GH_LINKS_TOKEN;
60
+ const gh = (args) => execFileSync('gh', args, { encoding: 'utf8', env: { ...process.env, ...(linksToken ? { GH_TOKEN: linksToken } : {}) } });
61
+ const result = await fetchReferences({
62
+ references: design.references,
63
+ images: config.design.images === true,
64
+ links: config.links,
65
+ exists: (path) => existsSync(join(repoRoot, path)),
66
+ fetch: globalThis.fetch,
67
+ gh,
68
+ token: process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null,
69
+ });
70
+ const refused = [...design.problems.map((p) => ({ reference: 'Design reference', reason: p })), ...result.refused];
71
+
72
+ const EXT = { png: 'png', jpeg: 'jpg', gif: 'gif', webp: 'webp' };
73
+ const manifest = { files: [], refused };
74
+ mkdirSync(out, { recursive: true });
75
+ result.files.forEach((f, i) => {
76
+ const entry = { reference: f.reference, kind: f.kind, type: f.type };
77
+ if (f.kind === 'path') {
78
+ entry.file = f.path;
79
+ } else {
80
+ const base = f.kind === 'repo-file' ? f.path.split('/').pop().replace(/[^A-Za-z0-9._-]/g, '_') : `attachment.${EXT[f.type]}`;
81
+ const name = `${String(i + 1).padStart(2, '0')}-${base}`;
82
+ writeFileSync(join(out, name), f.buf);
83
+ entry.file = join(out, name);
84
+ entry.bytes = f.buf.length;
85
+ }
86
+ manifest.files.push(entry);
87
+ });
88
+ writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
89
+
90
+ // Keep the downloads out of git: a local exclusion, never committed.
91
+ try {
92
+ const exclude = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', cwd: repoRoot }).trim();
93
+ const file = resolve(repoRoot, exclude);
94
+ const pattern = `/${out.startsWith(`${repoRoot}/`) ? out.slice(repoRoot.length + 1) : '.relevo-design'}/`;
95
+ mkdirSync(join(file, '..'), { recursive: true });
96
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
97
+ if (!current.split('\n').includes(pattern)) appendFileSync(file, `${current && !current.endsWith('\n') ? '\n' : ''}${pattern}\n`);
98
+ } catch { /* not a git repository: nothing to exclude */ }
99
+
100
+ manifest.files.forEach((f) => console.log(`available: ${f.reference} -> ${f.file}${f.bytes ? ` (${f.bytes} bytes)` : ''}`));
101
+ refused.forEach((r) => console.error(`refused: ${r.reference}: ${r.reason}`));
102
+ console.log(`${manifest.files.length} available, ${refused.length} refused; manifest in ${join(out, 'manifest.json')}`);
103
+ process.exit(refused.length > 0 ? 1 : 0);
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Finds the Android device to review on. Prints its serial, or explains what is wrong.
3
+ // exit 0: a device was found exit 2: none, unauthorised or several (ANDROID_SERIAL picks one)
4
+
5
+ import { listDevices, pickDevice } from './lib/device.mjs';
6
+
7
+ try {
8
+ console.log(pickDevice(listDevices(), process.env.ANDROID_SERIAL ?? null));
9
+ } catch (err) {
10
+ console.error(err.code === 'ENOENT' ? 'adb is not installed: install the Android platform tools' : err.message);
11
+ process.exit(2);
12
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // Posts the daily digest as a comment on the issue named by digest.issue (chat tools subscribe to GitHub events).
3
+ // Posts nothing when nothing is waiting, and nothing on weekends.
4
+ // node scripts/digest.mjs --repo owner/name [--config relevo.yml] [--dry-run] [--now ISO]
5
+ // node scripts/digest.mjs --input state.json [--now ISO] (never writes)
6
+ // Prints the digest text, or nothing. exit 0: done exit 2: invalid configuration or bad arguments
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import { loadConfig } from './lib/config.mjs';
11
+ import { realGh, realGhLinks } from './lib/github.mjs';
12
+ import { buildStatus, renderDigest } from './lib/status.mjs';
13
+ import { gatherStatus } from './lib/status-data.mjs';
14
+
15
+ const arg = (name) => {
16
+ const i = process.argv.indexOf(`--${name}`);
17
+ return i === -1 ? undefined : process.argv[i + 1];
18
+ };
19
+ const fail = (m) => {
20
+ console.error(m);
21
+ process.exit(2);
22
+ };
23
+
24
+ const configPath = resolve(arg('config') ?? 'relevo.yml');
25
+ if (!existsSync(configPath)) fail(`relevo.yml not found at ${configPath}. Run /relevo:init to create it.`);
26
+ const loaded = loadConfig(readFileSync(configPath, 'utf8'));
27
+ if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
28
+ const { config } = loaded;
29
+
30
+ const now = arg('now') ? new Date(arg('now')) : new Date();
31
+ const day = now.getUTCDay();
32
+ if (day === 0 || day === 6) process.exit(0);
33
+
34
+ const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
35
+ let data;
36
+ if (arg('input')) {
37
+ data = JSON.parse(readFileSync(arg('input') === '-' ? 0 : arg('input'), 'utf8'));
38
+ } else {
39
+ if (!repo) fail('give --repo owner/name (or set GITHUB_REPOSITORY), or --input state.json');
40
+ data = gatherStatus({ gh: realGh, ghLinks: realGhLinks(), repo, config });
41
+ }
42
+
43
+ const text = renderDigest(buildStatus({ config, ...data, me: null, now }), config.language);
44
+ if (!text) process.exit(0);
45
+ const dry = process.argv.includes('--dry-run') || Boolean(arg('input'));
46
+ if (!dry) {
47
+ if (!config.digest.issue) fail('digest.issue is not set in relevo.yml: name the issue where the digest is posted');
48
+ realGh(['issue', 'comment', String(config.digest.issue), '--repo', repo, '--body', text]);
49
+ }
50
+ console.log(text);
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // Checks that the machine, the repository and GitHub are ready for Relevo. Read-only: it changes nothing.
3
+ // node scripts/doctor.mjs [--repo owner/name] [--json]
4
+ // exit 0: no check failed (warnings allowed) exit 1: at least one check failed
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { gatherFacts, readOnlyGh, renderReport, runChecks } from './lib/doctor.mjs';
10
+
11
+ const arg = (name) => {
12
+ const i = process.argv.indexOf(`--${name}`);
13
+ return i === -1 ? undefined : process.argv[i + 1];
14
+ };
15
+
16
+ let repo = arg('repo') ?? process.env.GITHUB_REPOSITORY ?? null;
17
+ const tool = (name, args) => execFileSync(name, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
18
+ const gh = (args) => tool('gh', args);
19
+ if (!repo) {
20
+ try {
21
+ repo = tool('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']).trim() || null;
22
+ } catch {
23
+ repo = null;
24
+ }
25
+ }
26
+
27
+ // Files of a skill folder, for the comparison with skills-lock.json.
28
+ const readFolder = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
29
+ const path = `${dir}/${e.name}`;
30
+ if (e.isDirectory()) return readFolder(path).map((f) => ({ ...f, path: `${e.name}/${f.path}` }));
31
+ return e.isFile() ? [{ path: e.name, content: readFileSync(path) }] : [];
32
+ });
33
+
34
+ const facts = gatherFacts({
35
+ tool,
36
+ gh: readOnlyGh(gh),
37
+ exists: existsSync,
38
+ read: (p) => readFileSync(p, 'utf8'),
39
+ listDir: (p) => readdirSync(p),
40
+ skillDirs: ['.claude/skills', '.agents/skills', `${homedir()}/.claude/skills`],
41
+ git: (args) => tool('git', args),
42
+ readFolder,
43
+ repo,
44
+ });
45
+ const results = runChecks(facts);
46
+ console.log(process.argv.includes('--json') ? JSON.stringify(results, null, 2) : renderReport(results));
47
+ process.exit(results.some((x) => x.status === 'fail') ? 1 : 0);
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // Checks that the specification was updated before any code was written for a fix. Usage:
3
+ // node scripts/fix-guard.mjs openspec/changes/<id>
4
+ // exit 0: the developer may run exit 1: the specification is not ready (reasons printed)
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { checkSpecFirst } from './lib/fix-guard.mjs';
9
+
10
+ const changeDir = (process.argv[2] ?? '').replace(/\/+$/, '');
11
+ if (!changeDir || !existsSync(changeDir)) {
12
+ console.error('usage: fix-guard.mjs <change-directory>');
13
+ process.exit(2);
14
+ }
15
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
16
+ const lines = (text) => text.split('\n').filter(Boolean);
17
+
18
+ const changedFiles = [...new Set([...lines(git('diff', '--name-only', 'HEAD')), ...lines(git('ls-files', '--others', '--exclude-standard'))])];
19
+
20
+ // Lines added to tracked files (diff against HEAD) plus every line of new, untracked files.
21
+ const trackedAdded = (path) => lines(git('diff', '-U0', 'HEAD', '--', path)).filter((l) => l.startsWith('+') && !l.startsWith('+++'));
22
+ const untrackedAdded = (prefix) => changedFiles
23
+ .filter((f) => (f === prefix || f.startsWith(prefix)) && git('ls-files', '--', f).trim() === '')
24
+ .flatMap((f) => lines(readFileSync(f, 'utf8')).map((l) => `+${l}`));
25
+ const added = (path, prefix) => [...trackedAdded(path), ...untrackedAdded(prefix)];
26
+
27
+ const { ok, reasons } = checkSpecFirst({
28
+ changedFiles,
29
+ changeDir,
30
+ specLinesAdded: added(`${changeDir}/specs`, `${changeDir}/specs/`),
31
+ taskLinesAdded: added(`${changeDir}/tasks.md`, `${changeDir}/tasks.md`),
32
+ });
33
+ if (ok) console.log('the specification is ready: the developer may run');
34
+ else {
35
+ console.log('the specification is NOT ready; the developer must not run yet:');
36
+ reasons.forEach((r) => console.log(` - ${r}`));
37
+ }
38
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Keeps the work of a fix that QA rejected, and gives it back. Usage:
3
+ // node scripts/fix-stash.mjs save --issue 42 --iteration 2 save the working tree (untracked files included)
4
+ // node scripts/fix-stash.mjs find --issue 42 print the newest stash of the issue as JSON
5
+ // node scripts/fix-stash.mjs pop --issue 42 restore it; on a conflict it stays listed
6
+ // node scripts/fix-stash.mjs drop --issue 42 discard it (only after the person's written confirmation)
7
+ // exit 0: done exit 1: nothing to do, or a conflict exit 2: bad arguments
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import { findStash, stashMessage } from './lib/fix-stash.mjs';
11
+
12
+ const [command] = process.argv.slice(2);
13
+ const arg = (name) => {
14
+ const i = process.argv.indexOf(`--${name}`);
15
+ return i === -1 ? undefined : process.argv[i + 1];
16
+ };
17
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
18
+ const issue = arg('issue');
19
+ if (!['save', 'find', 'pop', 'drop'].includes(command) || !/^\d+$/.test(issue ?? '') || (command === 'save' && !/^\d+$/.test(arg('iteration') ?? ''))) {
20
+ console.error('usage: fix-stash.mjs save --issue N --iteration K | find|pop|drop --issue N');
21
+ process.exit(2);
22
+ }
23
+
24
+ if (command === 'save') {
25
+ if (git('status', '--porcelain').trim() === '') {
26
+ console.error('the working tree is clean: there is nothing to save');
27
+ process.exit(1);
28
+ }
29
+ const message = stashMessage(issue, arg('iteration'));
30
+ git('stash', 'push', '--include-untracked', '-m', message);
31
+ console.log(`saved as "${message}". Resume with /relevo:fix ${issue}; the stash is local to this clone.`);
32
+ process.exit(0);
33
+ }
34
+
35
+ const found = findStash(git('stash', 'list'), issue);
36
+ if (!found) {
37
+ if (command === 'find') console.log('null');
38
+ else console.error(`no saved fix for issue ${issue}`);
39
+ process.exit(1);
40
+ }
41
+ if (command === 'find') {
42
+ console.log(JSON.stringify(found));
43
+ process.exit(0);
44
+ }
45
+ if (command === 'drop') {
46
+ git('stash', 'drop', found.ref);
47
+ console.log(`dropped "${found.message}"`);
48
+ process.exit(0);
49
+ }
50
+ try {
51
+ git('stash', 'pop', found.ref);
52
+ console.log(`restored "${found.message}"`);
53
+ } catch (error) {
54
+ const files = git('diff', '--name-only', '--diff-filter=U').split('\n').filter(Boolean);
55
+ console.error(`restoring "${found.message}" conflicts with the branch in: ${files.join(', ') || 'unknown files'}. The stash is kept.`);
56
+ process.exit(1);
57
+ }