@spec-wave/cli 0.29.0 → 0.32.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 (44) hide show
  1. package/package.json +5 -3
  2. package/protocol/qa-result.v1.json +62 -0
  3. package/protocol/qa-trail-report.v1.json +113 -0
  4. package/src/api/github-graphql.mjs +6 -1
  5. package/src/api/github-rest.mjs +21 -0
  6. package/src/cli.mjs +114 -9
  7. package/src/commands/decompose.mjs +29 -3
  8. package/src/commands/doctor.mjs +183 -3
  9. package/src/commands/generate-qa-plan.mjs +421 -0
  10. package/src/commands/implement.mjs +56 -44
  11. package/src/commands/merge.mjs +43 -14
  12. package/src/commands/order.mjs +350 -96
  13. package/src/commands/qa-lead.mjs +748 -0
  14. package/src/commands/qa-run.mjs +892 -0
  15. package/src/commands/run.mjs +5 -1
  16. package/src/config.mjs +32 -1
  17. package/src/lib/artifact-pr.mjs +2 -0
  18. package/src/lib/artifact-publish.mjs +5 -2
  19. package/src/lib/board.mjs +14 -0
  20. package/src/lib/critique.mjs +38 -9
  21. package/src/lib/decomposition-doc.mjs +5 -1
  22. package/src/lib/dependency-map.mjs +300 -0
  23. package/src/lib/doc-paths.mjs +9 -2
  24. package/src/lib/git-retry.mjs +82 -0
  25. package/src/lib/net-cache.mjs +142 -0
  26. package/src/lib/next-step.mjs +15 -3
  27. package/src/lib/qa-exec.mjs +335 -0
  28. package/src/lib/qa-lead-backend.mjs +213 -0
  29. package/src/lib/qa-lead.mjs +627 -0
  30. package/src/lib/qa-plan-doc.mjs +340 -0
  31. package/src/lib/qa-report.mjs +396 -0
  32. package/src/lib/skill-compose.mjs +234 -0
  33. package/src/lib/story-graph.mjs +256 -0
  34. package/src/plugin/.claude-plugin/plugin.json +1 -1
  35. package/src/plugin/skills/merge/SKILL.md +1 -0
  36. package/src/plugin/skills/order/SKILL.md +21 -5
  37. package/src/plugin/skills/qa/SKILL.md +107 -0
  38. package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
  39. package/src/plugin/skills/qa/model-prompt.md +68 -0
  40. package/src/plugin/skills/qa-executor/SKILL.md +76 -0
  41. package/src/plugin/skills/qa-lead/SKILL.md +89 -0
  42. package/src/templates/skill/SKILL.md +981 -279
  43. package/src/templates/skill/core.md +584 -0
  44. package/src/templates/workflows/generate-qa-plan.yml +64 -0
