@spec-wave/cli 0.30.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 +80 -5
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +102 -3
- 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 +104 -25
- package/src/config.mjs +15 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +4 -0
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/qa-exec.mjs +23 -2
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-report.mjs +65 -9
- 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 +3 -1
- 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 +953 -298
- package/src/templates/skill/core.md +584 -0
package/src/lib/qa-report.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// (`v1` → `v2`).
|
|
8
8
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
-
import { REQUIRED_BUG_SECTIONS } from '../config.mjs';
|
|
10
|
+
import { REQUIRED_BUG_SECTIONS, QA_BLOCKED_REASONS } from '../config.mjs';
|
|
11
11
|
|
|
12
12
|
export const QA_REPORT_VERSION = 1;
|
|
13
13
|
|
|
@@ -74,11 +74,21 @@ export function aggregateVerdict(results = []) {
|
|
|
74
74
|
*
|
|
75
75
|
* O executor (o agente acionado por `qa.command`) grava um JSON com o veredito
|
|
76
76
|
* de cada cenário. Fora do contrato → erro nomeando o campo: um resultado
|
|
77
|
-
* ilegível não pode virar aprovação nem reprovação
|
|
77
|
+
* ilegível não pode virar aprovação nem reprovação (D-QAL5 — falha alta, nunca
|
|
78
|
+
* inferência).
|
|
79
|
+
*
|
|
80
|
+
* O contrato é o formato ADOTADO da implementação original (`cenario` /
|
|
81
|
+
* `evidencia` — ver `protocol/qa-result.v1.json`), estendido pela
|
|
82
|
+
* rfc/spec-qa-lead.md:
|
|
83
|
+
* • `blocked` exige `blockedReason` do enum fechado (D-QAL6) — o agregado por
|
|
84
|
+
* motivo do relatório de trilha depende dele;
|
|
85
|
+
* • `fail` exige `evidencia` não vazia — sem evidência não há bug.md
|
|
86
|
+
* determinístico;
|
|
87
|
+
* • `blockedReason: outro` exige `evidencia` não vazia (é o texto livre).
|
|
78
88
|
*
|
|
79
89
|
* @param {*} payload objeto lido do arquivo de resultados
|
|
80
90
|
* @param {number[]} expected números dos cenários que DEVIAM ter sido executados
|
|
81
|
-
* @returns {Array<{numero:number, verdict:'pass'|'fail'|'blocked', evidencia:string}>}
|
|
91
|
+
* @returns {Array<{numero:number, verdict:'pass'|'fail'|'blocked', evidencia:string, blockedReason:string|null}>}
|
|
82
92
|
*/
|
|
83
93
|
export function validateQaResults(payload, expected = []) {
|
|
84
94
|
if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.scenarios)) {
|
|
@@ -98,10 +108,30 @@ export function validateQaResults(payload, expected = []) {
|
|
|
98
108
|
`(aceitos, exatamente: ${QA_VERDICTS.join(' | ')}).`
|
|
99
109
|
);
|
|
100
110
|
}
|
|
111
|
+
const evidencia = String(item?.evidencia ?? '').trim();
|
|
112
|
+
const blockedReason = item?.blockedReason == null ? null : String(item.blockedReason).trim();
|
|
113
|
+
if (verdict === 'blocked') {
|
|
114
|
+
if (!blockedReason || !QA_BLOCKED_REASONS.includes(blockedReason)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`${at}: verdict "blocked" exige blockedReason do enum ` +
|
|
117
|
+
`(${QA_BLOCKED_REASONS.join(' | ')}), veio ${JSON.stringify(item?.blockedReason)}.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (blockedReason === 'outro' && !evidencia) {
|
|
121
|
+
throw new Error(`${at}: blockedReason "outro" exige evidencia com o motivo em texto livre.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (verdict === 'fail' && !evidencia) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`${at}: verdict "fail" sem evidencia — sem a evidência bruta não há bug.md ` +
|
|
127
|
+
'determinístico, e reprovação sem registro não abre Bug.'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
101
130
|
out.set(numero, {
|
|
102
131
|
numero,
|
|
103
132
|
verdict,
|
|
104
|
-
evidencia
|
|
133
|
+
evidencia,
|
|
134
|
+
blockedReason: verdict === 'blocked' ? blockedReason : null,
|
|
105
135
|
});
|
|
106
136
|
});
|
|
107
137
|
const missing = expected.filter(n => !out.has(n));
|
|
@@ -111,6 +141,13 @@ export function validateQaResults(payload, expected = []) {
|
|
|
111
141
|
'ser executado deve vir como "blocked", nunca ser omitido.'
|
|
112
142
|
);
|
|
113
143
|
}
|
|
144
|
+
const extra = [...out.keys()].filter(n => !expected.includes(n));
|
|
145
|
+
if (extra.length > 0) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`veredito para cenário(s) fora do alvo: ${extra.join(', ')} — o escopo desta corrida é ` +
|
|
148
|
+
`${expected.join(', ') || '(vazio)'}. Resultado sobrando indica plano trocado ou corrida velha.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
114
151
|
return expected.map(n => out.get(n));
|
|
115
152
|
}
|
|
116
153
|
|
|
@@ -139,7 +176,10 @@ export function combineWithPrevious({ executed = [], previous = new Map(), allNu
|
|
|
139
176
|
}
|
|
140
177
|
const prev = previous.get(n);
|
|
141
178
|
if (prev) {
|
|
142
|
-
combined.push({
|
|
179
|
+
combined.push({
|
|
180
|
+
numero: n, verdict: prev.verdict, evidencia: prev.evidencia || '(corrida anterior)',
|
|
181
|
+
blockedReason: prev.blockedReason || null, carried: true,
|
|
182
|
+
});
|
|
143
183
|
} else {
|
|
144
184
|
pendingNumbers.push(n);
|
|
145
185
|
}
|
|
@@ -162,6 +202,7 @@ export function parseLastQaReport(comments = []) {
|
|
|
162
202
|
let run = 0;
|
|
163
203
|
let verdict = null;
|
|
164
204
|
let results = new Map();
|
|
205
|
+
let bugs = [];
|
|
165
206
|
for (const comment of comments) {
|
|
166
207
|
const body = String(comment?.body || '');
|
|
167
208
|
QA_REPORT_MARKER_RE.lastIndex = 0;
|
|
@@ -176,11 +217,20 @@ export function parseLastQaReport(comments = []) {
|
|
|
176
217
|
if (Array.isArray(payload?.scenarios)) {
|
|
177
218
|
results = new Map(payload.scenarios
|
|
178
219
|
.filter(s => Number.isInteger(s?.cenario))
|
|
179
|
-
.map(s => [s.cenario, {
|
|
220
|
+
.map(s => [s.cenario, {
|
|
221
|
+
verdict: s.verdict,
|
|
222
|
+
evidencia: s.evidencia || '',
|
|
223
|
+
blockedReason: s.blockedReason || null,
|
|
224
|
+
}]));
|
|
180
225
|
}
|
|
226
|
+
// Bugs abertos pela corrida — o `qa-lead` compõe o `bugsOpened` do
|
|
227
|
+
// relatório de trilha a partir daqui, nunca parseando o markdown.
|
|
228
|
+
bugs = Array.isArray(payload?.bugs)
|
|
229
|
+
? payload.bugs.filter(b => Number.isInteger(b?.number))
|
|
230
|
+
: [];
|
|
181
231
|
} catch { /* bloco ilegível não vira estado */ }
|
|
182
232
|
}
|
|
183
|
-
return { run, verdict, results };
|
|
233
|
+
return { run, verdict, results, bugs };
|
|
184
234
|
}
|
|
185
235
|
|
|
186
236
|
const VERDICT_ICON = { pass: '✅ pass', fail: '❌ fail', blocked: '⚪ blocked' };
|
|
@@ -228,7 +278,8 @@ export function renderQaReport({
|
|
|
228
278
|
const linhas = ['| Cenário | Veredito | Evidência |', '|---|---|---|'];
|
|
229
279
|
for (const r of results) {
|
|
230
280
|
const carried = r.carried ? ' _(corrida anterior)_' : '';
|
|
231
|
-
|
|
281
|
+
const reason = r.verdict === 'blocked' && r.blockedReason ? ` (${r.blockedReason})` : '';
|
|
282
|
+
linhas.push(`| ${r.numero} | ${VERDICT_ICON[r.verdict] || r.verdict}${reason} | ${sanitizeCell(r.evidencia)}${carried} |`);
|
|
232
283
|
}
|
|
233
284
|
parts.push(linhas.join('\n'));
|
|
234
285
|
|
|
@@ -253,8 +304,13 @@ export function renderQaReport({
|
|
|
253
304
|
run,
|
|
254
305
|
verdict,
|
|
255
306
|
scenarios: results.map(r => ({
|
|
256
|
-
cenario: r.numero,
|
|
307
|
+
cenario: r.numero,
|
|
308
|
+
verdict: r.verdict,
|
|
309
|
+
evidencia: sanitizeCell(r.evidencia, 400),
|
|
310
|
+
...(r.verdict === 'blocked' && r.blockedReason ? { blockedReason: r.blockedReason } : {}),
|
|
257
311
|
})),
|
|
312
|
+
// Aditivo ao v1: o `qa-lead` lê daqui os Bugs da corrida.
|
|
313
|
+
bugs: bugs.map(b => ({ number: b.number, cenario: b.cenario, existing: !!b.existing })),
|
|
258
314
|
}, null, 2),
|
|
259
315
|
'```',
|
|
260
316
|
].join('\n'));
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Composição da skill MONOLÍTICA a partir das skills do plugin.
|
|
2
|
+
//
|
|
3
|
+
// A monolítica (`src/templates/skill/SKILL.md`) e as skills por comando
|
|
4
|
+
// (`src/plugin/skills/<cmd>/SKILL.md`) diziam a mesma coisa em dois lugares, e
|
|
5
|
+
// toda mudança era feita duas vezes — o PR do QA editou as duas, e nada
|
|
6
|
+
// impedia as cópias de divergirem. Agora a monolítica é GERADA: o preâmbulo
|
|
7
|
+
// fixo mora em `core.md` (visão de conjunto: fluxo, labels, Regra fundamental,
|
|
8
|
+
// referência da CLI) e as seções de sub-comando saem das skills do plugin, que
|
|
9
|
+
// viram a única fonte de verdade por comando.
|
|
10
|
+
//
|
|
11
|
+
// Mesmo padrão do docs:gen: o arquivo é gerado, COMMITADO, e um teste de
|
|
12
|
+
// paridade (test/skill-compose.test.mjs) regenera em memória e compara com o
|
|
13
|
+
// disco — a divergência vira falha de teste, não descoberta em produção.
|
|
14
|
+
//
|
|
15
|
+
// Dois níveis, e a escolha é deliberada: compor TODAS as 26 skills verbatim
|
|
16
|
+
// dobraria o tamanho do arquivo (que vai inteiro para AGENTS.md e afins).
|
|
17
|
+
// Os SUB-COMANDOS que o roteador despacha entram com o corpo completo; os
|
|
18
|
+
// fluxos de orquestração e utilitários de board entram como lista com a
|
|
19
|
+
// descrição (quem os usa direto tem a skill dedicada do plugin ou o --help).
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { TEMPLATES_DIR } from './templates.mjs';
|
|
25
|
+
import { listPluginSkills } from './plugin-skills.mjs';
|
|
26
|
+
import { parseSkill } from './skill-file.mjs';
|
|
27
|
+
import { fenceScanner } from './decomposition-doc.mjs';
|
|
28
|
+
|
|
29
|
+
/** Preâmbulo fixo, editado à mão — a única metade autoral da monolítica. */
|
|
30
|
+
export const SKILL_CORE_FILE = path.join(TEMPLATES_DIR, 'skill', 'core.md');
|
|
31
|
+
|
|
32
|
+
/** Saída gerada — o que o install-skill instala em todos os agentes. */
|
|
33
|
+
export const SKILL_OUTPUT_FILE = path.join(TEMPLATES_DIR, 'skill', 'SKILL.md');
|
|
34
|
+
|
|
35
|
+
/** Placeholder do core.md substituído pelas seções compostas. */
|
|
36
|
+
export const SUBCOMMANDS_PLACEHOLDER = '{{SUBCOMANDOS}}';
|
|
37
|
+
|
|
38
|
+
// Aviso de arquivo gerado, logo após o frontmatter. O install-skill insere o
|
|
39
|
+
// banner de versão no mesmo ponto na INSTALAÇÃO — os dois coexistem.
|
|
40
|
+
export const GENERATED_MARKER =
|
|
41
|
+
'<!-- GERADO por scripts/generate-skill.mjs a partir de core.md + src/plugin/skills/*/SKILL.md.\n' +
|
|
42
|
+
' NÃO edite este arquivo à mão: edite o core.md (visão de conjunto) ou a skill do\n' +
|
|
43
|
+
' comando no plugin, e rode `npm run skill:gen`. -->';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sub-comandos com corpo COMPLETO, na ordem do fluxo. São os que o roteador
|
|
47
|
+
* (`/spec-wave <cmd>`) despacha — o leitor de arquivo único precisa do passo a
|
|
48
|
+
* passo deles sem ter as skills granulares por perto.
|
|
49
|
+
*/
|
|
50
|
+
export const SKILL_FULL_ORDER = [
|
|
51
|
+
'info', 'setup', 'update', 'doctor',
|
|
52
|
+
'issue',
|
|
53
|
+
'spec', 'plan', 'ready',
|
|
54
|
+
'decompose', 'implement', 'qa',
|
|
55
|
+
'bug', 'triage',
|
|
56
|
+
'rfc', 'fix-pr', 'uninstall',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Skills que entram como LISTA (nome + descrição + ponteiro): fluxos de
|
|
61
|
+
* orquestração (grandes demais para um arquivo único) e utilitários de board
|
|
62
|
+
* que a Referência da CLI do core já documenta flag a flag.
|
|
63
|
+
*/
|
|
64
|
+
export const SKILL_SUMMARY_ORDER = [
|
|
65
|
+
'workflow', 'preparar-feature', 'preparar-specs',
|
|
66
|
+
'audit', 'order', 'merge', 'run',
|
|
67
|
+
'qa-lead', 'qa-executor',
|
|
68
|
+
'task', 'story', 'move',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reescritas para o contexto de ARQUIVO ÚNICO (função PURA via applyRewrites).
|
|
73
|
+
*
|
|
74
|
+
* Uma skill do plugin pode apontar para um arquivo de apoio "ao lado" dela
|
|
75
|
+
* (ex.: `reference/tech-context.md` da skill plan) — ao lado da monolítica
|
|
76
|
+
* esse arquivo não existe, mas o conteúdo correspondente existe como seção do
|
|
77
|
+
* core. Substituição por string EXATA, de propósito: se a frase de origem
|
|
78
|
+
* mudar, a reescrita vira no-op (o ponteiro fica só impreciso) em vez de
|
|
79
|
+
* corromper texto parecido.
|
|
80
|
+
*/
|
|
81
|
+
export const SINGLE_FILE_REWRITES = [
|
|
82
|
+
[
|
|
83
|
+
'o passo a passo está em `reference/tech-context.md`, ao lado deste arquivo',
|
|
84
|
+
'o passo a passo está na seção **Tech Context** deste documento',
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
'o passo a passo está na skill **plan** (`reference/tech-context.md`)',
|
|
88
|
+
'o passo a passo está na seção **Tech Context** deste documento',
|
|
89
|
+
],
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/** Aplica as reescritas de arquivo único (função PURA). */
|
|
93
|
+
export function applyRewrites(body, rewrites = SINGLE_FILE_REWRITES) {
|
|
94
|
+
let out = String(body ?? '');
|
|
95
|
+
for (const [from, to] of rewrites) out = out.split(from).join(to);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rebaixa os headings de um corpo markdown (função PURA, ciente de fences).
|
|
101
|
+
*
|
|
102
|
+
* `## Passos` dentro de uma skill vira `#### Passos` dentro da seção
|
|
103
|
+
* `### /spec-wave <cmd>` da monolítica. Linhas dentro de blocos de código não
|
|
104
|
+
* são tocadas (o mesmo rastreador do decomposition.md decide o que é código).
|
|
105
|
+
*
|
|
106
|
+
* @param {string} body
|
|
107
|
+
* @param {number} [by] níveis a rebaixar (teto em ######)
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function demoteHeadings(body, by = 2) {
|
|
111
|
+
const scan = fenceScanner();
|
|
112
|
+
return String(body ?? '').split('\n').map((line) => {
|
|
113
|
+
if (scan.inFence(line)) return line;
|
|
114
|
+
const m = /^(#{1,6})([ \t].*)$/.exec(line);
|
|
115
|
+
if (!m) return line;
|
|
116
|
+
const level = Math.min(6, m[1].length + by);
|
|
117
|
+
return '#'.repeat(level) + m[2];
|
|
118
|
+
}).join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// "spec-wave implement — etapa 🚧 Desenvolvimento" → "etapa 🚧 Desenvolvimento".
|
|
122
|
+
function h1Tail(h1Text, name) {
|
|
123
|
+
const text = String(h1Text ?? '').trim();
|
|
124
|
+
const m = new RegExp(`^spec-wave[ \\t]+${name}[ \\t]*(?:[—–:-][ \\t]*(.*))?$`, 'i').exec(text);
|
|
125
|
+
if (m) return (m[1] || '').trim();
|
|
126
|
+
return text; // H1 fora do padrão: vira o subtítulo inteiro
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Separa o H1 do restante do corpo (o H1 vira o título da seção composta).
|
|
130
|
+
function splitH1(body) {
|
|
131
|
+
const lines = String(body ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
132
|
+
const scan = fenceScanner();
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
if (scan.inFence(lines[i])) continue;
|
|
135
|
+
const m = /^#[ \t]+(.*)$/.exec(lines[i]);
|
|
136
|
+
if (m) return { h1: m[1].trim(), rest: lines.slice(i + 1).join('\n').trim() };
|
|
137
|
+
if (lines[i].trim()) break; // primeiro conteúdo não é H1 — não há o que separar
|
|
138
|
+
}
|
|
139
|
+
return { h1: null, rest: String(body ?? '').trim() };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Renderiza a seção de UM sub-comando de corpo completo (função PURA).
|
|
144
|
+
*
|
|
145
|
+
* @param {{name: string, meta: object, body: string}} skill
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function renderFullSection({ name, meta, body }) {
|
|
149
|
+
const { h1, rest } = splitH1(body);
|
|
150
|
+
const tail = h1 ? h1Tail(h1, name) : '';
|
|
151
|
+
const header = `### \`/spec-wave ${name}\`${tail ? ` — ${tail}` : ''}`;
|
|
152
|
+
const quando = meta?.description
|
|
153
|
+
? `> **Quando usar:** ${String(meta.description).replace(/\s+/g, ' ').trim()}`
|
|
154
|
+
: '';
|
|
155
|
+
return [header, quando, applyRewrites(demoteHeadings(rest, 2))].filter(Boolean).join('\n\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Renderiza a lista das skills resumidas (função PURA).
|
|
160
|
+
*
|
|
161
|
+
* @param {Array<{name: string, meta: object}>} skills na ordem de SKILL_SUMMARY_ORDER
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function renderSummarySection(skills) {
|
|
165
|
+
const linhas = [
|
|
166
|
+
'### Outras skills (fluxos e utilitários)',
|
|
167
|
+
'',
|
|
168
|
+
'Cada uma existe como skill dedicada do plugin (`/spec-wave:<nome>`) — e os',
|
|
169
|
+
'utilitários de board estão flag a flag na *Referência da CLI* acima. Rode',
|
|
170
|
+
'`npx @spec-wave/cli@latest <comando> --help` para os parâmetros.',
|
|
171
|
+
'',
|
|
172
|
+
];
|
|
173
|
+
for (const { name, meta } of skills) {
|
|
174
|
+
const desc = String(meta?.description || '').replace(/\s+/g, ' ').trim();
|
|
175
|
+
linhas.push(`- **\`${name}\`** — ${desc}`);
|
|
176
|
+
}
|
|
177
|
+
return linhas.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Compõe a monolítica inteira (função PURA — testável sem fs).
|
|
182
|
+
*
|
|
183
|
+
* @param {object} params
|
|
184
|
+
* @param {string} params.core conteúdo do core.md (com o placeholder)
|
|
185
|
+
* @param {Map<string, {meta: object, body: string}>} params.skills por nome de diretório
|
|
186
|
+
* @returns {string} SKILL.md final
|
|
187
|
+
*/
|
|
188
|
+
export function renderMonolithicSkill({ core, skills }) {
|
|
189
|
+
const raw = String(core ?? '');
|
|
190
|
+
if (!raw.includes(SUBCOMMANDS_PLACEHOLDER)) {
|
|
191
|
+
throw new Error(`core.md não contém o placeholder ${SUBCOMMANDS_PLACEHOLDER}.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const faltando = [...SKILL_FULL_ORDER, ...SKILL_SUMMARY_ORDER].filter(n => !skills.has(n));
|
|
195
|
+
if (faltando.length > 0) {
|
|
196
|
+
throw new Error(`skill(s) declaradas na ordem mas ausentes do plugin: ${faltando.join(', ')}.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const cheias = SKILL_FULL_ORDER
|
|
200
|
+
.map(name => renderFullSection({ name, ...skills.get(name) }))
|
|
201
|
+
.join('\n\n---\n\n');
|
|
202
|
+
const resumidas = renderSummarySection(
|
|
203
|
+
SKILL_SUMMARY_ORDER.map(name => ({ name, meta: skills.get(name).meta })),
|
|
204
|
+
);
|
|
205
|
+
const sections = `${cheias}\n\n---\n\n${resumidas}`;
|
|
206
|
+
|
|
207
|
+
let out = raw.replace(SUBCOMMANDS_PLACEHOLDER, sections);
|
|
208
|
+
|
|
209
|
+
// Marcador de "gerado" logo após o frontmatter — quem abrir o arquivo para
|
|
210
|
+
// editar precisa ser mandado de volta para a fonte antes da primeira linha útil.
|
|
211
|
+
const fm = /^---\n[\s\S]*?\n---\n/.exec(out);
|
|
212
|
+
if (!fm) throw new Error('core.md sem frontmatter YAML — a monolítica precisa dele.');
|
|
213
|
+
out = `${fm[0]}\n${GENERATED_MARKER}\n${out.slice(fm[0].length)}`;
|
|
214
|
+
|
|
215
|
+
return out.endsWith('\n') ? out : `${out}\n`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Lê as fontes do disco e compõe (I/O). É o que o gerador e o teste de
|
|
220
|
+
* paridade chamam — os dois produzem exatamente o mesmo texto.
|
|
221
|
+
*
|
|
222
|
+
* @returns {{ content: string, skillNames: string[] }}
|
|
223
|
+
*/
|
|
224
|
+
export function composeMonolithicSkill() {
|
|
225
|
+
const core = readFileSync(SKILL_CORE_FILE, 'utf-8');
|
|
226
|
+
const skills = new Map();
|
|
227
|
+
const skillNames = [];
|
|
228
|
+
for (const skill of listPluginSkills()) {
|
|
229
|
+
const { meta, body } = parseSkill(readFileSync(path.join(skill.dir, 'SKILL.md'), 'utf-8'));
|
|
230
|
+
skills.set(skill.name, { meta, body });
|
|
231
|
+
skillNames.push(skill.name);
|
|
232
|
+
}
|
|
233
|
+
return { content: renderMonolithicSkill({ core, skills }), skillNames };
|
|
234
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Carregamento do grafo de Stories e do snapshot do board — a CÓPIA ÚNICA do
|
|
2
|
+
// laço que order/implement/merge/qa-lead mantinham cada um por si (e que
|
|
3
|
+
// custava 1..3 chamadas POR STORY em cada comando).
|
|
4
|
+
//
|
|
5
|
+
// Fontes das ARESTAS, da mais barata para a mais cara:
|
|
6
|
+
// 1. `docs/features/<slug>/dependency-map.json` — artefato COMMITADO, escrito
|
|
7
|
+
// pelo decompose --apply e atualizado pelo `order --sync`;
|
|
8
|
+
// 2. `decomposition.md` aplicado (mesma informação, formato de prosa);
|
|
9
|
+
// 3. linhas "Depende de: #N" do corpo das sub-issues — que o listSubIssues já
|
|
10
|
+
// devolve DE GRAÇA (zero chamada extra);
|
|
11
|
+
// 4. `remote: true` → blocked_by nativo via API, 1 chamada por Story (cache
|
|
12
|
+
// `blockedby-*` com TTL). É a única fonte que enxerga aresta criada SÓ
|
|
13
|
+
// pela UI — por isso o merge --yes a exige fresca.
|
|
14
|
+
// 1–3 são sempre UNIDAS (são grátis); a origem informada ao usuário diz o que
|
|
15
|
+
// entrou.
|
|
16
|
+
//
|
|
17
|
+
// Etapa/estado vêm do snapshot (`listProjectItems`, 1 chamada paginada),
|
|
18
|
+
// cacheado com TTL — NUNCA do par addProjectItem+getItemSingleSelectValue, que
|
|
19
|
+
// além de custar 2 chamadas por item é MUTAÇÃO em caminho de leitura.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { listSubIssues, listProjectItems } from '../api/github-graphql.mjs';
|
|
25
|
+
import { listBlockedBy, getIssue } from '../api/github-rest.mjs';
|
|
26
|
+
import { detectIssueType } from './issue-type.mjs';
|
|
27
|
+
import { featureDocPaths } from './doc-paths.mjs';
|
|
28
|
+
import { parseDecompositionDoc } from './decomposition-doc.mjs';
|
|
29
|
+
import { parseDependencies } from './dependencies.mjs';
|
|
30
|
+
import { indexBoardItems } from './board.mjs';
|
|
31
|
+
import {
|
|
32
|
+
parseDependencyMap, storiesFromAppliedDoc, mergeDependencyEdges, isFresh,
|
|
33
|
+
DEPENDENCY_MAP_FILE,
|
|
34
|
+
} from './dependency-map.mjs';
|
|
35
|
+
import {
|
|
36
|
+
readCacheEntry, writeCacheEntry, DEFAULT_CACHE_TTL_SEC,
|
|
37
|
+
} from './net-cache.mjs';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stories de uma Feature com as arestas resolvidas — fonte híbrida.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {string} params.token
|
|
44
|
+
* @param {string} params.owner
|
|
45
|
+
* @param {string} params.repo
|
|
46
|
+
* @param {string} params.root raiz do projeto (docs/ e cache moram nela)
|
|
47
|
+
* @param {{number:number, nodeId?:string, node_id?:string, title:string}} params.feature
|
|
48
|
+
* @param {boolean} [params.remote] une também o blocked_by da API (S chamadas)
|
|
49
|
+
* @param {boolean} [params.refresh] ignora TODO cache na leitura (mas grava)
|
|
50
|
+
* @param {number} [params.ttlSec]
|
|
51
|
+
* @returns {Promise<{
|
|
52
|
+
* stories: Array<{number, title, nodeId, body, state, stateReason, createdAt,
|
|
53
|
+
* milestone, labels, dependsOn:number[]}>,
|
|
54
|
+
* allSubs: Array<object>,
|
|
55
|
+
* origin: { edges: string, mapGeneratedAt: string|null, subsFrom: 'cache'|'api',
|
|
56
|
+
* fetchedAt: string|null },
|
|
57
|
+
* warnings: string[],
|
|
58
|
+
* }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function loadFeatureStories({
|
|
61
|
+
token, owner, repo, root, feature, remote = false, refresh = false,
|
|
62
|
+
ttlSec = DEFAULT_CACHE_TTL_SEC,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const nodeId = feature.nodeId || feature.node_id;
|
|
66
|
+
|
|
67
|
+
// ── sub-issues (com body!) — cache subissues-<n> ───────────────────────────
|
|
68
|
+
const subsKey = `subissues-${feature.number}`;
|
|
69
|
+
const cached = refresh ? null : readCacheEntry(root, subsKey, { owner, repo });
|
|
70
|
+
let allSubs;
|
|
71
|
+
let subsFrom;
|
|
72
|
+
let fetchedAt;
|
|
73
|
+
if (cached && isFresh(cached, ttlSec)) {
|
|
74
|
+
allSubs = cached.data;
|
|
75
|
+
subsFrom = 'cache';
|
|
76
|
+
fetchedAt = cached.fetchedAt;
|
|
77
|
+
} else {
|
|
78
|
+
allSubs = await listSubIssues(token, nodeId);
|
|
79
|
+
subsFrom = 'api';
|
|
80
|
+
fetchedAt = new Date().toISOString();
|
|
81
|
+
writeCacheEntry(root, subsKey, 'sub-issues', allSubs, { owner, repo });
|
|
82
|
+
}
|
|
83
|
+
const storySubs = allSubs
|
|
84
|
+
.filter(s => detectIssueType({ title: s.title, labels: s.labels }) === 'Story');
|
|
85
|
+
|
|
86
|
+
// ── fontes de aresta locais (grátis — sempre unidas) ───────────────────────
|
|
87
|
+
const sources = [];
|
|
88
|
+
const parts = [];
|
|
89
|
+
let mapGeneratedAt = null;
|
|
90
|
+
|
|
91
|
+
const paths = root ? safeDocPaths(root, feature.title) : null;
|
|
92
|
+
if (paths) {
|
|
93
|
+
const mapAbs = path.join(root, paths['dependency-map'].rel);
|
|
94
|
+
if (existsSync(mapAbs)) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = parseDependencyMap(JSON.parse(readFileSync(mapAbs, 'utf-8')));
|
|
97
|
+
if (parsed.ok) {
|
|
98
|
+
sources.push(parsed.map.stories);
|
|
99
|
+
parts.push('map');
|
|
100
|
+
mapGeneratedAt = parsed.map.generatedAt || null;
|
|
101
|
+
} else {
|
|
102
|
+
warnings.push(`${DEPENDENCY_MAP_FILE} ignorado: ${parsed.reason} — regenere com \`order ${feature.number} --sync\`.`);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
warnings.push(`${DEPENDENCY_MAP_FILE} ilegível — regenere com \`order ${feature.number} --sync\`.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (parts.length === 0) {
|
|
109
|
+
const docAbs = path.join(root, paths.decomposition.rel);
|
|
110
|
+
if (existsSync(docAbs)) {
|
|
111
|
+
try {
|
|
112
|
+
const converted = storiesFromAppliedDoc(parseDecompositionDoc(readFileSync(docAbs, 'utf-8')));
|
|
113
|
+
if (converted.ok) {
|
|
114
|
+
sources.push(converted.stories);
|
|
115
|
+
parts.push('doc');
|
|
116
|
+
}
|
|
117
|
+
} catch { /* doc ilegível → segue com o body */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Body: as linhas "Depende de: #N" que o apply gravou (ou o humano editou).
|
|
123
|
+
sources.push(storySubs.map(s => ({ number: s.number, dependsOn: parseDependencies(s.body) })));
|
|
124
|
+
parts.push('body');
|
|
125
|
+
|
|
126
|
+
// ── blocked_by remoto (opcional — a única fonte com aresta só-da-UI) ───────
|
|
127
|
+
if (remote) {
|
|
128
|
+
const remoteEdges = [];
|
|
129
|
+
for (const s of storySubs) {
|
|
130
|
+
const key = `blockedby-${s.number}`;
|
|
131
|
+
const hit = refresh ? null : readCacheEntry(root, key, { owner, repo });
|
|
132
|
+
if (hit && isFresh(hit, ttlSec)) {
|
|
133
|
+
remoteEdges.push({ number: s.number, dependsOn: hit.data });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const deps = (await listBlockedBy(token, owner, repo, s.number)).map(b => b.number);
|
|
138
|
+
remoteEdges.push({ number: s.number, dependsOn: deps });
|
|
139
|
+
writeCacheEntry(root, key, 'blocked-by', deps, { owner, repo });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// Falha de rede NÃO entra no cache e não derruba: as fontes locais
|
|
142
|
+
// seguem valendo — mas o usuário precisa saber que o remoto ficou fora.
|
|
143
|
+
warnings.push(`blocked_by de #${s.number} não consultável agora (${err.message}).`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
sources.push(remoteEdges);
|
|
147
|
+
parts.push('remote');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const edges = mergeDependencyEdges(...sources);
|
|
151
|
+
return {
|
|
152
|
+
stories: storySubs.map(s => ({ ...s, dependsOn: edges.get(s.number) || [] })),
|
|
153
|
+
allSubs,
|
|
154
|
+
origin: { edges: parts.join('+'), mapGeneratedAt, subsFrom, fetchedAt },
|
|
155
|
+
warnings,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sub-issues de um item, com cache `subissues-<n>` (TTL).
|
|
161
|
+
*
|
|
162
|
+
* Para quem precisa dos filhos CRUS (ex.: as Tasks de uma Story no implement)
|
|
163
|
+
* sem o resto do grafo.
|
|
164
|
+
*
|
|
165
|
+
* @returns {Promise<{subs:Array, fromCache:boolean, fetchedAt:string}>}
|
|
166
|
+
*/
|
|
167
|
+
export async function cachedSubIssues({
|
|
168
|
+
token, owner, repo, root, parent, refresh = false, ttlSec = DEFAULT_CACHE_TTL_SEC,
|
|
169
|
+
} = {}) {
|
|
170
|
+
const key = `subissues-${parent.number}`;
|
|
171
|
+
if (!refresh) {
|
|
172
|
+
const hit = readCacheEntry(root, key, { owner, repo });
|
|
173
|
+
if (hit && isFresh(hit, ttlSec)) return { subs: hit.data, fromCache: true, fetchedAt: hit.fetchedAt };
|
|
174
|
+
}
|
|
175
|
+
const subs = await listSubIssues(token, parent.nodeId || parent.node_id);
|
|
176
|
+
writeCacheEntry(root, key, 'sub-issues', subs, { owner, repo });
|
|
177
|
+
return { subs, fromCache: false, fetchedAt: new Date().toISOString() };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// featureDocPaths lança para título vazio/eslug inválido — aqui vira "sem docs".
|
|
181
|
+
function safeDocPaths(root, title) {
|
|
182
|
+
try {
|
|
183
|
+
return featureDocPaths(root, { title }, 'Feature');
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Snapshot do board (Etapa/estado/milestone de todos os itens) — 1 chamada
|
|
191
|
+
* paginada, cacheada com TTL.
|
|
192
|
+
*
|
|
193
|
+
* `fresh: true` = caminho de DECISÃO QUE ESCREVE (gates do implement, escopo do
|
|
194
|
+
* qa-lead run): ignora o cache na leitura, mas grava — o comando seguinte da
|
|
195
|
+
* sessão ganha o snapshot de graça.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} params
|
|
198
|
+
* @param {string} params.token
|
|
199
|
+
* @param {{id:string}} params.project
|
|
200
|
+
* @param {string} params.root
|
|
201
|
+
* @param {boolean} [params.refresh] força reconsulta (flag do usuário)
|
|
202
|
+
* @param {boolean} [params.fresh] exige dado novo (decisão de escrita)
|
|
203
|
+
* @param {number} [params.ttlSec]
|
|
204
|
+
* @returns {Promise<{items:Array, index:Map<number,object>, fetchedAt:string, fromCache:boolean}>}
|
|
205
|
+
*/
|
|
206
|
+
export async function loadBoardSnapshot({
|
|
207
|
+
token, project, root, refresh = false, fresh = false, ttlSec = DEFAULT_CACHE_TTL_SEC,
|
|
208
|
+
} = {}) {
|
|
209
|
+
const key = 'board-items';
|
|
210
|
+
if (!refresh && !fresh) {
|
|
211
|
+
const hit = readCacheEntry(root, key, { projectId: project.id });
|
|
212
|
+
if (hit && isFresh(hit, ttlSec)) {
|
|
213
|
+
return {
|
|
214
|
+
items: hit.data,
|
|
215
|
+
index: indexBoardItems(hit.data),
|
|
216
|
+
fetchedAt: hit.fetchedAt,
|
|
217
|
+
fromCache: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const items = await listProjectItems(token, project.id);
|
|
222
|
+
writeCacheEntry(root, key, 'board-items', items, { projectId: project.id });
|
|
223
|
+
return {
|
|
224
|
+
items,
|
|
225
|
+
index: indexBoardItems(items),
|
|
226
|
+
fetchedAt: new Date().toISOString(),
|
|
227
|
+
fromCache: false,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Estado (aberta/fechada, título) de uma issue externa — cache `issue-<n>`.
|
|
233
|
+
*
|
|
234
|
+
* Só para EXIBIÇÃO (a lista "Bloqueadas por fora" do order); decisão de escrita
|
|
235
|
+
* nunca passa por aqui.
|
|
236
|
+
*
|
|
237
|
+
* @returns {Promise<{title:string, state:string|null, aberta:boolean, fromCache:boolean}>}
|
|
238
|
+
*/
|
|
239
|
+
export async function externalIssueInfo({
|
|
240
|
+
token, owner, repo, root, number, refresh = false, ttlSec = DEFAULT_CACHE_TTL_SEC,
|
|
241
|
+
} = {}) {
|
|
242
|
+
const key = `issue-${number}`;
|
|
243
|
+
if (!refresh) {
|
|
244
|
+
const hit = readCacheEntry(root, key, { owner, repo });
|
|
245
|
+
if (hit && isFresh(hit, ttlSec)) return { ...hit.data, fromCache: true };
|
|
246
|
+
}
|
|
247
|
+
const issue = await getIssue(token, owner, repo, number).catch(() => null);
|
|
248
|
+
if (!issue) {
|
|
249
|
+
// Falha não entra no cache: "não foi possível ler" com cara de fresco por
|
|
250
|
+
// 10 minutos esconderia uma issue que voltou a ser legível.
|
|
251
|
+
return { title: '(não foi possível ler)', state: null, aberta: true, fromCache: false };
|
|
252
|
+
}
|
|
253
|
+
const data = { title: issue.title, state: issue.state, aberta: issue.state === 'open' };
|
|
254
|
+
writeCacheEntry(root, key, 'issue', data, { owner, repo });
|
|
255
|
+
return { ...data, fromCache: false };
|
|
256
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.32.0",
|
|
5
5
|
"description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Astratech",
|
|
@@ -22,6 +22,7 @@ O `implement` empilha os PRs de propósito (cada Story revisável sozinha, diff
|
|
|
22
22
|
## O que saber antes de rodar
|
|
23
23
|
|
|
24
24
|
- **Sem `--yes` nada é mergeado** — a saída é o plano: a fila na ordem, qual base será reapontada, o que já está mergeado.
|
|
25
|
+
- O **plano** calcula a ordem pelas fontes locais (dependency-map.json/decomposition.md/corpo — barato); a **execução com `--yes` reconsulta tudo FRESCO da API**, incluindo o blocked_by nativo: merge de pilha é irreversível e não decide com cache.
|
|
25
26
|
- **PR em rascunho bloqueia o plano inteiro.** Marcar pronto é a revisão humana (e o que dispara o CI) — revise e marque cada PR como pronto antes. O comando não faz isso por você, de propósito.
|
|
26
27
|
- **Rodar de novo retoma.** PR mergeado sai do plano sozinho; uma falha no meio (check pendente, conflito) para a fila com as branches dos dependentes intactas.
|
|
27
28
|
- Story **sem PR** vira aviso, não bloqueio — mas se um PR da fila depende do código dela, o merge leva esse código junto; confira antes de confirmar.
|