@spec-wave/cli 0.26.0 → 0.28.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-rest.mjs +52 -0
  3. package/src/cli.mjs +12 -0
  4. package/src/commands/decompose.mjs +166 -39
  5. package/src/commands/doctor.mjs +214 -3
  6. package/src/commands/generate-bug.mjs +22 -16
  7. package/src/commands/generate-plan.mjs +72 -23
  8. package/src/commands/generate-spec.mjs +19 -15
  9. package/src/commands/implement.mjs +47 -22
  10. package/src/commands/install-skill.mjs +18 -8
  11. package/src/commands/preflight.mjs +322 -0
  12. package/src/commands/run.mjs +51 -30
  13. package/src/commands/update.mjs +143 -12
  14. package/src/commands/validate.mjs +84 -17
  15. package/src/config.mjs +18 -0
  16. package/src/lib/artifact-pr.mjs +272 -0
  17. package/src/lib/artifact-publish.mjs +169 -0
  18. package/src/lib/doc-availability.mjs +23 -1
  19. package/src/lib/doc-source.mjs +162 -0
  20. package/src/lib/flow-run.mjs +9 -218
  21. package/src/lib/next-step.mjs +27 -4
  22. package/src/lib/pr-branch.mjs +106 -7
  23. package/src/lib/repo-links.mjs +8 -2
  24. package/src/plugin/.claude-plugin/plugin.json +1 -1
  25. package/src/plugin/README.md +5 -0
  26. package/src/plugin/skills/bug/SKILL.md +2 -2
  27. package/src/plugin/skills/decompose/SKILL.md +4 -4
  28. package/src/plugin/skills/plan/SKILL.md +1 -1
  29. package/src/plugin/skills/preparar-feature/SKILL.md +245 -0
  30. package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
  31. package/src/plugin/skills/preparar-specs/SKILL.md +171 -0
  32. package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
  33. package/src/plugin/skills/preparar-specs/reference/revisao.md +107 -0
  34. package/src/plugin/skills/run/SKILL.md +3 -1
  35. package/src/plugin/skills/spec/SKILL.md +4 -4
  36. package/src/plugin/skills/update/SKILL.md +10 -4
  37. package/src/plugin/skills/workflow/SKILL.md +8 -3
  38. package/src/templates/skill/SKILL.md +13 -10
  39. package/src/templates/workflows/code-review.yml +13 -2
  40. package/src/templates/workflows/critique.yml +1 -1
  41. package/src/templates/workflows/decompose.yml +13 -2
  42. package/src/templates/workflows/generate-bug.yml +17 -6
  43. package/src/templates/workflows/generate-plan.yml +20 -7
  44. package/src/templates/workflows/generate-spec.yml +20 -7
  45. package/src/templates/workflows/qa.yml +13 -0
@@ -13,6 +13,7 @@ import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue
13
13
  import { detectIssueType } from '../lib/issue-type.mjs';
14
14
  import { bugDocPaths } from '../lib/bug-doc.mjs';
15
15
  import { missingDocMessage, existsOnRemote } from '../lib/doc-availability.mjs';
16
+ import { loadArtifact } from '../lib/doc-source.mjs';
16
17
  import { buildBugContext } from '../lib/bug-context.mjs';
17
18
  import { slugify } from '../lib/slugify.mjs';
18
19
  import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
@@ -45,6 +46,32 @@ async function resolveFeature(token, startNodeId) {
45
46
  return null;
46
47
  }
47
48
 
