@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.
- package/package.json +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +114 -9
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +183 -3
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +892 -0
- package/src/commands/run.mjs +5 -1
- package/src/config.mjs +32 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/critique.mjs +38 -9
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +9 -2
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/qa-exec.mjs +335 -0
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +396 -0
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +107 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +981 -279
- package/src/templates/skill/core.md +584 -0
- 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
|
+
}
|