@spec-wave/cli 0.21.0 → 0.24.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/README.md +25 -5
- package/bin/spec-wave.mjs +2 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +43 -20
- package/src/agent/errors.mjs +59 -13
- package/src/agent/index.mjs +104 -38
- package/src/agent/openrouter-agent.mjs +5 -1
- package/src/api/github-graphql.mjs +63 -0
- package/src/api/github-rest.mjs +9 -2
- package/src/commands/decompose.mjs +82 -5
- package/src/commands/doctor.mjs +38 -12
- package/src/commands/init.mjs +5 -0
- package/src/commands/move.mjs +52 -0
- package/src/commands/order.mjs +200 -7
- package/src/commands/validate.mjs +39 -18
- package/src/config.mjs +45 -8
- package/src/lib/board.mjs +34 -1
- package/src/lib/bug-doc.mjs +71 -0
- package/src/lib/claude.mjs +36 -9
- package/src/lib/decomposition-doc.mjs +66 -14
- package/src/lib/dependencies.mjs +14 -4
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/decompose/SKILL.md +3 -3
- package/src/plugin/skills/doctor/SKILL.md +1 -1
- package/src/plugin/skills/order/SKILL.md +8 -4
- package/src/plugin/skills/ready/SKILL.md +1 -1
- package/src/plugin/skills/setup/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +5 -5
- package/src/templates/workflows/critique.yml +1 -0
- package/src/templates/workflows/decompose.yml +1 -0
- package/src/templates/workflows/generate-bug.yml +1 -0
- package/src/templates/workflows/generate-plan.yml +1 -0
- package/src/templates/workflows/generate-spec.yml +1 -0
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, REQUIRED_BUG_SECTIONS, labelNames,
|
|
9
9
|
} from '../config.mjs';
|
|
10
10
|
import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
|
|
11
|
-
import { bugDocPaths,
|
|
11
|
+
import { bugDocPaths, describeMissingSections } from '../lib/bug-doc.mjs';
|
|
12
12
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
13
13
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
14
14
|
|
|
@@ -47,8 +47,8 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
47
47
|
);
|
|
48
48
|
} else {
|
|
49
49
|
const content = readFileSync(fileAbs, 'utf-8');
|
|
50
|
-
for (const
|
|
51
|
-
errors.push(
|
|
50
|
+
for (const faltante of describeMissingSections(content, REQUIRED_BUG_SECTIONS)) {
|
|
51
|
+
errors.push(renderMissingSection('bug.md', faltante));
|
|
52
52
|
}
|
|
53
53
|
for (const problem of findIncompleteDocSigns(content)) {
|
|
54
54
|
errors.push(`❌ \`bug.md\` parece incompleto: ${problem}`);
|
|
@@ -79,6 +79,22 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
|
|
|
79
79
|
console.log('bug.md validado.');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Linha de erro de seção ausente (função PURA).
|
|
84
|
+
*
|
|
85
|
+
* Dizer só o que falta obriga o humano a comparar título a título; dizer o que
|
|
86
|
+
* EXISTE no lugar resolve em segundos o caso real (um "# Rollout e
|
|
87
|
+
* Monitoramento" onde se esperava "# Rollback e Monitoramento", com o conteúdo
|
|
88
|
+
* certo embaixo).
|
|
89
|
+
*/
|
|
90
|
+
export function renderMissingSection(doc, { section, found }) {
|
|
91
|
+
const base = `❌ Seção obrigatória ausente no ${doc}: **${section}**`;
|
|
92
|
+
return found
|
|
93
|
+
? `${base} — encontrei \`# ${found}\`, esperava \`# ${section}\`. ` +
|
|
94
|
+
'Se o conteúdo é o certo, basta renomear o título.'
|
|
95
|
+
: base;
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
export async function validate({ issueNumber }) {
|
|
83
99
|
const token = await resolveToken();
|
|
84
100
|
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
@@ -133,8 +149,8 @@ export async function validate({ issueNumber }) {
|
|
|
133
149
|
errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
|
|
134
150
|
} else {
|
|
135
151
|
const planContent = readFileSync(planPath, 'utf-8');
|
|
136
|
-
for (const
|
|
137
|
-
errors.push(
|
|
152
|
+
for (const faltante of describeMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
|
|
153
|
+
errors.push(renderMissingSection('plan.md', faltante));
|
|
138
154
|
}
|
|
139
155
|
for (const problem of findIncompleteDocSigns(planContent)) {
|
|
140
156
|
errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
|
|
@@ -147,8 +163,8 @@ export async function validate({ issueNumber }) {
|
|
|
147
163
|
errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
|
|
148
164
|
} else {
|
|
149
165
|
const specContent = readFileSync(specPath, 'utf-8');
|
|
150
|
-
for (const
|
|
151
|
-
errors.push(
|
|
166
|
+
for (const faltante of describeMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
|
|
167
|
+
errors.push(renderMissingSection('spec.md', faltante));
|
|
152
168
|
}
|
|
153
169
|
// Seções presentes não garantem documento completo: um corte dentro da
|
|
154
170
|
// última seção passa na checagem acima (foi o caso da EP2-F13).
|
|
@@ -165,18 +181,23 @@ export async function validate({ issueNumber }) {
|
|
|
165
181
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
166
182
|
`⚠️ **Validação falhou — Feature não está pronta.**\n\n` +
|
|
167
183
|
errors.join('\n') +
|
|
168
|
-
`\n\
|
|
184
|
+
`\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.` +
|
|
185
|
+
`\n\nSe os documentos precisam mesmo ser REGERADOS (e não só corrigidos), aplique ` +
|
|
186
|
+
`você a label \`spec-wave:spec\` — ela **sobrescreve** o \`spec.md\`, inclusive o que ` +
|
|
187
|
+
'foi revisado à mão.'
|
|
169
188
|
);
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
189
|
+
// A reprova NÃO aplica mais `spec-wave:spec` sozinha.
|
|
190
|
+
//
|
|
191
|
+
// `spec-wave:spec` é label de GATILHO: aplicá-la manda o generate-spec
|
|
192
|
+
// sobrescrever o spec.md. Uma reprova por título trocado ("# Rollout" onde
|
|
193
|
+
// se esperava "# Rollback", com o conteúdo certo embaixo) apagaria uma spec
|
|
194
|
+
// revisada à mão por causa de uma palavra. Isso não aconteceu até hoje por
|
|
195
|
+
// um acidente: o GitHub não dispara `labeled` para eventos do GITHUB_TOKEN,
|
|
196
|
+
// então a label ficava inerte. Quem trocasse por um PAT — o que a própria
|
|
197
|
+
// documentação sugere para alcançar Projects de organização — perderia o
|
|
198
|
+
// documento sem nunca ter pedido isso.
|
|
199
|
+
//
|
|
200
|
+
// Regenerar é decisão humana, e o comentário acima diz como e o que custa.
|
|
180
201
|
console.error('Validação falhou:', errors.join(', '));
|
|
181
202
|
process.exit(1);
|
|
182
203
|
}
|
package/src/config.mjs
CHANGED
|
@@ -11,17 +11,36 @@ export const PORTAL_URL = 'https://spec-wave.astratech.net.br';
|
|
|
11
11
|
// O provider e o modelo escolhidos no `init` são persistidos em .spec-wave.json
|
|
12
12
|
// (bloco `ai`) e lidos em runtime por src/lib/claude.mjs. Cada provider declara
|
|
13
13
|
// o secret do GitHub Actions de onde a chave é lida.
|
|
14
|
+
//
|
|
15
|
+
// `value` é o que o usuário escolhe; `backend` é QUEM executa (src/agent/).
|
|
16
|
+
// Os dois não coincidem: `anthropic` e `claude-oauth` rodam no mesmo backend e
|
|
17
|
+
// diferem só na credencial — chave de API por token vs. token da assinatura.
|
|
18
|
+
// Separar os campos evita que cada consumidor tenha que decorar essa relação.
|
|
14
19
|
export const AI_PROVIDERS = [
|
|
15
20
|
{
|
|
16
21
|
value: 'anthropic',
|
|
22
|
+
backend: 'anthropic',
|
|
17
23
|
label: 'Anthropic (API direta)',
|
|
18
24
|
hint: 'Usa o secret ANTHROPIC_API_KEY',
|
|
19
25
|
secret: 'ANTHROPIC_API_KEY',
|
|
20
26
|
defaultModel: 'claude-sonnet-4-6',
|
|
21
27
|
modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
|
|
22
28
|
},
|
|
29
|
+
{
|
|
30
|
+
// Mesmo backend do `anthropic`, credencial diferente: o token da assinatura
|
|
31
|
+
// Claude Pro/Max (`claude setup-token`), com escopo só de inferência. O
|
|
32
|
+
// consumo sai do limite do plano, não de crédito por token.
|
|
33
|
+
value: 'claude-oauth',
|
|
34
|
+
backend: 'anthropic',
|
|
35
|
+
label: 'Claude (assinatura Pro/Max via OAuth)',
|
|
36
|
+
hint: 'Usa o secret CLAUDE_CODE_OAUTH_TOKEN — gere com `claude setup-token`',
|
|
37
|
+
secret: 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
38
|
+
defaultModel: 'claude-sonnet-4-6',
|
|
39
|
+
modelHint: 'ex.: claude-sonnet-4-6, claude-opus-4-1',
|
|
40
|
+
},
|
|
23
41
|
{
|
|
24
42
|
value: 'openrouter',
|
|
43
|
+
backend: 'openrouter',
|
|
25
44
|
label: 'OpenRouter (multi-modelo)',
|
|
26
45
|
hint: 'Usa o secret OPENROUTER_API_KEY',
|
|
27
46
|
secret: 'OPENROUTER_API_KEY',
|
|
@@ -49,10 +68,12 @@ export const MODEL_LABEL_PREFIX = 'spec-wave:model:';
|
|
|
49
68
|
// `spec-wave:model:<apelido>` cubra "roda de novo num modelo mais forte" sem
|
|
50
69
|
// obrigar cada projeto a inventar a própria nomenclatura.
|
|
51
70
|
//
|
|
52
|
-
// O mapa é POR
|
|
53
|
-
// slug com "/" (anthropic/claude-opus-5) e a
|
|
54
|
-
// (claude-opus-5). Sugerir o formato errado geraria
|
|
55
|
-
// "apelido com formato incompatível" que o doctor emite
|
|
71
|
+
// O mapa é POR BACKEND, não por provider, porque o que ele distingue é o
|
|
72
|
+
// formato do id: a OpenRouter usa slug com "/" (anthropic/claude-opus-5) e a
|
|
73
|
+
// Anthropic usa o id nu (claude-opus-5). Sugerir o formato errado geraria
|
|
74
|
+
// exatamente o aviso de "apelido com formato incompatível" que o doctor emite
|
|
75
|
+
// na linha seguinte. `anthropic` e `claude-oauth` compartilham o mesmo mapa
|
|
76
|
+
// porque compartilham o backend.
|
|
56
77
|
export const RECOMMENDED_MODEL_ALIASES = {
|
|
57
78
|
openrouter: {
|
|
58
79
|
opus: 'anthropic/claude-opus-5',
|
|
@@ -78,7 +99,17 @@ export const RECOMMENDED_MODEL_ALIASES = {
|
|
|
78
99
|
* @returns {Record<string,string>}
|
|
79
100
|
*/
|
|
80
101
|
export function recommendedModelAliases(provider) {
|
|
81
|
-
return RECOMMENDED_MODEL_ALIASES[provider]
|
|
102
|
+
return RECOMMENDED_MODEL_ALIASES[providerBackend(provider)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Backend que executa um provider (função PURA).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} [value] valor de `ai.provider`
|
|
109
|
+
* @returns {'anthropic'|'openrouter'}
|
|
110
|
+
*/
|
|
111
|
+
export function providerBackend(value) {
|
|
112
|
+
return (getProvider(value) || getProvider(DEFAULT_PROVIDER)).backend;
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
// Quantas críticas seguidas podem reprovar a mesma issue antes de exigir revisão
|
|
@@ -372,6 +403,12 @@ export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
|
|
|
372
403
|
export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
|
|
373
404
|
export const LABEL_DECOMPOSE_READY = 'spec-wave:decompose-ready';
|
|
374
405
|
export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
|
|
406
|
+
// Selo de que spec+plan passaram pelo `validate`. É o que distingue uma Feature
|
|
407
|
+
// pronta de uma que só foi movida à mão para ✅ Ready.
|
|
408
|
+
export const LABEL_PLAN_APPROVED = 'spec-wave:plan-approved';
|
|
409
|
+
// Gatilhos que REGERAM documento: presentes na issue, indicam etapa pendente.
|
|
410
|
+
export const LABEL_SPEC = 'spec-wave:spec';
|
|
411
|
+
export const LABEL_PLAN = 'spec-wave:plan';
|
|
375
412
|
// Achado GRAVE que o Tech Leader aceitou como risco conhecido. Fica na issue
|
|
376
413
|
// depois que a crítica passa: sem ela, o risco aceito some da vista assim que a
|
|
377
414
|
// rodada seguinte roda limpa, e ninguém mais sabe que houve uma decisão.
|
|
@@ -382,11 +419,11 @@ export const LABEL_RISK_ACCEPTED = 'spec-wave:risk-accepted';
|
|
|
382
419
|
export const LABEL_CRITIQUE = 'spec-wave:critique';
|
|
383
420
|
|
|
384
421
|
export const TRIGGER_LABELS = [
|
|
385
|
-
{ name:
|
|
386
|
-
{ name:
|
|
422
|
+
{ name: LABEL_SPEC, color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
423
|
+
{ name: LABEL_PLAN, color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
|
|
387
424
|
{ name: LABEL_CRITIQUE, color: 'BFD4F2', description: 'Re-criticar o plan.md COMO ESTÁ, sem regerar' },
|
|
388
425
|
{ name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
|
|
389
|
-
{ name:
|
|
426
|
+
{ name: LABEL_PLAN_APPROVED, color: '0E8A16', description: 'Spec+plan validados com sucesso' },
|
|
390
427
|
{ name: LABEL_DECOMPOSE, color: 'BFD4F2', description: 'Gerar/re-criticar o rascunho da decomposição (decomposition.md)' },
|
|
391
428
|
{ name: LABEL_DECOMPOSE_APPLY, color: 'BFD4F2', description: 'Aplicar o decomposition.md revisado: criar Stories e Tasks' },
|
|
392
429
|
{ name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
|
package/src/lib/board.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
|
|
3
3
|
// (task/story/order). Ver a distinção Etapa × Status em config.mjs.
|
|
4
4
|
import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
|
|
5
|
-
import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES } from '../config.mjs';
|
|
5
|
+
import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES, STAGE_DONE } from '../config.mjs';
|
|
6
6
|
import { loadConfig } from './project-root.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -116,6 +116,39 @@ export function resolveStageName(input) {
|
|
|
116
116
|
return { stage: null, error: `Etapa "${input}" não existe. Use uma destas: ${list()}.` };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Índice do board por número de issue (função PURA).
|
|
121
|
+
*
|
|
122
|
+
* @param {Array<{number:number}>} items saída de listProjectItems
|
|
123
|
+
* @returns {Map<number, object>}
|
|
124
|
+
*/
|
|
125
|
+
export function indexBoardItems(items) {
|
|
126
|
+
return new Map((items || []).filter(i => i?.number).map(i => [i.number, i]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* As Features que ainda têm trabalho (função PURA).
|
|
131
|
+
*
|
|
132
|
+
* "Ainda tem trabalho" = issue aberta e Etapa diferente de 🎉 Done. É este o
|
|
133
|
+
* conjunto do `order` sem argumento: o mapa de quem depende de quem quando o
|
|
134
|
+
* trabalho de uma onda inteira está espalhado por várias Features.
|
|
135
|
+
*
|
|
136
|
+
* O tipo sai do campo "Work Item Type", com FALLBACK no prefixo do título: o
|
|
137
|
+
* campo ficou vazio em todo item criado pelo apply até a v0.21.0, e um board com
|
|
138
|
+
* esse buraco não pode virar um mapa vazio em silêncio.
|
|
139
|
+
*
|
|
140
|
+
* @param {Array<object>} items saída de listProjectItems
|
|
141
|
+
* @returns {Array<object>} Features, em ordem crescente de número
|
|
142
|
+
*/
|
|
143
|
+
export function selectOpenFeatures(items) {
|
|
144
|
+
return (items || [])
|
|
145
|
+
.filter(i => i?.number
|
|
146
|
+
&& String(i.state || '').toUpperCase() !== 'CLOSED'
|
|
147
|
+
&& (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
|
|
148
|
+
&& i.fields?.Etapa !== STAGE_DONE)
|
|
149
|
+
.sort((a, b) => a.number - b.number);
|
|
150
|
+
}
|
|
151
|
+
|
|
119
152
|
/**
|
|
120
153
|
* Preenche o "Work Item Type" do item quando ele está VAZIO (best-effort).
|
|
121
154
|
*
|
package/src/lib/bug-doc.mjs
CHANGED
|
@@ -49,3 +49,74 @@ export function findMissingSections(content, sections) {
|
|
|
49
49
|
const text = String(content || '');
|
|
50
50
|
return (sections || []).filter(section => !text.includes(`# ${section}`));
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Títulos presentes no documento (função PURA).
|
|
55
|
+
*/
|
|
56
|
+
export function listHeadings(content) {
|
|
57
|
+
return [...String(content || '').matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*$/gm)].map(m => m[1].trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Comparação tolerante: sem acento, sem pontuação, caixa única.
|
|
61
|
+
function normalizeHeading(value) {
|
|
62
|
+
return String(value ?? '')
|
|
63
|
+
.normalize('NFD')
|
|
64
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
65
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, ' ')
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function bigrams(value) {
|
|
71
|
+
const s = normalizeHeading(value).replace(/ /g, '');
|
|
72
|
+
const out = new Set();
|
|
73
|
+
for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Semelhança entre dois títulos, 0..1 (função PURA — coeficiente de Dice).
|
|
79
|
+
*/
|
|
80
|
+
export function headingSimilarity(a, b) {
|
|
81
|
+
const A = bigrams(a);
|
|
82
|
+
const B = bigrams(b);
|
|
83
|
+
if (A.size === 0 || B.size === 0) return normalizeHeading(a) === normalizeHeading(b) ? 1 : 0;
|
|
84
|
+
let comuns = 0;
|
|
85
|
+
for (const g of A) if (B.has(g)) comuns += 1;
|
|
86
|
+
return (2 * comuns) / (A.size + B.size);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Abaixo disto são dois títulos diferentes, não um errado. "Rollout e
|
|
90
|
+
// Monitoramento" × "Rollback e Monitoramento" fica bem acima; "Riscos" ×
|
|
91
|
+
// "Rollback e Monitoramento", bem abaixo.
|
|
92
|
+
const SIMILARIDADE_MINIMA = 0.6;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* O que falta E o que existe no lugar (função PURA).
|
|
96
|
+
*
|
|
97
|
+
* `findMissingSections` só sabe dizer o que não achou, e foi isso que fez uma
|
|
98
|
+
* Feature ser reprovada por ter escrito "# Rollout e Monitoramento" no lugar de
|
|
99
|
+
* "# Rollback e Monitoramento" — com todo o conteúdo certo embaixo. A mensagem
|
|
100
|
+
* dizia "seção ausente" e o humano tinha que caçar a diferença de uma palavra.
|
|
101
|
+
*
|
|
102
|
+
* Só sugere um título que NÃO satisfaz nenhuma seção obrigatória: senão o
|
|
103
|
+
* "Riscos" legítimo do documento vira sugestão para o "Rollback" que falta.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Array<{section: string, found: string|null}>}
|
|
106
|
+
*/
|
|
107
|
+
export function describeMissingSections(content, sections) {
|
|
108
|
+
const faltando = findMissingSections(content, sections);
|
|
109
|
+
if (faltando.length === 0) return [];
|
|
110
|
+
const presentes = (sections || []).filter(s => !faltando.includes(s)).map(normalizeHeading);
|
|
111
|
+
const candidatos = listHeadings(content)
|
|
112
|
+
.filter(h => !presentes.includes(normalizeHeading(h)));
|
|
113
|
+
return faltando.map(section => {
|
|
114
|
+
let melhor = null;
|
|
115
|
+
let score = SIMILARIDADE_MINIMA;
|
|
116
|
+
for (const h of candidatos) {
|
|
117
|
+
const s = headingSimilarity(h, section);
|
|
118
|
+
if (s >= score) { score = s; melhor = h; }
|
|
119
|
+
}
|
|
120
|
+
return { section, found: melhor };
|
|
121
|
+
});
|
|
122
|
+
}
|
package/src/lib/claude.mjs
CHANGED
|
@@ -109,6 +109,9 @@ export function resolveAiConfig({ env = {}, fileAi = {}, action, labels = [], mo
|
|
|
109
109
|
|
|
110
110
|
return {
|
|
111
111
|
provider: meta.value,
|
|
112
|
+
// Quem executa. `anthropic` e `claude-oauth` caem no mesmo backend e
|
|
113
|
+
// diferem só na credencial — src/agent/ nunca vê essa distinção.
|
|
114
|
+
backend: meta.backend,
|
|
112
115
|
model: rawModel.trim(),
|
|
113
116
|
modelSource,
|
|
114
117
|
labelAlias,
|
|
@@ -193,6 +196,21 @@ export function isTransientProviderError(err) {
|
|
|
193
196
|
.test(err.message || '');
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* O teto de turnos merece UMA repetição? (função PURA)
|
|
201
|
+
*
|
|
202
|
+
* Só quando o erro se classificou como exploração (ver MaxTurnsError): aí a
|
|
203
|
+
* falha é variância entre execuções, e a repetição costuma custar uma fração do
|
|
204
|
+
* run que falhou. Loop degenerado continua sem retry — repetir o determinístico
|
|
205
|
+
* foi o que custou 55 minutos de Action.
|
|
206
|
+
*
|
|
207
|
+
* UMA, e não `attempts`: o ganho observado está na segunda tentativa; da
|
|
208
|
+
* terceira em diante o padrão vira "gastar caro para confirmar o óbvio".
|
|
209
|
+
*/
|
|
210
|
+
export function shouldRetryMaxTurns(err) {
|
|
211
|
+
return Boolean(err?.maxTurns && err.exploration);
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs = RETRY_BASE_MS } = {}) {
|
|
197
215
|
let lastErr;
|
|
198
216
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
@@ -200,11 +218,12 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
|
|
|
200
218
|
return await fn();
|
|
201
219
|
} catch (err) {
|
|
202
220
|
lastErr = err;
|
|
203
|
-
|
|
221
|
+
const repeteTeto = attempt === 1 && shouldRetryMaxTurns(err);
|
|
222
|
+
if (attempt === attempts || (!isTransientProviderError(err) && !repeteTeto)) break;
|
|
204
223
|
const delayMs = baseMs * 2 ** (attempt - 1); // 2s, 4s, 8s…
|
|
205
224
|
console.warn(
|
|
206
|
-
`${label}:
|
|
207
|
-
`repetindo em ${delayMs / 1000}s.`
|
|
225
|
+
`${label}: ${repeteTeto ? 'teto de turnos gasto explorando' : 'falha transitória'} na ` +
|
|
226
|
+
`tentativa ${attempt}/${attempts} (${err.message}) — repetindo em ${delayMs / 1000}s.`
|
|
208
227
|
);
|
|
209
228
|
await sleep(delayMs);
|
|
210
229
|
}
|
|
@@ -343,7 +362,7 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
|
343
362
|
// Extraída do generateDocument para o generateStructured usar a mesma contagem.
|
|
344
363
|
function recordUsageEntry(opts, ai, { inputTokens, outputTokens, cost }) {
|
|
345
364
|
if (!Array.isArray(opts.usage)) return;
|
|
346
|
-
const finalCost = cost === null && ai.
|
|
365
|
+
const finalCost = cost === null && ai.backend === 'anthropic'
|
|
347
366
|
? computeCost({ model: ai.model, inputTokens, outputTokens, pricing: ai.pricing })
|
|
348
367
|
: cost;
|
|
349
368
|
opts.usage.push({
|
|
@@ -491,7 +510,7 @@ async function generateViaEngine(
|
|
|
491
510
|
systemPrompt, userContent, ai, { temperature, maxTokens, maxTurns, schema, strict, action, tools },
|
|
492
511
|
) {
|
|
493
512
|
const result = await runAgent(userContent, {
|
|
494
|
-
provider: ai.
|
|
513
|
+
provider: ai.backend,
|
|
495
514
|
model: ai.model,
|
|
496
515
|
systemPromptAppend: systemPrompt,
|
|
497
516
|
// Sessão/usuário alimentam o agrupamento das traces no Langfuse. Sem
|
|
@@ -520,7 +539,14 @@ async function generateViaEngine(
|
|
|
520
539
|
if (result.structured === null || result.structured === undefined) {
|
|
521
540
|
const err = new Error(
|
|
522
541
|
`O modelo ${ai.model} não devolveu a saída estruturada "${schema.name}" ` +
|
|
523
|
-
`(subtype=${result.resultSubtype}).`
|
|
542
|
+
`(subtype=${result.resultSubtype}).` +
|
|
543
|
+
(result.resultSubtype === 'error_max_structured_output_retries'
|
|
544
|
+
// Subtype só do backend anthropic: o CLI repetiu o turno e a saída
|
|
545
|
+
// nunca bateu com o schema. Repetir tende a resolver; se insistir, o
|
|
546
|
+
// modelo é que não dá conta do contrato.
|
|
547
|
+
? ' O Claude Code esgotou as tentativas de casar a resposta com o schema —' +
|
|
548
|
+
' se repetir, aponte `ai.models.critique` para um modelo mais forte.'
|
|
549
|
+
: '')
|
|
524
550
|
);
|
|
525
551
|
err.transient = true; // repetir a mesma requisição costuma resolver
|
|
526
552
|
throw err;
|
|
@@ -530,9 +556,9 @@ async function generateViaEngine(
|
|
|
530
556
|
|
|
531
557
|
const text = stripReasoning(result.outputText || '');
|
|
532
558
|
if (!text) {
|
|
533
|
-
// Teto de turnos tem causa e remédio próprios
|
|
534
|
-
//
|
|
535
|
-
//
|
|
559
|
+
// Teto de turnos tem causa e remédio próprios. Só é repetível quando as
|
|
560
|
+
// tool calls foram de EXPLORAÇÃO (variância); loop degenerado não é.
|
|
561
|
+
// Quem classifica é o próprio erro. Ver MaxTurnsError.
|
|
536
562
|
if (result.resultSubtype === 'error_max_turns') {
|
|
537
563
|
throw new MaxTurnsError({
|
|
538
564
|
provider: ai.provider,
|
|
@@ -540,6 +566,7 @@ async function generateViaEngine(
|
|
|
540
566
|
turns: result.numTurns,
|
|
541
567
|
action: action || null,
|
|
542
568
|
toolCalls: result.toolCalls || [],
|
|
569
|
+
toolSignatures: result.toolSignatures || [],
|
|
543
570
|
});
|
|
544
571
|
}
|
|
545
572
|
const err = new Error(
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// ## Story 1 — <título curto>
|
|
21
21
|
//
|
|
22
22
|
// **User story:** Como <perfil>, quero <objetivo>, para <benefício>
|
|
23
|
-
// **Depende de:** Story 1,
|
|
23
|
+
// **Depende de:** Story 1, #412 (ou "—" para nenhuma)
|
|
24
24
|
//
|
|
25
25
|
// <corpo livre, multi-linha>
|
|
26
26
|
//
|
|
@@ -162,6 +162,16 @@ function normalizeDependsOn(dependsOn, index) {
|
|
|
162
162
|
.sort((a, b) => a - b);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Dependências para FORA da Feature: números de issue que já existem.
|
|
166
|
+
//
|
|
167
|
+
// A regra "só aponta para trás" NÃO se aplica a elas — ela existe para que a
|
|
168
|
+
// ordem de criação do apply seja topologicamente válida, e uma issue que já
|
|
169
|
+
// existe não é criada por este apply. O que vale aqui é ser inteiro positivo.
|
|
170
|
+
function normalizeDependsOnIssues(list) {
|
|
171
|
+
if (!Array.isArray(list)) return [];
|
|
172
|
+
return [...new Set(list.filter(n => Number.isInteger(n) && n > 0))].sort((a, b) => a - b);
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
/**
|
|
166
176
|
* Renderiza o decomposition.md (função PURA — testável).
|
|
167
177
|
*
|
|
@@ -223,12 +233,16 @@ export function renderDecompositionDoc({
|
|
|
223
233
|
(stories || []).forEach((story, i) => {
|
|
224
234
|
blocks.push(`## Story ${i + 1} — ${flatten(story?.title) || '(sem título)'}`);
|
|
225
235
|
const deps = normalizeDependsOn(story?.dependsOn, i);
|
|
236
|
+
// Irmãs primeiro (por índice), depois as externas (por número): a saída é
|
|
237
|
+
// canônica, então duas escritas do mesmo conteúdo dão o mesmo arquivo.
|
|
238
|
+
const externas = normalizeDependsOnIssues(story?.dependsOnIssues);
|
|
239
|
+
const refs = [...deps.map(d => `Story ${d + 1}`), ...externas.map(n => `#${n}`)];
|
|
226
240
|
// As DUAS linhas de campo saem SEMPRE, seguidas de linha em branco: é essa
|
|
227
241
|
// linha em branco que impede um corpo começando com "**Depende de:** …" de
|
|
228
242
|
// ser confundido com o campo.
|
|
229
243
|
const campos = [
|
|
230
244
|
`**User story:** ${flatten(story?.userStory) || '—'}`,
|
|
231
|
-
`**Depende de:** ${
|
|
245
|
+
`**Depende de:** ${refs.length ? refs.join(', ') : '—'}`,
|
|
232
246
|
];
|
|
233
247
|
const linhaIssue = issueLine(story);
|
|
234
248
|
if (linhaIssue) campos.push(linhaIssue);
|
|
@@ -276,28 +290,62 @@ function requireTitle(value, anchor) {
|
|
|
276
290
|
// "Story 1, Story 3" → [0, 2]. Linha ausente ou "—" → []. Referência a si mesma
|
|
277
291
|
// ou a uma story POSTERIOR é erro: num arquivo editado à mão, filtrar em
|
|
278
292
|
// silêncio (como se fazia com o JSON do modelo) esconderia o engano do humano.
|
|
293
|
+
// Só separadores e conjunções podem sobrar depois de extrair as referências.
|
|
294
|
+
// Sem esta checagem, "**Depende de:** #12 no sistema legado" viraria "depende da
|
|
295
|
+
// issue 12" e a prosa sumiria em silêncio — o erro que este parser existe para
|
|
296
|
+
// não cometer.
|
|
297
|
+
const DEPENDS_LEFTOVER_RE = /^[\s,;+&/–—-]*(?:\b(?:e|and)\b[\s,;+&/–—-]*)*$/i;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Interpreta o valor de "**Depende de:**" (função PURA).
|
|
301
|
+
*
|
|
302
|
+
* Duas formas convivem na mesma linha, e cada uma diz uma coisa diferente:
|
|
303
|
+
* • `Story N` — irmã, no MESMO documento, sempre para trás (é o que mantém a
|
|
304
|
+
* ordem de criação do apply topologicamente válida);
|
|
305
|
+
* • `#N` — issue que JÁ existe, de qualquer Feature. Sem ela, tudo que cruza a
|
|
306
|
+
* fronteira da Feature vivia na prosa, e nenhuma automação enxergava.
|
|
307
|
+
*
|
|
308
|
+
* @returns {{ siblings: number[], issues: number[] }} siblings 0-based
|
|
309
|
+
*/
|
|
279
310
|
function parseDependsValue(raw, index, anchor) {
|
|
280
|
-
if (raw === null || raw === undefined) return [];
|
|
311
|
+
if (raw === null || raw === undefined) return { siblings: [], issues: [] };
|
|
281
312
|
const value = raw.trim();
|
|
282
|
-
if (!value || EMPTY_VALUE_RE.test(value)) return [];
|
|
283
|
-
|
|
284
|
-
|
|
313
|
+
if (!value || EMPTY_VALUE_RE.test(value)) return { siblings: [], issues: [] };
|
|
314
|
+
|
|
315
|
+
const storyMatches = [...value.matchAll(/story[ \t]*(\d+)/gi)];
|
|
316
|
+
const issueMatches = [...value.matchAll(/#(\d+)/g)];
|
|
317
|
+
if (storyMatches.length === 0 && issueMatches.length === 0) {
|
|
318
|
+
throw invalid(
|
|
319
|
+
`não entendi "**Depende de:** ${value}" em ${anchor} — use "Story 1", "#412" ou "—"`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const resto = value
|
|
323
|
+
.replace(/story[ \t]*\d+/gi, '')
|
|
324
|
+
.replace(/#\d+/g, '');
|
|
325
|
+
if (!DEPENDS_LEFTOVER_RE.test(resto)) {
|
|
285
326
|
throw invalid(
|
|
286
|
-
`não entendi "**Depende de:** ${value}" em ${anchor} —
|
|
327
|
+
`não entendi "**Depende de:** ${value}" em ${anchor} — sobrou "${resto.trim()}". ` +
|
|
328
|
+
'Use só referências ("Story 1, #412") ou "—"'
|
|
287
329
|
);
|
|
288
330
|
}
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
|
|
332
|
+
const siblings = [];
|
|
333
|
+
for (const m of storyMatches) {
|
|
291
334
|
const n = parseInt(m[1], 10);
|
|
292
335
|
if (n < 1 || n - 1 >= index) {
|
|
293
336
|
throw invalid(
|
|
294
337
|
`${anchor} depende de "Story ${n}", que não é uma Story anterior a ela — ` +
|
|
295
|
-
'dependências só apontam para trás'
|
|
338
|
+
'dependências entre irmãs só apontam para trás (para outra Feature, use "#<issue>")'
|
|
296
339
|
);
|
|
297
340
|
}
|
|
298
|
-
if (!
|
|
341
|
+
if (!siblings.includes(n - 1)) siblings.push(n - 1);
|
|
342
|
+
}
|
|
343
|
+
const issues = [];
|
|
344
|
+
for (const m of issueMatches) {
|
|
345
|
+
const n = parseInt(m[1], 10);
|
|
346
|
+
if (n > 0 && !issues.includes(n)) issues.push(n);
|
|
299
347
|
}
|
|
300
|
-
return
|
|
348
|
+
return { siblings: siblings.sort((a, b) => a - b), issues: issues.sort((a, b) => a - b) };
|
|
301
349
|
}
|
|
302
350
|
|
|
303
351
|
// Tasks não têm campos de gramática além do título — só a linha `**Issue:** #N`
|
|
@@ -352,7 +400,8 @@ function splitStorySection(lines, anchor) {
|
|
|
352
400
|
* Interpreta o decomposition.md (função PURA — testável).
|
|
353
401
|
*
|
|
354
402
|
* Devolve exatamente a shape que o loop de criação de issues consome
|
|
355
|
-
* (`stories[].{title,userStory,body,dependsOn,tasks[]}`, com
|
|
403
|
+
* (`stories[].{title,userStory,body,dependsOn,dependsOnIssues,tasks[]}`, com
|
|
404
|
+
* dependsOn 0-based entre irmãs e dependsOnIssues em números de issue),
|
|
356
405
|
* mais os metadados do marcador e a âncora estável de cada item ("Story 3",
|
|
357
406
|
* "Task 3.2") — é a âncora que a crítica cita no comentário da issue.
|
|
358
407
|
*
|
|
@@ -473,7 +522,10 @@ export function parseDecompositionDoc(markdown) {
|
|
|
473
522
|
userStory,
|
|
474
523
|
body,
|
|
475
524
|
issue,
|
|
476
|
-
|
|
525
|
+
...(() => {
|
|
526
|
+
const { siblings, issues } = parseDependsValue(depends, doc.stories.length, anchor);
|
|
527
|
+
return { dependsOn: siblings, dependsOnIssues: issues };
|
|
528
|
+
})(),
|
|
477
529
|
tasks: [],
|
|
478
530
|
});
|
|
479
531
|
continue;
|
package/src/lib/dependencies.mjs
CHANGED
|
@@ -44,8 +44,14 @@ export function parseDependencies(body) {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Ordena Stories topologicamente pelas dependências (Kahn). Estável: entre as
|
|
47
|
-
* Stories liberadas ao mesmo tempo, vence a de menor number.
|
|
48
|
-
*
|
|
47
|
+
* Stories liberadas ao mesmo tempo, vence a de menor number.
|
|
48
|
+
*
|
|
49
|
+
* Dependência para FORA do conjunto (uma Story de outra Feature) não participa
|
|
50
|
+
* da ordenação — sem ela no conjunto não há como saber onde entra —, mas deixou
|
|
51
|
+
* de ser DESCARTADA: volta em `external`, para o chamador mostrar quem está
|
|
52
|
+
* bloqueado por fora. Antes ela era lida, filtrada aqui e ignorada de novo no
|
|
53
|
+
* aviso de fora-de-ordem: o dado entrava e sumia sem nenhuma mensagem, que é
|
|
54
|
+
* pior do que não aceitá-lo.
|
|
49
55
|
*
|
|
50
56
|
* Contrato: NUNCA lança. Retorna sempre `{ order, cycle }`:
|
|
51
57
|
* • sem ciclo → order = todos os numbers em ordem de execução, cycle = [];
|
|
@@ -54,15 +60,19 @@ export function parseDependencies(body) {
|
|
|
54
60
|
* O chamador decide se trata cycle.length > 0 como erro.
|
|
55
61
|
*
|
|
56
62
|
* @param {Array<{ number: number, dependsOn: number[] }>} stories
|
|
57
|
-
* @returns {{ order: number[], cycle: number[] }}
|
|
63
|
+
* @returns {{ order: number[], cycle: number[], external: Map<number, number[]> }}
|
|
64
|
+
* external: number da Story → dependências fora do conjunto
|
|
58
65
|
*/
|
|
59
66
|
export function orderStories(stories) {
|
|
60
67
|
const known = new Set(stories.map(s => s.number));
|
|
61
68
|
// indegree = quantas dependências INTERNAS ainda não resolvidas.
|
|
62
69
|
const indegree = new Map();
|
|
63
70
|
const dependents = new Map(); // number → numbers que dependem dele
|
|
71
|
+
const external = new Map();
|
|
64
72
|
for (const s of stories) {
|
|
65
73
|
const deps = (s.dependsOn || []).filter(d => known.has(d) && d !== s.number);
|
|
74
|
+
const fora = (s.dependsOn || []).filter(d => !known.has(d) && d !== s.number);
|
|
75
|
+
if (fora.length > 0) external.set(s.number, fora.sort((a, b) => a - b));
|
|
66
76
|
indegree.set(s.number, deps.length);
|
|
67
77
|
for (const d of deps) {
|
|
68
78
|
if (!dependents.has(d)) dependents.set(d, []);
|
|
@@ -88,7 +98,7 @@ export function orderStories(stories) {
|
|
|
88
98
|
.filter(([, deg]) => deg > 0)
|
|
89
99
|
.map(([n]) => n)
|
|
90
100
|
.sort((a, b) => a - b);
|
|
91
|
-
return { order, cycle };
|
|
101
|
+
return { order, cycle, external };
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.24.0",
|
|
5
5
|
"description": "Fluxo spec-driven no GitHub (RFC-001): Projects v2, labels de gatilho, spec/plan gerados por Action, decomposição em duas etapas e implementação orientada a Stories/Tasks.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Astratech",
|
|
@@ -64,7 +64,7 @@ spec-wave:decompose-apply
|
|
|
64
64
|
```
|
|
65
65
|
Aplicar essa label **é** a aprovação humana — não há nova crítica.
|
|
66
66
|
|
|
67
|
-
5. Após a aplicação, as issues filhas aparecem como comentário na issue pai. Pai e filhas entram no board em **✅ Ready** (Status Todo; a Etapa nunca retrocede — itens já adiante não são tocados). As Stories trazem `Depende de: #N` — use a skill **order** para ver a ordem de execução.
|
|
67
|
+
5. Após a aplicação, as issues filhas aparecem como comentário na issue pai. Pai e filhas entram no board em **✅ Ready** (Status Todo; a Etapa nunca retrocede — itens já adiante não são tocados). As Stories trazem `Depende de: #N` — use a skill **order** para ver a ordem de execução. Se a issue pai tiver **milestone**, as filhas nascem nele (a entrega da Story pertence à release da Feature); sem milestone no pai, nascem sem.
|
|
68
68
|
|
|
69
69
|
6. A issue recebe `spec-wave:decomposed` (guard de idempotência): rodar de novo **não** duplica as issues.
|
|
70
70
|
|
|
@@ -93,7 +93,7 @@ Corpo técnico.
|
|
|
93
93
|
**Ao editar à mão:**
|
|
94
94
|
|
|
95
95
|
- a **posição** manda, não o número escrito — inserir uma Story no meio sem renumerar funciona
|
|
96
|
-
- `**Depende de:**`
|
|
96
|
+
- `**Depende de:**` aceita **irmãs** (`Story 1, Story 3`, 1-based, só para trás — apontar para si mesma ou para frente é **erro**, não filtro silencioso) e **issues de outras Features** (`#412`, que precisam JÁ existir); as duas formas convivem na mesma linha (`Story 1, #412`), e `—` significa nenhuma
|
|
97
97
|
- o corpo aceita markdown livre (`## Backend`, cercas de código) — só `## Story N` e `### Task N.M` são estrutura
|
|
98
98
|
- **para gerar outro rascunho do zero:** apague o arquivo e reaplique `spec-wave:decompose`
|
|
99
99
|
|
|
@@ -114,4 +114,4 @@ Depois **apague/feche as sub-issues antigas** (senão a detecção por sub-issue
|
|
|
114
114
|
|
|
115
115
|
## Dependências entre Stories
|
|
116
116
|
|
|
117
|
-
O `decompose` grava `Depende de: #N, #M` no corpo das Stories e cria a relação nativa *blocked by
|
|
117
|
+
O `decompose` grava `Depende de: #N, #M` no corpo das Stories e cria a relação nativa *blocked by* — para irmãs e para as issues de outras Features referenciadas com `#N` no rascunho (a issue precisa existir: o apply reprova o rascunho ANTES de criar qualquer coisa se não conseguir lê-la). Isso alimenta as skills **order** e **implement**. **Não apague essa linha** ao editar o corpo de uma Story; para mudar dependências, edite a linha (e/ou a relação *blocked by*).
|