@elalfner/relevo 0.0.0-stage → 0.3.1

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 (142) 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/failure-comment.mjs +30 -0
  27. package/scripts/fix-guard.mjs +38 -0
  28. package/scripts/fix-stash.mjs +57 -0
  29. package/scripts/fix-state.mjs +28 -0
  30. package/scripts/fix-tasks.mjs +22 -0
  31. package/scripts/go-local.mjs +49 -0
  32. package/scripts/go-plan.mjs +82 -0
  33. package/scripts/init.mjs +109 -0
  34. package/scripts/intent-to-add.mjs +20 -0
  35. package/scripts/issue-coverage.mjs +30 -0
  36. package/scripts/issue-hash.mjs +44 -0
  37. package/scripts/issue-sections.mjs +42 -0
  38. package/scripts/label-change.mjs +35 -0
  39. package/scripts/lib/appid.mjs +12 -0
  40. package/scripts/lib/archive-check.mjs +29 -0
  41. package/scripts/lib/archive-state.mjs +15 -0
  42. package/scripts/lib/branch-work.mjs +13 -0
  43. package/scripts/lib/branch.mjs +43 -0
  44. package/scripts/lib/can-edit-issue.mjs +34 -0
  45. package/scripts/lib/checklist.mjs +68 -0
  46. package/scripts/lib/cli.mjs +82 -0
  47. package/scripts/lib/commit-change.mjs +57 -0
  48. package/scripts/lib/config.mjs +296 -0
  49. package/scripts/lib/design-fetch.mjs +156 -0
  50. package/scripts/lib/device.mjs +67 -0
  51. package/scripts/lib/doctor.mjs +636 -0
  52. package/scripts/lib/execution-mode.mjs +24 -0
  53. package/scripts/lib/failure-comment.mjs +19 -0
  54. package/scripts/lib/fix-guard.mjs +23 -0
  55. package/scripts/lib/fix-stash.mjs +17 -0
  56. package/scripts/lib/fix.mjs +31 -0
  57. package/scripts/lib/github.mjs +161 -0
  58. package/scripts/lib/go-local.mjs +102 -0
  59. package/scripts/lib/init.mjs +328 -0
  60. package/scripts/lib/intent-to-add.mjs +32 -0
  61. package/scripts/lib/issue-check.mjs +63 -0
  62. package/scripts/lib/issue-coverage.mjs +47 -0
  63. package/scripts/lib/issue-hash.mjs +25 -0
  64. package/scripts/lib/issue-parse.mjs +194 -0
  65. package/scripts/lib/issue-sections.mjs +43 -0
  66. package/scripts/lib/labels.mjs +65 -0
  67. package/scripts/lib/language.mjs +9 -0
  68. package/scripts/lib/logaudit.mjs +123 -0
  69. package/scripts/lib/messages.mjs +341 -0
  70. package/scripts/lib/next-step.mjs +153 -0
  71. package/scripts/lib/openspec-commands.mjs +44 -0
  72. package/scripts/lib/openspec-status.mjs +16 -0
  73. package/scripts/lib/plan.mjs +256 -0
  74. package/scripts/lib/platform-of.mjs +32 -0
  75. package/scripts/lib/platform-scope.mjs +32 -0
  76. package/scripts/lib/platforms.mjs +90 -0
  77. package/scripts/lib/preflight.mjs +71 -0
  78. package/scripts/lib/protected-paths.mjs +102 -0
  79. package/scripts/lib/requeue.mjs +23 -0
  80. package/scripts/lib/review-adapters.mjs +37 -0
  81. package/scripts/lib/review-brief.mjs +43 -0
  82. package/scripts/lib/review-events.mjs +56 -0
  83. package/scripts/lib/review-state.mjs +157 -0
  84. package/scripts/lib/reviewers.mjs +39 -0
  85. package/scripts/lib/role-skills.mjs +112 -0
  86. package/scripts/lib/run-flags.mjs +16 -0
  87. package/scripts/lib/run-usage.mjs +65 -0
  88. package/scripts/lib/scan.mjs +73 -0
  89. package/scripts/lib/ship.mjs +161 -0
  90. package/scripts/lib/sla.mjs +90 -0
  91. package/scripts/lib/stack-profile.mjs +169 -0
  92. package/scripts/lib/staging.mjs +21 -0
  93. package/scripts/lib/status-data.mjs +109 -0
  94. package/scripts/lib/status.mjs +199 -0
  95. package/scripts/lib/tasks-file.mjs +81 -0
  96. package/scripts/lib/web-review.mjs +98 -0
  97. package/scripts/lib/wip-commit.mjs +24 -0
  98. package/scripts/lib/yaml-lite.mjs +157 -0
  99. package/scripts/needs-reviewer.mjs +41 -0
  100. package/scripts/next-step.mjs +39 -0
  101. package/scripts/pick-reviewer.mjs +42 -0
  102. package/scripts/platform-of.mjs +49 -0
  103. package/scripts/preflight.mjs +32 -0
  104. package/scripts/render-message.mjs +35 -0
  105. package/scripts/requeue.mjs +53 -0
  106. package/scripts/review-adapter.mjs +32 -0
  107. package/scripts/review-brief.mjs +29 -0
  108. package/scripts/review-event.mjs +70 -0
  109. package/scripts/review-sla.mjs +41 -0
  110. package/scripts/review-state.mjs +171 -0
  111. package/scripts/review-who.mjs +21 -0
  112. package/scripts/role-skills.mjs +53 -0
  113. package/scripts/run-usage.mjs +22 -0
  114. package/scripts/scan-diff.mjs +16 -0
  115. package/scripts/ship-check.mjs +54 -0
  116. package/scripts/spec-ready.mjs +40 -0
  117. package/scripts/stack-profile.mjs +67 -0
  118. package/scripts/stage.mjs +14 -0
  119. package/scripts/status.mjs +47 -0
  120. package/scripts/usage-limit.mjs +17 -0
  121. package/scripts/validate-config.mjs +44 -0
  122. package/scripts/web-review.mjs +134 -0
  123. package/skills/doctor/SKILL.md +28 -0
  124. package/skills/fix/SKILL.md +129 -0
  125. package/skills/go/SKILL.md +250 -0
  126. package/skills/init/SKILL.md +72 -0
  127. package/skills/issue/SKILL.md +128 -0
  128. package/skills/issue/references/update.md +15 -0
  129. package/skills/review/SKILL.md +157 -0
  130. package/skills/ship/SKILL.md +128 -0
  131. package/skills/status/SKILL.md +27 -0
  132. package/skills/think/SKILL.md +76 -0
  133. package/stacks/flutter.md +63 -0
  134. package/stacks/laravel.md +65 -0
  135. package/stacks/vue.md +70 -0
  136. package/templates/issue.md +99 -0
  137. package/templates/relevo.yml +59 -0
  138. package/templates/workflows/relevo-digest.yml +54 -0
  139. package/templates/workflows/relevo-go.yml +268 -0
  140. package/templates/workflows/relevo-queue.yml +72 -0
  141. package/templates/workflows/relevo-review.yml +60 -0
  142. package/templates/workflows/relevo-sla.yml +53 -0
