@spec-wave/cli 0.25.0 → 0.27.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.
Files changed (38) hide show
  1. package/bin/spec-wave.mjs +4 -374
  2. package/package.json +1 -1
  3. package/src/api/github-rest.mjs +25 -0
  4. package/src/cli.mjs +400 -0
  5. package/src/commands/decompose.mjs +186 -45
  6. package/src/commands/doctor.mjs +190 -3
  7. package/src/commands/generate-bug.mjs +22 -16
  8. package/src/commands/generate-plan.mjs +72 -23
  9. package/src/commands/generate-spec.mjs +19 -15
  10. package/src/commands/implement.mjs +40 -20
  11. package/src/commands/mode.mjs +16 -5
  12. package/src/commands/run.mjs +156 -40
  13. package/src/commands/validate.mjs +84 -17
  14. package/src/config.mjs +18 -0
  15. package/src/lib/artifact-pr.mjs +272 -0
  16. package/src/lib/artifact-publish.mjs +169 -0
  17. package/src/lib/doc-availability.mjs +23 -1
  18. package/src/lib/doc-source.mjs +162 -0
  19. package/src/lib/execution-mode.mjs +22 -0
  20. package/src/lib/flow-run.mjs +9 -218
  21. package/src/lib/next-step.mjs +87 -8
  22. package/src/lib/pr-branch.mjs +10 -0
  23. package/src/lib/repo-links.mjs +8 -2
  24. package/src/plugin/.claude-plugin/plugin.json +1 -1
  25. package/src/plugin/skills/bug/SKILL.md +2 -2
  26. package/src/plugin/skills/decompose/SKILL.md +4 -4
  27. package/src/plugin/skills/plan/SKILL.md +1 -1
  28. package/src/plugin/skills/run/SKILL.md +4 -2
  29. package/src/plugin/skills/spec/SKILL.md +4 -4
  30. package/src/plugin/skills/workflow/SKILL.md +2 -2
  31. package/src/templates/skill/SKILL.md +7 -7
  32. package/src/templates/workflows/code-review.yml +13 -2
  33. package/src/templates/workflows/critique.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +13 -2
  35. package/src/templates/workflows/generate-bug.yml +17 -6
  36. package/src/templates/workflows/generate-plan.yml +20 -7
  37. package/src/templates/workflows/generate-spec.yml +20 -7
  38. package/src/templates/workflows/qa.yml +13 -0
@@ -17,11 +17,10 @@
17
17
  // edições humanas. Para regerar do zero, apague o arquivo.
18
18
 
19
19
  import { execSync } from 'node:child_process';
20
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
- import path from 'node:path';
22
20
  import { resolveToken } from '../api/auth.mjs';
23
21
  import {
24
22
  getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
23
+ getRepoDefaultBranch,
25
24
  } from '../api/github-rest.mjs';
26
25
  import { addSubIssue, listSubIssues, getProjectSnapshot } from '../api/github-graphql.mjs';
27
26
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
@@ -37,7 +36,11 @@ import { resolveDocDir } from '../lib/doc-paths.mjs';
37
36
  import { docBlobUrl } from '../lib/repo-links.mjs';
38
37
  import { detectIssueType } from '../lib/issue-type.mjs';
39
38
  import { loadConfig } from '../lib/project-root.mjs';
