@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.
@@ -8,18 +8,22 @@ 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 { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
12
- import { getProjectSnapshot } from '../api/github-graphql.mjs';
11
+ import {
12
+ resolveToken, verifyTokenScopes, describeTokenSource, activeGhAccount,
13
+ tokenMismatchWarning, parseActiveAccount,
14
+ } from '../api/auth.mjs';
15
+ import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
13
16
  import {
14
17
  CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
15
- RETIRED_STAGES, ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
16
- DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS,
18
+ RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
19
+ DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS,
17
20
  } from '../config.mjs';
18
21
  import { findConfigPath } from '../lib/project-root.mjs';
19
22
  import {
20
- DEFAULT_MAX_TOKENS, MAX_TOKENS_BY_ACTION, supportsStrictSchema,
23
+ DEFAULT_MAX_TOKENS, supportsStrictSchema, resolveAiConfig,
21
24
  } from '../lib/claude.mjs';
22
25
  import { CLI_VERSION } from '../lib/templates.mjs';
26
+ import { parseDecompositionDoc, DECOMPOSITION_FILE } from '../lib/decomposition-doc.mjs';
23
27
 
24
28
  // Mesmo padrão de instanciação de github-rest.mjs, mas com o logger mudo:
25
29
  // aqui 404/403 são resultados esperados dos checks, não erros a logar.
@@ -60,11 +64,20 @@ export function renderDoctorReport(results) {
60
64
 
61
65
  async function checkToken(ctx) {
62
66
  const name = 'Token GitHub';
63
- let source = 'gh CLI (`gh auth token`)';
64
- if (process.env.GITHUB_TOKEN) source = 'variável de ambiente GITHUB_TOKEN';
65
- else if (process.env.GH_TOKEN) source = 'variável de ambiente GH_TOKEN';
67
+ // A origem sai do MESMO resolvedor que o comando usa — antes esta função
68
+ // reimplementava a precedência, e uma divergência entre as duas seria
69
+ // exatamente o tipo de erro que o doctor existe para pegar.
70
+ const source = describeTokenSource();
66
71
  try {
67
- ctx.token = await resolveToken();
72
+ ctx.token = await resolveToken({ quiet: true });
73
+ // Divergência entre a conta ativa do gh e a origem do token: o sintoma é um
74
+ // GraphQL dizendo "Could not resolve to a Repository" enquanto o REST
75
+ // funciona — 403 mascarado de 404.
76
+ ctx.activeGhAccount = activeGhAccount();
77
+ const mismatch = tokenMismatchWarning();
78
+ if (mismatch) {
79
+ return { name, status: 'warn', detail: `Token resolvido via ${source}.\n${mismatch}` };
80
+ }
68
81
  return { name, status: 'ok', detail: `Token resolvido via ${source}.` };
69
82
  } catch {
70
83
  return {
@@ -157,19 +170,10 @@ async function checkGhAccount(ctx) {
157
170
  return { name, status: 'warn', detail: 'gh não instalado ou não logado — verificação de conta pulada.' };
158
171
  }
159
172
 
160
- // Formato: "✓ Logged in to github.com account <login> (...)" seguido de
161
- // "- Active account: true". Em versões antigas (uma conta só) não há a
162
- // linha "Active account"usa a primeira conta encontrada.
163
- let active = null;
164
- let last = null;
165
- for (const line of out.split('\n')) {
166
- const m = line.match(/account\s+(\S+)/);
167
- if (m) {
168
- last = m[1];
169
- if (!active) active = last;
170
- }
171
- if (/Active account:\s*true/i.test(line) && last) active = last;
172
- }
173
+ // O parser vive em api/auth.mjs, junto de quem RESOLVE o token: se as duas
174
+ // leituras de "conta ativa" divergissem, o doctor apontaria uma conta e o
175
+ // comando usaria outraexatamente o erro que este check existe para pegar.
176
+ const active = parseActiveAccount(out);
173
177
  if (!active) {
174
178
  return { name, status: 'warn', detail: 'Não foi possível identificar a conta ativa na saída de `gh auth status`.' };
175
179
  }
@@ -310,7 +314,7 @@ export function inspectStageIds({ configOptions = null, boardOptions = null } =
310
314
  return { staleIds, checked: true };
311
315
  }
312
316
 
313
- export function inspectBoardHygiene({ boardStages = null, repoLabels = null } = {}) {
317
+ export function inspectBoardHygiene({ boardStages = null, repoLabels = null, modelAliases = null } = {}) {
314
318
  const canonical = STATUS_OPTIONS.map(s => s.name);
315
319
  const known = new Set(canonical);
316
320
  const retired = new Map(RETIRED_STAGES.map(s => [s.name, s]));
@@ -321,12 +325,25 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null } =
321
325
  const unknownStages = foreign.filter(s => !retired.has(s));
322
326
  const missingStages = boardStages ? canonical.filter(s => !boardStages.includes(s)) : [];
323
327
 
324
- const knownLabels = new Set(ALL_LABELS.map(l => l.name));
328
+ // As labels de modelo saem de `ai.modelAliases`, não de ALL_LABELS. Compará-las
329
+ // com a lista estática acusava TODO alias configurado como descontinuado, com
330
+ // o conselho de rodar `update` para removê-los — e o update, com a mesma
331
+ // cegueira, os removeria mesmo, quebrando o override de modelo por issue em
332
+ // silêncio. Pior: o próprio doctor lista esses apelidos como usáveis quatro
333
+ // linhas abaixo, no check de IA, contradizendo-se na mesma saída.
334
+ //
335
+ // Com o config em mãos, `spec-wave:model:<x>` só é órfã quando <x> NÃO está em
336
+ // ai.modelAliases — aí o aviso é verdadeiro (label aponta para apelido que não
337
+ // existe mais). Sem config (doctor sem acesso ao arquivo), nenhuma label de
338
+ // modelo é acusada: melhor calar do que mandar apagar o que funciona.
339
+ const wantedLabels = allLabelsFor(modelAliases ? { modelAliases } : undefined);
340
+ const knownLabels = new Set(wantedLabels.map(l => l.name));
325
341
  const orphanLabels = repoLabels
326
- ? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n))
342
+ ? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n)
343
+ && (modelAliases !== null || !n.startsWith(MODEL_LABEL_PREFIX)))
327
344
  : [];
328
345
  const missingLabels = repoLabels
329
- ? ALL_LABELS.map(l => l.name).filter(n => !repoLabels.includes(n))
346
+ ? wantedLabels.map(l => l.name).filter(n => !repoLabels.includes(n))
330
347
  : [];
331
348
  return { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels };
332
349
  }
@@ -391,7 +408,7 @@ async function checkBoardHygiene(ctx) {
391
408
  }
392
409
 
393
410
  const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels } =
394
- inspectBoardHygiene({ boardStages, repoLabels });
411
+ inspectBoardHygiene({ boardStages, repoLabels, modelAliases: cfg?.ai?.modelAliases ?? null });
395
412
 
396
413
  const notes = [];
397
414
  let status = 'ok';
@@ -521,18 +538,22 @@ async function checkAi(ctx) {
521
538
  // Teto de saída: quando o documento não cabe, a geração falha com
522
539
  // TruncatedOutputError em vez de gravar um arquivo cortado — mostrar o valor
523
540
  // resolvido evita ter que abrir o log do Action para descobri-lo.
541
+ //
542
+ // Mostrar o teto EFETIVO por ação (e não só o default global) porque a leitura
543
+ // "32768 (default) · critique=8192 (default)" já foi entendida como "o teto da
544
+ // crítica ignora a configuração", quando na verdade `ai.maxTokens` valia para
545
+ // ela também. Aqui cada ação vem com o número que de fato será usado.
524
546
  const maxTokensNote = fileAi.maxTokens
525
547
  ? `${fileAi.maxTokens} (ai.maxTokens)`
526
548
  : `${DEFAULT_MAX_TOKENS} (default)`;
527
- const byAction = fileAi.maxTokensByAction && Object.keys(fileAi.maxTokensByAction).length > 0
528
- ? ` · por ação: ${Object.entries(fileAi.maxTokensByAction).map(([a, t]) => `${a}=${t}`).join(', ')}`
529
- : '';
530
- const actionDefaults = Object.entries(MAX_TOKENS_BY_ACTION)
531
- .filter(([a]) => !fileAi.maxTokensByAction?.[a] && !fileAi.maxTokens)
532
- .map(([a, t]) => `${a}=${t} (default)`)
549
+ const efetivo = (action) => resolveAiConfig({ env: process.env, fileAi, action }).maxTokens;
550
+ const porAcao = AI_ACTIONS
551
+ .map(a => [a, efetivo(a)])
552
+ .filter(([, teto]) => teto !== efetivo(undefined))
553
+ .map(([a, teto]) => `${a}=${teto}`)
533
554
  .join(', ');
534
555
  notes.push(
535
- `Teto de saída: ${maxTokensNote}${byAction}${actionDefaults ? ` · ${actionDefaults}` : ''}.`
556
+ `Teto de saída: ${maxTokensNote}${porAcao ? ` · efetivo por ação: ${porAcao}` : ' (vale para todas as ações)'}.`
536
557
  );
537
558
 
538
559
  let status = 'ok';
@@ -646,7 +667,16 @@ async function checkAi(ctx) {
646
667
  status = 'warn';
647
668
  notes.push(`Secrets do Actions faltando em ${cfg.owner}/${cfg.repo}: ${missing.join(', ')} — configure em Settings → Secrets.`);
648
669
  } else {
649
- notes.push(`Secrets do Actions presentes: ${required.join(', ')}.`);
670
+ // PRESENÇA, não validade — a API expõe só os nomes dos secrets, nunca os
671
+ // valores, então não há como testar daqui se o token funciona. Dizer só
672
+ // "presentes" dava uma confiança que o check não sustenta: o
673
+ // GH_PROJECT_TOKEN do caso real ESTAVA presente e não alcançava o
674
+ // Project da organização, e as 27 issues criadas nasceram sem Etapa.
675
+ notes.push(
676
+ `Secrets do Actions presentes: ${required.join(', ')} ` +
677
+ '(só o NOME é verificável — a API não expõe o valor, então isto não atesta ' +
678
+ 'que os tokens funcionam nem que têm o escopo necessário).'
679
+ );
650
680
  }
651
681
  } catch (err) {
652
682
  if (err.status === 403) {
@@ -660,6 +690,135 @@ async function checkAi(ctx) {
660
690
  }
661
691
 
662
692
  // Exportado para teste: só lê ctx.cfg e process.env — sem rede/filesystem.
693
+ /**
694
+ * Compara o que o decomposition.md registra com as issues que existem hoje
695
+ * (função PURA — testável sem rede).
696
+ *
697
+ * Só faz sentido em arquivo com `applied=`: antes do apply ele é proposta, e
698
+ * proposta não tem com o que divergir. Depois, ele é REGISTRO — e registro que
699
+ * não bate com a realidade é pior que registro nenhum, porque parece confiável.
700
+ *
701
+ * @param {object} doc documento parseado (parseDecompositionDoc)
702
+ * @param {Map<number,{state?:string,title?:string}>} atual issues por número
703
+ * @returns {string[]} divergências em pt-BR (vazio = arquivo confere)
704
+ */
705
+ export function decompositionDrift(doc, atual = new Map()) {
706
+ if (!doc?.appliedAt) return [];
707
+ const itens = [
708
+ ...(doc.stories || []).flatMap(s => [s, ...(s.tasks || [])]),
709
+ ...(doc.tasks || []),
710
+ ];
711
+ const out = [];
712
+ const registradas = new Set();
713
+ for (const item of itens) {
714
+ if (!item.issue) {
715
+ out.push(`${item.anchor} não tem "**Issue:** #N" — foi acrescentada ao arquivo depois do apply?`);
716
+ continue;
717
+ }
718
+ registradas.add(item.issue);
719
+ const issue = atual.get(item.issue);
720
+ if (!issue) {
721
+ out.push(`${item.anchor} aponta para #${item.issue}, que não existe mais (rescopada ou apagada).`);
722
+ continue;
723
+ }
724
+ if (issue.state === 'closed' && issue.stateReason === 'not_planned') {
725
+ out.push(`${item.anchor} (#${item.issue}) foi fechada como "não planejada" — o arquivo ainda a descreve.`);
726
+ }
727
+ if (issue.title && item.title && !issue.title.includes(item.title)) {
728
+ out.push(`${item.anchor} (#${item.issue}) foi renomeada: o arquivo diz "${item.title}", a issue diz "${issue.title}".`);
729
+ }
730
+ }
731
+ for (const [numero, issue] of atual) {
732
+ if (!registradas.has(numero)) {
733
+ out.push(`#${numero} ${issue.title || ''} existe na árvore mas não está no arquivo — criada fora do apply.`);
734
+ }
735
+ }
736
+ return out;
737
+ }
738
+
739
+ // Lê os decomposition.md aplicados e confere se ainda descrevem a árvore real.
740
+ // Best-effort e sempre warn: divergência aqui é informação para o humano, não
741
+ // falha de configuração — rescopar issues à mão é legítimo, esquecer de dizer
742
+ // isso no arquivo é que não deveria passar despercebido.
743
+ async function checkDecompositions(ctx) {
744
+ const name = 'Decomposições aplicadas (decomposition.md × issues)';
745
+ const root = ctx.root;
746
+ if (!root) return { name, status: 'warn', detail: `Sem ${CONFIG_FILE} — verificação pulada.` };
747
+
748
+ const featuresDir = path.join(root, 'docs', 'features');
749
+ if (!existsSync(featuresDir)) {
750
+ return { name, status: 'ok', detail: 'Nenhuma pasta docs/features — nada a conferir.' };
751
+ }
752
+
753
+ const arquivos = [];
754
+ for (const dir of readdirSync(featuresDir, { withFileTypes: true })) {
755
+ if (!dir.isDirectory()) continue;
756
+ const file = path.join(featuresDir, dir.name, DECOMPOSITION_FILE);
757
+ if (existsSync(file)) arquivos.push({ rel: `docs/features/${dir.name}/${DECOMPOSITION_FILE}`, file });
758
+ }
759
+ if (arquivos.length === 0) {
760
+ return { name, status: 'ok', detail: 'Nenhum decomposition.md encontrado.' };
761
+ }
762
+
763
+ const notes = [];
764
+ let status = 'ok';
765
+ let aplicados = 0;
766
+
767
+ for (const { rel, file } of arquivos) {
768
+ let doc;
769
+ try {
770
+ doc = parseDecompositionDoc(readFileSync(file, 'utf-8'));
771
+ } catch (err) {
772
+ status = 'warn';
773
+ notes.push(`${rel}: não foi possível ler — ${err.message}`);
774
+ continue;
775
+ }
776
+ if (!doc.appliedAt) continue; // ainda é proposta, não registro
777
+ aplicados += 1;
778
+
779
+ if (!ctx.token || !doc.issueNumber) {
780
+ notes.push(`${rel}: aplicado em ${doc.appliedAt} (issues não verificadas — sem token ou sem número no marcador).`);
781
+ continue;
782
+ }
783
+
784
+ // Monta a árvore real: sub-issues da Feature + as de cada Story.
785
+ const atual = new Map();
786
+ try {
787
+ const issue = await makeOctokit(ctx.token).rest.issues.get({
788
+ owner: ctx.cfg.owner, repo: ctx.cfg.repo, issue_number: doc.issueNumber,
789
+ });
790
+ const subs = await listSubIssues(ctx.token, issue.data.node_id);
791
+ for (const s of subs) {
792
+ atual.set(s.number, { title: s.title, state: s.state, stateReason: s.stateReason });
793
+ for (const t of await listSubIssues(ctx.token, s.nodeId).catch(() => [])) {
794
+ atual.set(t.number, { title: t.title, state: t.state, stateReason: t.stateReason });
795
+ }
796
+ }
797
+ } catch (err) {
798
+ notes.push(`${rel}: árvore de #${doc.issueNumber} não verificável agora (${err.message}).`);
799
+ continue;
800
+ }
801
+
802
+ const drift = decompositionDrift(doc, atual);
803
+ if (drift.length === 0) {
804
+ notes.push(`${rel}: confere com as ${atual.size} issue(s) de #${doc.issueNumber}.`);
805
+ continue;
806
+ }
807
+ status = 'warn';
808
+ notes.push(
809
+ `${rel} descreve um desenho diferente do que existe hoje:\n` +
810
+ drift.map(d => ` - ${d}`).join('\n') + '\n' +
811
+ ' Atualize o arquivo ou marque-o como histórico — aplicado ele é registro, e registro ' +
812
+ 'errado parece confiável.'
813
+ );
814
+ }
815
+
816
+ if (aplicados === 0) {
817
+ return { name, status: 'ok', detail: `${arquivos.length} rascunho(s) ainda não aplicado(s) — nada a conferir.` };
818
+ }
819
+ return { name, status, detail: notes.join('\n') };
820
+ }
821
+
663
822
  export function checkSpecKit(ctx) {
664
823
  const name = 'Spec-kit (specKit.command para o implement)';
665
824
  const fromEnv = process.env.SPEC_WAVE_IMPLEMENT_CMD;
@@ -769,6 +928,7 @@ export async function doctor() {
769
928
  checkRepoAccess,
770
929
  checkBoardHygiene,
771
930
  checkAi,
931
+ checkDecompositions,
772
932
  checkSpecKit,
773
933
  checkWorkflows,
774
934
  ];
@@ -8,8 +8,6 @@
8
8
  // pergunta que ela responde é se a causa raiz proposta explica OS SINTOMAS
9
9
  // RELATADOS — sem o relato, ela só avalia coerência interna.
10
10
  // 3. Escreve em docs/bugs/<slug>/, fora de docs/features/.
11
- import { execSync } from 'node:child_process';
12
- import { mkdirSync, writeFileSync } from 'node:fs';
13
11
  import { resolveToken } from '../api/auth.mjs';
14
12
  import {
15
13
  getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
@@ -18,9 +16,10 @@ import { generateDocument } from '../lib/claude.mjs';
18
16
  import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
19
17
  import { recordUsage } from '../lib/usage-report.mjs';
20
18
  import { loadConfig } from '../lib/project-root.mjs';
21
- import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
19
+ import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
22
20
  import { detectIssueType } from '../lib/issue-type.mjs';
23
21
  import { bugDocPaths } from '../lib/bug-doc.mjs';
22
+ import { commitGenerated, executionMode } from '../lib/flow-run.mjs';
24
23
  import {
25
24
  runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
26
25
  } from '../lib/critique.mjs';
@@ -84,7 +83,7 @@ export async function generateBug({ issueNumber }) {
84
83
  return;
85
84
  }
86
85
 
87
- const { slug, dirRel, fileRel, dirAbs, fileAbs } = bugDocPaths(issue.title, root);
86
+ const { slug, dirRel, fileRel, fileAbs } = bugDocPaths(issue.title, root);
88
87
 
89
88
  const comments = await listIssueComments(token, owner, repo, n).catch(() => []);
90
89
  const report = buildReport(issue, comments);
@@ -103,9 +102,11 @@ export async function generateBug({ issueNumber }) {
103
102
  const usageEntries = [];
104
103
  try {
105
104
  console.log(`Gerando bug.md para: ${issue.title}`);
106
- const systemPrompt = toolFreeSystemPrompt(loadPrompt('bug', { cwd: root }));
105
+ const bugPrompt = loadPrompt('bug', { cwd: root });
106
+ const systemPrompt = systemPromptWithTools(bugPrompt);
107
107
  const { content: bruto } = await generateDocument(systemPrompt, userContent, {
108
108
  action: 'bug',
109
+ maxTurns: bugPrompt.maxTurns,
109
110
  labels: issueLabels,
110
111
  lint: { lang: TARGET_LANGUAGE },
111
112
  withReport: true,
@@ -116,16 +117,18 @@ export async function generateBug({ issueNumber }) {
116
117
  // ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
117
118
  const content = unwrapGeneratedDoc(bruto);
118
119
 
119
- mkdirSync(dirAbs, { recursive: true });
120
- writeFileSync(fileAbs, content, 'utf-8');
121
-
122
- const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
123
- git('git config user.email "spec-wave[bot]@github.com"');
124
- git('git config user.name "spec-wave[bot]"');
125
- git(`git add "${fileAbs}"`);
126
- git(`git commit -m "docs: generate bug.md for ${slug} [spec-wave]"`);
127
- git('git pull --rebase');
128
- git('git push');
120
+ // Publica pelo mesmo caminho do generate-spec/plan/decompose: commit
121
+ // escopado ao arquivo, identidade do bot só no runner e repetição quando
122
+ // outro run rouba a ponta da branch. O bloco de git solto que existia aqui
123
+ // não tinha nada disso — em particular, o `git commit` sem `-- <arquivo>`
124
+ // varria para dentro do commit qualquer coisa que estivesse no index.
125
+ const published = commitGenerated({
126
+ filePath: fileAbs,
127
+ content,
128
+ message: `docs: generate bug.md for ${slug} [spec-wave]`,
129
+ mode: executionMode(),
130
+ });
131
+ if (published.warning) console.warn(`⚠️ ${published.warning}`);
129
132
 
130
133
  await removeLabel(token, owner, repo, n, LABEL_BUG);
131
134
 
@@ -21,7 +21,7 @@ import { slugify } from '../lib/slugify.mjs';
21
21
  import { resolveFromRoot } from '../lib/project-root.mjs';
22
22
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
23
23
  import { buildTechContext } from '../lib/tech-context.mjs';
24
- import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
24
+ import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
25
25
 
26
26
  // Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
27
27
  // retry automático do generateDocument (excertos ao redor de cada vazamento).
@@ -35,8 +35,10 @@ function formatLintWarning(lintFindings) {
35
35
  }
36
36
 
37
37
  // O prompt vive em `src/plugin/skills/plan/model-prompt.md` (sobrescrevível pelo
38
- // projeto em `.spec-wave/prompts/plan.md`). `toolFreeSystemPrompt` remove a orientação que
39
- // assume Read/Glob/Grep: aqui a geração é UMA chamada, sem tool loop.
38
+ // projeto em `.spec-wave/prompts/plan.md`). `systemPromptWithTools` PRESERVA o
39
+ // bloco `requires-tools`: o `generateDocument` entrega Read/Glob/Grep ao modelo,
40
+ // e é esse bloco que o ensina a explorar o repositório e a parar de explorar.
41
+ // Recortá-lo daqui deixava o modelo com as ferramentas e sem o manual.
40
42
 
41
43
  /**
42
44
  * Roda a crítica do plan e aplica as labels de bloqueio.
@@ -181,9 +183,11 @@ export async function generatePlan({ issueNumber }) {
181
183
  const usageEntries = [];
182
184
  try {
183
185
  console.log(`Gerando plan.md para: ${issue.title}`);
184
- const systemPrompt = toolFreeSystemPrompt(loadPrompt('plan', { cwd: root }));
186
+ const planPrompt = loadPrompt('plan', { cwd: root });
187
+ const systemPrompt = systemPromptWithTools(planPrompt);
185
188
  const { content: bruto, lintFindings } = await generateDocument(systemPrompt, userContent, {
186
189
  action: 'plan',
190
+ maxTurns: planPrompt.maxTurns,
187
191
  labels: issueLabels,
188
192
  lint: { lang: TARGET_LANGUAGE },
189
193
  withReport: true,
@@ -258,7 +262,91 @@ export async function generatePlan({ issueNumber }) {
258
262
  * presente é criticado como está. Para gerar outro plano do zero, o caminho
259
263
  * continua sendo `spec-wave:plan`.
260
264
  */
261
- export async function critique({ issueNumber }) {
265
+ /**
266
+ * Tipo de crítica a partir do nome do arquivo (função PURA).
267
+ *
268
+ * `decomposition.md` é `stories` (e não `decomposition`) porque é esse o nome do
269
+ * kind na crítica desde antes de o rascunho virar arquivo — renomear aqui
270
+ * quebraria o contador de tentativas, que grava o kind no marcador.
271
+ *
272
+ * @param {string} filePath
273
+ * @returns {'plan'|'spec'|'stories'|'bug'|null}
274
+ */
275
+ export function inferCritiqueKind(filePath) {
276
+ const base = path.basename(String(filePath || '')).toLowerCase();
277
+ if (base === 'plan.md') return 'plan';
278
+ if (base === 'spec.md') return 'spec';
279
+ if (base === 'decomposition.md') return 'stories';
280
+ if (base === 'bug.md') return 'bug';
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Crítica AVULSA de um arquivo, sem issue e sem efeito colateral.
286
+ *
287
+ * Modo "quero saber como está": lê o arquivo (e os vizinhos que servem de
288
+ * contexto), roda a crítica e imprime. Não comenta, não aplica label, não conta
289
+ * tentativa — é consulta, não portão. É o que faltava enquanto se corrige um
290
+ * documento à mão, que é justamente o que o fluxo exige quando a crítica reprova.
291
+ */
292
+ async function critiqueFile({ file, kind, failOnGrave }) {
293
+ const filePath = path.resolve(file);
294
+ if (!existsSync(filePath)) {
295
+ throw new Error(`Arquivo não encontrado: ${filePath}`);
296
+ }
297
+ const resolvedKind = kind || inferCritiqueKind(filePath);
298
+ if (!resolvedKind) {
299
+ throw new Error(
300
+ `Não consegui inferir o tipo de crítica de "${path.basename(filePath)}". ` +
301
+ 'Use --kind plan|spec|stories|bug.'
302
+ );
303
+ }
304
+
305
+ // Vizinhos do mesmo diretório entram como contexto quando existem: o plan é
306
+ // auditado CONTRA a spec, e a decomposição contra as duas.
307
+ const dir = path.dirname(filePath);
308
+ const ler = (nome) => {
309
+ const p = path.join(dir, nome);
310
+ return existsSync(p) && p !== filePath ? readFileSync(p, 'utf-8') : null;
311
+ };
312
+ const conteudo = readFileSync(filePath, 'utf-8');
313
+
314
+ console.log(`Crítica avulsa de ${path.relative(process.cwd(), filePath)} (kind=${resolvedKind}).`);
315
+ const result = await runCritique({
316
+ kind: resolvedKind,
317
+ spec: resolvedKind === 'spec' ? conteudo : ler('spec.md'),
318
+ plan: resolvedKind === 'plan' ? conteudo : ler('plan.md'),
319
+ decomposition: resolvedKind === 'stories' ? conteudo : null,
320
+ bugDoc: resolvedKind === 'bug' ? conteudo : null,
321
+ cwd: dir,
322
+ standalone: true,
323
+ usage: [],
324
+ });
325
+
326
+ console.log(`\n${result.markdown}\n`);
327
+ if (result.grave && failOnGrave) {
328
+ console.error('Findings graves encontrados (--fail-on-grave).');
329
+ process.exitCode = 1;
330
+ }
331
+ return result;
332
+ }
333
+
334
+ export async function critique({ issueNumber, file, kind, failOnGrave = false }) {
335
+ // `--file` é o modo local: sem issue, sem label, sem contador de tentativas.
336
+ if (file && !issueNumber) {
337
+ await critiqueFile({ file, kind, failOnGrave });
338
+ return;
339
+ }
340
+ if (!issueNumber) {
341
+ throw new Error('Informe --issue-number <n> ou --file <caminho>.');
342
+ }
343
+ if (kind && kind !== 'plan') {
344
+ throw new Error(
345
+ `--kind ${kind} só funciona com --file: pela issue, o gatilho \`spec-wave:critique\` ` +
346
+ 'audita o plan.md (é ele que destrava o `spec-wave:ready`).'
347
+ );
348
+ }
349
+
262
350
  const token = await resolveToken();
263
351
  const { owner, repo, root, config, mode } = resolveFlowContext({ command: 'critique' });
264
352
  console.log(`Modo de execução: ${mode}`);
@@ -7,7 +7,7 @@ import { recordUsage } from '../lib/usage-report.mjs';
7
7
  import { slugify } from '../lib/slugify.mjs';
8
8
  import { resolveFromRoot } from '../lib/project-root.mjs';
9
9
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
10
- import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
10
+ import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
11
11
  import { detectIssueType } from '../lib/issue-type.mjs';
12
12
  import {
13
13
  allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, labelNames,
@@ -25,8 +25,9 @@ function formatLintWarning(lintFindings) {
25
25
  }
26
26
 
27
27
  // O prompt vive em `src/plugin/skills/spec/model-prompt.md` (sobrescrevível pelo
28
- // projeto em `.spec-wave/prompts/spec.md`). `toolFreeSystemPrompt` remove a orientação que assume
29
- // Read/Glob/Grep: aqui a geração é UMA chamada de completions, sem tool loop.
28
+ // projeto em `.spec-wave/prompts/spec.md`). `systemPromptWithTools` PRESERVA o
29
+ // bloco `requires-tools`: o `generateDocument` entrega Read/Glob/Grep ao modelo,
30
+ // e é esse bloco que o ensina a explorar o repositório e a parar de explorar.
30
31
 
31
32
  export async function generateSpec({ issueNumber }) {
32
33
  const token = await resolveToken();
@@ -79,9 +80,11 @@ export async function generateSpec({ issueNumber }) {
79
80
  const usageEntries = [];
80
81
  try {
81
82
  console.log(`Gerando spec.md para: ${issue.title}`);
82
- const systemPrompt = toolFreeSystemPrompt(loadPrompt('spec', { cwd: root }));
83
+ const specPrompt = loadPrompt('spec', { cwd: root });
84
+ const systemPrompt = systemPromptWithTools(specPrompt);
83
85
  const { content: bruto, lintFindings } = await generateDocument(systemPrompt, userContent, {
84
86
  action: 'spec',
87
+ maxTurns: specPrompt.maxTurns,
85
88
  labels: issueLabels,
86
89
  lint: { lang: TARGET_LANGUAGE },
87
90
  withReport: true,