@@ -0,0 +1,213 @@
1
+ // Backends de execução do `qa-lead run` (rfc/spec-qa-lead.md §6.4).
2
+ //
3
+ // Interface única, duas implementações, escolhidas por `qa.lead.backend`:
4
+ //
5
+ // • `docker` (default desta entrega): um `docker run` por Feature, com rede
6
+ // isolada, clone próprio do checkout e cleanup garantido — inclusive na
7
+ // morte do Lead (handler de sinal registrado pelo comando): container órfão
8
+ // consome recurso do cliente.
9
+ // • `sandbox`: a interface existe, a implementação é follow-up — a Tarefa Zero
10
+ // confirmou que o spec-wave-sandbox ainda não expõe API de criação de
11
+ // sessão. Escolhê-lo recusa orientando, em vez de fingir que roda.
12
+ //
13
+ // O container NÃO compartilha o checkout do host: ele CLONA `/repo` (montado
14
+ // read-only) para um diretório próprio. Dois `qa` paralelos commitando bug.md
15
+ // no MESMO working tree corromperiam o index um do outro; com clones isolados,
16
+ // a contenção fica onde a §3.2 já a resolve — no push, com retry+rebase.
17
+
18
+ import { spawn, execFileSync } from 'node:child_process';
19
+ import { setTimeout as sleep } from 'node:timers/promises';
20
+
21
+ function sh(args, opts = {}) {
22
+ return execFileSync('docker', args, {
23
+ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts,
24
+ }).trim();
25
+ }
26
+
27
+ /**
28
+ * Monta o script executado DENTRO do container (função PURA — testável).
29
+ *
30
+ * `qa.setup` roda antes do `qa` (§6.2: "Dentro do container: qa.setup →
31
+ * qa <feature>"); o endereço real do serviço chega por env injetada (§3.1).
32
+ *
33
+ * @param {object} params
34
+ * @param {number} params.issue
35
+ * @param {number[]|null} [params.only]
36
+ * @param {string|null} [params.setup] `qa.setup` do .spec-wave.json
37
+ * @param {string} params.cliVersion versão fixada da CLI
38
+ * @param {string|null} [params.originUrl] remoto real (o clone nasce apontando
39
+ * para /repo, que é read-only — sem isto o push do bug.md morre)
40
+ * @returns {string} script bash
41
+ */
42
+ export function containerScript({ issue, only = null, setup = null, cliVersion, originUrl = null }) {
43
+ const lines = [
44
+ 'set -euo pipefail',
45
+ 'git clone /repo /spec-wave-work',
46
+ 'cd /spec-wave-work',
47
+ 'git config user.name "spec-wave-qa"',
48
+ 'git config user.email "spec-wave-qa[bot]@users.noreply.github.com"',
49
+ ];
50
+ if (originUrl) lines.push(`git remote set-url origin ${JSON.stringify(originUrl)}`);
51
+ if (setup) lines.push(setup);
52
+ const onlyFlag = only && only.length > 0 ? ` --only ${only.join(',')}` : '';
53
+ lines.push(`npx -y @spec-wave/cli@${cliVersion} qa ${issue}${onlyFlag}`);
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /** Backend Docker — um container e uma rede por execução. */
58
+ export class DockerBackend {
59
+ /**
60
+ * @param {object} params
61
+ * @param {string} params.image `qa.lead.container.image`
62
+ * @param {string} params.checkoutDir raiz do clone do host (montada em /repo)
63
+ */
64
+ constructor({ image, checkoutDir }) {
65
+ this.image = image;
66
+ this.checkoutDir = checkoutDir;
67
+ /** @type {Set<string>} containers vivos (para o cleanup de sinal) */
68
+ this.live = new Set();
69
+ /** @type {Set<string>} redes criadas */
70
+ this.networks = new Set();
71
+ }
72
+
73
+ /** O docker responde? (best-effort — o preflight consome) */
74
+ available() {
75
+ try {
76
+ sh(['info', '--format', '{{.ServerVersion}}']);
77
+ return { ok: true, detail: null };
78
+ } catch (err) {
79
+ return { ok: false, detail: String(err.stderr || err.message).split('\n')[0] };
80
+ }
81
+ }
82
+
83
+ /** A imagem existe localmente ou é puxável? (preflight) */
84
+ imageAvailable() {
85
+ try {
86
+ sh(['image', 'inspect', this.image]);
87
+ return { ok: true, detail: 'imagem presente localmente' };
88
+ } catch {
89
+ try {
90
+ sh(['pull', this.image], { stdio: ['ignore', 'pipe', 'pipe'] });
91
+ return { ok: true, detail: 'imagem puxada do registry' };
92
+ } catch (err) {
93
+ return { ok: false, detail: String(err.stderr || err.message).split('\n')[0] };
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Roda um script num container isolado até terminar ou estourar o timeout.
100
+ *
101
+ * @param {object} params
102
+ * @param {string} params.name sufixo estável (ex.: `qa-318`)
103
+ * @param {string} params.script bash de containerScript()
104
+ * @param {Record<string,string>} [params.env]
105
+ * @param {number} params.timeoutMin
106
+ * @param {(line: string) => void} [params.onLog]
107
+ * @returns {Promise<{ ok: boolean, timedOut: boolean, code: number|null, durationSec: number }>}
108
+ */
109
+ async run({ name, script, env = {}, timeoutMin, onLog = () => {} }) {
110
+ const id = `spec-wave-${name}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
111
+ const network = `${id}-net`;
112
+ const started = Date.now();
113
+
114
+ sh(['network', 'create', network]);
115
+ this.networks.add(network);
116
+
117
+ const args = [
118
+ 'run', '--rm', '--name', id, '--network', network,
119
+ '-v', `${this.checkoutDir}:/repo:ro`,
120
+ '-w', '/',
121
+ ];
122
+ for (const [key, value] of Object.entries(env)) {
123
+ if (value == null || value === '') continue; // credencial vazia não entra
124
+ args.push('-e', `${key}=${value}`);
125
+ }
126
+ args.push(this.image, 'bash', '-lc', script);
127
+
128
+ this.live.add(id);
129
+ let timedOut = false;
130
+ const code = await new Promise((resolve) => {
131
+ const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
132
+ const feed = (chunk) => String(chunk).split('\n').filter(Boolean).forEach(onLog);
133
+ child.stdout.on('data', feed);
134
+ child.stderr.on('data', feed);
135
+ const timer = setTimeout(() => {
136
+ timedOut = true;
137
+ try { sh(['rm', '-f', id]); } catch { /* já morreu */ }
138
+ }, timeoutMin * 60 * 1000);
139
+ child.on('close', (exitCode) => {
140
+ clearTimeout(timer);
141
+ resolve(exitCode);
142
+ });
143
+ child.on('error', () => {
144
+ clearTimeout(timer);
145
+ resolve(null);
146
+ });
147
+ });
148
+ this.live.delete(id);
149
+ try { sh(['network', 'rm', network]); this.networks.delete(network); } catch { /* fica para o cleanup */ }
150
+
151
+ return {
152
+ ok: !timedOut && code === 0,
153
+ timedOut,
154
+ code,
155
+ durationSec: Math.round((Date.now() - started) / 1000),
156
+ };
157
+ }
158
+
159
+ /** Mata containers vivos e remove redes — chamado no fim E no sinal. */
160
+ async cleanup() {
161
+ for (const id of [...this.live]) {
162
+ try { sh(['rm', '-f', id]); } catch { /* já removido */ }
163
+ this.live.delete(id);
164
+ }
165
+ // A rede só desmonta depois que o container solta — uma tentativa tardia.
166
+ await sleep(200);
167
+ for (const net of [...this.networks]) {
168
+ try { sh(['network', 'rm', net]); this.networks.delete(net); } catch { /* melhor esforço */ }
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Backend do spec-wave-sandbox — INTERFACE pronta, implementação follow-up.
175
+ *
176
+ * A Tarefa Zero (item 4) verificou que a API de sessão ainda não existe; até
177
+ * ela existir, escolher este backend é um erro orientado, não um stub que
178
+ * finge executar.
179
+ */
180
+ export class SandboxBackend {
181
+ constructor() {
182
+ this.reason =
183
+ 'O backend `sandbox` ainda não está disponível: o spec-wave-sandbox não expõe API de ' +
184
+ 'criação de sessão (Tarefa Zero, rfc/spec-qa-lead.md). Use `qa.lead.backend: "docker"`.';
185
+ }
186
+
187
+ available() {
188
+ return { ok: false, detail: this.reason };
189
+ }
190
+
191
+ imageAvailable() {
192
+ return { ok: false, detail: this.reason };
193
+ }
194
+
195
+ async run() {
196
+ throw new Error(this.reason);
197
+ }
198
+
199
+ async cleanup() { /* nada a limpar */ }
200
+ }
201
+
202
+ /**
203
+ * Fabrica o backend configurado.
204
+ *
205
+ * @param {object} params
206
+ * @param {'docker'|'sandbox'} params.backend
207
+ * @param {string|null} params.image
208
+ * @param {string} params.checkoutDir
209
+ */
210
+ export function createExecutionBackend({ backend, image, checkoutDir }) {
211
+ if (backend === 'sandbox') return new SandboxBackend();
212
+ return new DockerBackend({ image, checkoutDir });
213
+ }