@spec-wave/cli 0.32.0 → 0.34.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 +1 -1
- package/src/api/github-graphql.mjs +28 -0
- package/src/api/github-rest.mjs +24 -0
- package/src/cli.mjs +6 -2
- package/src/commands/audit.mjs +10 -1
- package/src/commands/dev-agent.mjs +20 -4
- package/src/commands/doctor.mjs +107 -13
- package/src/commands/implement.mjs +167 -34
- package/src/commands/merge.mjs +18 -0
- package/src/commands/update.mjs +55 -10
- package/src/commands/validate.mjs +14 -0
- package/src/config.mjs +57 -4
- package/src/lib/bug-context.mjs +17 -6
- package/src/lib/commit-trailers.mjs +134 -0
- package/src/lib/delivery-mode.mjs +26 -0
- package/src/lib/rate-budget.mjs +36 -0
- package/src/lib/spec-audit.mjs +135 -19
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/audit/SKILL.md +2 -2
- package/src/plugin/skills/doctor/SKILL.md +6 -2
- package/src/plugin/skills/implement/SKILL.md +8 -2
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/spec/model-prompt.md +1 -1
- package/src/setup/labels.mjs +6 -5
- package/src/templates/issue/spec-template.md +7 -2
- package/src/templates/skill/SKILL.md +14 -4
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Rastro verificável de Story/Task no commit — item 6 do rfc/plano-hardening-
|
|
2
|
+
// agentes-2026-08.md.
|
|
3
|
+
//
|
|
4
|
+
// O assunto do commit é prosa: nada no CLI o formata (o `implement.mjs:405`
|
|
5
|
+
// só pede "faça o commit"), e o `feature_prompt` do daemon pede um formato
|
|
6
|
+
// que ninguém verifica. Um script de verificação que lê o ASSUNTO dá falso
|
|
7
|
+
// negativo assim que o modelo escreve algo mais descritivo — foi o que
|
|
8
|
+
// aconteceu com 7 Stories reais da #734. `git trailer` é a parte que não
|
|
9
|
+
// varia: sobrevive a squash, rebase e reescrita de assunto, e
|
|
10
|
+
// `git log --format='%(trailers:key=...)'` lê sem depender de convenção.
|
|
11
|
+
//
|
|
12
|
+
// Módulo PURO: render/parse de texto, sem git nem I/O.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { TRAILER_STORY, TRAILER_TASKS, TRAILER_AGENT } from '../config.mjs';
|
|
16
|
+
|
|
17
|
+
// Uma linha de trailer é "Chave: valor" — mesma forma que `git interpret-
|
|
18
|
+
// trailers` reconhece. Só suportamos trailer de uma linha (nunca precisamos
|
|
19
|
+
// de mais que isso aqui).
|
|
20
|
+
const TRAILER_LINE_RE = /^([A-Za-z][\w-]*):\s*(.+)$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Monta o bloco de trailers para colar no rodapé do commit (função PURA).
|
|
24
|
+
*
|
|
25
|
+
* @param {object} params
|
|
26
|
+
* @param {number|string} [params.story] número da Story
|
|
27
|
+
* @param {Array<number|string>} [params.tasks] números das Tasks
|
|
28
|
+
* @param {string} [params.agent] identificador do executor (ex.: 'spec-wave-agent')
|
|
29
|
+
* @returns {string} bloco pronto para colar — vazio se nada foi passado
|
|
30
|
+
*/
|
|
31
|
+
export function renderTrailers({ story, tasks = [], agent } = {}) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (story !== undefined && story !== null && story !== '') {
|
|
34
|
+
lines.push(`${TRAILER_STORY}: #${String(story).replace(/^#/, '')}`);
|
|
35
|
+
}
|
|
36
|
+
if (tasks && tasks.length > 0) {
|
|
37
|
+
lines.push(`${TRAILER_TASKS}: ${tasks.map(t => `#${String(t).replace(/^#/, '')}`).join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
if (agent) {
|
|
40
|
+
lines.push(`${TRAILER_AGENT}: ${agent}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lê os trailers do BLOCO FINAL de uma mensagem de commit (função PURA).
|
|
47
|
+
*
|
|
48
|
+
* Só o bloco final conta: caminha da última linha não-vazia para trás
|
|
49
|
+
* enquanto as linhas casarem "Chave: valor", e para no primeiro que não casa
|
|
50
|
+
* — o mesmo contrato de `git interpret-trailers`. Um trailer que apareça no
|
|
51
|
+
* meio do corpo (citado em prosa, por exemplo) não é o rastro real.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} message corpo completo do commit (ex.: `git log --format=%B`)
|
|
54
|
+
* @returns {{ story: number|null, tasks: number[], agent: string|null }}
|
|
55
|
+
*/
|
|
56
|
+
export function parseTrailers(message = '') {
|
|
57
|
+
const lines = String(message).replace(/\r\n?/g, '\n').split('\n');
|
|
58
|
+
let end = lines.length;
|
|
59
|
+
while (end > 0 && lines[end - 1].trim() === '') end--;
|
|
60
|
+
let start = end;
|
|
61
|
+
while (start > 0 && TRAILER_LINE_RE.test(lines[start - 1])) start--;
|
|
62
|
+
const block = lines.slice(start, end);
|
|
63
|
+
|
|
64
|
+
const out = { story: null, tasks: [], agent: null };
|
|
65
|
+
for (const line of block) {
|
|
66
|
+
const m = TRAILER_LINE_RE.exec(line);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
const [, key, rawValue] = m;
|
|
69
|
+
const value = rawValue.trim();
|
|
70
|
+
if (key === TRAILER_STORY) {
|
|
71
|
+
const n = parseInt(value.replace('#', ''), 10);
|
|
72
|
+
if (Number.isInteger(n)) out.story = n;
|
|
73
|
+
} else if (key === TRAILER_TASKS) {
|
|
74
|
+
out.tasks = value
|
|
75
|
+
.split(',')
|
|
76
|
+
.map(s => parseInt(s.trim().replace('#', ''), 10))
|
|
77
|
+
.filter(Number.isInteger);
|
|
78
|
+
} else if (key === TRAILER_AGENT) {
|
|
79
|
+
out.agent = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Quais Stories/Tasks esperadas não aparecem em NENHUM commit (função PURA).
|
|
87
|
+
*
|
|
88
|
+
* A verificação que hoje é script à mão contra o ASSUNTO do commit (e falha
|
|
89
|
+
* assim que o modelo escreve algo mais descritivo, como aconteceu com 7
|
|
90
|
+
* Stories reais da #734) vira leitura de trailer: basta UM commit, entre
|
|
91
|
+
* quantos existirem no branch/PR, trazer o trailer certo.
|
|
92
|
+
*
|
|
93
|
+
* @param {string[]} messages corpos de commit (ex.: um por commit do branch/PR)
|
|
94
|
+
* @param {{ stories?: number[], tasks?: number[] }} expected o que deveria
|
|
95
|
+
* aparecer — normalmente `[story.number]` e os números das Tasks
|
|
96
|
+
* @returns {{ missingStories: number[], missingTasks: number[] }}
|
|
97
|
+
*/
|
|
98
|
+
export function missingTrailers(messages = [], { stories = [], tasks = [] } = {}) {
|
|
99
|
+
const seenStories = new Set();
|
|
100
|
+
const seenTasks = new Set();
|
|
101
|
+
for (const msg of messages) {
|
|
102
|
+
const t = parseTrailers(msg);
|
|
103
|
+
if (t.story != null) seenStories.add(t.story);
|
|
104
|
+
for (const n of t.tasks) seenTasks.add(n);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
missingStories: stories.filter(s => !seenStories.has(s)),
|
|
108
|
+
missingTasks: tasks.filter(t => !seenTasks.has(t)),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Mensagens dos commits mais recentes do checkout local (I/O — best-effort,
|
|
114
|
+
* NUNCA lança). Usado por `implement --verify-commits`.
|
|
115
|
+
*
|
|
116
|
+
* Por CONTAGEM, não por range contra uma branch base: `implement` não cria
|
|
117
|
+
* branch (quem cria é o executor ou o daemon) e não sabe de forma confiável
|
|
118
|
+
* qual é o remoto/branch base neste checkout. Um teto generoso cobre o caso
|
|
119
|
+
* real (uma Story tem poucos commits) sem depender de configuração de git.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} cwd raiz do checkout
|
|
122
|
+
* @param {number} [limit]
|
|
123
|
+
* @returns {string[]}
|
|
124
|
+
*/
|
|
125
|
+
export function readLocalCommitMessages(cwd, limit = 50) {
|
|
126
|
+
try {
|
|
127
|
+
const out = execFileSync('git', ['log', `-n${limit}`, '--format=%B%x1e'], {
|
|
128
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
});
|
|
130
|
+
return out.split('\x1e').map(s => s.trim()).filter(Boolean);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Modo de entrega da implementação — quem cria branch e abre PR.
|
|
2
|
+
//
|
|
3
|
+
// O daemon Rust (spec-wave-agent) mantém UM branch e UM PR por issue
|
|
4
|
+
// (`agent/issue-<n>`, criado em `runner.rs:59-61`) e abre o PR ele mesmo ao
|
|
5
|
+
// final da execução (`open_pull_request`, `runner.rs:77`, chamada tanto para
|
|
6
|
+
// Bug quanto para Feature — `main.rs:171`). O contexto que o `implement`
|
|
7
|
+
// monta não pode instruir o modelo a criar branch nem abrir PR nesse modo:
|
|
8
|
+
// as duas coisas já existem, e a instrução de "abra o PR da Story" — certa no
|
|
9
|
+
// fluxo manual de PR empilhado — é o que produzia `agent/issue-<n>-story-<m>`,
|
|
10
|
+
// um branch lateral cujo trabalho nunca entra no PR que o daemon abre.
|
|
11
|
+
//
|
|
12
|
+
// Função PURA: a detecção não faz I/O, recebe env e branch já resolvidos.
|
|
13
|
+
|
|
14
|
+
const AGENT_BRANCH_RE = /^agent\/issue-\d+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} [params]
|
|
18
|
+
* @param {NodeJS.ProcessEnv} [params.env] variáveis de ambiente
|
|
19
|
+
* @param {string|null} [params.branch] branch git atual (HEAD), se conhecida
|
|
20
|
+
* @returns {'agent'|'pr-stack'}
|
|
21
|
+
*/
|
|
22
|
+
export function deliveryMode({ env = process.env, branch = null } = {}) {
|
|
23
|
+
if (env?.SPEC_WAVE_DEV_AGENT === '1') return 'agent';
|
|
24
|
+
if (branch && AGENT_BRANCH_RE.test(branch)) return 'agent';
|
|
25
|
+
return 'pr-stack';
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Orçamento de rate limit para checks caros do `doctor` (função PURA).
|
|
2
|
+
//
|
|
3
|
+
// Item 5 do rfc/plano-hardening-agentes-2026-08.md: o `checkDecompositions`
|
|
4
|
+
// fazia até 1 GraphQL por Story sem nenhuma noção de cota, e com a cota
|
|
5
|
+
// estourada o modo degradado do doctor pintava seis checks de "!" sem dizer
|
|
6
|
+
// que a causa era a mesma — o próprio doctor piorando o esgotamento que
|
|
7
|
+
// estava diagnosticando. A correção é medir ANTES de gastar (getRateLimit,
|
|
8
|
+
// que não custa pontos) e decidir aqui, fora de qualquer I/O.
|
|
9
|
+
|
|
10
|
+
/** Reserva default: fica de fora do orçamento de um check para sobrar cota
|
|
11
|
+
* para o resto da sessão (outro comando, o próprio executor do dev-agent). */
|
|
12
|
+
export const DEFAULT_RESERVE = 200;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Um check caro pode rodar?
|
|
16
|
+
*
|
|
17
|
+
* `remaining: null` (cota não pôde ser medida) SEMPRE roda — "não sei" não
|
|
18
|
+
* pode virar "não posso": um doctor sem acesso ao endpoint de rateLimit não
|
|
19
|
+
* deveria ficar mais cego do que já era antes desta correção existir.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} params
|
|
22
|
+
* @param {number|null} params.remaining pontos restantes (getRateLimit().remaining)
|
|
23
|
+
* @param {number} params.estimated custo estimado do check, em pontos
|
|
24
|
+
* @param {number} [params.reserve] pontos que ficam de fora do orçamento
|
|
25
|
+
* @param {string|null} [params.resetAt] ISO do reset, só para a mensagem
|
|
26
|
+
* @returns {{ run: boolean, motivo: string|null }}
|
|
27
|
+
*/
|
|
28
|
+
export function affordCheck({ remaining, estimated, reserve = DEFAULT_RESERVE, resetAt = null } = {}) {
|
|
29
|
+
if (remaining === null || remaining === undefined) return { run: true, motivo: null };
|
|
30
|
+
if (remaining - reserve >= estimated) return { run: true, motivo: null };
|
|
31
|
+
const quando = resetAt ? ` (reset ${new Date(resetAt).toLocaleTimeString('pt-BR')})` : '';
|
|
32
|
+
return {
|
|
33
|
+
run: false,
|
|
34
|
+
motivo: `restam ${remaining} pontos de GraphQL${quando} e este check custaria ~${estimated} — pulado para não esgotar a cota.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/lib/spec-audit.mjs
CHANGED
|
@@ -29,26 +29,67 @@ const norm = (s) => String(s || '')
|
|
|
29
29
|
// Título sem o prefixo [FEATURE]/[BUG]/etc., para casar com a prosa.
|
|
30
30
|
const bareTitle = (title) => String(title || '').replace(/^\s*\[.*?\]\s*/, '').trim();
|
|
31
31
|
|
|
32
|
+
// H1 a H3 com "Dependências" logo após os `#` — aceita sufixo ("Dependências e
|
|
33
|
+
// Premissas", "Dependências (Internas e Externas)"), não só o título exato.
|
|
34
|
+
const DEPENDENCY_HEADING_RE = /^(#{1,3})\s+depend[êe]ncias\b/i;
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
|
-
* O texto da seção
|
|
37
|
+
* O texto da seção de Dependências de uma spec (PURA).
|
|
34
38
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
39
|
+
* Heading `#`/`##`/`###` com "Dependências" no início (sufixo livre — item 3
|
|
40
|
+
* do rfc/plano-hardening-agentes-2026-08.md: `# Dependências e Premissas` e
|
|
41
|
+
* `## Dependências` também casam agora, não só o H1 exato). Fecha no próximo
|
|
42
|
+
* heading de nível IGUAL OU MENOR (menos ou igual `#`s) — os `## Internas`/
|
|
43
|
+
* `## Externas` ficam dentro quando o heading casado é H1. `null` quando a
|
|
44
|
+
* seção não existe (spec fora do template).
|
|
37
45
|
*
|
|
38
46
|
* @param {string} markdown
|
|
39
47
|
* @returns {string|null}
|
|
40
48
|
*/
|
|
41
49
|
export function dependencySection(markdown = '') {
|
|
42
50
|
const lines = String(markdown).split('\n');
|
|
43
|
-
|
|
51
|
+
let start = -1;
|
|
52
|
+
let level = 0;
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const m = DEPENDENCY_HEADING_RE.exec(norm(lines[i]).trim());
|
|
55
|
+
if (m) { start = i; level = m[1].length; break; }
|
|
56
|
+
}
|
|
44
57
|
if (start === -1) return null;
|
|
45
58
|
let end = lines.length;
|
|
46
59
|
for (let i = start + 1; i < lines.length; i++) {
|
|
47
|
-
|
|
60
|
+
const h = /^(#{1,6})\s+\S/.exec(lines[i]);
|
|
61
|
+
if (h && h[1].length <= level) { end = i; break; }
|
|
48
62
|
}
|
|
49
63
|
return lines.slice(start + 1, end).join('\n');
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
// Bullet/linha "Nenhuma"/"N/A"/vazia — resposta legítima do gerador, mesmo
|
|
67
|
+
// texto que `untraceableBullets` já usa para não flagar como órfã.
|
|
68
|
+
const EMPTY_ANSWER_RE = /^(nenhuma|n\/a|—|-)\.?$/i;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A seção de Dependências está vazia de propósito (PURA)?
|
|
72
|
+
*
|
|
73
|
+
* "Vazia" = toda linha com conteúdo (fora headings de subseção) é uma
|
|
74
|
+
* resposta tipo "Nenhuma"/"N/A" — bullet ou prosa solta. Distingue de "sem
|
|
75
|
+
* alvo" (item 3a do plano): há prosa real, só que `mentionedIssues` não
|
|
76
|
+
* conseguiu resolvê-la a nenhuma Feature do catálogo.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} section
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function dependencySectionIsEmpty(section = '') {
|
|
82
|
+
for (const linha of String(section).split('\n')) {
|
|
83
|
+
const trimmed = linha.trim();
|
|
84
|
+
if (!trimmed) continue;
|
|
85
|
+
if (/^#{1,6}\s+/.test(trimmed)) continue; // heading de subseção (## Internas)
|
|
86
|
+
const bullet = trimmed.match(/^[-*]\s+(.*\S)\s*$/);
|
|
87
|
+
const conteudo = bullet ? bullet[1] : trimmed;
|
|
88
|
+
if (!EMPTY_ANSWER_RE.test(norm(conteudo).trim())) return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
/**
|
|
53
94
|
* Todas as referências `#N` de um texto (PURA).
|
|
54
95
|
*
|
|
@@ -107,20 +148,40 @@ export function mentionedIssues(text = '', catalog = [], selfNumber = null) {
|
|
|
107
148
|
* terceiro) não rastreia a Feature nenhuma POR DEFINIÇÃO — flagá-la seria
|
|
108
149
|
* ensinar a ignorar o aviso. Sem subseções, a seção inteira é o fallback.
|
|
109
150
|
*
|
|
151
|
+
* Reconhece DUAS formas de marcar a subseção (item 3d do rfc/plano-
|
|
152
|
+
* hardening-agentes-2026-08.md): o heading `## Internas` e o bullet-rótulo
|
|
153
|
+
* `- **Internas:** ...` que `templates/issue/spec-template.md` de fato gera —
|
|
154
|
+
* sem a segunda forma, o recorte falhava e a seção inteira (Internas +
|
|
155
|
+
* Externas) virava alvo, sinalizando toda dependência Externa como órfã.
|
|
156
|
+
*
|
|
110
157
|
* @param {string} section texto da seção Dependências
|
|
111
158
|
* @param {Array<{number:number, title:string, slug?:string}>} catalog
|
|
112
159
|
* @returns {string[]} texto dos bullets sem alvo, aparados
|
|
113
160
|
*/
|
|
114
161
|
export function untraceableBullets(section = '', catalog = []) {
|
|
115
162
|
const lines = String(section).split('\n');
|
|
116
|
-
const começo = lines.findIndex(l => /^#{2,}\s+.*internas/i.test(norm(l)));
|
|
117
163
|
let alvo = lines;
|
|
118
|
-
|
|
164
|
+
|
|
165
|
+
const headingIdx = lines.findIndex(l => /^#{2,}\s+.*internas/i.test(norm(l)));
|
|
166
|
+
if (headingIdx !== -1) {
|
|
119
167
|
let fim = lines.length;
|
|
120
|
-
for (let i =
|
|
168
|
+
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
121
169
|
if (/^#{2,}\s+\S/.test(lines[i])) { fim = i; break; }
|
|
122
170
|
}
|
|
123
|
-
alvo = lines.slice(
|
|
171
|
+
alvo = lines.slice(headingIdx + 1, fim);
|
|
172
|
+
} else {
|
|
173
|
+
const bulletIdx = lines.findIndex(l => /^\s*[-*]\s+\*{0,2}internas\*{0,2}\s*:?/i.test(norm(l)));
|
|
174
|
+
if (bulletIdx !== -1) {
|
|
175
|
+
let fim = lines.length;
|
|
176
|
+
for (let i = bulletIdx + 1; i < lines.length; i++) {
|
|
177
|
+
if (/^\s*[-*]\s+\S/.test(lines[i])) { fim = i; break; } // próximo bullet de topo (Externas)
|
|
178
|
+
}
|
|
179
|
+
// Tira o rótulo "Internas:" da própria linha, preservando o marcador de
|
|
180
|
+
// lista — o que sobra entra no mesmo scanner de bullets abaixo.
|
|
181
|
+
alvo = lines.slice(bulletIdx, fim).map((l, i) => (i === 0
|
|
182
|
+
? l.replace(/^(\s*[-*]\s+)\*{0,2}internas\*{0,2}:?\*{0,2}\s*/i, '$1')
|
|
183
|
+
: l));
|
|
184
|
+
}
|
|
124
185
|
}
|
|
125
186
|
const out = [];
|
|
126
187
|
for (const line of alvo) {
|
|
@@ -128,7 +189,7 @@ export function untraceableBullets(section = '', catalog = []) {
|
|
|
128
189
|
if (!m) continue;
|
|
129
190
|
const bullet = m[1];
|
|
130
191
|
// "Nenhuma"/"N/A" é resposta válida do gerador, não dependência sem alvo.
|
|
131
|
-
if (
|
|
192
|
+
if (EMPTY_ANSWER_RE.test(norm(bullet).trim())) continue;
|
|
132
193
|
if (issueRefs(bullet).length > 0) continue;
|
|
133
194
|
if (mentionedIssues(bullet, catalog).length > 0) continue;
|
|
134
195
|
out.push(bullet.length > 120 ? `${bullet.slice(0, 119)}…` : bullet);
|
|
@@ -229,8 +290,14 @@ export function unownedDecisions(staticContext = {}) {
|
|
|
229
290
|
*
|
|
230
291
|
* @param {object} params
|
|
231
292
|
* @param {Array<{number:number, title:string, slug?:string, spec?:string|null,
|
|
293
|
+
* specState?:'local'|'remote'|'pending-pr'|'branch-only'|'missing'|'unknown'|null,
|
|
232
294
|
* milestone?:{number?:number, title?:string, due_on?:string|null}|null}>}
|
|
233
|
-
* params.features Features ABERTAS da milestone-alvo, com o conteúdo da spec
|
|
295
|
+
* params.features Features ABERTAS da milestone-alvo, com o conteúdo da spec.
|
|
296
|
+
* `specState` (opcional) distingue "não existe" (`missing`) de "não deu
|
|
297
|
+
* para verificar agora" (`unknown` — rede/permissão), o mesmo estado
|
|
298
|
+
* que `loadArtifact` já produz — item 8 do rfc/plano-hardening-
|
|
299
|
+
* agentes-2026-08.md. Sem `specState`, `spec` nulo sempre cai em
|
|
300
|
+
* `semSpec` (comportamento de antes, retrocompatível).
|
|
234
301
|
* @param {Array<{number:number, title:string, slug?:string, closed?:boolean,
|
|
235
302
|
* milestone?:object|null}>} params.catalog
|
|
236
303
|
* tudo que uma spec pode citar: as Features de TODAS as milestones (e as
|
|
@@ -239,8 +306,8 @@ export function unownedDecisions(staticContext = {}) {
|
|
|
239
306
|
* @param {string[]} [params.files] caminhos do repositório para a heurística de código
|
|
240
307
|
* @param {object|null} [params.techContext] o tech_context estático, para as decisões sem dono
|
|
241
308
|
* @returns {{
|
|
242
|
-
* semSpec: number[], semSecao: number[],
|
|
243
|
-
* grafo: Array<{number:number, dependsOn:number[]}>,
|
|
309
|
+
* semSpec: number[], specIndisponivel: number[], semSecao: number[],
|
|
310
|
+
* grafo: Array<{number:number, dependsOn:number[], secao:'ok'|'vazia'|'sem-alvo'}>,
|
|
244
311
|
* ciclos: number[],
|
|
245
312
|
* inversoes: Array<{feature:number, dep:number, featureMilestone:string, depMilestone:string}>,
|
|
246
313
|
* bloqueantesSemMilestone: Array<{feature:number, dep:number}>,
|
|
@@ -254,6 +321,7 @@ export function auditMilestone({
|
|
|
254
321
|
} = {}) {
|
|
255
322
|
const byNumber = new Map(catalog.map(f => [f.number, f]));
|
|
256
323
|
const semSpec = [];
|
|
324
|
+
const specIndisponivel = [];
|
|
257
325
|
const semSecao = [];
|
|
258
326
|
const grafo = [];
|
|
259
327
|
const inversoes = [];
|
|
@@ -268,12 +336,26 @@ export function auditMilestone({
|
|
|
268
336
|
const hits = codeOverlap(slug, files);
|
|
269
337
|
if (hits.length > 0) sobreposicoes.push({ feature: f.number, slug, hits });
|
|
270
338
|
|
|
271
|
-
if (!f.spec) {
|
|
339
|
+
if (!f.spec) {
|
|
340
|
+
// 'unknown' = rede/permissão falhou ao verificar, não que a spec não
|
|
341
|
+
// exista — afirmar "sem spec" aqui é exatamente o erro que o preflight
|
|
342
|
+
// já corrigiu (PRs #65/#66) e que o audit herdou por reusar loadArtifact
|
|
343
|
+
// sem herdar a distinção (item 8 do rfc/plano-hardening-agentes-2026-08.md).
|
|
344
|
+
if (f.specState === 'unknown') specIndisponivel.push(f.number);
|
|
345
|
+
else semSpec.push(f.number);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
272
348
|
const section = dependencySection(f.spec);
|
|
273
349
|
if (section == null) { semSecao.push(f.number); continue; }
|
|
274
350
|
|
|
275
351
|
const deps = mentionedIssues(section, catalog, f.number);
|
|
276
|
-
|
|
352
|
+
// 'ok' = achou aresta legível; 'vazia' = a seção diz "Nenhuma" de propósito
|
|
353
|
+
// (legível, só que sem dependências); 'sem-alvo' = há prosa real mas nada
|
|
354
|
+
// dela resolveu a uma Feature do catálogo — item 3a do rfc/plano-
|
|
355
|
+
// hardening-agentes-2026-08.md: sem isso, os dois casos produziam o MESMO
|
|
356
|
+
// `dependsOn: []` mudo, indistinguível de "auditada, sem dependências".
|
|
357
|
+
const secao = deps.length > 0 ? 'ok' : (dependencySectionIsEmpty(section) ? 'vazia' : 'sem-alvo');
|
|
358
|
+
grafo.push({ number: f.number, dependsOn: deps, secao });
|
|
277
359
|
|
|
278
360
|
const mortas = issueRefs(section).filter(n => missingRefs.includes(n));
|
|
279
361
|
if (mortas.length > 0) inexistentes.push({ feature: f.number, refs: mortas });
|
|
@@ -302,7 +384,7 @@ export function auditMilestone({
|
|
|
302
384
|
const { cycle } = orderStories(grafo);
|
|
303
385
|
|
|
304
386
|
return {
|
|
305
|
-
semSpec, semSecao, grafo, ciclos: cycle, inversoes,
|
|
387
|
+
semSpec, specIndisponivel, semSecao, grafo, ciclos: cycle, inversoes,
|
|
306
388
|
bloqueantesSemMilestone, inexistentes, naoRastreaveis, sobreposicoes,
|
|
307
389
|
semDono: unownedDecisions(techContext || {}),
|
|
308
390
|
};
|
|
@@ -339,12 +421,26 @@ export function auditVerdict(audit) {
|
|
|
339
421
|
if (audit.semSpec.length > 0) {
|
|
340
422
|
avisos.push(`Sem spec para auditar: ${lista(audit.semSpec)}.`);
|
|
341
423
|
}
|
|
424
|
+
if ((audit.specIndisponivel || []).length > 0) {
|
|
425
|
+
avisos.push(`Spec não verificável agora (rede/permissão) — não é ausência: ${lista(audit.specIndisponivel)}.`);
|
|
426
|
+
}
|
|
342
427
|
if (audit.semSecao.length > 0) {
|
|
343
428
|
avisos.push(`Spec sem seção "# Dependências" (fora do template): ${lista(audit.semSecao)}.`);
|
|
344
429
|
}
|
|
345
430
|
for (const b of audit.bloqueantesSemMilestone) {
|
|
346
431
|
avisos.push(`#${b.feature} depende de #${b.dep}, que não está em milestone nenhuma — sem data, não há como sequenciar.`);
|
|
347
432
|
}
|
|
433
|
+
// secao 'sem-alvo': a seção tem prosa real, mas NADA dela resolveu a uma
|
|
434
|
+
// Feature do catálogo — o `dependsOn: []` resultante era, antes deste
|
|
435
|
+
// item, indistinguível de "auditada, sem dependências" (item 3a do rfc/
|
|
436
|
+
// plano-hardening-agentes-2026-08.md).
|
|
437
|
+
const semAlvo = audit.grafo.filter(g => g.secao === 'sem-alvo');
|
|
438
|
+
for (const g of semAlvo) {
|
|
439
|
+
avisos.push(
|
|
440
|
+
`#${g.number} declara dependências em prosa que não rastreiam nenhuma Feature — ` +
|
|
441
|
+
'o grafo desta Feature está vazio (cite `#N` na seção para torná-la legível).'
|
|
442
|
+
);
|
|
443
|
+
}
|
|
348
444
|
for (const n of audit.naoRastreaveis) {
|
|
349
445
|
avisos.push(
|
|
350
446
|
`#${n.feature} declara ${n.bullets.length} dependência(s) que não rastreiam a nenhuma Feature — ` +
|
|
@@ -366,7 +462,27 @@ export function auditVerdict(audit) {
|
|
|
366
462
|
);
|
|
367
463
|
}
|
|
368
464
|
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
465
|
+
let status;
|
|
466
|
+
if (materiais.length) status = 'problema';
|
|
467
|
+
else if (avisos.length) status = 'aviso';
|
|
468
|
+
else status = 'ok';
|
|
469
|
+
|
|
470
|
+
// Cobertura do grafo (item 3b): a linha resume quanto do grafo é legível —
|
|
471
|
+
// é o que faltava para quem lê "ok" saber se isso significa "conferi e
|
|
472
|
+
// fecha" ou "não consegui ler quase nada". Não precisa de um limiar próprio
|
|
473
|
+
// para rebaixar o status: toda causa de baixa cobertura (semSpec,
|
|
474
|
+
// specIndisponivel, semSecao, 'sem-alvo') já gera o SEU PRÓPRIO aviso acima
|
|
475
|
+
// — 100% de cobertura é a única forma de `avisos` continuar vazio, então
|
|
476
|
+
// "ok" e "grafo incompleto" nunca coexistem por construção.
|
|
477
|
+
const specIndisponivel = audit.specIndisponivel || [];
|
|
478
|
+
const total = audit.grafo.length + audit.semSpec.length + specIndisponivel.length + audit.semSecao.length;
|
|
479
|
+
const legiveis = audit.grafo.filter(g => g.secao === 'ok' || g.secao === 'vazia').length;
|
|
480
|
+
if (total > 0) {
|
|
481
|
+
const partes = [`${audit.semSecao.length} sem seção`, `${semAlvo.length} sem alvo`];
|
|
482
|
+
if (audit.semSpec.length) partes.push(`${audit.semSpec.length} sem spec`);
|
|
483
|
+
if (specIndisponivel.length) partes.push(`${specIndisponivel.length} indisponível(is)`);
|
|
484
|
+
avisos.unshift(`Grafo: ${legiveis}/${total} Feature(s) com arestas legíveis (${partes.join(', ')}).`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { status, materiais, avisos };
|
|
372
488
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.34.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",
|
|
@@ -21,9 +21,9 @@ A crítica do fluxo normal audita cada documento contra os insumos da **mesma**
|
|
|
21
21
|
|
|
22
22
|
## O que a saída traz
|
|
23
23
|
|
|
24
|
-
- O **grafo de dependências entre Features**, extraído
|
|
24
|
+
- O **grafo de dependências entre Features**, extraído da seção de Dependências (heading `#`/`##`/`###` começando com "Dependências" — aceita sufixo, ex.: "Dependências e Premissas") por `#N`, slug e título (best-effort sobre prosa). Uma linha `Grafo: N/M Feature(s) com arestas legíveis (...)` sempre abre os avisos — cobertura baixa é sinal de que a auditoria não leu quase nada, mesmo quando não há achado material
|
|
25
25
|
- **Materiais** (exit 1): ciclo entre Features, bloqueante em milestone **posterior** à de quem depende dela, referência a issue inexistente
|
|
26
|
-
- **Avisos**: dependência que não rastreia a Feature nenhuma (recurso sem dono — quem cria?), sobreposição do slug com caminhos do código, decisão de modelagem do `tech_context.yml` com `criada_por: SEM DONO`, spec ausente ou fora do template
|
|
26
|
+
- **Avisos**: dependência que não rastreia a Feature nenhuma (recurso sem dono — quem cria?), seção com prosa que não resolveu a NENHUMA Feature (`#123 ⟨sem arestas legíveis⟩` no grafo — distinto de uma seção que diz "Nenhuma" de propósito), sobreposição do slug com caminhos do código, decisão de modelagem do `tech_context.yml` com `criada_por: SEM DONO`, spec ausente, spec não verificável agora (rede/permissão — não é ausência), ou spec fora do template (sem a seção de Dependências)
|
|
27
27
|
- Com `--critique`: os **findings da crítica de conjunto** — uma chamada de modelo sobre todas as specs juntas, procurando a mesma regra contada de dois jeitos. Cada finding cita as Features envolvidas (`#412 × #415`); grave conta como material
|
|
28
28
|
|
|
29
29
|
## Passos
|
|
@@ -9,20 +9,24 @@ allowed-tools:
|
|
|
9
9
|
|
|
10
10
|
# spec-wave doctor — preflight de auth e configuração
|
|
11
11
|
|
|
12
|
-
Comando **local
|
|
12
|
+
Comando **local**:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npx @spec-wave/cli@latest doctor
|
|
16
|
+
npx @spec-wave/cli@latest doctor --deep # confere também as Tasks de cada decomposição
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
## O que ele checa
|
|
19
20
|
|
|
20
|
-
- **Token GitHub** e a fonte dele
|
|
21
|
+
- **Token GitHub** e a fonte dele
|
|
22
|
+
- **Cota da API (GraphQL)**: pontos restantes e horário do reset — medido de graça (a consulta não custa pontos) e usado pelos checks abaixo para decidir se cabem no orçamento
|
|
23
|
+
- **Escopos** (`repo`, `project`, `workflow`), com degradação para checks funcionais em fine-grained PATs
|
|
21
24
|
- **Conta ativa do `gh`** vs. o owner do repositório
|
|
22
25
|
- **`.spec-wave.json`**: campos presentes e sincronia com o Project real
|
|
23
26
|
- **Acesso ao repositório**
|
|
24
27
|
- **Configuração de IA**: provider, modelo, `ai.models`, escalada da crítica, apelidos de modelo, teto de saída e os secrets do Actions
|
|
25
28
|
- **Higiene do board e das labels**: colunas fora do fluxo canônico, labels `spec-wave:*` descontinuadas ou ausentes
|
|
29
|
+
- **Decomposições aplicadas**: `decomposition.md` × issues reais. Por padrão confere só até o nível de Story (1 chamada por Feature); `--deep` desce até as Tasks (1 chamada a mais por Story) — mais completo, mais caro. Se a cota estiver baixa, o check é pulado com a causa explícita em vez de estourar em silêncio.
|
|
26
30
|
- **spec-kit**: `specKit.command` / env `SPEC_WAVE_IMPLEMENT_CMD` — se ausente, sugere exemplos por agente (Claude Code, opencode, Codex, Copilot CLI, Kiro CLI, Qwen Code)
|
|
27
31
|
- **Workflows**: presença + **versão da CLI fixada** (não `@latest`)
|
|
28
32
|
|
|
@@ -25,6 +25,7 @@ Comando **local** (lê o `.spec-wave.json`, como o `issue`), **não** disparado
|
|
|
25
25
|
| `<issue>` | **Obrigatório**, posicional. Número da Feature, Story ou Task (`12` ou `#12`). |
|
|
26
26
|
| `--feature-dir <path>` | Caminho `docs/features/<slug>` para anexar `spec.md`/`plan.md` (sobrescreve a resolução automática). |
|
|
27
27
|
| `--dry-run` | Monta o contexto e imprime o comando **sem executar** e **sem escrever nada no GitHub**. |
|
|
28
|
+
| `--verify-commits` | Depois de executar, confere se os commits recentes trazem o trailer `Spec-Wave-Story`/`Spec-Wave-Tasks` (rastro verificável, não depende do assunto do commit). Best-effort — nunca bloqueia. |
|
|
28
29
|
|
|
29
30
|
**Pré-requisitos:** `.spec-wave.json` presente (senão → skill **setup**) e a issue ser Feature, Story ou Task. Para executar de fato, o spec-kit precisa estar configurado em `specKit.command` (no `.spec-wave.json`) ou na env `SPEC_WAVE_IMPLEMENT_CMD`.
|
|
30
31
|
|
|
@@ -78,15 +79,20 @@ npx @spec-wave/cli@latest task start <n> # Etapa 🚧 Desenvolvimento + Status
|
|
|
78
79
|
npx @spec-wave/cli@latest task done <n> # Etapa 🎉 Done + Status Done
|
|
79
80
|
```
|
|
80
81
|
|
|
81
|
-
**Ao concluir toda a Story:** faça o commit,
|
|
82
|
+
**Ao concluir toda a Story:** faça o commit e mova a Story. O **assunto** do commit é livre (o que você achar mais claro), mas o **rodapé** precisa trazer o rastro exato que o contexto montado dita — `Spec-Wave-Story: #<n>` e `Spec-Wave-Tasks: #<n>, #<n>...` — copiado literalmente, sem alterar as chaves. É esse rodapé, não o assunto, que `--verify-commits` e o `merge` conferem:
|
|
82
83
|
|
|
83
84
|
```bash
|
|
84
85
|
npx @spec-wave/cli@latest story review <n> # Etapa 👀 Code Review, Status Todo
|
|
85
86
|
```
|
|
86
87
|
|
|
88
|
+
Se abre PR e onde faz push depende do **modo de entrega** — o contexto montado já diz qual é (não adivinhe pelo tipo de issue):
|
|
89
|
+
|
|
90
|
+
- **`pr-stack`** (fluxo manual padrão): abra o **Pull Request da Story como rascunho** (`gh pr create --draft`) antes de mover a Story. Numa Feature com várias Stories os PRs ficam **empilhados** (cada um baseado no anterior) — o merge é ordem-dependente; use `spec-wave merge <feature>`, nunca `--delete-branch` manual num PR da pilha.
|
|
91
|
+
- **`agent`** (rodando sob o `dev-agent`): **NÃO crie branch, NÃO abra PR** — faça só o commit e o push no branch atual. O daemon mantém um único branch (`agent/issue-<n>`) e é ele quem abre o PR ao final, para a Feature inteira. Abrir um PR por Story aqui cria um branch lateral (`agent/issue-<n>-story-<m>`) cujo trabalho não entra no PR do agente.
|
|
92
|
+
|
|
87
93
|
**A Feature só avança** para 👀 Code Review quando **TODAS** as suas Stories já estiverem lá. Enquanto houver Story pendente, deixe a Feature em 🚧 Desenvolvimento. No modo Feature isso acontece dentro da mesma execução.
|
|
88
94
|
|
|
89
|
-
**No modo Feature**, siga o contexto Story a Story, **na ordem listada**: implemente as Tasks, depois commit + PR + `story review`; só então passe à próxima Story.
|
|
95
|
+
**No modo Feature**, siga o contexto Story a Story, **na ordem listada**: implemente as Tasks, depois commit (+ PR só em modo `pr-stack`) + `story review`; só então passe à próxima Story.
|
|
90
96
|
|
|
91
97
|
> **Aviso de dependência pendente** no contexto (a issue depende de outra não concluída, via `Depende de: #N` ou *blocked by*) → **confirme com o usuário** antes de seguir fora de ordem.
|
|
92
98
|
|
|
@@ -26,6 +26,7 @@ O `implement` empilha os PRs de propósito (cada Story revisável sozinha, diff
|
|
|
26
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.
|
|
27
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.
|
|
28
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.
|
|
29
|
+
- Cada PR da fila é conferido contra o trailer `Spec-Wave-Story: #<n>` (rastro do `implement`, best-effort — nunca bloqueia): PR sem nenhum commit com o trailer certo vira aviso, vale conferir se é mesmo o trabalho daquela Story antes de mergear.
|
|
29
30
|
|
|
30
31
|
## Passos
|
|
31
32
|
|
|
@@ -48,7 +48,7 @@ O spec deve conter EXATAMENTE estas seções em português, nesta ordem:
|
|
|
48
48
|
- OBRIGATORIAMENTE no formato Gherkin, dentro de um bloco ```gherkin com
|
|
49
49
|
Given/When/Then. Um cenário por critério.
|
|
50
50
|
# Dependências
|
|
51
|
-
- Subdivida em Internas e Externas.
|
|
51
|
+
- Subdivida em `## Internas` e `## Externas` (subseções, não bullets).
|
|
52
52
|
# Requisitos Não-Funcionais
|
|
53
53
|
- Performance, Segurança e Usabilidade.
|
|
54
54
|
```
|
package/src/setup/labels.mjs
CHANGED
|
@@ -7,11 +7,12 @@ function sleep(ms) {
|
|
|
7
7
|
return new Promise(r => setTimeout(r, ms));
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
// `fileAi` traz as labels de modelo derivadas de `ai.modelAliases
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
// `fileAi` traz as labels de modelo derivadas de `ai.modelAliases`, o mesmo
|
|
11
|
+
// vale para `devAgent` e as filas extra do dev-agent. No `init` os dois vêm
|
|
12
|
+
// vazios (o config ainda nem foi gravado) e o conjunto é só o do fluxo; quem
|
|
13
|
+
// cria as de modelo/fila depois é o `update`, que já lê o config do repo.
|
|
14
|
+
export async function setupLabels(token, owner, repo, spinner, fileAi, devAgent) {
|
|
15
|
+
const labels = allLabelsFor(fileAi, devAgent);
|
|
15
16
|
for (let i = 0; i < labels.length; i++) {
|
|
16
17
|
const label = labels[i];
|
|
17
18
|
spinner.message(`Criando label ${i + 1}/${labels.length}: ${label.name}`);
|
|
@@ -44,8 +44,13 @@ Feature: [Nome da Feature]
|
|
|
44
44
|
|
|
45
45
|
# Dependências
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
## Internas
|
|
48
|
+
|
|
49
|
+
- <!-- Serviços/APIs dentro do sistema -->
|
|
50
|
+
|
|
51
|
+
## Externas
|
|
52
|
+
|
|
53
|
+
- <!-- Sistemas de terceiros -->
|
|
49
54
|
|
|
50
55
|
# Requisitos Não-Funcionais
|
|
51
56
|
|