49
+ /**
50
+ * Onde está o documento que não veio no clone — incluindo Pull Request aberto.
51
+ *
52
+ * `existsOnRemote` sozinho consulta só a branch base, então um documento
53
+ * recém-gerado (que vive na branch do PR até alguém mergear) era reportado como
54
+ * "não existe no repositório". Mentira cara: manda o executor refazer do zero um
55
+ * trabalho que está pronto, esperando revisão.
56
+ *
57
+ * Best-effort como a sonda que substitui: nunca lança, nunca bloqueia.
58
+ */
59
+ async function probeMissingDoc({ token, owner, repo, root, pathRel, doc, issueNumber, fallback }) {
60
+ if (issueNumber) {
61
+ const achado = await loadArtifact({ token, owner, repo, root, pathRel, doc, issueNumber })
62
+ .catch(() => null);
63
+ if (achado?.state === 'pending-pr' || achado?.state === 'branch-only') {
64
+ return missingDocMessage({
65
+ pathRel, onRemote: false, fallback, pr: achado.pr, branch: achado.ref,
66
+ });
67
+ }
68
+ if (achado?.state === 'remote') return missingDocMessage({ pathRel, onRemote: true, fallback });
69
+ if (achado?.state === 'missing') return missingDocMessage({ pathRel, onRemote: false, fallback });
70
+ }
71
+ const onRemote = await existsOnRemote({ getFileContent, token, owner, repo, pathRel });
72
+ return missingDocMessage({ pathRel, onRemote, fallback });
73
+ }
74
+
48
75
  // Lê spec.md/plan.md de um docs/features/<slug> se existirem.