40
- import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
39
+ import { resolveFlowContext } from '../lib/flow-run.mjs';
40
+ import { publishArtifact } from '../lib/artifact-publish.mjs';
41
+ import { loadArtifact } from '../lib/doc-source.mjs';
42
+ import { awaitingMergeBlock } from '../lib/artifact-pr.mjs';
43
+ import { isAwaitingMerge } from '../lib/doc-source.mjs';
41
44
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
42
45
  import {
43
46
  renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
@@ -157,16 +160,27 @@ const CHILD_PREFIX = { Feature: '[STORY]', RFC: '[TASK]' };
157
160
  /**
158
161
  * Modo do run (função PURA — testável).
159
162
  *
160
- * A flag `--apply` vem do workflow, onde a label que disparou é a fonte de
161
- * verdade. O fallback por label cobre a execução manual e o retry de runner.
163
+ * TRI-ESTADO, e a distinção é o que impede um estrago: `apply` pode ser `true`
164
+ * (aplique), `false` (NÃO aplique) ou `undefined` (ninguém decidiu use a
165
+ * label). Só o `undefined` cai no fallback por label, que é o caminho do
166
+ * workflow (onde a label que disparou é a fonte de verdade) e do retry de
167
+ * runner.
168
+ *
169
+ * Antes, o default `apply = false` colapsava "não aplique" em "ninguém
170
+ * decidiu": o `run` despachava o rascunho com `apply: false` e a label
171
+ * `spec-wave:decompose-apply` esquecida na issue por uma Action que falhou
172
+ * promovia a execução a aplicação. O comando anunciava "gera/re-critica o
173
+ * rascunho" e CRIAVA Stories e Tasks — sem passar pelo portão de confirmação
174
+ * do `run`, que existe exatamente para isso.
162
175
  *
163
176
  * @param {object} params
164
177
  * @param {Array<string|{name:string}>} [params.labels] labels da issue
165
- * @param {boolean} [params.apply] flag --apply
178
+ * @param {boolean} [params.apply] `true`/`false` decidem; `undefined` consulta a label
166
179
  * @returns {'draft'|'apply'}
167
180
  */
168
- export function resolveDecomposeMode({ labels = [], apply = false } = {}) {
169
- if (apply) return 'apply';
181
+ export function resolveDecomposeMode({ labels = [], apply } = {}) {
182
+ if (apply === true) return 'apply';
183
+ if (apply === false) return 'draft';
170
184
  return labelNames(labels).includes(LABEL_DECOMPOSE_APPLY) ? 'apply' : 'draft';
171
185
  }
172
186
 
@@ -235,11 +249,26 @@ function formatItemsLintWarning(texts) {
235
249
  return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
236
250
  }
237
251
 
238
- // Grava e commita o rascunho. O modo (actions|local) decide identidade do git e
239
- // o que fazer com falha de push — ver `lib/flow-run.mjs`.
240
- function commitFile(filePath, content, message, mode) {
241
- const published = commitGenerated({ filePath, content, message, mode });
252
+ /**
253
+ * Publica o documento em branch própria + Pull Request — ver lib/artifact-publish.mjs.
254
+ *
255
+ * ASSÍNCRONA, e todo chamador precisa de `await`. O `apply` chama isto dentro de
256
+ * um `try/catch` cuja única função é degradar a falha em aviso (as issues já
257
+ * existem; derrubar o run aqui mandaria o humano reaplicar o gatilho e duplicar
258
+ * dezenas de itens). Sem o `await`, a promise rejeitada não é capturada por esse
259
+ * catch: o aviso some e vira unhandled rejection.
260
+ */
261
+ async function publishFile(ctx, { doc, pathRel, content, nextLabel = null }) {
262
+ const { token, owner, repo, issue, issueNumber, base } = ctx;
263
+ const published = await publishArtifact({
264
+ token, owner, repo, doc,
265
+ issueNumber: parseInt(issueNumber, 10),
266
+ issueTitle: issue?.title || '',
267
+ issueUrl: issue?.html_url || '',
268
+ pathRel, content, base, nextLabel,
269
+ });
242
270
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
271
+ return published;
243
272
  }
244
273
 
245
274
  // Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
@@ -252,23 +281,67 @@ function commitFile(filePath, content, message, mode) {
252
281
  // ---------------------------------------------------------------------------
253
282
 
254
283
  async function draftDecomposition(ctx) {
255
- const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
284
+ const {
285
+ token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode,
286
+ docRel, dirRel, base,
287
+ } = ctx;
256
288
  const number = parseInt(issueNumber, 10);
257
289
  const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
258
- const blobUrl = docBlobUrl({ owner, repo, pathRel: docRel, mode: runMode, root });
259
290
 
260
- const specPath = path.join(docDir, 'spec.md');
261
- const planPath = path.join(docDir, 'plan.md');
262
- const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
263
- const planContent = existsSync(planPath) ? readFileSync(planPath, 'utf-8') : null;
291
+ const ler = (doc, pathRel) => loadArtifact({
292
+ token, owner, repo, root, pathRel, doc, issueNumber: number, base,
293
+ });
294
+
295
+ // spec e plan alimentam o rascunho de Feature. Um deles preso num PR não
296
+ // mergeado com leitura ingênua viraria "(spec.md não encontrado)" no payload:
297
+ // rascunho gerado sobre o vazio, sem erro nenhum. Recusar é mais barato.
298
+ const spec = type === 'RFC' ? null : await ler('spec', `${dirRel}/spec.md`);
299
+ const plan = type === 'RFC' ? null : await ler('plan', `${dirRel}/plan.md`);
300
+ for (const [nome, doc] of [['spec.md', spec], ['plan.md', plan]]) {
301
+ if (doc && isAwaitingMerge(doc.state)) {
302
+ const bloqueio = awaitingMergeBlock({
303
+ pathRel: `${dirRel}/${nome}`, state: doc.state, pr: doc.pr, branch: doc.ref,
304
+ });
305
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE).catch(() => {});
306
+ await commentOnIssue(token, owner, repo, number,
307
+ `⏸️ **decompose parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
308
+ `Reaplique \`${LABEL_DECOMPOSE}\` depois do merge.`
309
+ ).catch(() => {});
310
+ throw new DecomposeBlockedError(bloqueio.message);
311
+ }
312
+ }
313
+ const specContent = spec?.content ?? null;
314
+ const planContent = plan?.content ?? null;
264
315
 
265
316
  // Rascunho existente é preservado COMO ESTÁ: o humano corrige o arquivo e
266
317
  // re-aplica a label para uma nova crítica. Regenerar aqui apagaria a correção
267
318
  // — é justamente o que fazia o ciclo não convergir.
319
+ //
320
+ // Com a publicação por Pull Request, "existente" deixou de ser `existsSync`: o
321
+ // rascunho que o revisor está editando mora na branch do PR. Ler só o disco
322
+ // aqui INVERTERIA a invariante — regeneraria por cima da revisão, e ainda
323
+ // pagaria a IA de novo.
324
+ const rascunho = await ler('decomposition', docRel);
325
+
268
326
  let markdown;
269
- if (existsSync(docPath)) {
270
- console.log(`Rascunho encontrado em ${docRel} — criticando o arquivo como está (sem regerar).`);
271
- markdown = readFileSync(docPath, 'utf-8');
327
+ let publicado = null;
328
+ if (rascunho.content != null) {
329
+ const onde = rascunho.state === 'pending-pr'
330
+ ? `no PR #${rascunho.pr?.number} (ainda não mergeado)`
331
+ : rascunho.state === 'branch-only'
332
+ ? `na branch ${rascunho.ref} (sem PR aberto)`
333
+ : docRel;
334
+ console.log(`Rascunho encontrado ${onde} — criticando o arquivo como está (sem regerar).`);
335
+ markdown = rascunho.content;
336
+ // O PR que já existe é o mesmo lugar onde o revisor vai corrigir: o
337
+ // comentário de fecho precisa apontá-lo, senão a re-crítica sai sem link e a
338
+ // pessoa fica procurando onde editar.
339
+ if (isAwaitingMerge(rascunho.state)) {
340
+ // `branch-only` entra aqui de propósito: republicar reaproveita a branch e
341
+ // TENTA abrir o PR de novo — é o caminho de recuperação quando a abertura
342
+ // falhou antes. O conteúdo é o do revisor, não um regerado.
343
+ publicado = { pr: rascunho.pr, branch: rascunho.ref, unchanged: true };
344
+ }
272
345
  } else {
273
346
  console.log(`Gerando rascunho de decomposição para ${type}: ${issue.title}`);
274
347
  const userContent = type === 'RFC'
@@ -301,10 +374,22 @@ async function draftDecomposition(ctx) {
301
374
  stories: generated.stories || [],
302
375
  tasks: generated.tasks || [],
303
376
  });
304
- commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`, runMode);
305
- console.log(`Rascunho commitado em ${docRel}.`);
377
+ publicado = await publishFile(ctx, {
378
+ doc: 'decomposition', pathRel: docRel, content: markdown,
379
+ nextLabel: LABEL_DECOMPOSE_APPLY,
380
+ });
381
+ console.log(`Rascunho publicado em ${publicado.branch} (${docRel}).`);
306
382
  }
307
383
 
384
+ // O link só pode ser montado DEPOIS de saber onde o documento está: na branch
385
+ // do PR recém-aberto, na do PR que já existia, ou na base. Inferir a ref pelo
386
+ // ambiente (o que o docBlobUrl faz por padrão) daria a branch default num
387
+ // evento `issues: labeled` — e lá o arquivo ainda não está.
388
+ const refDoDocumento = publicado?.branch || rascunho.ref || undefined;
389
+ const blobUrl = docBlobUrl({
390
+ owner, repo, pathRel: docRel, mode: runMode, root, ref: refDoDocumento,
391
+ });
392
+
308
393
  // Valida a estrutura ANTES de gastar tokens com a crítica: um arquivo quebrado
309
394
  // por edição humana precisa de mensagem clara, não de uma crítica sobre nada.
310
395
  let doc;
@@ -327,7 +412,7 @@ async function draftDecomposition(ctx) {
327
412
 
328
413
  // O RFC não tem spec/plan para auditar contra — a crítica não teria referência.
329
414
  if (kind !== 'stories') {
330
- await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false });
415
+ return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: false, publicado });
331
416
  return;
332
417
  }
333
418
 
@@ -411,26 +496,41 @@ async function draftDecomposition(ctx) {
411
496
  // Crítica limpa: remove o bloqueio anterior, que também zera o contador de
412
497
  // tentativas na próxima rodada (ver resolveCritiqueAttempt).
413
498
  await removeLabel(token, owner, repo, number, LABEL_CRITIQUE_FAILED).catch(() => {});
414
- await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true });
499
+ return await finishDraft(ctx, { doc, blobUrl, itemCount, critiqued: true, publicado });
415
500
  }
416
501
 
417
502
  // Fecho comum do rascunho aprovado: libera para revisão humana e o apply.
418
- async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued }) {
503
+ async function finishDraft(
504
+ { token, owner, repo, issueNumber, docRel }, { blobUrl, itemCount, critiqued, publicado }
505
+ ) {
419
506
  const number = parseInt(issueNumber, 10);
420
507
  await addLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY);
421
508
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE);
509
+
510
+ // O apply LÊ o rascunho da branch base, então ele só roda depois do merge —
511
+ // dizer "crie as issues agora" sem essa ressalva manda o humano num passo que
512
+ // vai ser recusado.
513
+ const pr = publicado?.pr;
514
+ const revisao = pr?.number
515
+ ? `🔀 Pull Request: #${pr.number} — ${pr.url}\n\n` +
516
+ '**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo **no PR**, ' +
517
+ 'faça o merge e então crie as issues:\n'
518
+ : '**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n';
519
+
422
520
  await commentOnIssue(token, owner, repo, number,
423
521
  `📝 **Rascunho de decomposição pronto para revisão** (${itemCount}).\n\n` +
424
522
  `📄 Arquivo: [\`${docRel}\`](${blobUrl})\n\n` +
425
523
  (critiqued
426
524
  ? 'A crítica adversarial não encontrou contradições graves. '
427
525
  : '') +
428
- `**Nada foi criado ainda.** Revise (e edite, se quiser) o arquivo e então crie as issues:\n` +
526
+ revisao +
429
527
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE_APPLY}"\n\`\`\`\n` +
430
528
  `Se preferir uma nova crítica depois de editar, reaplique \`${LABEL_DECOMPOSE}\` — ` +
431
- 'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, apague-o.'
529
+ 'o arquivo é criticado como está, sem ser regerado. Para gerar outro do zero, ' +
530
+ 'feche o PR e apague a branch.'
432
531
  ).catch(err => console.warn(`Falha ao comentar o rascunho: ${err.message}`));
433
532
  console.log(`Rascunho liberado para revisão: ${itemCount}.`);
533
+ return { pr: pr || null };
434
534
  }
435
535
 
436
536
  // ---------------------------------------------------------------------------
@@ -438,14 +538,38 @@ async function finishDraft({ token, owner, repo, issueNumber, docRel }, { blobUr
438
538
  // ---------------------------------------------------------------------------
439
539
 
440
540
  async function applyDecomposition(ctx) {
441
- const { token, owner, repo, issueNumber, docPath, docRel } = ctx;
541
+ const { token, owner, repo, issueNumber, docRel, root, base } = ctx;
442
542
  const number = parseInt(issueNumber, 10);
443
543
 
444
- if (!existsSync(docPath)) {
544
+ const rascunho = await loadArtifact({
545
+ token, owner, repo, root, pathRel: docRel,
546
+ doc: 'decomposition', issueNumber: number, base,
547
+ });
548
+
549
+ // Rascunho ainda em Pull Request é recusa DURA, não aviso: aplicar criaria
550
+ // dezenas de issues a partir de um documento que ninguém aprovou, e o próprio
551
+ // arquivo passaria a afirmar `applied=<data>` enquanto o revisor ainda decide.
552
+ // O merge do PR É a aprovação humana.
553
+ // Vale para os DOIS estados fora da base. Cobrir só `pending-pr` deixaria o
554
+ // apply criar dezenas de issues a partir de um rascunho que está numa branch
555
+ // sem PR — ou seja, que ninguém teve como revisar.
556
+ if (isAwaitingMerge(rascunho.state)) {
557
+ const bloqueio = awaitingMergeBlock({
558
+ pathRel: docRel, state: rascunho.state, pr: rascunho.pr, branch: rascunho.ref,
559
+ });
560
+ await commentOnIssue(token, owner, repo, number,
561
+ `⏸️ **decompose-apply parado:** ${bloqueio.message}\n\n${bloqueio.unblock}\n\n` +
562
+ `Nenhuma issue foi criada. Reaplique \`${LABEL_DECOMPOSE_APPLY}\` depois do merge.`
563
+ ).catch(() => {});
564
+ await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
565
+ throw new DecomposeBlockedError(bloqueio.message);
566
+ }
567
+
568
+ if (rascunho.content == null) {
445
569
  await commentOnIssue(token, owner, repo, number,
446
570
  `❌ **Não há rascunho de decomposição para aplicar.**\n\n` +
447
571
  `Esperava encontrar \`${docRel}\`. Gere o rascunho primeiro:\n` +
448
- `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\``
572
+ `\`\`\`\ngh issue edit ${issueNumber} --add-label "${LABEL_DECOMPOSE}"\n\`\`\`\n`
449
573
  ).catch(() => {});
450
574
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY).catch(() => {});
451
575
  throw new DecomposeBlockedError(`${docRel} não encontrado — aplique ${LABEL_DECOMPOSE} primeiro.`);
@@ -453,7 +577,7 @@ async function applyDecomposition(ctx) {
453
577
 
454
578
  let doc;
455
579
  try {
456
- doc = parseDecompositionDoc(readFileSync(docPath, 'utf-8'));
580
+ doc = parseDecompositionDoc(rascunho.content);
457
581
  } catch (err) {
458
582
  await commentOnIssue(token, owner, repo, number,
459
583
  `❌ **Não consegui ler o rascunho da decomposição.**\n\n` +
@@ -497,14 +621,19 @@ async function applyDecomposition(ctx) {
497
621
  // Depois da criação de propósito: se este commit falhar, as issues já existem
498
622
  // e o pior caso é o comportamento anterior (arquivo sem anotação), avisado no
499
623
  // log. O contrário — anotar antes e falhar na criação — inventaria issues.
624
+ //
625
+ // Vai para uma branch PRÓPRIA (`spec-wave/<n>-decompose-apply`), nunca por cima
626
+ // da branch do rascunho: aquela já foi mergeada (é pré-condição do apply), e
627
+ // reabri-la produziria um PR reintroduzindo estado antigo.
500
628
  try {
501
- commitFile(
502
- docPath,
503
- renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
504
- `docs: registra as issues criadas em ${docRel} [spec-wave]`,
505
- ctx.runMode,
506
- );
507
- console.log(`${docRel} anotado com as issues criadas.`);
629
+ const anotado = await publishFile(ctx, {
630
+ doc: 'decomposition-apply',
631
+ pathRel: docRel,
632
+ content: renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
633
+ });
634
+ console.log(anotado.pr?.number
635
+ ? `${docRel} anotado com as issues criadas — PR #${anotado.pr.number}.`
636
+ : `${docRel} anotado com as issues criadas em ${anotado.branch}.`);
508
637
  } catch (err) {
509
638
  console.warn(
510
639
  `⚠️ Issues criadas, mas ${docRel} não foi anotado (${err.message}). ` +
@@ -917,7 +1046,10 @@ export function renderApplyFailureComment({ trigger, message, createdItems = [],
917
1046
  'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
918
1047
  }
919
1048
 
920
- export async function decompose({ issueNumber, apply = false }) {
1049
+ // `apply` SEM default: `undefined` significa "ninguém decidiu" e é o que faz o
1050
+ // fallback por label valer no workflow. Um default `false` aqui reintroduziria
1051
+ // o bug, porque a CLI passa `undefined` quando a flag não é usada.
1052
+ export async function decompose({ issueNumber, apply }) {
921
1053
  const token = await resolveToken();
922
1054
  // PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
923
1055
  // Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
@@ -978,8 +1110,12 @@ export async function decompose({ issueNumber, apply = false }) {
978
1110
 
979
1111
  // Config do repo: raiz para os caminhos de documento e escalada da crítica.
980
1112
  const { config, root } = loadConfig();
981
- const { rel: docRel, dir: docDir } = resolveDocDir(root, issue, type);
982
- const docPath = path.join(docDir, DECOMPOSITION_FILE);
1113
+ const { rel: docRel } = resolveDocDir(root, issue, type);
1114
+
1115
+ // Branch base: é dela que os documentos são lidos quando não estão no clone, e
1116
+ // é contra ela que o PR do rascunho é aberto. Best-effort — sem ela a leitura
1117
+ // cai para o default da API, que é o mesmo lugar.
1118
+ const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
983
1119
 
984
1120
  // Comentários só são necessários no rascunho (contador de tentativas).
985
1121
  let comments = [];
@@ -996,7 +1132,9 @@ export async function decompose({ issueNumber, apply = false }) {
996
1132
  const usageEntries = [];
997
1133
  const ctx = {
998
1134
  token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
999
- root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
1135
+ root, runMode, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
1136
+ dirRel: docRel,
1137
+ base,
1000
1138
  // Números das issues já criadas por este run. Compartilhado por referência
1001
1139
  // com o applyCtx: o catch externo precisa saber se houve criação para não
1002
1140
  // aconselhar um retry que duplicaria itens.
@@ -1010,8 +1148,11 @@ export async function decompose({ issueNumber, apply = false }) {
1010
1148
  };
1011
1149
 
1012
1150
  try {
1013
- if (mode === 'apply') await applyDecomposition(ctx);
1014
- else await draftDecomposition(ctx);
1151
+ // O desfecho volta ao chamador (o `run` usa o PR para parar a cadeia: o passo
1152
+ // seguinte lê o documento da base, então depende do merge).
1153
+ return mode === 'apply'
1154
+ ? await applyDecomposition(ctx)
1155
+ : await draftDecomposition(ctx);
1015
1156
  } catch (err) {
1016
1157
  // Paradas por decisão do fluxo já comentaram na issue; erros inesperados não.
1017
1158
  if (!err.blocked) {
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import * as p from '@clack/prompts';
9
9
  import chalk from 'chalk';
10
10
  import { Octokit } from '@octokit/rest';
11
+ import yaml from 'js-yaml';
11
12
  import {
12
13
  resolveToken, verifyTokenScopes, describeTokenSource, activeGhAccount,
13
14
  tokenMismatchWarning, parseActiveAccount,
@@ -15,7 +16,8 @@ import {
15
16
  import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
16
17
  import { getRepoVariable } from '../api/github-rest.mjs';
17
18
  import {
18
- CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, AI_PROVIDERS, STATUS_OPTIONS,
19
+ CONFIG_FILE, WORKFLOW_FILES, ARTIFACT_WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER,
20
+ AI_PROVIDERS, STATUS_OPTIONS,
19
21
  RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
20
22
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
21
23
  modelLabels,
@@ -903,10 +905,21 @@ async function checkDecompositions(ctx) {
903
905
  );
904
906
  }
905
907
 
908
+ // Esta checagem lê o DISCO de propósito: ela audita registro consolidado, e um
909
+ // rascunho ainda em Pull Request não é registro. A consequência a declarar é
910
+ // que rascunhos não mergeados ficam FORA da conferência de deriva — não é
911
+ // omissão, é o recorte; mas quem lê o relatório precisa saber.
912
+ const nota = 'Rascunhos ainda em Pull Request não entram nesta conferência — ' +
913
+ 'só o que está na branch base é registro.';
914
+
906
915
  if (aplicados === 0) {
907
- return { name, status: 'ok', detail: `${arquivos.length} rascunho(s) ainda não aplicado(s) — nada a conferir.` };
916
+ return {
917
+ name,
918
+ status: 'ok',
919
+ detail: `${arquivos.length} rascunho(s) ainda não aplicado(s) — nada a conferir. ${nota}`,
920
+ };
908
921
  }
909
- return { name, status, detail: notes.join('\n') };
922
+ return { name, status, detail: [...notes, nota].join('\n') };
910
923
  }
911
924
 
912
925
  export function checkSpecKit(ctx) {
@@ -967,6 +980,179 @@ async function checkExecutionMode(ctx) {
967
980
  return { name, status, detail: [estado.summary, ...estado.notes, ...estado.fixes].join('\n') };
968
981
  }
969
982
 
983
+ /**
984
+ * Veredito sobre a capacidade de publicar documentos por Pull Request (função PURA).
985
+ *
986
+ * Três coisas precisam ser verdade, e as três falham de formas diferentes:
987
+ *
988
+ * • **Permissões no YAML.** Sem `pull-requests: write`, o commit é criado e o PR
989
+ * não — o documento fica numa branch que ninguém vê. Sem `contents: write`, a
990
+ * Git Data API responde 403 e nada é publicado.
991
+ * • **O Actions pode abrir PR.** "Allow GitHub Actions to create and approve
992
+ * pull requests" vem DESLIGADO em muitas organizações, e é a falha mais
993
+ * provável deste fluxo. Warn e não fail: a consulta exige `administration:
994
+ * read`, que o GITHUB_TOKEN não tem, então "não consegui ver" é comum e não
995
+ * pode virar vermelho.
996
+ * • **Status checks obrigatórios na branch default.** Este é FAIL, e o motivo é
997
+ * contraintuitivo: PR aberto pelo GITHUB_TOKEN não dispara workflow nenhum,
998
+ * logo o check obrigatório nunca sai de "expected" e NENHUM PR de documento
999
+ * fica mergeável. O fluxo trava no primeiro passo, sem erro visível em lugar
1000
+ * nenhum — a issue simplesmente para.
1001
+ *
1002
+ * @param {object} params
1003
+ * @param {boolean|null} [params.canCreatePr] null = não foi possível consultar
1004
+ * @param {Record<string, {contents?: string, pullRequests?: string}>} [params.workflowPerms]
1005
+ * @param {string[]|null} [params.requiredChecks] null = não foi possível consultar
1006
+ * @param {boolean|null} [params.prTokenPresent] o secret alternativo existe no repo
1007
+ * @param {string} [params.prTokenSecret] nome do secret alternativo
1008
+ * @returns {{status: 'ok'|'warn'|'fail', notes: string[]}}
1009
+ */
1010
+ export function inspectPrPublishing({
1011
+ canCreatePr = null, workflowPerms = {}, requiredChecks = null,
1012
+ prTokenPresent = null, prTokenSecret = 'GH_PR_TOKEN',
1013
+ } = {}) {
1014
+ const notes = [];
1015
+ let status = 'ok';
1016
+ const piora = (novo) => {
1017
+ if (novo === 'fail' || status === 'fail') status = 'fail';
1018
+ else if (novo === 'warn') status = 'warn';
1019
+ };
1020
+
1021
+ const semPr = Object.entries(workflowPerms)
1022
+ .filter(([, perm]) => perm?.pullRequests !== 'write').map(([f]) => f);
1023
+ const semContents = Object.entries(workflowPerms)
1024
+ .filter(([, perm]) => perm?.contents !== 'write').map(([f]) => f);
1025
+
1026
+ if (semPr.length > 0) {
1027
+ piora('fail');
1028
+ notes.push(
1029
+ `Sem \`pull-requests: write\`: ${semPr.join(', ')}. O commit é criado e o Pull ` +
1030
+ 'Request não — o documento fica numa branch que ninguém vê. Rode `update`.'
1031
+ );
1032
+ }
1033
+ if (semContents.length > 0) {
1034
+ piora('fail');
1035
+ notes.push(
1036
+ `Sem \`contents: write\`: ${semContents.join(', ')}. O commit vai por Git Data API, ` +
1037
+ 'que é autorizada por essa permissão. Rode `update`.'
1038
+ );
1039
+ }
1040
+
1041
+ if (canCreatePr === false && !prTokenPresent) {
1042
+ piora('warn');
1043
+ notes.push(
1044
+ 'O GitHub Actions está proibido de criar Pull Requests neste repositório. ' +
1045
+ 'Ligue Settings → Actions → General → "Allow GitHub Actions to create and approve ' +
1046
+ `pull requests", ou defina o secret \`${prTokenSecret}\` com um PAT.`
1047
+ );
1048
+ } else if (canCreatePr === false) {
1049
+ // Mitigado: o PR é aberto com o PAT, não com o GITHUB_TOKEN. Continuar
1050
+ // pedindo a configuração que a pessoa ACABOU de fazer é o jeito mais rápido
1051
+ // de ensinar que o doctor pode ser ignorado.
1052
+ notes.push(
1053
+ `O Actions não pode abrir PR com o GITHUB_TOKEN, mas \`${prTokenSecret}\` está ` +
1054
+ 'definido e é ele que será usado — só o NOME é verificável daqui, não o valor ' +
1055
+ 'nem as permissões.'
1056
+ );
1057
+ } else if (canCreatePr === null) {
1058
+ notes.push(
1059
+ 'Não foi possível verificar se o Actions pode criar Pull Requests (a consulta ' +
1060
+ 'exige `administration: read`). Se a publicação falhar com 403, é aqui.'
1061
+ );
1062
+ }
1063
+
1064
+ if (requiredChecks?.length && !prTokenPresent) {
1065
+ piora('fail');
1066
+ notes.push(
1067
+ `A branch default exige status check(s) obrigatório(s): ${requiredChecks.join(', ')}. ` +
1068
+ 'Pull Request aberto pelo GITHUB_TOKEN NÃO dispara workflow, então esses checks ' +
1069
+ 'nunca ficam verdes e nenhum PR de documento se torna mergeável — o fluxo trava no ' +
1070
+ `primeiro passo. Use um PAT no secret \`${prTokenSecret}\` (PR aberto por PAT dispara ` +
1071
+ 'os workflows), ou dispense os checks para as branches `spec-wave/*`.'
1072
+ );
1073
+ } else if (requiredChecks?.length) {
1074
+ notes.push(
1075
+ `A branch default exige ${requiredChecks.join(', ')}; como o PR é aberto por ` +
1076
+ `\`${prTokenSecret}\`, os workflows disparam e os checks podem ficar verdes.`
1077
+ );
1078
+ }
1079
+
1080
+ if (status === 'ok' && notes.length === 0) {
1081
+ notes.push('Os workflows podem publicar documentos por Pull Request.');
1082
+ }
1083
+ return { status, notes };
1084
+ }
1085
+
1086
+ async function checkPrPublishing(ctx) {
1087
+ const name = 'Publicação por Pull Request';
1088
+ const dir = path.join(ctx.root || ctx.cwd, '.github', 'workflows');
1089
+
1090
+ const workflowPerms = {};
1091
+ for (const file of ARTIFACT_WORKFLOW_FILES) {
1092
+ const caminho = path.join(dir, file);
1093
+ if (!existsSync(caminho)) continue; // ausência já é reportada por checkWorkflows
1094
+ let wf;
1095
+ try {
1096
+ wf = yaml.load(readFileSync(caminho, 'utf-8'));
1097
+ } catch {
1098
+ continue; // YAML ilegível é problema de outro check
1099
+ }
1100
+ // O job mais restritivo manda: basta um sem a permissão para o fluxo quebrar.
1101
+ for (const job of Object.values(wf?.jobs || {})) {
1102
+ const atual = workflowPerms[file];
1103
+ const perm = {
1104
+ contents: job?.permissions?.contents,
1105
+ pullRequests: job?.permissions?.['pull-requests'],
1106
+ };
1107
+ if (!atual || perm.pullRequests !== 'write' || perm.contents !== 'write') {
1108
+ workflowPerms[file] = perm;
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ if (Object.keys(workflowPerms).length === 0) {
1114
+ return { name, status: 'warn', detail: 'Workflows não encontrados — rode `init` ou `update`.' };
1115
+ }
1116
+
1117
+ const cfg = ctx.cfg || {};
1118
+ const owner = cfg.owner;
1119
+ const repo = cfg.repo;
1120
+
1121
+ let canCreatePr = null;
1122
+ let requiredChecks = null;
1123
+ let prTokenPresent = null;
1124
+ if (ctx.token && owner && repo) {
1125
+ const octokit = makeOctokit(ctx.token);
1126
+ try {
1127
+ const res = await octokit.request('GET /repos/{owner}/{repo}/actions/secrets',
1128
+ { owner, repo });
1129
+ prTokenPresent = (res.data.secrets || []).some(sec => sec.name === 'GH_PR_TOKEN');
1130
+ } catch {
1131
+ prTokenPresent = null; // sem permissão de ler secrets — não afirmar ausência
1132
+ }
1133
+ try {
1134
+ const res = await octokit.request('GET /repos/{owner}/{repo}/actions/permissions/workflow',
1135
+ { owner, repo });
1136
+ canCreatePr = res.data?.can_approve_pull_request_reviews ?? null;
1137
+ } catch {
1138
+ canCreatePr = null; // exige administration:read — ausência é comum, não é erro
1139
+ }
1140
+ try {
1141
+ const info = await octokit.rest.repos.get({ owner, repo });
1142
+ const branch = info.data.default_branch;
1143
+ const prot = await octokit.rest.repos.getBranchProtection({ owner, repo, branch });
1144
+ requiredChecks = prot.data?.required_status_checks?.contexts || [];
1145
+ } catch {
1146
+ requiredChecks = null; // sem proteção (404) ou sem permissão — nos dois casos, não afirmar
1147
+ }
1148
+ }
1149
+
1150
+ const { status, notes } = inspectPrPublishing({
1151
+ canCreatePr, workflowPerms, requiredChecks, prTokenPresent,
1152
+ });
1153
+ return { name, status, detail: notes.join('\n ') };
1154
+ }
1155
+
970
1156
  async function checkWorkflows(ctx) {
971
1157
  const name = 'Workflows do Actions';
972
1158
  // Ancorado na raiz do projeto, não no cwd: rodar o doctor de um subdiretório
@@ -1068,6 +1254,7 @@ export async function doctor() {
1068
1254
  checkDecompositions,
1069
1255
  checkSpecKit,
1070
1256
  checkWorkflows,
1257
+ checkPrPublishing,
1071
1258
  checkExecutionMode,
1072
1259
  ];
1073
1260
  const results = [];