@spec-wave/cli 0.18.1 → 0.19.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/bin/spec-wave.mjs CHANGED
@@ -11,7 +11,19 @@ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', 'package.json'), 'utf
11
11
  program
12
12
  .name('spec-wave')
13
13
  .description('Setup spec-driven GitHub workflow with Projects v2')
14
- .version(pkg.version);
14
+ .version(pkg.version)
15
+ // Conta do `gh` a usar nesta execução. Existe porque um GH_TOKEN exportado no
16
+ // shell (um `.envrc` na raiz de um diretório de projetos, por exemplo) vale
17
+ // para repositórios de QUALQUER org abaixo dele — e o GraphQL do GitHub
18
+ // mascara o 403 resultante como "Could not resolve to a Repository".
19
+ .option('--account <login>', 'Conta do gh a usar (vence GITHUB_TOKEN/GH_TOKEN fora do CI)')
20
+ .hook('preAction', async (thisCommand) => {
21
+ const { account } = thisCommand.opts();
22
+ if (account) {
23
+ const { setAccountOverride } = await import('../src/api/auth.mjs');
24
+ setAccountOverride(account);
25
+ }
26
+ });
15
27
 
16
28
  program
17
29
  .command('init')
@@ -172,8 +184,11 @@ program
172
184
 
173
185
  program
174
186
  .command('critique')