@@ -0,0 +1,156 @@
1
+ // Downloads the references of a design reference, inside fixed limits. Implements "The fetcher stays inside fixed
2
+ // limits" of openspec/specs/go/spec.md. The network and `gh` are injected so the module is tested without either.
3
+
4
+ import { ATTACHMENT_HOSTS, MAX_REFERENCES } from './issue-parse.mjs';
5
+
6
+ export const IMAGE_MAX = 5 * 1024 * 1024;
7
+ export const TEXT_MAX = 200 * 1024;
8
+ export const FILES_MAX = MAX_REFERENCES;
9
+ export const HOPS_MAX = 5;
10
+ // Where a download may be redirected to: the attachment domains and the host that serves them signed.
11
+ export const REDIRECT_HOSTS = [...ATTACHMENT_HOSTS, 'objects.githubusercontent.com'];
12
+ // The token is sent only here.
13
+ const TOKEN_HOSTS = new Set(['github.com', 'api.github.com']);
14
+
15
+ /** png, jpeg, gif or webp from the first bytes, or null. The name and the Content-Type are never trusted. */
16
+ export function sniffImage(buf) {
17
+ const b = Buffer.from(buf);
18
+ if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
19
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg';
20
+ if (b.length >= 6 && ['GIF87a', 'GIF89a'].includes(b.subarray(0, 6).toString('latin1'))) return 'gif';
21
+ if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'webp';
22
+ return null;
23
+ }
24
+
25
+ /** Is it UTF-8 text, with no NUL byte? */
26
+ export function isText(buf) {
27
+ const b = Buffer.from(buf);
28
+ if (b.includes(0)) return false;
29
+ try {
30
+ new TextDecoder('utf-8', { fatal: true }).decode(b);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ const hostOf = (url) => {
38
+ try {
39
+ const u = new URL(url);
40
+ return u.protocol === 'https:' ? u.hostname : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ async function readCapped(res, max) {
47
+ const declared = Number(res.headers?.get?.('content-length'));
48
+ if (Number.isFinite(declared) && declared > max) return { tooLarge: true };
49
+ if (!res.body?.getReader) {
50
+ const buf = Buffer.from(await res.arrayBuffer());
51
+ return buf.length > max ? { tooLarge: true } : { buf };
52
+ }
53
+ const reader = res.body.getReader();
54
+ const chunks = [];
55
+ let total = 0;
56
+ for (;;) {
57
+ const { done, value } = await reader.read();
58
+ if (done) break;
59
+ total += value.length;
60
+ if (total > max) {
61
+ await reader.cancel().catch(() => {});
62
+ return { tooLarge: true };
63
+ }
64
+ chunks.push(Buffer.from(value));
65
+ }
66
+ return { buf: Buffer.concat(chunks) };
67
+ }
68
+
69
+ /**
70
+ * Download one pasted attachment. Redirects are followed by hand and only to REDIRECT_HOSTS; the token goes only to
71
+ * GitHub. Returns { ok: true, buf, type } or { ok: false, reason }.
72
+ */
73
+ export async function downloadAttachment(url, { fetch, token = null, max = IMAGE_MAX } = {}) {
74
+ let current = url;
75
+ for (let hop = 0; hop <= HOPS_MAX; hop += 1) {
76
+ const host = hostOf(current);
77
+ if (!host || !REDIRECT_HOSTS.includes(host)) return { ok: false, reason: `${host ?? 'that address'} is not an allowed host` };
78
+ const headers = token && TOKEN_HOSTS.has(host) ? { authorization: `Bearer ${token}` } : {};
79
+ let res;
80
+ try {
81
+ res = await fetch(current, { redirect: 'manual', headers });
82
+ } catch (error) {
83
+ return { ok: false, reason: `the download failed: ${String(error?.message ?? error).split('\n')[0]}` };
84
+ }
85
+ if (res.status >= 300 && res.status < 400) {
86
+ const location = res.headers?.get?.('location');
87
+ if (!location) return { ok: false, reason: 'the redirect has no destination' };
88
+ current = new URL(location, current).href;
89
+ continue;
90
+ }
91
+ if (res.status !== 200) return { ok: false, reason: `GitHub answered ${res.status}${res.status === 404 || res.status === 403 ? ' (the token may not read it)' : ''}` };
92
+ const { buf, tooLarge } = await readCapped(res, max);
93
+ if (tooLarge) return { ok: false, reason: `the file is larger than ${Math.round(max / 1024 / 1024)} MB` };
94
+ const type = sniffImage(buf);
95
+ if (!type) return { ok: false, reason: 'the file is not a PNG, JPEG, GIF or WebP image' };
96
+ return { ok: true, buf, type };
97
+ }
98
+ return { ok: false, reason: `more than ${HOPS_MAX} redirects` };
99
+ }
100
+
101
+ /** One file of a linked repository, read through `gh api` (injected). Returns { ok, buf, type } or { ok: false, reason }. */
102
+ export async function fetchRepoFile({ repo, path }, { gh, max = TEXT_MAX } = {}) {
103
+ let info;
104
+ try {
105
+ info = JSON.parse(gh(['api', `repos/${repo}/contents/${path}`]));
106
+ } catch (error) {
107
+ return { ok: false, reason: `${repo} could not be read: ${String(error?.message ?? error).split('\n')[0]}` };
108
+ }
109
+ if (Array.isArray(info) || info.type !== 'file') return { ok: false, reason: `${path} is not a file` };
110
+ if (info.size > max) return { ok: false, reason: `the file is larger than ${Math.round(max / 1024)} KB` };
111
+ if (info.encoding !== 'base64' || typeof info.content !== 'string') return { ok: false, reason: 'the file could not be read' };
112
+ const buf = Buffer.from(info.content, 'base64');
113
+ if (buf.length > max) return { ok: false, reason: `the file is larger than ${Math.round(max / 1024)} KB` };
114
+ if (!isText(buf)) return { ok: false, reason: 'the file is not text' };
115
+ return { ok: true, buf, type: 'text' };
116
+ }
117
+
118
+ /**
119
+ * The references of an issue (from parseDesign) turned into files.
120
+ * references [{ kind, target, repo?, path?, allowed?, match }]
121
+ * images design.images
122
+ * links the linked repositories
123
+ * exists (path) => boolean, for the references to files of the repository
124
+ * Returns { files: [{ reference, kind, buf?, type }], refused: [{ reference, reason }] }.
125
+ * A `path` reference needs no download: it is checked and the builder reads it where it is.
126
+ */
127
+ export async function fetchReferences({ references, images = false, links = [], exists, fetch, gh, token = null }) {
128
+ const files = [];
129
+ const refused = [];
130
+ for (const ref of references) {
131
+ if (files.length >= FILES_MAX) {
132
+ refused.push({ reference: ref.target, reason: `more than ${FILES_MAX} references` });
133
+ continue;
134
+ }
135
+ if (ref.kind === 'path') {
136
+ if (exists(ref.path)) files.push({ reference: ref.target, kind: 'path', path: ref.path, type: 'repository' });
137
+ else refused.push({ reference: ref.target, reason: 'the file does not exist in the repository' });
138
+ } else if (ref.kind === 'attachment') {
139
+ if (!ref.allowed) refused.push({ reference: ref.target, reason: 'it is not a GitHub attachment' });
140
+ else if (!images) refused.push({ reference: ref.target, reason: 'design.images is off: turn it on in relevo.yml, or commit the picture to the repository and point to the file' });
141
+ else {
142
+ const r = await downloadAttachment(ref.target, { fetch, token });
143
+ if (r.ok) files.push({ reference: ref.target, kind: 'attachment', buf: r.buf, type: r.type });
144
+ else refused.push({ reference: ref.target, reason: r.reason });
145
+ }
146
+ } else if (ref.kind === 'repo-file') {
147
+ if (!links.includes(ref.repo)) refused.push({ reference: ref.target, reason: `${ref.repo} is not in links of relevo.yml` });
148
+ else {
149
+ const r = await fetchRepoFile(ref, { gh });
150
+ if (r.ok) files.push({ reference: ref.target, kind: 'repo-file', buf: r.buf, type: r.type, path: ref.path });
151
+ else refused.push({ reference: ref.target, reason: r.reason });
152
+ }
153
+ }
154
+ }
155
+ return { files, refused };
156
+ }
@@ -0,0 +1,67 @@
1
+ // Android device detection and screenshot capture. The adb client is injected so this can be tested
2
+ // without a device. Screenshots are only written to disk, never opened or sent anywhere.
3
+
4
+ import { mkdirSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+
8
+ /** Parse the output of `adb devices` into [{ serial, state }]. */
9
+ export function parseDevices(output) {
10
+ return String(output)
11
+ .split('\n')
12
+ .slice(1)
13
+ .map((l) => l.trim())
14
+ .filter((l) => l && !l.startsWith('*'))
15
+ .map((l) => {
16
+ const [serial, state] = l.split(/\s+/);
17
+ return { serial, state };
18
+ });
19
+ }
20
+
21
+ /** Pick the device to use: the only ready one, or the one named in `preferred`. Throws a message a person can act on. */
22
+ export function pickDevice(devices, preferred = null) {
23
+ const ready = devices.filter((d) => d.state === 'device');
24
+ if (preferred) {
25
+ if (ready.some((d) => d.serial === preferred)) return preferred;
26
+ throw new Error(`device ${preferred} is not connected and ready`);
27
+ }
28
+ if (ready.length === 1) return ready[0].serial;
29
+ if (ready.length === 0) {
30
+ const stuck = devices.find((d) => d.state === 'unauthorized');
31
+ throw new Error(
32
+ stuck
33
+ ? 'the device is connected but unauthorised: unlock it and accept the USB debugging prompt'
34
+ : 'no device is connected: connect an Android device with USB debugging enabled (run `adb devices` to check)',
35
+ );
36
+ }
37
+ throw new Error(`several devices are connected (${ready.map((d) => d.serial).join(', ')}): set ANDROID_SERIAL to the one to use`);
38
+ }
39
+
40
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,60}$/;
41
+
42
+ /** Capture names come from a person: only plain file names are accepted, never paths. */
43
+ export function safeCaptureName(name) {
44
+ const n = String(name ?? '').replace(/\.png$/i, '');
45
+ if (!NAME_RE.test(n) || n.includes('..')) {
46
+ throw new Error('capture names may use letters, digits, "_", "-" and "." only (for example 02_after_tap)');
47
+ }
48
+ return n;
49
+ }
50
+
51
+ export const realAdb = (args, options = {}) => execFileSync('adb', args, { maxBuffer: 64 * 1024 * 1024, ...options });
52
+
53
+ /** List devices through the injected client. */
54
+ export function listDevices(adb = realAdb) {
55
+ return parseDevices(adb(['devices'], { encoding: 'utf8' }));
56
+ }
57
+
58
+ /** Take a screenshot and save it as <dir>/<name>.png. Returns the path. */
59
+ export function capture({ adb = realAdb, name, dir = 'evidence', serial, write = writeFileSync, mkdir = mkdirSync }) {
60
+ const safe = safeCaptureName(name);
61
+ const png = adb(['-s', serial, 'exec-out', 'screencap', '-p']);
62
+ if (!png || png.length < 8) throw new Error('the device returned no image; is the screen on and the device unlocked?');
63
+ mkdir(dir, { recursive: true });
64
+ const path = join(dir, `${safe}.png`);
65
+ write(path, png);
66
+ return path;
67
+ }