@spec-wave/cli 0.33.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.
@@ -7,6 +7,7 @@ import { resolveToken } from '../api/auth.mjs';
7
7
  import {
8
8
  CONFIG_FILE, STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, STAGE_DONE, STAGE_ORDER,
9
9
  PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE, labelNames,
10
+ TRAILER_STORY, TRAILER_TASKS, TRAILER_AGENT,
10
11
  } from '../config.mjs';
11
12
  import { getIssue, listIssueComments, listBlockedBy, getFileContent } from '../api/github-rest.mjs';
12
13
  import { listSubIssues, getIssueParent } from '../api/github-graphql.mjs';
@@ -23,6 +24,9 @@ import { extractPathsFromPlan, buildCodeDigest } from '../lib/code-digest.mjs';
23
24
  import { findConfigPath, resolveFromRoot, loadConfig } from '../lib/project-root.mjs';
24
25
  import { loadFeatureStories, loadBoardSnapshot, cachedSubIssues } from '../lib/story-graph.mjs';
25
26
  import { resolveCacheTtl } from '../lib/net-cache.mjs';
27
+ import { deliveryMode } from '../lib/delivery-mode.mjs';
28
+ import { renderTrailers, missingTrailers, readLocalCommitMessages } from '../lib/commit-trailers.mjs';
29
+ import { currentGitBranch } from '../lib/repo-links.mjs';
26
30
 
27
31
  // Diretório onde montamos o arquivo de contexto entregue ao spec-kit.
28
32
  const WORK_DIR = '.spec-wave';
