@spec-wave/cli 0.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -294,6 +294,33 @@ export async function createIssue(token, owner, repo, title, body, labels, { mil
294
294
  return { number: res.data.number, nodeId: res.data.node_id, url: res.data.html_url, id: res.data.id };
295
295
  }
296
296
 
297
+ // Milestones do repositório (abertas e fechadas).
298
+ //
299
+ // O usuário fala o TÍTULO da milestone ("v06"), a API de issues filtra pelo
300
+ // NÚMERO. A tradução mora aqui — e a lista completa também serve para a
301
+ // mensagem de erro: "milestone não encontrada" sem dizer quais existem manda o
302
+ // usuário adivinhar entre nome errado e API fora do ar.
303
+ export async function listMilestones(token, owner, repo) {
304
+ const octokit = makeOctokit(token);
305
+ return await octokit.paginate(octokit.rest.issues.listMilestones, {
306
+ owner, repo, state: 'all', per_page: 100,
307
+ });
308
+ }
309
+
310
+ // Issues de uma milestone (pelo NÚMERO dela), abertas e fechadas.
311
+ //
312
+ // `state: 'all'` de propósito: uma Feature fechada continua contando para o
313
+ // inventário — o que muda é que ela não entra na lista do que gerar.
314
+ export async function listIssuesByMilestone(token, owner, repo, milestoneNumber) {
315
+ const octokit = makeOctokit(token);
316
+ const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
317
+ owner, repo, milestone: String(milestoneNumber), state: 'all', per_page: 100,
318
+ });
319
+ // `listForRepo` devolve Pull Requests junto — eles são issues para a API, e
320
+ // não para o fluxo.
321
+ return issues.filter(i => !i.pull_request);
322
+ }
323
+
297
324
  export async function getIssue(token, owner, repo, issueNumber) {
298
325
  const octokit = makeOctokit(token);
299
326
  const res = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber });
package/src/cli.mjs CHANGED
@@ -162,6 +162,16 @@ export function buildProgram() {
162
162
  await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
163
163
  });
164
164
 
165
+ program
166
+ .command('preflight')
167
+ .description('Confere, antes de gerar, tudo que decide uma rodada de specs de uma milestone')
168
+ .requiredOption('--milestone <nome>', 'Título da milestone a inventariar')
169
+ .option('--json', 'Imprime o relatório em JSON')
170
+ .action(async (options) => {
171
+ const { preflight } = await import('./commands/preflight.mjs');
172
+ await preflight(options).catch(err => { console.error(err.message); process.exit(1); });
173
+ });
174
+
165
175
  program
166
176
  .command('mode')
167
177
  .description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
