@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 +34 -3
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +10 -1
- package/src/agent/errors.mjs +57 -3
- package/src/agent/openrouter-agent.mjs +12 -2
- package/src/api/auth.mjs +217 -9
- package/src/api/github-graphql.mjs +33 -0
- package/src/commands/code-review.mjs +186 -31
- package/src/commands/decompose.mjs +151 -13
- package/src/commands/doctor.mjs +195 -35
- package/src/commands/generate-bug.mjs +18 -15
- package/src/commands/generate-plan.mjs +93 -5
- package/src/commands/generate-spec.mjs +7 -4
- package/src/commands/repair-stage.mjs +232 -0
- package/src/commands/update.mjs +13 -5
- package/src/config.mjs +43 -0
- package/src/lib/board.mjs +44 -0
- package/src/lib/claude.mjs +59 -9
- package/src/lib/critique.mjs +186 -14
- package/src/lib/decomposition-doc.mjs +59 -7
- package/src/lib/flow-run.mjs +134 -5
- package/src/lib/prompt-loader.mjs +30 -3
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/move/SKILL.md +29 -0
- package/src/plugin/skills/plan/SKILL.md +13 -0
- package/src/plugin/skills/spec/model-prompt.critique.md +58 -0
- package/src/setup/labels.mjs +10 -6
- package/src/templates/workflows/code-review.yml +34 -1
- package/src/templates/workflows/decompose.yml +12 -1
- package/src/templates/workflows/generate-bug.yml +8 -1
- package/src/templates/workflows/generate-plan.yml +16 -1
- package/src/templates/workflows/generate-spec.yml +16 -1
package/src/commands/doctor.mjs
CHANGED
|
@@ -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 {
|
|
12
|
-
|
|
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,
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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 outra — exatamente 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
|
-
|
|
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
|
-
?
|
|
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
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
.
|
|
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}${
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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,
|
|
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`). `
|
|
39
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|
|
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`). `
|
|
29
|
-
// Read/Glob/Grep
|
|
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
|
|
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,
|