@@ -170,9 +174,9 @@ function pushDigestSpecPlan(lines, { codeDigest, spec, plan, specPath, planPath
170
174
  }
171
175
 
172
176
  // Monta o markdown de contexto que será entregue ao spec-kit implement.
173
- function buildContext({
177
+ export function buildContext({
174
178
  type, issue, tasks, feature, siblingStories = [], spec, plan, specPath, planPath,
175
- comments = [], codeDigest = null, blockedByWarnings = [],
179
+ comments = [], codeDigest = null, blockedByWarnings = [], mode = 'pr-stack',
176
180
  }) {
177
181
  const lines = [];
178
182
  lines.push(`# Contexto de implementação — ${type} #${issue.number}`);
@@ -216,13 +220,35 @@ function buildContext({
216
220
  lines.push(' 4. Só então avance para a próxima Task.');
217
221
  lines.push('');
218
222
  lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
219
- lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
220
- // Draft de propósito: numa pilha de Stories (um PR baseado no anterior),
221
- // cada rebase em cascata dispararia o CI inteiro em PRs que ninguém vai
222
- // mergear naquele estado. Rascunho não mergeia (o GitHub bloqueia) e o
223
- // required check continua valendo: quem revisa marca o PR como pronto, o
224
- // `ready_for_review` dispara o CI, e só então o merge destrava.
225
- lines.push(` 2. Abra o **Pull Request** da Story #${issue.number} **como rascunho** (\`gh pr create --draft\`) — o CI não roda em rascunho; quem revisa marca o PR como pronto e é aí que os checks disparam.`);
223
+ lines.push(
224
+ ` 1. Faça o **commit** de todas as mudanças da implementação. O assunto é livre — ` +
225
+ 'inclua estes `git trailer`(s) no **rodapé** (após uma linha em branco), literalmente ' +
226
+ 'assim, sem alterar as chaves:'
227
+ );
228
+ lines.push(' ```');
229
+ lines.push(renderTrailers({
230
+ story: issue.number, tasks: tasks.map(t => t.number),
231
+ agent: mode === 'agent' ? 'spec-wave-agent' : undefined,
232
+ }).split('\n').map(l => ` ${l}`).join('\n'));
233
+ lines.push(' ```');
234
+ if (mode === 'agent') {
235
+ // Sob o dev-agent, o daemon mantém UM branch e UM PR por Feature
236
+ // (agent/issue-<n>) e é ele quem abre o PR ao final — nunca o modelo.
237
+ // Instruir "abra o PR da Story" aqui é o que produzia um branch lateral
238
+ // (agent/issue-<n>-story-<m>) cujo trabalho nunca entra nesse PR.
239
+ lines.push(
240
+ ' 2. Faça **push no branch atual** — **NÃO crie um branch novo** e ' +
241
+ '**NÃO abra Pull Request**: o agente já mantém um único branch e um único ' +
242
+ 'Pull Request para a Feature inteira, aberto por ele mesmo ao final.'
243
+ );
244
+ } else {
245
+ // Draft de propósito: numa pilha de Stories (um PR baseado no anterior),
246
+ // cada rebase em cascata dispararia o CI inteiro em PRs que ninguém vai
247
+ // mergear naquele estado. Rascunho não mergeia (o GitHub bloqueia) e o
248
+ // required check continua valendo: quem revisa marca o PR como pronto, o
249
+ // `ready_for_review` dispara o CI, e só então o merge destrava.
250
+ lines.push(` 2. Abra o **Pull Request** da Story #${issue.number} **como rascunho** (\`gh pr create --draft\`) — o CI não roda em rascunho; quem revisa marca o PR como pronto e é aí que os checks disparam.`);
251
+ }
226
252
  lines.push(
227
253
  ` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
228
254
  `(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
@@ -374,7 +400,7 @@ export function planFeatureImplementation(stories, {
374
400
  export function buildFeatureContext({
375
401
  feature, stories, skipped = [],
376
402
  spec, plan, specPath, planPath,
377
- comments = [], codeDigest = null, blockedByWarnings = [],
403
+ comments = [], codeDigest = null, blockedByWarnings = [], mode = 'pr-stack',
378
404
  }) {
379
405
  const lines = [];
380
406
  lines.push(`# Contexto de implementação — Feature #${feature.number}`);
@@ -402,7 +428,32 @@ export function buildFeatureContext({
402
428
  lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
403
429
  lines.push(' 2. **Implemente** a Task por completo.');
404
430
  lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
405
- lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story **como rascunho** (\`gh pr create --draft\` — o CI não roda em rascunho; quem revisa marca o PR como pronto e os checks disparam) e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
431
+ // O rodapé do commit é o rastro verificável: assunto livre, mas os
432
+ // trailers abaixo são o contrato (item 6 do rfc/plano-hardening-agentes-
433
+ // 2026-08.md — sobrevivem a squash/rebase, ao contrário de qualquer
434
+ // convenção de assunto).
435
+ const trailerHint = mode === 'agent'
436
+ ? `${TRAILER_STORY}: #<número da Story>\n${TRAILER_TASKS}: #<números das Tasks, separados por vírgula>\n${TRAILER_AGENT}: spec-wave-agent`
437
+ : `${TRAILER_STORY}: #<número da Story>\n${TRAILER_TASKS}: #<números das Tasks, separados por vírgula>`;
438
+ if (mode === 'agent') {
439
+ // Mesma razão do buildContext: o daemon mantém UM branch e UM PR para a
440
+ // Feature inteira e abre o PR ele mesmo ao final — o modelo não cria
441
+ // branch nem abre PR por Story aqui.
442
+ lines.push(
443
+ `3. **Ao concluir TODAS as Tasks da Story:** faça o **commit** (assunto livre; rodapé com os trailers abaixo), ` +
444
+ `faça **push no branch atual** (**NÃO crie branch novo, NÃO abra Pull Request**) ` +
445
+ `e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`
446
+ );
447
+ } else {
448
+ lines.push(
449
+ `3. **Ao concluir TODAS as Tasks da Story:** faça o **commit** (assunto livre; rodapé com os trailers abaixo), ` +
450
+ `abra o **Pull Request** da Story **como rascunho** (\`gh pr create --draft\` — o CI não roda em rascunho; ` +
451
+ `quem revisa marca o PR como pronto e os checks disparam) e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`
452
+ );
453
+ }
454
+ lines.push(' ```');
455
+ lines.push(trailerHint.split('\n').map(l => ` ${l}`).join('\n'));
456
+ lines.push(' ```');
406
457
  lines.push('4. Só então inicie a próxima Story.');
407
458
  lines.push('');
408
459
  lines.push(
@@ -411,17 +462,24 @@ export function buildFeatureContext({
411
462
  `Enquanto houver Story pendente, a Feature permanece em ${STAGE_DEVELOPMENT}.`
412
463
  );
413
464
  lines.push('');
414
- // O trecho pós-implementação era o único do fluxo inteiramente manual — e o
415
- // merge de PRs empilhados é ordem-dependente (apagar a branch do primeiro já
416
- // fechou o segundo). O contexto termina apontando o comando que encapsula a
417
- // sequência segura, em vez de deixar a mecânica por conta de quem mergeia.
418
- lines.push(
419
- '**Ao terminar, informe no relatório final:** os PRs ficam **empilhados** (cada um baseado no anterior) — ' +
420
- 'o merge é **ordem-dependente**. Depois da revisão humana (marcar cada PR como pronto), o merge é ' +
421
- `\`npx @spec-wave/cli@latest merge ${feature.number}\`: ele mergeia na ordem das dependências, ` +
422
- 'reaponta as bases, move o board até 🧪 QA e apaga as branches no fim. ' +
423
- '**NUNCA** mergeie um PR da pilha com `--delete-branch` à mão apagar a branch antes de reapontar o dependente fecha o PR seguinte.'
424
- );
465
+ if (mode === 'agent') {
466
+ lines.push(
467
+ '**Ao terminar, informe no relatório final:** o agente mantém um único branch e um único ' +
468
+ 'Pull Request para a Feature inteira, aberto por ele mesmo ao final não há pilha de PRs a mergear aqui.'
469
+ );
470
+ } else {
471
+ // O trecho pós-implementação era o único do fluxo inteiramente manual e o
472
+ // merge de PRs empilhados é ordem-dependente (apagar a branch do primeiro já
473
+ // fechou o segundo). O contexto termina apontando o comando que encapsula a
474
+ // sequência segura, em vez de deixar a mecânica por conta de quem mergeia.
475
+ lines.push(
476
+ '**Ao terminar, informe no relatório final:** os PRs ficam **empilhados** (cada um baseado no anterior) — ' +
477
+ 'o merge é **ordem-dependente**. Depois da revisão humana (marcar cada PR como pronto), o merge é ' +
478
+ `\`npx @spec-wave/cli@latest merge ${feature.number}\`: ele mergeia na ordem das dependências, ` +
479
+ 'reaponta as bases, move o board até 🧪 QA e só apaga as branches no fim. ' +
480
+ '**NUNCA** mergeie um PR da pilha com `--delete-branch` à mão — apagar a branch antes de reapontar o dependente fecha o PR seguinte.'
481
+ );
482
+ }
425
483
  lines.push('');
426
484
  lines.push(boardRuleBlockquote());
427
485
 
@@ -550,9 +608,14 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
550
608
  // dependências não legíveis — segue
551
609
  }
552
610
 
611
+ const currentBranch = currentGitBranch(repoRoot);
612
+ const mode = deliveryMode({ branch: currentBranch });
613
+ if (mode === 'agent') {
614
+ p.log.info(`Modo dev-agent detectado (branch ${chalk.cyan(currentBranch)}) — o PR é aberto pelo agente ao final.`);
615
+ }
553
616
  const context = buildBugContext({
554
617
  bug: { number: issueNumber, title: bug.title, body: bug.body || '' },
555
- bugDoc, parent, comments, codeDigest, blockedByWarnings, severity,
618
+ bugDoc, parent, comments, codeDigest, blockedByWarnings, severity, mode,
556
619
  });
557
620
 
558
621
  await writeContextAndRunSpecKit({
@@ -563,7 +626,12 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
563
626
  specPlan: { spec: null, plan: null, specPath: null, planPath: null },
564
627
  context,
565
628
  dryRun,
566
- outroSuccess: `Bug #${issueNumber} corrigido — abra o PR com \`Fixes #${issueNumber}\`.`,
629
+ mode,
630
+ repoRoot,
631
+ expectedBranch: currentBranch,
632
+ outroSuccess: mode === 'agent'
633
+ ? `Bug #${issueNumber} corrigido — o PR é aberto pelo próprio agente ao final.`
634
+ : `Bug #${issueNumber} corrigido — abra o PR com \`Fixes #${issueNumber}\`.`,
567
635
  onSuccess: async () => {
568
636
  await applyBoardMoves({
569
637
  token,
@@ -577,6 +645,11 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
577
645
 
578
646
  async function implementFeature({ token, owner, repo, config, feature, featureDirOpt, dryRun, repoRoot, refresh = false }) {
579
647
  const ttlSec = resolveCacheTtl(config);
648
+ const currentBranch = currentGitBranch(repoRoot);
649
+ const mode = deliveryMode({ branch: currentBranch });
650
+ if (mode === 'agent') {
651
+ p.log.info(`Modo dev-agent detectado (branch ${chalk.cyan(currentBranch)}) — sem branch/PR por Story.`);
652
+ }
580
653
 
581
654
  // F1-F2. Stories da Feature com as arestas já resolvidas (lib/story-graph):
582
655
  // dependency-map.json/decomposition.md ∪ linhas "Depende de:" do body — zero
@@ -748,6 +821,7 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
748
821
  comments,
749
822
  codeDigest,
750
823
  blockedByWarnings,
824
+ mode,
751
825
  });
752
826
  await writeContextAndRunSpecKit({
753
827
  config,
@@ -757,10 +831,16 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
757
831
  specPlan,
758
832
  context,
759
833
  dryRun,
760
- outroSuccess:
761
- `${chalk.green('✓')} Implementação acionada para a Feature #${feature.number} ` +
762
- `(${pending.length} story(ies) pendente(s)).\n` +
763
- ` Próximo: acompanhe os PRs de cada Story; a Feature avança para ${STAGE_CODE_REVIEW} após a última.`,
834
+ mode,
835
+ repoRoot,
836
+ expectedBranch: currentBranch,
837
+ outroSuccess: mode === 'agent'
838
+ ? `${chalk.green('✓')} Implementação acionada para a Feature #${feature.number} ` +
839
+ `(${pending.length} story(ies) pendente(s)).\n` +
840
+ ` Próximo: acompanhe o PR único da Feature; ele é aberto pelo próprio agente ao final.`
841
+ : `${chalk.green('✓')} Implementação acionada para a Feature #${feature.number} ` +
842
+ `(${pending.length} story(ies) pendente(s)).\n` +
843
+ ` Próximo: acompanhe os PRs de cada Story; a Feature avança para ${STAGE_CODE_REVIEW} após a última.`,
764
844
  onSuccess: async () => {
765
845
  // Execução única para todas as pendentes: no sucesso, Tasks → Done e
766
846
  // Stories → Code Review.
@@ -787,7 +867,9 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
787
867
  });
788
868
  }
789
869
 
790
- export async function implement({ issue: issueArg, featureDir: featureDirOpt, dryRun, refresh = false }) {
870
+ export async function implement({
871
+ issue: issueArg, featureDir: featureDirOpt, dryRun, refresh = false, verifyCommits = false,
872
+ }) {
791
873
  const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
792
874
  if (!Number.isInteger(issueNumber)) {
793
875
  p.log.error(`Issue inválida: "${issueArg}". Use o número da issue, ex.: 12 ou #12.`);
@@ -979,15 +1061,28 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
979
1061
  } catch { /* aviso é best-effort — segue sem ele */ }
980
1062
 
981
1063
  // 5-6. Monta o contexto e aciona o spec-kit (comando configurável).
1064
+ const currentBranch = currentGitBranch(repoRoot);
1065
+ const mode = deliveryMode({ branch: currentBranch });
1066
+ if (mode === 'agent') {
1067
+ p.log.info(`Modo dev-agent detectado (branch ${chalk.cyan(currentBranch)}) — sem branch/PR novo para esta ${type}.`);
1068
+ }
982
1069
  const context = buildContext({
983
1070
  type, issue, tasks, feature, siblingStories, ...specPlan,
984
- comments, codeDigest, blockedByWarnings,
1071
+ comments, codeDigest, blockedByWarnings, mode,
985
1072
  });
986
1073
  await writeContextAndRunSpecKit({
987
1074
  config, issueNumber, type, title: issue.title, specPlan, context, dryRun,
988
- outroSuccess:
989
- `${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
990
- ' Próximo: revise as mudanças e abra o PR — o board já foi atualizado.',
1075
+ mode, repoRoot, expectedBranch: currentBranch,
1076
+ verifyCommits,
1077
+ expectedTrailers: {
1078
+ stories: type === 'Story' ? [issue.number] : [],
1079
+ tasks: tasks.map(t => t.number),
1080
+ },
1081
+ outroSuccess: mode === 'agent'
1082
+ ? `${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
1083
+ ' Próximo: o board já foi atualizado; o PR é aberto pelo próprio agente ao final.'
1084
+ : `${chalk.green('✓')} Implementação acionada para ${type} #${issueNumber}.\n` +
1085
+ ' Próximo: revise as mudanças e abra o PR — o board já foi atualizado.',
991
1086
  onSuccess: async () => {
992
1087
  await applyBoardMoves({ token, moves: planBoardMoves('success', {
993
1088
  story: type === 'Story' ? { nodeId: issue.node_id, number: issue.number } : null,
@@ -1020,7 +1115,11 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
1020
1115
 
1021
1116
  // Grava o arquivo de contexto e aciona o spec-kit (comando configurável) —
1022
1117
  // fecho comum dos modos Feature e Story/Task.
1023
- async function writeContextAndRunSpecKit({ config, issueNumber, type, title, specPlan, context, dryRun, outroSuccess, onSuccess }) {
1118
+ async function writeContextAndRunSpecKit({
1119
+ config, issueNumber, type, title, specPlan, context, dryRun, outroSuccess, onSuccess,
1120
+ mode = 'pr-stack', repoRoot = null, expectedBranch = null,
1121
+ verifyCommits = false, expectedTrailers = null,
1122
+ }) {
1024
1123
  mkdirSync(WORK_DIR, { recursive: true });
1025
1124
  const tasksFile = path.join(WORK_DIR, `implement-${issueNumber}.md`);
1026
1125
  writeFileSync(tasksFile, context);
@@ -1075,6 +1174,40 @@ async function writeContextAndRunSpecKit({ config, issueNumber, type, title, spe
1075
1174
  return;
1076
1175
  }
1077
1176
 
1177
+ // Guarda barata contra o sintoma do item 1 do rfc/plano-hardening-agentes-
1178
+ // 2026-08.md: sob o dev-agent, se o executor saiu do branch único do agente
1179
+ // (por exemplo criando um branch lateral por Story), o commit foi parar
1180
+ // fora do PR que o daemon abre ao final. O aviso aparece na própria saída
1181
+ // em vez de custar um ciclo de "onde foi parar meu trabalho".
1182
+ if (mode === 'agent' && expectedBranch) {
1183
+ const branchAfter = currentGitBranch(repoRoot);
1184
+ if (branchAfter && branchAfter !== expectedBranch) {
1185
+ p.log.warn(
1186
+ `⚠️ o executor saiu de ${chalk.cyan(expectedBranch)} para ${chalk.cyan(branchAfter)} — ` +
1187
+ 'se algo foi commitado nesse branch, mova o trabalho de volta antes do daemon abrir o PR.'
1188
+ );
1189
+ }
1190
+ }
1191
+
1192
+ // --verify-commits (item 6 do rfc/plano-hardening-agentes-2026-08.md): lê
1193
+ // os commits recentes do checkout e confere se o rastro (trailer) do que
1194
+ // acabou de ser implementado está lá — best-effort, nunca bloqueia.
1195
+ if (verifyCommits && expectedTrailers) {
1196
+ const messages = readLocalCommitMessages(repoRoot || process.cwd());
1197
+ const { missingStories, missingTasks } = missingTrailers(messages, expectedTrailers);
1198
+ if (missingStories.length === 0 && missingTasks.length === 0) {
1199
+ p.log.success('Trailers de rastro (Spec-Wave-Story/Tasks) confirmados nos commits recentes.');
1200
+ } else {
1201
+ const partes = [];
1202
+ if (missingStories.length) partes.push(`Story ${missingStories.map(n => `#${n}`).join(', ')}`);
1203
+ if (missingTasks.length) partes.push(`Task(s) ${missingTasks.map(n => `#${n}`).join(', ')}`);
1204
+ p.log.warn(
1205
+ `⚠️ Sem trailer de rastro para: ${partes.join('; ')} — nenhum commit recente traz ` +
1206
+ '`Spec-Wave-Story`/`Spec-Wave-Tasks`. Confira se o commit saiu como instruído no contexto.'
1207
+ );
1208
+ }
1209
+ }
1210
+
1078
1211
  if (onSuccess) await onSuccess();
1079
1212
  p.outro(outroSuccess);
1080
1213
  }
@@ -22,8 +22,10 @@ import chalk from 'chalk';
22
22
  import { resolveToken } from '../api/auth.mjs';
23
23
  import {
24
24
  getIssue, getPR, getRepoDefaultBranch, updatePRBase, mergePR, deleteBranch,
25
+ listPullRequestCommitMessages,
25
26
  } from '../api/github-rest.mjs';
26
27
  import { listIssuePullRequests } from '../api/github-graphql.mjs';
28
+ import { missingTrailers } from '../lib/commit-trailers.mjs';
27
29
  import { detectIssueType } from '../lib/issue-type.mjs';
28
30
  import { orderStories } from '../lib/dependencies.mjs';
29
31
  import { resolveRepoContext, loadConfig } from '../lib/project-root.mjs';
@@ -242,6 +244,22 @@ export async function merge({
242
244
  for (const a of plano.avisos) p.log.warn(a);
243
245
  for (const b of plano.bloqueios) p.log.error(b);
244
246
 
247
+ // Conferência de trailers (item 6 do rfc/plano-hardening-agentes-2026-08.md):
248
+ // best-effort, nunca bloqueia o merge — nasce como relatório, não como
249
+ // guarda. Só as PRs que de fato vão ser mergeadas (plano.fila) valem a
250
+ // chamada extra por PR; falha de rede não afirma nada, só é pulada.
251
+ for (const { story, pr } of plano.fila) {
252
+ const messages = await listPullRequestCommitMessages(token, owner, repo, pr.number).catch(() => null);
253
+ if (messages === null) continue;
254
+ const { missingStories } = missingTrailers(messages, { stories: [story] });
255
+ if (missingStories.length > 0) {
256
+ p.log.warn(
257
+ `PR #${pr.number} (Story #${story}): nenhum commit traz o trailer \`Spec-Wave-Story: #${story}\` ` +
258
+ '— confira se é mesmo o trabalho certo antes de mergear.'
259
+ );
260
+ }
261
+ }
262
+
245
263
  if (plano.bloqueios.length > 0) {
246
264
  p.outro('Bloqueado — merge parcial de pilha é o pior estado; resolva e rode de novo.');
247
265
  process.exitCode = 1;
@@ -9,6 +9,7 @@ import { getProjectSnapshot } from '../api/github-graphql.mjs';
9
9
  import {
10
10
  getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
11
11
  getRepoDefaultBranch, compareBranches, commitFilesToBranch, ensurePullRequest,
12
+ countOpenIssuesWithLabel,
12
13
  } from '../api/github-rest.mjs';
13
14
  import {
14
15
  resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
@@ -116,18 +117,21 @@ export function skillRepoTargets(job, repoRoot) {
116
117
  * 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
117
118
  * namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
118
119
  *
119
- * As labels de modelo (`spec-wave:model:<apelido>`) entram pelo `fileAi`: são
120
- * derivadas de `ai.modelAliases`, e tratá-las como desconhecidas fazia o update
121
- * APAGAR justamente o override de modelo por issue o mecanismo que destrava
122
- * uma Feature que não gera plano no modelo default. Passar o bloco `ai` também
123
- * é o que faz o update CRIÁ-LAS, em vez de exigir `gh label create` à mão.
120
+ * As labels de modelo (`spec-wave:model:<apelido>`) entram pelo `fileAi`, e as
121
+ * de trilha do dev-agent (`spec-wave:dev-agent-b`, …) pelo `devAgent`: as duas
122
+ * são derivadas do `.spec-wave.json`, e tratá-las como desconhecidas fazia o
123
+ * update APAGAR o override de modelo por issue ou a fila de uma trilha
124
+ * paralela mecanismos que destravam trabalho, não lixo a limpar. Passar os
125
+ * dois blocos também é o que faz o update CRIÁ-LAS, em vez de exigir
126
+ * `gh label create` à mão.
124
127
  *
125
128
  * @param {Array<{name,color,description}>} existing labels do repo
126
129
  * @param {object} [fileAi] bloco `ai` do .spec-wave.json
130
+ * @param {object} [devAgent] bloco `devAgent` do .spec-wave.json
127
131
  * @returns {{ missing: object[], changed: object[], orphan: object[] }}
128
132
  */
129
- export function diffLabels(existing, fileAi) {
130
- const wanted = allLabelsFor(fileAi);
133
+ export function diffLabels(existing, fileAi, devAgent) {
134
+ const wanted = allLabelsFor(fileAi, devAgent);
131
135
  const byName = new Map((existing || []).map(l => [l.name, l]));
132
136
  const missing = [];
133
137
  const changed = [];
@@ -147,6 +151,30 @@ export function diffLabels(existing, fileAi) {
147
151
  return { missing, changed, orphan };
148
152
  }
149
153
 
154
+ /**
155
+ * Decide se uma label órfã pode ser deletada (função PURA — testável sem rede).
156
+ *
157
+ * Deletar uma label no GitHub a remove de TODA issue aberta que a tem — é
158
+ * irreversível, e a label nem fica disponível para reaplicar. Por isso o
159
+ * default é conservador nos dois sentidos: `inUse` desconhecido (rede falhou
160
+ * ao contar) trava a remoção do mesmo jeito que `inUse > 0`, porque "não sei"
161
+ * não é "zero". `forceLabels` pula a checagem por completo — para quem sabe
162
+ * que a label é lixo de verdade.
163
+ *
164
+ * @param {{ inUse: number|null, forceLabels?: boolean }} params
165
+ * @returns {{ ok: boolean, motivo: string|null }}
166
+ */
167
+ export function shouldDeleteOrphanLabel({ inUse, forceLabels = false }) {
168
+ if (forceLabels) return { ok: true, motivo: null };
169
+ if (inUse === null) {
170
+ return { ok: false, motivo: 'não foi possível confirmar o uso — mantida por precaução' };
171
+ }
172
+ if (inUse > 0) {
173
+ return { ok: false, motivo: `está em ${inUse} issue(s) aberta(s) — NÃO removida` };
174
+ }
175
+ return { ok: true, motivo: null };
176
+ }
177
+
150
178
  /**
151
179
  * Regenera o conteúdo do .spec-wave.json a partir do Project remoto SEM GRAVAR.
152
180
  *
@@ -186,11 +214,20 @@ async function buildConfigContent(token, config) {
186
214
  * Label NUNCA entra num PR: é metadado do repositório, não arquivo versionado —
187
215
  * não existe forma de propor a mudança para revisão.
188
216
  *
217
+ * Órfã em uso não é removida por default (item 7 do
218
+ * rfc/plano-hardening-agentes-2026-08.md): apagar uma label no GitHub a
219
+ * remove de TODA issue aberta que a tem, e isso é irreversível — a label nem
220
+ * fica disponível para reaplicar. A guarda vale mesmo com `devAgent.queues`
221
+ * configurado certo: um erro de digitação no config não pode custar uma fila
222
+ * inteira. `forceLabels` pula a checagem, para quem sabe que a label é lixo
223
+ * de verdade e quer removê-la mesmo com issues abertas.
224
+ *
225
+ * @param {boolean} [forceLabels]
189
226
  * @returns {Promise<{created: string[], updated: string[], removed: string[]}>}
190
227
  * só o que REALMENTE passou — é o que o corpo do PR vai afirmar a quem
191
228
  * revisa, e ele não pode prometer o que falhou.
192
229
  */
193
- async function applyLabels(token, owner, repo, labelDiff) {
230
+ async function applyLabels(token, owner, repo, labelDiff, forceLabels = false) {
194
231
  const done = { created: [], updated: [], removed: [] };
195
232
  for (const label of labelDiff.missing) {
196
233
  try {
@@ -212,6 +249,14 @@ async function applyLabels(token, owner, repo, labelDiff) {
212
249
  }
213
250
  for (const label of labelDiff.orphan) {
214
251
  try {
252
+ const inUse = forceLabels
253
+ ? 0
254
+ : await countOpenIssuesWithLabel(token, owner, repo, label.name).catch(() => null);
255
+ const decision = shouldDeleteOrphanLabel({ inUse, forceLabels });
256
+ if (!decision.ok) {
257
+ p.log.warn(`Label ${label.name}: ${decision.motivo}. Use --force-labels para remover mesmo assim.`);
258
+ continue;
259
+ }
215
260
  await deleteLabel(token, owner, repo, label.name);
216
261
  done.removed.push(label.name);
217
262
  p.log.success(`Label descontinuada removida: ${label.name}`);
@@ -310,7 +355,7 @@ export async function update(options = {}) {
310
355
  if (remote === null) repoFiles.push({ ...f, reason: 'ausente', local });
311
356
  else if (remote !== local) repoFiles.push({ ...f, reason: 'desatualizado', local });
312
357
  }
313
- labelDiff = diffLabels(await listLabels(tk, owner, repo), config?.ai);
358
+ labelDiff = diffLabels(await listLabels(tk, owner, repo), config?.ai, config?.devAgent);
314
359
  repoChecked = true;
315
360
  s.stop('Repositório comparado.');
316
361
  } catch (err) {
@@ -626,7 +671,7 @@ export async function update(options = {}) {
626
671
  // Labels — sempre direto na base (metadado do repo, não versionável).
627
672
  let labelResult = { created: [], updated: [], removed: [] };
628
673
  if (labelTotal) {
629
- labelResult = await applyLabels(await getToken(), owner, repo, labelDiff);
674
+ labelResult = await applyLabels(await getToken(), owner, repo, labelDiff, !!options.forceLabels);
630
675
  }
631
676
 
632
677
  // Arquivos do repo
@@ -16,6 +16,7 @@ import { loadArtifact } from '../lib/doc-source.mjs';
16
16
  import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
17
17
  import { isAwaitingMerge } from '../lib/doc-source.mjs';
18
18
  import { getRepoDefaultBranch } from '../api/github-rest.mjs';
19
+ import { dependencySection } from '../lib/spec-audit.mjs';
19
20
 
20
21
  /**
21
22
  * Aborto por documento ainda em Pull Request.
@@ -241,6 +242,7 @@ export async function validate({ issueNumber }) {
241
242
  }
242
243
 
243
244
  // Check spec.md
245
+ const warnings = [];
244
246
  if (spec.content == null) {
245
247
  errors.push('❌ `spec.md` não encontrado em `' + specRel + '`');
246
248
  } else {
@@ -253,6 +255,17 @@ export async function validate({ issueNumber }) {
253
255
  for (const problem of findIncompleteDocSigns(specContent)) {
254
256
  errors.push(`❌ \`spec.md\` parece incompleto: ${problem}`);
255
257
  }
258
+ // Aviso, NUNCA erro (item 3e do rfc/plano-hardening-agentes-2026-08.md):
259
+ // "Dependências" não está em REQUIRED_SPEC_SECTIONS de propósito — exigi-la
260
+ // quebraria specs antigas num comando que é gatilho de Action. O parser
261
+ // aqui é o mesmo (tolerante) que o `audit` usa, então "não achei" já
262
+ // significa "o audit também não vai achar" — vale avisar sem bloquear.
263
+ if (dependencySection(specContent) == null) {
264
+ warnings.push(
265
+ '⚠️ Não encontrei a seção de Dependências (heading `#`/`##`/`###` começando com ' +
266
+ '"Dependências") — o `audit` não vai enxergar as dependências desta Feature.'
267
+ );
268
+ }
256
269
  }
257
270
 
258
271
  // Remove trigger label
@@ -292,6 +305,7 @@ export async function validate({ issueNumber }) {
292
305
  `✅ **Validação concluída com sucesso!**\n\n` +
293
306
  `- [\`${featureRel}/spec.md\`](${featureRel}/spec.md) ✓\n` +
294
307
  `- [\`${featureRel}/plan.md\`](${featureRel}/plan.md) ✓\n\n` +
308
+ (warnings.length > 0 ? `${warnings.join('\n')}\n\n` : '') +
295
309
  `A Feature está pronta para a decomposição. O próximo passo gera o **rascunho** ` +
296
310
  `em \`${featureRel}/decomposition.md\` para revisão (nada é criado ainda):\n` +
297
311
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:decompose"\n\`\`\``
package/src/config.mjs CHANGED
@@ -341,6 +341,13 @@ export const LABEL_DECOMPOSE_APPLY = 'spec-wave:decompose-apply';
341
341
  // rotina apagava a label da fila e desligava o agente sem aviso.
342
342
  export const LABEL_DEV_AGENT = 'spec-wave:dev-agent';
343
343
 
344
+ // Prefixo das labels de trilha EXTRA do dev-agent (multi-trilha: rodar mais de
345
+ // um daemon em paralelo, um por fila — `spec-wave:dev-agent-b`, `-c`, …). A
346
+ // fila default (`dev-agent`) já é `LABEL_DEV_AGENT`, coberta acima; este
347
+ // prefixo só identifica as trilhas adicionais, declaradas em `devAgent.queues`
348
+ // do .spec-wave.json (ver `queueLabels`).
349
+ export const DEV_AGENT_QUEUE_PREFIX = `${LABEL_DEV_AGENT}-`;
350
+
344
351
  // Gatilho e estado do bug.md (RFC-004 §5). O bug.md é o artefato do Bug — leve
345
352
  // por decisão: um defeito não gera spec.md + plan.md.
346
353
  export const LABEL_BUG = 'spec-wave:bug';
@@ -447,6 +454,17 @@ export const LABEL_RISK_ACCEPTED = 'spec-wave:risk-accepted';
447
454
  // a correção — e troca os findings, fazendo as decisões do TL deixarem de casar.
448
455
  export const LABEL_CRITIQUE = 'spec-wave:critique';
449
456
 
457
+ // Rastro verificável do commit de implementação (item 6 do rfc/plano-
458
+ // hardening-agentes-2026-08.md). O ASSUNTO do commit continua livre — quem
459
+ // implementa escreve o que quiser ("feat: story #1533 [spec-wave-agent]" ou
460
+ // "feat(ep3-f3): wizard de aprovação"); os `git trailer` no rodapé é que são o
461
+ // contrato, porque sobrevivem a squash, rebase e reescrita de assunto (e
462
+ // `git log --format='%(trailers:key=...)'` lê sem depender de convenção de
463
+ // prosa). Ver `lib/commit-trailers.mjs`.
464
+ export const TRAILER_STORY = 'Spec-Wave-Story';
465
+ export const TRAILER_TASKS = 'Spec-Wave-Tasks';
466
+ export const TRAILER_AGENT = 'Spec-Wave-Agent';
467
+
450
468
  export const TRIGGER_LABELS = [
451
469
  { name: LABEL_SPEC, color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
452
470
  { name: LABEL_PLAN, color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
@@ -511,15 +529,50 @@ export function modelLabels(modelAliases) {
511
529
  }
512
530
 
513
531
  /**
514
- * Labels do fluxo + as de modelo deste repo (função PURA).
532
+ * Labels das trilhas EXTRA do dev-agent, derivadas de `devAgent.queues`
533
+ * (função PURA) — item 7 do rfc/plano-hardening-agentes-2026-08.md.
534
+ *
535
+ * Mesma armadilha de `modelLabels`, mesmo remédio: sem isto, uma fila custom
536
+ * (`spec-wave:dev-agent-b`, para rodar um segundo daemon em paralelo) é a
537
+ * ÚNICA label que não cabe em `ALL_LABELS` — e o `update` REMOVE toda
538
+ * `spec-wave:*` que não esteja na lista conhecida. Apagar a label a remove de
539
+ * todas as issues: a fila inteira esvazia e o paralelismo morre em silêncio.
540
+ *
541
+ * A fila default (`dev-agent`) fica de fora do retorno — já está coberta por
542
+ * `LABEL_DEV_AGENT`/`ALL_LABELS`, e repeti-la aqui arriscaria duas descrições
543
+ * divergindo da mesma label entre execuções.
544
+ *
545
+ * @param {object} [devAgent] bloco `devAgent` do .spec-wave.json
546
+ * @returns {Array<{name: string, color: string, description: string}>}
547
+ */
548
+ export function queueLabels(devAgent) {
549
+ const queues = Array.isArray(devAgent?.queues) ? devAgent.queues : [];
550
+ const extra = new Set(
551
+ queues
552
+ .filter(q => typeof q === 'string' && q.trim())
553
+ .map(q => q.trim())
554
+ .filter(q => q !== 'dev-agent')
555
+ );
556
+ return [...extra].map(q => ({
557
+ name: `spec-wave:${q}`,
558
+ color: '5319E7',
559
+ description: `Enfileira a issue para a trilha "${q}" do dev-agent`,
560
+ }));
561
+ }
562
+
563
+ /**
564
+ * Labels do fluxo + as de modelo + as de trilha do dev-agent deste repo
565
+ * (função PURA).
515
566
  *
516
567
  * É este o conjunto que `update`, `doctor` e `init` devem usar — ALL_LABELS
517
- * sozinha é incompleta em qualquer repo que configure `ai.modelAliases`.
568
+ * sozinha é incompleta em qualquer repo que configure `ai.modelAliases` ou
569
+ * `devAgent.queues`.
518
570
  *
519
571
  * @param {object} [fileAi] bloco `ai` do .spec-wave.json
572
+ * @param {object} [devAgent] bloco `devAgent` do .spec-wave.json
520
573
  */
521
- export function allLabelsFor(fileAi) {
522
- return [...ALL_LABELS, ...modelLabels(fileAi?.modelAliases)];
574
+ export function allLabelsFor(fileAi, devAgent) {
575
+ return [...ALL_LABELS, ...modelLabels(fileAi?.modelAliases), ...queueLabels(devAgent)];
523
576
  }
524
577
 
525
578
  /**
@@ -23,11 +23,12 @@ import { STAGE_DEVELOPMENT, STAGE_CODE_REVIEW, PROGRESS_IN_PROGRESS } from '../c
23
23
  * @param {string|null} [params.codeDigest]
24
24
  * @param {string[]} [params.blockedByWarnings]
25
25
  * @param {string|null} [params.severity] P0–P3
26
+ * @param {'agent'|'pr-stack'} [params.mode] quem abre o PR — ver lib/delivery-mode.mjs
26
27
  * @returns {string}
27
28
  */
28
29
  export function buildBugContext({
29
30
  bug, bugDoc = null, parent = null, comments = [], codeDigest = null,
30
- blockedByWarnings = [], severity = null,
31
+ blockedByWarnings = [], severity = null, mode = 'pr-stack',
31
32
  }) {
32
33
  const lines = [];
33
34
  lines.push(`# Contexto de correção — Bug #${bug.number}`);
@@ -102,11 +103,21 @@ export function buildBugContext({
102
103
  `1. Commit com a **causa raiz** na mensagem: \`fix: <o que estava errado> (#${bug.number})\`, ` +
103
104
  'e o corpo explicando a origem — não o sintoma.'
104
105
  );
105
- lines.push(`2. Abra o Pull Request com \`Fixes #${bug.number}\` no corpo.`);
106
- lines.push(
107
- `3. O board move sozinho: o Bug sai de **${STAGE_DEVELOPMENT}** (${PROGRESS_IN_PROGRESS}) ` +
108
- `para **${STAGE_CODE_REVIEW}** quando o PR abre. Não mova à mão.`
109
- );
106
+ if (mode === 'agent') {
107
+ // O daemon mantém UM branch e UM PR por issue e abre o PR ele mesmo ao
108
+ // final (inclusive para Bug) o modelo faz push no branch atual.
109
+ lines.push('2. Faça **push no branch atual** — **NÃO abra Pull Request**: o agente abre o PR ele mesmo ao final.');
110
+ lines.push(
111
+ `3. O board move sozinho: o Bug sai de **${STAGE_DEVELOPMENT}** (${PROGRESS_IN_PROGRESS}) ` +
112
+ `para **${STAGE_CODE_REVIEW}** quando o PR abre. Não mova à mão.`
113
+ );
114
+ } else {
115
+ lines.push(`2. Abra o Pull Request com \`Fixes #${bug.number}\` no corpo.`);
116
+ lines.push(
117
+ `3. O board move sozinho: o Bug sai de **${STAGE_DEVELOPMENT}** (${PROGRESS_IN_PROGRESS}) ` +
118
+ `para **${STAGE_CODE_REVIEW}** quando o PR abre. Não mova à mão.`
119
+ );
120
+ }
110
121
 
111
122
  if (bugDoc && bugDoc.trim()) {
112
123
  lines.push('');