175
- .description('Re-critica o plan.md COMO ESTÁ, sem regerar — para depois de corrigi-lo à mão')
176
- .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
187
+ .description('Critica um documento COMO ESTÁ, sem regerar — para depois de corrigi-lo à mão')
188
+ .option('--issue-number <n>', 'Número da issue: critica o plan.md dela e comenta (fluxo canônico)')
189
+ .option('--file <caminho>', 'Critica ESTE arquivo e imprime o resultado — sem issue, sem label, sem contar tentativa')
190
+ .option('--kind <tipo>', 'plan | spec | stories | bug (default: inferido do nome do arquivo)')
191
+ .option('--fail-on-grave', 'Sai com código 1 se houver finding grave (útil em script)')
177
192
  .action(async (options) => {
178
193
  const { critique } = await import('../src/commands/generate-plan.mjs');
179
194
  await critique(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -220,6 +235,7 @@ program
220
235
  .command('code-review')
221
236
  .description('Move Feature para Code Review ao abrir um PR (usado pelo GitHub Action)')
222
237
  .requiredOption('--pr-number <n>', 'Número do Pull Request')
238
+ .option('--resolve-only', 'Só descobre a Feature-alvo e imprime feature=<n>, sem tocar no board')
223
239
  .action(async (options) => {
224
240
  const { codeReview } = await import('../src/commands/code-review.mjs');
225
241
  await codeReview(options).catch(err => { console.error(err.message); process.exit(1); });
@@ -276,6 +292,21 @@ program
276
292
  .catch(err => { console.error(err.message); process.exit(1); });
277
293
  });
278
294
 
295
+ program
296
+ .command('repair-stage')
297
+ .description('Corrige a Etapa de itens que a automação errou — inclusive retrocedendo. Exige --yes e --reason, e registra o reparo na issue')
298
+ .argument('<issues>', 'Número(s) da(s) issue(s), ex.: 529 ou 529,530,531')
299
+ .argument('<etapa>', 'Etapa correta, com ou sem emoji, ex.: "ready", "✅ Ready"')
300
+ .option('--reason <motivo>', 'Por que o reparo é necessário (vai para o comentário de auditoria)')
301
+ .option('--status <valor>', 'Também corrige o Status: Todo, In Progress ou Done (default: não mexe)')
302
+ .option('--yes', 'Confirma o reparo (obrigatório)')
303
+ .option('--dry-run', 'Mostra o que seria reparado sem alterar nada')
304
+ .action(async (issues, etapa, options) => {
305
+ const { repairStage } = await import('../src/commands/repair-stage.mjs');
306
+ await repairStage(issues, etapa, options)
307
+ .catch(err => { console.error(err.message); process.exit(1); });
308
+ });
309
+
279
310
  program
280
311
  .command('story')
281
312
  .description('Gerencia uma Story no board: review (move para Code Review)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.18.1",
3
+ "version": "0.19.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": {
@@ -25,6 +25,7 @@
25
25
  import path from 'node:path';
26
26
  import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
27
27
  import { ENV_FILE_PATTERN } from './tools.mjs';
28
+ import { summarizeToolCalls } from './errors.mjs';
28
29
  import { TruncatedOutputError } from './errors.mjs';
29
30
 
30
31
  const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
@@ -68,6 +69,9 @@ export async function runAnthropicAgent(prompt, options) {
68
69
  numTurns: 0,
69
70
  structured: null,
70
71
  usage: { inputTokens: 0, outputTokens: 0 },
72
+ // Simétrico ao backend openrouter: sem isto, um run que estoura o teto de
73
+ // turnos não deixa registro do que o modelo esteve fazendo.
74
+ toolCalls: [],
71
75
  };
72
76
  const quiet = options.quiet !== false;
73
77
 
@@ -154,6 +158,7 @@ export async function runAnthropicAgent(prompt, options) {
154
158
  };
155
159
  }
156
160
 
161
+ runResult.toolCalls.push(input.tool_name);
157
162
  if (options.verbose) {
158
163
  console.log(`\n[Tool] ${input.tool_name}(${JSON.stringify(input.tool_input)})`);
159
164
  }
@@ -288,7 +293,10 @@ export async function runAnthropicAgent(prompt, options) {
288
293
  }
289
294
  } else {
290
295
  output = `[${message.subtype}]`;
291
- console.warn(`\n[Agent] Run ended without success: ${message.subtype}`);
296
+ console.warn(
297
+ `\n[Agent] Run ended without success: ${message.subtype} — ` +
298
+ `ferramentas chamadas: ${summarizeToolCalls(runResult.toolCalls) || 'nenhuma'}`
299
+ );
292
300
  }
293
301
  updateSpan(span, {
294
302
  output,
@@ -328,6 +336,7 @@ export async function runAnthropicAgent(prompt, options) {
328
336
  maxTokens: options.maxTokens ?? null,
329
337
  reason: lastStopReason,
330
338
  chars: runResult.outputText.length,
339
+ action: options.action ?? null,
331
340
  });
332
341
  }
333
342
  },
@@ -19,15 +19,69 @@ export function isTruncationReason(reason) {
19
19
  // custo. Por isso NÃO é marcado como transitório: o erro sobe, o Action falha
20
20
  // visível, destrava a label e comenta na issue o que ajustar.
21
21
  export class TruncatedOutputError extends Error {
22
- constructor({ provider, model, maxTokens, reason, chars }) {
22
+ constructor({ provider, model, maxTokens, reason, chars, action }) {
23
+ // O remédio depende da AÇÃO: dizer "aumente ai.maxTokens" a quem viu a
24
+ // crítica cortar manda mexer no teto errado se houver
25
+ // `maxTokensByAction.critique` configurado — e é o teto por ação que está
26
+ // apertando. Nomear a chave exata poupa uma rodada de tentativa e erro.
27
+ const knob = action
28
+ ? `\`ai.maxTokensByAction.${action}\` (ou \`ai.maxTokens\`)`
29
+ : '`ai.maxTokens` (ou `ai.maxTokensByAction`)';
30
+ const extra = action === 'critique'
31
+ ? ' Em modelo que raciocina por padrão, o raciocínio consome o teto antes de a resposta sair — ' +
32
+ 'a crítica não conclui e a rodada se perde.'
33
+ : '';
23
34
  super(
24
35
  `Saída truncada pelo teto de tokens (${provider} · ${model} · max_tokens=${maxTokens} · ` +
25
- `motivo=${reason}). Foram gerados ~${chars} caracteres antes do corte. ` +
26
- 'Aumente `ai.maxTokens` (ou `ai.maxTokensByAction`) no .spec-wave.json, ou reduza o ' +
36
+ `motivo=${reason}${action ? ` · ação=${action}` : ''}). ` +
37
+ `Foram gerados ~${chars} caracteres antes do corte.${extra} ` +
38
+ `Aumente ${knob} no .spec-wave.json, ou reduza o ` +
27
39
  'tamanho da issue de origem. O documento NÃO foi gravado — um documento cortado ' +
28
40
  'passaria na validação de seções e valeria menos que nenhum.'
29
41
  );
30
42
  this.name = 'TruncatedOutputError';
31
43
  this.truncated = true;
44
+ this.action = action || null;
32
45
  }
33
46
  }
47
+
48
+ // Teto de turnos esgotado: o modelo respondeu com tool calls em TODOS os turnos
49
+ // e nunca produziu o documento. Como o truncamento acima, NÃO é transitório —
50
+ // repetir a mesma requisição dá a mesma perambulação, só que mais cara.
51
+ //
52
+ // Marcá-lo como transitório custou 55 minutos de Action num caso real: três
53
+ // tentativas de 25 turnos (4min → 19min → 32min) para a mesma falha
54
+ // determinística, que devia ter aparecido na primeira.
55
+ export class MaxTurnsError extends Error {
56
+ constructor({ provider, model, turns, action, toolCalls = [] }) {
57
+ // O que o modelo ficou fazendo é a informação que decide o remédio: muitas
58
+ // leituras distintas sugerem teto baixo; a mesma chamada repetida é loop
59
+ // degenerado, e aí subir o teto só encarece.
60
+ const resumo = toolCalls.length > 0
61
+ ? ` Ferramentas mais chamadas: ${summarizeToolCalls(toolCalls)}.`
62
+ : '';
63
+ super(
64
+ `O modelo esgotou o teto de ${turns} turnos sem produzir o documento ` +
65
+ `(${provider} · ${model}${action ? ` · ação=${action}` : ''}): respondeu com chamadas de ` +
66
+ `ferramenta em todos eles.${resumo} ` +
67
+ 'Repetir não ajuda — o erro é determinístico. Ou o modelo não fecha o loop de ' +
68
+ 'ferramentas nesta tarefa (troque-o, por `ai.models` ou pela label ' +
69
+ '`spec-wave:model:<apelido>`), ou a exploração precisa de mais turnos.'
70
+ );
71
+ this.name = 'MaxTurnsError';
72
+ this.maxTurns = true;
73
+ this.turns = turns;
74
+ this.action = action || null;
75
+ this.toolCalls = toolCalls;
76
+ }
77
+ }
78
+
79
+ /** Contagem por nome de ferramenta, da mais chamada para a menos (função PURA). */
80
+ export function summarizeToolCalls(toolCalls) {
81
+ const counts = new Map();
82
+ for (const name of toolCalls) counts.set(name, (counts.get(name) || 0) + 1);
83
+ return [...counts.entries()]
84
+ .sort((a, b) => b[1] - a[1])
85
+ .map(([name, n]) => `${name}×${n}`)
86
+ .join(', ');
87
+ }
@@ -22,7 +22,7 @@ import {
22
22
  toolDefinitionsFor,
23
23
  } from './tools.mjs';
24
24
  import { withTrace, startObservation, updateSpan, endObservation } from './tracing.mjs';
25
- import { TruncatedOutputError, isTruncationReason } from './errors.mjs';
25
+ import { TruncatedOutputError, isTruncationReason, summarizeToolCalls } from './errors.mjs';
26
26
 
27
27
  const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
28
28
  const DEFAULT_TOOLS = ['Read', 'Glob', 'Grep'];
@@ -179,6 +179,11 @@ export async function runOpenRouterAgent(prompt, options) {
179
179
  numTurns: 0,
180
180
  structured: null,
181
181
  usage: { inputTokens: 0, outputTokens: 0 },
182
+ // Nomes das ferramentas chamadas, na ordem. Só o `verbose` imprimia as tool
183
+ // calls, e ele fica DESLIGADO no Action — quando um modelo queimou os 25
184
+ // turnos sem escrever nada, o log não dizia no que ele se perdeu, e o
185
+ // diagnóstico exigiu reconstruir o run à mão.
186
+ toolCalls: [],
182
187
  };
183
188
  const quiet = options.quiet !== false;
184
189
 
@@ -279,6 +284,7 @@ export async function runOpenRouterAgent(prompt, options) {
279
284
  // corte, que é o que distingue teto nosso de teto do provider.
280
285
  reason: `${choice.finish_reason}${choice.native_finish_reason ? `/${choice.native_finish_reason}` : ''}`,
281
286
  chars: (runResult.outputText + text).length,
287
+ action: options.action ?? null,
282
288
  });
283
289
  }
284
290
 
@@ -326,6 +332,7 @@ export async function runOpenRouterAgent(prompt, options) {
326
332
  parseError = `Could not parse tool arguments as JSON: ${errorMessage(err)}`;
327
333
  }
328
334
 
335
+ runResult.toolCalls.push(call.function.name);
329
336
  if (options.verbose) console.log(`\n[Tool] ${call.function.name}(${call.function.arguments})`);
330
337
 
331
338
  const observation = startObservation(
@@ -351,7 +358,10 @@ export async function runOpenRouterAgent(prompt, options) {
351
358
 
352
359
  if (runResult.resultSubtype === null) {
353
360
  runResult.resultSubtype = 'error_max_turns';
354
- console.warn(`\n[Agent] Run ended without success: error_max_turns (${maxTurns} turns)`);
361
+ console.warn(
362
+ `\n[Agent] Run ended without success: error_max_turns (${maxTurns} turns) — ` +
363
+ `ferramentas chamadas: ${summarizeToolCalls(runResult.toolCalls) || 'nenhuma'}`
364
+ );
355
365
  }
356
366
 
357
367
  updateSpan(span, {
package/src/api/auth.mjs CHANGED
@@ -1,22 +1,230 @@
1
+ // Resolução do token do GitHub.
2
+ //
3
+ // A precedência é env-first porque o CI depende disso: no GitHub Actions o
4
+ // `GITHUB_TOKEN` é injetado e não existe `gh` para consultar. Mas fora do CI a
5
+ // mesma regra já causou estrago: um `export GH_TOKEN=$(gh auth token -u fulano)`
6
+ // num `.envrc` vale para TODOS os repositórios abaixo daquele diretório,
7
+ // inclusive os de outra org. O token responde no REST (ele existe e tem escopo
8
+ // `repo`), e o GraphQL devolve "Could not resolve to a Repository" — que é como
9
+ // o GitHub mascara 403. O diagnóstico fica caríssimo para o sintoma.
10
+ //
11
+ // A saída não é inverter a precedência (quebraria o CI), e sim deixar a conta
12
+ // ser DECLARADA: `--account`, `SPEC_WAVE_GH_ACCOUNT` ou `github.account` no
13
+ // .spec-wave.json. Havendo conta declarada, ela vence o env fora do CI — o
14
+ // repositório passa a dizer de quem é a conta, em vez de depender do ambiente.
1
15
  import { execSync } from 'node:child_process';
16
+ import { readFileSync } from 'node:fs';
17
+ import { findConfigPath } from '../lib/project-root.mjs';
2
18
 
3
- export async function resolveToken() {
4
- if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
5
- if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
19
+ // Conta escolhida via `spec-wave --account <login>`. O bin grava aqui antes de
20
+ // despachar o comando; passar por parâmetro exigiria mudar toda a cadeia de
21
+ // chamadas por um caso que é global à execução.
22
+ let accountOverride = null;
23
+
24
+ /** Define a conta do `gh` a usar nesta execução (flag global `--account`). */
25
+ export function setAccountOverride(login) {
26
+ accountOverride = typeof login === 'string' && login.trim() ? login.trim() : null;
27
+ }
28
+
29
+ function gh(args) {
30
+ return execSync(`gh ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
31
+ }
32
+
33
+ // Conta declarada, na ordem: flag → env → .spec-wave.json.
34
+ function declaredAccount(cwd = process.cwd()) {
35
+ if (accountOverride) return { login: accountOverride, source: '--account' };
36
+ const fromEnv = (process.env.SPEC_WAVE_GH_ACCOUNT || '').trim();
37
+ if (fromEnv) return { login: fromEnv, source: 'SPEC_WAVE_GH_ACCOUNT' };
38
+ try {
39
+ const configPath = findConfigPath(cwd);
40
+ if (configPath) {
41
+ const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
42
+ const login = (cfg?.github?.account || '').trim();
43
+ if (login) return { login, source: 'github.account' };
44
+ }
45
+ } catch {
46
+ // config ausente ou ilegível — segue sem conta declarada
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Conta ativa do `gh` (função quase pura — só lê `gh auth status`).
53
+ *
54
+ * @returns {string|null} login, ou null se o gh não estiver disponível/logado
55
+ */
56
+ export function activeGhAccount() {
57
+ try {
58
+ return parseActiveAccount(gh('auth status'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Conta ativa a partir da saída de `gh auth status` (função PURA).
66
+ *
67
+ * Formato: "✓ Logged in to github.com account <login> (...)" seguido de
68
+ * "- Active account: true". Com VÁRIAS contas, a primeira listada não é
69
+ * necessariamente a ativa — pegar a primeira é o erro que faria este módulo
70
+ * avisar sobre a conta errada, que é pior que não avisar.
71
+ *
72
+ * @param {string} out saída de `gh auth status`
73
+ * @returns {string|null}
74
+ */
75
+ export function parseActiveAccount(out) {
76
+ let active = null;
77
+ let last = null;
78
+ for (const line of String(out || '').split('\n')) {
79
+ const m = line.match(/account\s+(\S+)/);
80
+ if (m) {
81
+ last = m[1];
82
+ if (!active) active = last; // versões antigas não têm "Active account"
83
+ }
84
+ if (/Active account:\s*true/i.test(line) && last) active = last;
85
+ }
86
+ return active;
87
+ }
88
+
89
+ /**
90
+ * Decide a ORIGEM do token (função PURA — testável sem env nem gh).
91
+ *
92
+ * @param {object} params
93
+ * @param {object} params.env objeto tipo process.env
94
+ * @param {{login:string,source:string}|null} params.account conta declarada
95
+ * @returns {{ kind: 'env'|'gh-account'|'gh-active', name?: string, login?: string, source?: string }}
96
+ */
97
+ export function resolveTokenSource({ env = {}, account = null } = {}) {
98
+ // Em CI o ambiente é a autoridade: é ele que injeta o GITHUB_TOKEN do run, e
99
+ // não há `gh` instalado para consultar.
100
+ const inCi = Boolean(env.GITHUB_ACTIONS || env.CI);
101
+ if (inCi) {
102
+ if (env.GITHUB_TOKEN) return { kind: 'env', name: 'GITHUB_TOKEN' };
103
+ if (env.GH_TOKEN) return { kind: 'env', name: 'GH_TOKEN' };
104
+ return { kind: 'gh-active' };
105
+ }
106
+ // Fora do CI, conta declarada vence a variável de ambiente — é a declaração
107
+ // explícita ganhando de um default herdado do shell.
108
+ if (account?.login) return { kind: 'gh-account', login: account.login, source: account.source };
109
+ if (env.GITHUB_TOKEN) return { kind: 'env', name: 'GITHUB_TOKEN' };
110
+ if (env.GH_TOKEN) return { kind: 'env', name: 'GH_TOKEN' };
111
+ return { kind: 'gh-active' };
112
+ }
113
+
114
+ /**
115
+ * Aviso de divergência entre a conta ativa do `gh` e a do token (função PURA).
116
+ *
117
+ * A comparação é entre os TOKENS, não entre logins: descobrir o login de um
118
+ * token exige uma chamada à API, e um aviso que custa rede em todo comando não
119
+ * se paga. O token da conta ativa vem de `gh auth token` (execução local), e se
120
+ * for igual ao do ambiente não há divergência nenhuma — é o caso comum de quem
121
+ * exportou a variável a partir da própria conta ativa.
122
+ *
123
+ * Sem os dois tokens para comparar, NÃO avisa: um aviso que dispara sempre vira
124
+ * ruído, e ruído em aviso de diagnóstico é pior que silêncio.
125
+ *
126
+ * @param {object} params
127
+ * @param {{kind:string,name?:string}} params.source origem do token em uso
128
+ * @param {string} [params.tokenValue] token que será usado
129
+ * @param {string} [params.activeToken] token da conta ativa do gh
130
+ * @param {string} [params.activeLogin] login da conta ativa do gh
131
+ * @returns {string|null} a linha de aviso, ou null quando não há o que dizer
132
+ */
133
+ export function accountMismatchWarning({
134
+ source, tokenValue, activeToken, activeLogin,
135
+ } = {}) {
136
+ if (source?.kind !== 'env') return null;
137
+ if (!activeLogin || !activeToken || !tokenValue) return null;
138
+ if (activeToken === tokenValue) return null; // mesma credencial — nada a dizer
139
+ return (
140
+ `⚠️ Usando o token da variável ${source.name}, que NÃO é o da conta ativa do gh (${activeLogin}).\n` +
141
+ ` Se o comando falhar com "Could not resolve to a Repository", é isto: o token não enxerga o repo.\n` +
142
+ ` Corrija com \`--account ${activeLogin}\` ou grave \`"github": { "account": "${activeLogin}" }\` no .spec-wave.json.`
143
+ );
144
+ }
145
+
146
+ // Token da conta ativa do gh — usado só para comparar com o do ambiente.
147
+ // Execução local, sem rede; sem gh, devolve null e o aviso não sai.
148
+ function activeGhToken() {
6
149
  try {
7
- const token = execSync('gh auth token', { stdio: ['pipe', 'pipe', 'pipe'] })
8
- .toString()
9
- .trim();
150
+ return gh('auth token') || null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Token do GitHub para esta execução.
158
+ *
159
+ * @param {object} [opts]
160
+ * @param {boolean} [opts.quiet=false] não imprime o aviso de divergência
161
+ * @returns {Promise<string>}
162
+ */
163
+ export async function resolveToken({ quiet = false } = {}) {
164
+ const source = resolveTokenSource({ env: process.env, account: declaredAccount() });
165
+
166
+ if (source.kind === 'env') {
167
+ const token = process.env[source.name];
168
+ // O aviso custa duas execuções locais do gh (sem rede). Só fora do CI.
169
+ if (!quiet && !process.env.GITHUB_ACTIONS && !process.env.CI) {
170
+ const warning = accountMismatchWarning({
171
+ source,
172
+ tokenValue: token,
173
+ activeToken: activeGhToken(),
174
+ activeLogin: activeGhAccount(),
175
+ });
176
+ if (warning) console.warn(warning);
177
+ }
178
+ return token;
179
+ }
180
+
181
+ if (source.kind === 'gh-account') {
182
+ try {
183
+ const token = gh(`auth token -u ${source.login}`);
184
+ if (token) return token;
185
+ } catch (err) {
186
+ throw new Error(
187
+ `Não consegui obter o token da conta "${source.login}" (declarada em ${source.source}).\n` +
188
+ `Rode \`gh auth login -u ${source.login}\` ou ajuste a conta declarada.\n` +
189
+ `Detalhe: ${err.message.split('\n')[0]}`
190
+ );
191
+ }
192
+ throw new Error(`\`gh auth token -u ${source.login}\` não devolveu token.`);
193
+ }
194
+
195
+ try {
196
+ const token = gh('auth token');
10
197
  if (token) return token;
11
198
  } catch {
12
- // gh not installed or not authenticated
199
+ // gh não instalado ou sem login
13
200
  }
14
201
  throw new Error(
15
- 'GitHub token not found.\n' +
16
- 'Set GITHUB_TOKEN or GH_TOKEN environment variable, or run: gh auth login'
202
+ 'Token do GitHub não encontrado.\n' +
203
+ 'Defina GITHUB_TOKEN/GH_TOKEN, rode `gh auth login`, ou declare a conta com ' +
204
+ '`--account <login>` (ou "github": { "account": "<login>" } no .spec-wave.json).'
17
205
  );
18
206
  }
19
207
 
208
+ /** De onde o token desta execução viria — usado pelo `doctor` no relatório. */
209
+ export function tokenMismatchWarning() {
210
+ if (process.env.GITHUB_ACTIONS || process.env.CI) return null;
211
+ const source = resolveTokenSource({ env: process.env, account: declaredAccount() });
212
+ if (source.kind !== 'env') return null;
213
+ return accountMismatchWarning({
214
+ source,
215
+ tokenValue: process.env[source.name],
216
+ activeToken: activeGhToken(),
217
+ activeLogin: activeGhAccount(),
218
+ });
219
+ }
220
+
221
+ export function describeTokenSource(cwd = process.cwd()) {
222
+ const source = resolveTokenSource({ env: process.env, account: declaredAccount(cwd) });
223
+ if (source.kind === 'env') return `variável ${source.name}`;
224
+ if (source.kind === 'gh-account') return `gh auth token -u ${source.login} (${source.source})`;
225
+ return 'gh auth token (conta ativa)';
226
+ }
227
+
20
228
  export async function verifyTokenScopes(token) {
21
229
  const { Octokit } = await import('@octokit/rest');
22
230
  const octokit = new Octokit({ auth: token });
@@ -220,6 +220,9 @@ export async function listSubIssues(token, issueNodeId) {
220
220
  number
221
221
  title
222
222
  body
223
+ createdAt
224
+ state
225
+ stateReason
223
226
  labels(first: 20) { nodes { name } }
224
227
  }
225
228
  }
@@ -233,10 +236,40 @@ export async function listSubIssues(token, issueNodeId) {
233
236
  title: n.title,
234
237
  body: n.body || '',
235
238
  nodeId: n.id,
239
+ // `createdAt` é o que permite ao code-review ignorar sub-issues nascidas
240
+ // depois da abertura do PR — ver partitionByCreation em code-review.mjs.
241
+ createdAt: n.createdAt || null,
242
+ // OPEN/CLOSED e COMPLETED/NOT_PLANNED/REOPENED — o doctor usa o par para
243
+ // distinguir "entregue" de "descartada no rescopo".
244
+ state: (n.state || '').toLowerCase() || null,
245
+ stateReason: (n.stateReason || '').toLowerCase() || null,
236
246
  labels: (n.labels?.nodes || []).map(l => l.name),
237
247
  }));
238
248
  }
239
249
 
250
+ /**
251
+ * Issues que o GitHub reconhece como fechadas por este PR
252
+ * (`Closes #N` e o vínculo feito pela UI). É a MESMA lista que fecha as issues
253
+ * no merge — por isso é a fonte primária de vínculo do code-review, acima do
254
+ * parse do corpo.
255
+ *
256
+ * @returns {Promise<number[]>} números das issues vinculadas
257
+ */
258
+ export async function getPRClosingIssues(token, owner, repo, prNumber) {
259
+ const client = makeClient(token);
260
+ const result = await client(`
261
+ query ClosingIssues($owner: String!, $repo: String!, $number: Int!) {
262
+ repository(owner: $owner, name: $repo) {
263
+ pullRequest(number: $number) {
264
+ closingIssuesReferences(first: 50) { nodes { number } }
265
+ }
266
+ }
267
+ }
268
+ `, { owner, repo, number: prNumber });
269
+ const nodes = result.repository?.pullRequest?.closingIssuesReferences?.nodes || [];
270
+ return nodes.map(n => n.number).filter(Boolean);
271
+ }
272
+
240
273
  // Lê o parent (issue pai) de uma sub-issue. Retorna { number, title, nodeId }
241
274
  // ou null se a issue não tiver pai. Usado para subir a cadeia Task→Story→Feature.
242
275
  export async function getIssueParent(token, issueNodeId) {