@@ -188,6 +198,8 @@ export function buildProgram() {
188
198
  .option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
189
199
  .option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
190
200
  .option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
201
+ .option('--skill-in-pr', 'Força incluir a skill dos agentes no Pull Request')
202
+ .option('--no-skill-in-pr', 'Força manter a skill dos agentes fora do Pull Request')
191
203
  .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
192
204
  .option('--yes', 'Aplica sem pedir confirmação')
193
205
  .action(async (options) => {
@@ -1083,10 +1083,15 @@ export function inspectPrPublishing({
1083
1083
  return { status, notes };
1084
1084
  }
1085
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
-
1086
+ /**
1087
+ * Permissões de PR declaradas nos workflows instalados (I/O — nunca lança).
1088
+ *
1089
+ * O job MAIS RESTRITIVO manda: basta um sem a permissão para o fluxo quebrar.
1090
+ *
1091
+ * @param {string} dir diretório `.github/workflows`
1092
+ * @returns {Record<string, {contents?: string, pullRequests?: string}>}
1093
+ */
1094
+ export function readWorkflowPrPermissions(dir) {
1090
1095
  const workflowPerms = {};
1091
1096
  for (const file of ARTIFACT_WORKFLOW_FILES) {
1092
1097
  const caminho = path.join(dir, file);
@@ -1097,7 +1102,6 @@ async function checkPrPublishing(ctx) {
1097
1102
  } catch {
1098
1103
  continue; // YAML ilegível é problema de outro check
1099
1104
  }
1100
- // O job mais restritivo manda: basta um sem a permissão para o fluxo quebrar.
1101
1105
  for (const job of Object.values(wf?.jobs || {})) {
1102
1106
  const atual = workflowPerms[file];
1103
1107
  const perm = {
@@ -1109,20 +1113,29 @@ async function checkPrPublishing(ctx) {
1109
1113
  }
1110
1114
  }
1111
1115
  }
1116
+ return workflowPerms;
1117
+ }
1112
1118
 
1113
- if (Object.keys(workflowPerms).length === 0) {
1114
- return { name, status: 'warn', detail: 'Workflows não encontradosrode `init` ou `update`.' };
1115
- }
1116
-
1117
- const cfg = ctx.cfg || {};
1118
- const owner = cfg.owner;
1119
- const repo = cfg.repo;
1119
+ /**
1120
+ * Coleta o contexto que `inspectPrPublishing` julga (I/Onunca lança).
1121
+ *
1122
+ * Separado do check para o `preflight` poder fazer a MESMA pergunta sem
1123
+ * reimplementá-la: as três consultas têm cada uma o seu motivo para falhar sem
1124
+ * que isso signifique "está errado", e duplicar esse tratamento em dois comandos
1125
+ * é como as duas respostas passam a divergir.
1126
+ *
1127
+ * @param {{token?: string, owner?: string, repo?: string, root?: string}} params
1128
+ * @returns {Promise<{canCreatePr: boolean|null, workflowPerms: object,
1129
+ * requiredChecks: string[]|null, prTokenPresent: boolean|null}>}
1130
+ */
1131
+ export async function readPrPublishingContext({ token, owner, repo, root }) {
1132
+ const workflowPerms = readWorkflowPrPermissions(path.join(root || process.cwd(), '.github', 'workflows'));
1120
1133
 
1121
1134
  let canCreatePr = null;
1122
1135
  let requiredChecks = null;
1123
1136
  let prTokenPresent = null;
1124
- if (ctx.token && owner && repo) {
1125
- const octokit = makeOctokit(ctx.token);
1137
+ if (token && owner && repo) {
1138
+ const octokit = makeOctokit(token);
1126
1139
  try {
1127
1140
  const res = await octokit.request('GET /repos/{owner}/{repo}/actions/secrets',
1128
1141
  { owner, repo });
@@ -1146,10 +1159,21 @@ async function checkPrPublishing(ctx) {
1146
1159
  requiredChecks = null; // sem proteção (404) ou sem permissão — nos dois casos, não afirmar
1147
1160
  }
1148
1161
  }
1162
+ return { canCreatePr, workflowPerms, requiredChecks, prTokenPresent };
1163
+ }
1149
1164
 
1150
- const { status, notes } = inspectPrPublishing({
1151
- canCreatePr, workflowPerms, requiredChecks, prTokenPresent,
1165
+ async function checkPrPublishing(ctx) {
1166
+ const name = 'Publicação por Pull Request';
1167
+ const cfg = ctx.cfg || {};
1168
+ const contexto = await readPrPublishingContext({
1169
+ token: ctx.token, owner: cfg.owner, repo: cfg.repo, root: ctx.root || ctx.cwd,
1152
1170
  });
1171
+
1172
+ if (Object.keys(contexto.workflowPerms).length === 0) {
1173
+ return { name, status: 'warn', detail: 'Workflows não encontrados — rode `init` ou `update`.' };
1174
+ }
1175
+
1176
+ const { status, notes } = inspectPrPublishing(contexto);
1153
1177
  return { name, status, detail: notes.join('\n ') };
1154
1178
  }
1155
1179
 
@@ -215,7 +215,12 @@ function buildContext({
215
215
  lines.push('');
216
216
  lines.push(`3. **Ao concluir TODA a Story** (todas as Tasks na Etapa ${STAGE_DONE}):`);
217
217
  lines.push(' 1. Faça o **commit** de todas as mudanças da implementação.');
218
- 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.`);
219
224
  lines.push(
220
225
  ` 3. **Avance a Etapa da Story #${issue.number} para ${STAGE_CODE_REVIEW}** ` +
221
226
  `(reinicie o Status para ${PROGRESS_TODO}). As Tasks já estão em ${STAGE_DONE}.`
@@ -398,7 +403,7 @@ export function buildFeatureContext({
398
403
  lines.push(` 1. **Ao começar:** Status da Task → **${PROGRESS_IN_PROGRESS}** (a Etapa continua ${STAGE_DEVELOPMENT}).`);
399
404
  lines.push(' 2. **Implemente** a Task por completo.');
400
405
  lines.push(` 3. **Ao concluir:** **avance a Task para a Etapa ${STAGE_DONE}** com Status **${PROGRESS_DONE}**.`);
401
- 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}).`);
402
407
  lines.push('4. Só então inicie a próxima Story.');
403
408
  lines.push('');
404
409
  lines.push(
@@ -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
+ }