49
76
  function readSpecPlan(featureDir) {
50
77
  const specPath = path.join(featureDir, 'spec.md');
@@ -188,7 +215,12 @@ function buildContext({
188
215
  lines.push('');
189
216
  lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
190
217
  lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
191
- lines.push(` 2. Abra o **Pull Request** da Story #${issue.number}.`);
218
+ // Draft de propósito: numa pilha de Stories (um PR baseado no anterior),
219
+ // cada rebase em cascata dispararia o CI inteiro em PRs que ninguém vai
220
+ // mergear naquele estado. Rascunho não mergeia (o GitHub bloqueia) e o
221
+ // required check continua valendo: quem revisa marca o PR como pronto, o
222
+ // `ready_for_review` dispara o CI, e só então o merge destrava.
223
+ 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.`);
192
224
  lines.push(
193
225
  ` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
194
226
  `(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
@@ -371,7 +403,7 @@ export function buildFeatureContext({
371
403
  lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
372
404
  lines.push(' 2. **Implemente** a Task por completo.');
373
405
  lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
374
- lines.push(`3. **Ao concluir TODAS as Tasks da Story:** faça o **commit**, abra o **Pull Request** da Story e **avance a Etapa da Story para ${STAGE_CODE_REVIEW}** (Status ${PROGRESS_TODO}).`);
406
+ 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}).`);
375
407
  lines.push('4. Só então inicie a próxima Story.');
376
408
  lines.push('');
377
409
  lines.push(
@@ -463,12 +495,9 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
463
495
  bugDoc = readFileSync(fileAbs, 'utf-8');
464
496
  p.log.info(`bug.md encontrado em ${chalk.cyan(fileRel)}.`);
465
497
  } else {
466
- const onRemote = await existsOnRemote({
467
- getFileContent, token, owner, repo, pathRel: fileRel,
468
- });
469
- p.log.warn(missingDocMessage({
470
- pathRel: fileRel,
471
- onRemote,
498
+ p.log.warn(await probeMissingDoc({
499
+ token, owner, repo, root: repoRoot,
500
+ pathRel: fileRel, doc: 'bug', issueNumber: bug.number,
472
501
  fallback: 'o contexto assume a investigação inteira.',
473
502
  }));
474
503
  }
@@ -626,12 +655,9 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
626
655
  specPlan = readSpecPlan(featureDir);
627
656
  } else {
628
657
  const specRel = `docs/features/${slugify(feature.title)}/spec.md`;
629
- const onRemote = await existsOnRemote({
630
- getFileContent, token, owner, repo, pathRel: specRel,
631
- });
632
- p.log.warn(missingDocMessage({
633
- pathRel: specRel,
634
- onRemote,
658
+ p.log.warn(await probeMissingDoc({
659
+ token, owner, repo, root: repoRoot,
660
+ pathRel: specRel, doc: 'spec', issueNumber: feature.number,
635
661
  fallback: 'seguindo só com as Stories.',
636
662
  }));
637
663
  }
@@ -839,14 +865,13 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
839
865
  const specRel = feature?.title
840
866
  ? `docs/features/${slugify(feature.title)}/spec.md`
841
867
  : featureDir;
842
- const onRemote = feature?.title
843
- ? await existsOnRemote({ getFileContent, token, owner, repo, pathRel: specRel })
844
- : null;
845
- p.log.warn(missingDocMessage({
846
- pathRel: specRel,
847
- onRemote,
848
- fallback: 'seguindo só com as tasks.',
849
- }));
868
+ p.log.warn(feature?.title
869
+ ? await probeMissingDoc({
870
+ token, owner, repo, root: repoRoot,
871
+ pathRel: specRel, doc: 'spec', issueNumber: feature?.number,
872
+ fallback: 'seguindo só com as tasks.',
873
+ })
874
+ : missingDocMessage({ pathRel: specRel, onRemote: null, fallback: 'seguindo só com as tasks.' }));
850
875
  } else {
851
876
  p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
852
877
  }
@@ -141,18 +141,28 @@ export function renderContent(format, parsed, version) {
141
141
  }
142
142
  }
143
143
 
144
- // Insere/atualiza o bloco spec-wave num arquivo compartilhado (AGENTS.md),
145
- // preservando o restante do conteúdo. Idempotente via marcadores.
146
- export function mergeAgentsFile(destPath, block) {
147
- const existing = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
144
+ // Insere/atualiza o bloco spec-wave num texto de arquivo compartilhado
145
+ // (AGENTS.md), preservando o restante do conteúdo. Idempotente via marcadores.
146
+ //
147
+ // Versão PURA, separada da que o disco porque o modo `--branch` do update
148
+ // precisa mesclar o bloco no conteúdo da BASE, não no do arquivo local: o
149
+ // AGENTS.md do desenvolvedor pode ter edições ainda não commitadas, e arrastá-las
150
+ // para dentro do Pull Request seria enviar o que ninguém pediu para revisar.
151
+ export function mergeAgentsContent(existing, block) {
152
+ const atual = existing || '';
148
153
  const blockRe = new RegExp(
149
154
  `${escapeRe(BLOCK_START)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`,
150
155
  );
151
- if (blockRe.test(existing)) {
152
- return existing.replace(blockRe, block);
156
+ if (blockRe.test(atual)) {
157
+ return atual.replace(blockRe, block);
153
158
  }
154
- if (existing.trim() === '') return block;
155
- return `${existing.trimEnd()}\n\n${block}`;
159
+ if (atual.trim() === '') return block;
160
+ return `${atual.trimEnd()}\n\n${block}`;
161
+ }
162
+
163
+ // Mesmo merge, lendo o arquivo de destino do disco.
164
+ export function mergeAgentsFile(destPath, block) {
165
+ return mergeAgentsContent(existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '', block);
156
166
  }
157
167
 
158
168
  function escapeRe(s) {
@@ -0,0 +1,322 @@
1
+ // Preflight de uma rodada de geração de specs por milestone.
2
+ //
3
+ // Existe porque **cada geração paga um modelo**: descobrir na sétima Feature que
4
+ // a credencial estava errada custa sete gerações. Este comando levanta de uma
5
+ // vez tudo que decide a estratégia da rodada e tudo que costuma fazê-la falhar
6
+ // no fim — antes de gerar qualquer coisa.
7
+ //
8
+ // Ele NÃO é um segundo `doctor`. As verificações de ambiente reaproveitam os
9
+ // mesmos inspetores puros que o doctor usa (`describeModeState`,
10
+ // `inspectPrPublishing`); o que é novo aqui é o **inventário da milestone**:
11
+ // quais Features existem, e onde está o `spec.md` de cada uma.
12
+ //
13
+ // E "onde está" é a parte que não dá para improvisar com `existsSync`. Um
14
+ // documento recém-gerado vive numa branch `spec-wave/<n>-spec` que ninguém
15
+ // mergeou: quem procura só no disco o declara ausente e manda gerar de novo —
16
+ // pagando o modelo pela segunda vez pelo mesmo documento. Por isso o estado sai
17
+ // de `artifactStates`, que enxerga as quatro camadas.
18
+
19
+ import * as p from '@clack/prompts';
20
+ import chalk from 'chalk';
21
+
22
+ import { resolveToken } from '../api/auth.mjs';
23
+ import {
24
+ getRepoDefaultBranch, getRepoVariable, listMilestones, listIssuesByMilestone,
25
+ } from '../api/github-rest.mjs';
26
+ import { CONFIG_FILE } from '../config.mjs';
27
+ import { inspectPrPublishing, readPrPublishingContext } from './doctor.mjs';
28
+ import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
29
+ import { detectIssueType } from '../lib/issue-type.mjs';
30
+ import { isAwaitingMerge, artifactStates } from '../lib/doc-source.mjs';
31
+ import { featureDocPaths } from '../lib/doc-paths.mjs';
32
+ import { STEPS } from '../lib/next-step.mjs';
33
+ import { loadConfig } from '../lib/project-root.mjs';
34
+ import { unguardedWorkflows } from './mode.mjs';
35
+
36
+ const TRIGGER_LABELS = Object.values(STEPS).map(s => s.trigger).filter(Boolean);
37
+
38
+ /**
39
+ * O inventário da milestone (função PURA — é onde mora a decisão).
40
+ *
41
+ * Separa as Features em quatro destinos porque cada um pede uma ação diferente,
42
+ * e confundi-los é caro nos dois sentidos: tratar `pending-pr` como ausente
43
+ * regera um documento já pago e ainda descarta a revisão em curso; tratar
44
+ * `unknown` (falha de rede) como pronto pula uma Feature que ninguém escreveu.
45
+ *
46
+ * @param {object} [a]
47
+ * @param {Array<{number:number,title:string,state?:string,labels?:Array}>} [a.issues]
48
+ * issues da milestone, como a API devolve (de qualquer tipo)
49
+ * @param {Record<number, {state: string, pr: object|null}>} [a.specStates]
50
+ * estado do `spec.md` por número de issue
51
+ * @returns {{ features: object[], gerar: object[], prontas: object[],
52
+ * aguardandoMerge: object[], indefinidas: object[], fechadas: object[],
53
+ * gatilhosPendentes: Array<{number:number,labels:string[]}> }}
54
+ */
55
+ export function inspectMilestone({ issues = [], specStates = {} } = {}) {
56
+ const features = (issues || [])
57
+ .filter(i => detectIssueType(i) === 'Feature')
58
+ .map(i => {
59
+ const { state = 'unknown', pr = null } = specStates[i.number] || {};
60
+ return {
61
+ number: i.number,
62
+ title: i.title,
63
+ closed: i.state === 'closed',
64
+ spec: state,
65
+ pr,
66
+ labels: (i.labels || []).map(l => (typeof l === 'string' ? l : l?.name)).filter(Boolean),
67
+ };
68
+ })
69
+ .sort((a, b) => a.number - b.number);
70
+
71
+ const abertas = features.filter(f => !f.closed);
72
+ return {
73
+ features,
74
+ fechadas: features.filter(f => f.closed),
75
+ gerar: abertas.filter(f => f.spec === 'missing'),
76
+ prontas: abertas.filter(f => f.spec === 'local' || f.spec === 'remote'),
77
+ aguardandoMerge: abertas.filter(f => isAwaitingMerge(f.spec)),
78
+ indefinidas: abertas.filter(f => f.spec === 'unknown'),
79
+ // Label de gatilho grudada significa Action em execução — ou uma que falhou e
80
+ // a deixou para trás. Nos dois casos o `run` se recusa a executar (portão
81
+ // `trigger-pending`), então a rodada travaria Feature a Feature.
82
+ gatilhosPendentes: features
83
+ .map(f => ({ number: f.number, labels: f.labels.filter(l => TRIGGER_LABELS.includes(l)) }))
84
+ .filter(f => f.labels.length > 0),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Resolve o título da milestone para o número que a API de issues aceita (PURA).
90
+ *
91
+ * Casamento exato primeiro; sem ele, um único casamento sem diferenciar
92
+ * maiúsculas. Ambíguo é erro, não escolha silenciosa: gerar as specs da
93
+ * milestone errada custa uma geração por Feature.
94
+ *
95
+ * @param {Array<{number:number,title:string,state?:string}>} milestones
96
+ * @param {string} titulo
97
+ * @returns {{ milestone: object|null, error: string|null }}
98
+ */
99
+ export function resolveMilestone(milestones = [], titulo = '') {
100
+ const alvo = String(titulo || '').trim();
101
+ const lista = () => (milestones.length
102
+ ? milestones.map(m => `"${m.title}"`).join(', ')
103
+ : '(o repositório não tem milestone nenhuma)');
104
+
105
+ if (!alvo) return { milestone: null, error: `Milestone não informada. Existem: ${lista()}.` };
106
+
107
+ const exata = milestones.find(m => m.title === alvo);
108
+ if (exata) return { milestone: exata, error: null };
109
+
110
+ const caseInsensitive = milestones.filter(m => m.title.toLowerCase() === alvo.toLowerCase());
111
+ if (caseInsensitive.length === 1) return { milestone: caseInsensitive[0], error: null };
112
+ if (caseInsensitive.length > 1) {
113
+ return { milestone: null, error: `Milestone "${alvo}" é ambígua entre ${lista()}.` };
114
+ }
115
+ return { milestone: null, error: `Milestone "${alvo}" não existe. Existem: ${lista()}.` };
116
+ }
117
+
118
+ /**
119
+ * Veredito final da rodada (função PURA).
120
+ *
121
+ * `bloqueios` impedem gerar; `avisos` não. A distinção é a razão de o comando
122
+ * existir: sair 1 por algo que não impede a geração faria o usuário aprender a
123
+ * ignorar o preflight, que é o mesmo que não o ter.
124
+ *
125
+ * @returns {{ status: 'ok'|'aviso'|'bloqueio', bloqueios: string[], avisos: string[] }}
126
+ */
127
+ export function inspectPreflight({
128
+ tokenOk = true, modo = null, publicacao = null, inventario = null,
129
+ } = {}) {
130
+ const bloqueios = [];
131
+ const avisos = [];
132
+
133
+ if (!tokenOk) bloqueios.push('Sem token utilizável para este repositório.');
134
+
135
+ if (modo?.status === 'problem') bloqueios.push(modo.summary);
136
+ else if (modo?.status === 'warn') avisos.push(modo.summary);
137
+
138
+ // A publicação por PR é o desfecho de TODA geração: sem ela o documento é
139
+ // gerado, o commit criado e nenhum PR aparece — a falha mais cara do fluxo,
140
+ // porque o modelo já foi pago quando ela acontece.
141
+ if (publicacao?.status === 'fail') bloqueios.push(...publicacao.notes);
142
+ else if (publicacao?.status === 'warn') avisos.push(...publicacao.notes);
143
+
144
+ if (inventario) {
145
+ if (inventario.features.length === 0) {
146
+ bloqueios.push('Nenhuma Feature [FEATURE] nesta milestone.');
147
+ } else if (inventario.gerar.length === 0) {
148
+ avisos.push('Nenhuma Feature pendente: todas já têm spec.md.');
149
+ }
150
+ if (inventario.gatilhosPendentes.length > 0) {
151
+ avisos.push(
152
+ `${inventario.gatilhosPendentes.length} issue(s) com label de gatilho pendente — ` +
153
+ 'o `run` se recusa a executar nelas até a label sair.'
154
+ );
155
+ }
156
+ if (inventario.aguardandoMerge.length > 0) {
157
+ avisos.push(
158
+ `${inventario.aguardandoMerge.length} spec(s) já geradas aguardando merge — ` +
159
+ 'NÃO as gere de novo: o documento existe e regerá-lo descarta a revisão.'
160
+ );
161
+ }
162
+ if (inventario.indefinidas.length > 0) {
163
+ avisos.push(
164
+ `${inventario.indefinidas.length} Feature(s) com estado indeterminado (falha de rede) — ` +
165
+ 'confirme antes de gerar, para não pagar duas vezes.'
166
+ );
167
+ }
168
+ }
169
+
170
+ if (bloqueios.length) return { status: 'bloqueio', bloqueios, avisos };
171
+ if (avisos.length) return { status: 'aviso', bloqueios, avisos };
172
+ return { status: 'ok', bloqueios, avisos };
173
+ }
174
+
175
+ const MARCA = { local: '✓ no disco', remote: '✓ na base', 'pending-pr': '⏳ em PR aberto', 'branch-only': '⚠ em branch sem PR', missing: '· gerar', unknown: '? indeterminado' };
176
+
177
+ export async function preflight({ milestone: milestoneArg, json = false } = {}) {
178
+ const saida = { milestone: null, modo: null, publicacao: null, inventario: null, veredito: null };
179
+ const falhar = (msg) => {
180
+ if (json) console.log(JSON.stringify({ ...saida, erro: msg }, null, 2));
181
+ else p.log.error(msg);
182
+ process.exitCode = 1;
183
+ };
184
+
185
+ if (!json) p.intro(chalk.bold('spec-wave preflight'));
186
+
187
+ const { config, root, error: configError } = loadConfig();
188
+ if (configError || !config?.owner || !config?.repo) {
189
+ return falhar(
190
+ `${configError || `${CONFIG_FILE} sem owner/repo`} — rode \`spec-wave init\` antes.`
191
+ );
192
+ }
193
+ const { owner, repo } = config;
194
+
195
+ let token;
196
+ try {
197
+ token = await resolveToken();
198
+ } catch (err) {
199
+ return falhar(`Sem token utilizável para ${owner}/${repo}: ${err.message}`);
200
+ }
201
+
202
+ const s = json ? null : p.spinner();
203
+ s?.start('Consultando o repositório...');
204
+
205
+ let base;
206
+ try {
207
+ base = await getRepoDefaultBranch(token, owner, repo);
208
+ } catch (err) {
209
+ s?.stop('');
210
+ return falhar(`Não foi possível ler o repositório ${owner}/${repo}: ${err.message}`);
211
+ }
212
+
213
+ // --- Modo de execução: config × variável (o mesmo par que o doctor confere).
214
+ let variable;
215
+ try {
216
+ variable = await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
217
+ } catch {
218
+ variable = undefined; // exige admin — "não deu para ler" não é "ausente"
219
+ }
220
+ const modo = describeModeState({
221
+ configured: configuredMode(config),
222
+ variable,
223
+ unguardedWorkflows: unguardedWorkflows(root),
224
+ });
225
+
226
+ // --- Publicação por Pull Request: é o desfecho de toda geração.
227
+ const publicacao = inspectPrPublishing(
228
+ await readPrPublishingContext({ token, owner, repo, root })
229
+ );
230
+
231
+ // --- Inventário da milestone.
232
+ let milestones;
233
+ try {
234
+ milestones = await listMilestones(token, owner, repo);
235
+ } catch (err) {
236
+ s?.stop('');
237
+ return falhar(`Não foi possível listar as milestones: ${err.message}`);
238
+ }
239
+ const { milestone, error: msError } = resolveMilestone(milestones, milestoneArg);
240
+ if (msError) {
241
+ s?.stop('');
242
+ return falhar(msError);
243
+ }
244
+
245
+ let issues;
246
+ try {
247
+ issues = await listIssuesByMilestone(token, owner, repo, milestone.number);
248
+ } catch (err) {
249
+ s?.stop('');
250
+ return falhar(`Não foi possível listar as issues da milestone "${milestone.title}": ${err.message}`);
251
+ }
252
+
253
+ // O estado do spec.md de cada Feature, nas quatro camadas. Uma issue por vez:
254
+ // são até 3 requisições cada, e disparar tudo de uma vez numa milestone de 20
255
+ // Features é o caminho mais curto para o rate limit secundário.
256
+ const featureIssues = issues.filter(i => detectIssueType(i) === 'Feature');
257
+ const specStates = {};
258
+ for (const issue of featureIssues) {
259
+ const docs = featureDocPaths(root, issue, 'Feature');
260
+ const estados = await artifactStates({
261
+ token, owner, repo, root, base,
262
+ issueNumber: issue.number,
263
+ docs: [{ doc: 'spec', pathRel: docs.spec.rel }],
264
+ });
265
+ specStates[issue.number] = estados.spec;
266
+ }
267
+
268
+ const inventario = inspectMilestone({ issues, specStates });
269
+ const veredito = inspectPreflight({ tokenOk: true, modo, publicacao, inventario });
270
+ s?.stop(`Milestone "${milestone.title}": ${inventario.features.length} Feature(s).`);
271
+
272
+ Object.assign(saida, {
273
+ milestone: { title: milestone.title, number: milestone.number, base },
274
+ modo: { configured: configuredMode(config), variable: variable ?? null, status: modo.status, summary: modo.summary },
275
+ publicacao: { status: publicacao.status, notes: publicacao.notes },
276
+ inventario,
277
+ veredito,
278
+ });
279
+
280
+ if (json) {
281
+ console.log(JSON.stringify(saida, null, 2));
282
+ if (veredito.status === 'bloqueio') process.exitCode = 1;
283
+ return saida;
284
+ }
285
+
286
+ // ---------- Relatório ----------
287
+ const linhas = [];
288
+ linhas.push(`${chalk.bold('Repositório:')} ${owner}/${repo} (base: ${base})`);
289
+ linhas.push(`${chalk.bold('Modo:')} config=${configuredMode(config)} · variável=${variable ?? '<ausente>'}`);
290
+ linhas.push(` ${modo.summary}`);
291
+ linhas.push(`${chalk.bold('Publicação por PR:')} ${publicacao.status}`);
292
+ for (const nota of publicacao.notes) linhas.push(` ${nota}`);
293
+ p.note(linhas.join('\n'), 'Ambiente');
294
+
295
+ const inv = [];
296
+ for (const f of inventario.features) {
297
+ const marca = f.closed ? '× fechada' : (MARCA[f.spec] || f.spec);
298
+ const pr = f.pr ? chalk.dim(` PR #${f.pr.number}`) : '';
299
+ inv.push(` ${marca.padEnd(20)} #${f.number} ${f.title.slice(0, 60)}${pr}`);
300
+ }
301
+ if (inventario.gatilhosPendentes.length) {
302
+ inv.push('');
303
+ inv.push(chalk.yellow(' Labels de gatilho pendentes (remova antes de gerar):'));
304
+ for (const g of inventario.gatilhosPendentes) inv.push(` #${g.number}: ${g.labels.join(', ')}`);
305
+ }
306
+ p.note(inv.join('\n'), `Milestone "${milestone.title}" — ${inventario.gerar.length} a gerar`);
307
+
308
+ for (const a of veredito.avisos) p.log.warn(a);
309
+ for (const b of veredito.bloqueios) p.log.error(b);
310
+
311
+ if (veredito.status === 'bloqueio') {
312
+ p.outro(`${veredito.bloqueios.length} bloqueio(s). Resolva antes de gerar.`);
313
+ process.exitCode = 1;
314
+ return saida;
315
+ }
316
+ p.outro(
317
+ inventario.gerar.length
318
+ ? `Preflight ok. Confirme a lista com o usuário antes de gerar ${inventario.gerar.length} spec(s).`
319
+ : 'Preflight ok. Nada a gerar.'
320
+ );
321
+ return saida;
322
+ }
@@ -20,9 +20,9 @@ import path from 'node:path';
20
20
  import chalk from 'chalk';
21
21
 
22
22
  import { resolveToken } from '../api/auth.mjs';
23
- import { getIssue, getPR, getFileContent, listPullRequestReviews } from '../api/github-rest.mjs';
23
+ import { getIssue, getPR, listPullRequestReviews } from '../api/github-rest.mjs';
24
24
  import { loadProjectConfig } from '../lib/board.mjs';
25
- import { existsOnRemote } from '../lib/doc-availability.mjs';
25
+ import { loadArtifact } from '../lib/doc-source.mjs';
26
26
  import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
27
27
  import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
28
28
  import { detectIssueType } from '../lib/issue-type.mjs';
@@ -114,19 +114,6 @@ function releaseLock(file) {
114
114
  } catch { /* já removido */ }
115
115
  }
116
116
 
117
- // Commits locais ainda não publicados: gerar o próximo documento por cima de um
118
- // anterior que ficou só no clone é execução parcial disfarçada de sucesso.
119
- function unpushedCommits(root) {
120
- try {
121
- const out = execSync('git rev-list --count @{u}..HEAD', {
122
- cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
123
- }).trim();
124
- return Number.parseInt(out, 10) || 0;
125
- } catch {
126
- return 0; // sem upstream configurado: não dá para afirmar nada
127
- }
128
- }
129
-
130
117
  function assertNotInActions() {
131
118
  if (!isActionsRun()) return;
132
119
  throw new Error(
@@ -189,18 +176,42 @@ export function docsToProbe(decision, type) {
189
176
  return stepDocs(decision.action, type);
190
177
  }
191
178
 
192
- async function probeRemote({ alvos, docs, docPaths, token, owner, repo }) {
193
- if (alvos.length === 0) return docs;
179
+ /**
180
+ * Resolve o estado remoto dos documentos que faltam no clone.
181
+ *
182
+ * Duas camadas, não uma: além da branch base, o documento pode estar num Pull
183
+ * Request ainda não mergeado — é onde ele NASCE agora. Sem essa segunda camada,
184
+ * o `run` concluiria "não existe" e regeraria a spec a cada execução até alguém
185
+ * mergear, pagando a IA de novo toda vez e descartando as edições do revisor.
186
+ *
187
+ * `doc` do resolvedor usa 'decomposition' com o mesmo nome do fluxo, então o
188
+ * mapeamento é direto.
189
+ */
190
+ async function probeRemote({ alvos, docs, docPaths, token, owner, repo, root, issueNumber }) {
191
+ if (alvos.length === 0) return { docs, docPrs: {} };
194
192
 
195
193
  const atualizado = { ...docs };
194
+ const docPrs = {};
196
195
  for (const doc of alvos) {
197
- const onRemote = await existsOnRemote({
198
- getFileContent, token, owner, repo, pathRel: docPaths[doc],
199
- });
200
- if (onRemote === true) atualizado[doc] = 'remote';
201
- else if (onRemote === null) atualizado[doc] = 'unknown';
196
+ const achado = await loadArtifact({
197
+ token, owner, repo, root, pathRel: docPaths[doc], doc, issueNumber,
198
+ }).catch(() => null);
199
+
200
+ if (!achado) {
201
+ atualizado[doc] = 'unknown';
202
+ continue;
203
+ }
204
+ // 'local' aqui só aconteceria se o arquivo tivesse aparecido no meio da
205
+ // execução; manter o estado que a sonda local já apurou é mais previsível.
206
+ if (achado.state === 'local') continue;
207
+ atualizado[doc] = achado.state;
208
+ // Guarda PR e branch: o bloqueio de `branch-only` cita a branch, porque não
209
+ // há PR nenhum a citar.
210
+ if (achado.state === 'pending-pr' || achado.state === 'branch-only') {
211
+ docPrs[doc] = { pr: achado.pr || null, branch: achado.ref || null };
212
+ }
202
213
  }
203
- return atualizado;
214
+ return { docs: atualizado, docPrs };
204
215
  }
205
216
 
206
217
  // ---------------------------------------------------------------------------
@@ -432,8 +443,10 @@ export async function run(issueArg, options = {}) {
432
443
  if (remoteCheck) {
433
444
  const alvos = docsToProbe(decision, type).filter(doc => docs[doc] === 'missing');
434
445
  if (alvos.length > 0) {
435
- const docsRemotos = await probeRemote({ alvos, docs, docPaths, token, owner, repo });
436
- decision = nextStep({ ...entrada, docs: docsRemotos });
446
+ const sondado = await probeRemote({
447
+ alvos, docs, docPaths, token, owner, repo, root, issueNumber,
448
+ });
449
+ decision = nextStep({ ...entrada, docs: sondado.docs, docPrs: sondado.docPrs });
437
450
  }
438
451
  }
439
452
  ultimaDecisao = decision;
@@ -470,11 +483,19 @@ export async function run(issueArg, options = {}) {
470
483
  break;
471
484
  }
472
485
 
473
- const pendentes = unpushedCommits(root);
474
- if (pendentes > 0) {
475
- console.warn(chalk.yellow(
476
- `\n⚠️ ${pendentes} commit(s) ainda não publicado(s). O próximo passo leria um documento ` +
477
- 'que existe no seu clone publique antes de continuar.'
486
+ // O passo publicou um documento em Pull Request? Então o próximo depende do
487
+ // merge, e encadear agora só produziria um bloqueio `pr-pending` uma volta
488
+ // depois — com uma mensagem menos direta e uma consulta à API a mais.
489
+ //
490
+ // Substitui o antigo guard de commits não publicados, que ficou
491
+ // permanentemente em zero: a publicação deixou de passar por git local.
492
+ const publicado = resultado?.pr || resultado?.published?.pr;
493
+ if (publicado?.number) {
494
+ console.log(chalk.yellow(
495
+ `\n⏸️ O documento foi publicado no PR #${publicado.number}` +
496
+ `${publicado.url ? ` (${publicado.url})` : ''}.\n` +
497
+ ' O próximo passo do fluxo lê esse documento da branch base — ' +
498
+ 'revise e faça o merge para continuar.'
478
499
  ));
479
500
  break;
480
501
  }