@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.
@@ -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
+ }
@@ -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 `# Dependências` de uma spec (PURA).
37
+ * O texto da seção de Dependências de uma spec (PURA).
34
38
  *
35
- * Do H1 `# Dependências` até o próximo H1os `## Internas`/`## Externas`
36
- * ficam dentro. `null` quando a seção não existe (spec fora do template).
39
+ * Heading `#`/`##`/`###` com "Dependências" no início (sufixo livreitem 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
- const start = lines.findIndex(l => /^#\s+depend[êe]ncias\s*$/i.test(norm(l.trim())));
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
- if (/^#\s+\S/.test(lines[i])) { end = i; break; }
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
- if (começo !== -1) {
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 = começo + 1; i < lines.length; 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(começo + 1, fim);
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 (/^(nenhuma|n\/a|—|-)\.?$/i.test(norm(bullet).trim())) continue;
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) { semSpec.push(f.number); continue; }
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
- grafo.push({ number: f.number, dependsOn: deps });
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
- if (materiais.length) return { status: 'problema', materiais, avisos };
370
- if (avisos.length) return { status: 'aviso', materiais, avisos };
371
- return { status: 'ok', materiais, avisos };
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.32.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 das seções `# Dependências` (por `#N`, slug e título best-effort sobre prosa)
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**, sem flags:
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; **escopos** (`repo`, `project`, `workflow`), com degradação para checks funcionais em fine-grained PATs
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, abra o PR e mova a Story:
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
  ```
@@ -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`. No `init` ele
11
- // vem vazio (o config ainda nem foi gravado) e o conjunto é o do fluxo; quem
12
- // cria as de modelo depois é o `update`, que o config do repo.
13
- export async function setupLabels(token, owner, repo, spinner, fileAi) {
14
- const labels = allLabelsFor(fileAi);
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 é 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
- - **Internas:** <!-- Serviços/APIs dentro do sistema -->
48
- - **Externas:** <!-- Sistemas de terceiros -->
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