@spec-wave/cli 0.2.2 → 0.4.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 +14 -2
- package/package.json +3 -2
- package/src/commands/generate-plan.mjs +41 -19
- package/src/commands/generate-spec.mjs +27 -18
- package/src/commands/init.mjs +1 -1
- package/src/commands/initiative.mjs +8 -0
- package/src/commands/issue.mjs +6 -1
- package/src/commands/validate.mjs +2 -2
- package/src/config.mjs +21 -9
- package/src/lib/claude.mjs +36 -11
- package/src/lib/tech-context.mjs +142 -0
- package/src/setup/files.mjs +21 -1
- package/src/templates/config/tech_context.yml +42 -0
- package/src/templates/issue/plan-template.md +30 -14
- package/src/templates/issue/spec-template.md +30 -8
package/bin/spec-wave.mjs
CHANGED
|
@@ -49,9 +49,9 @@ program
|
|
|
49
49
|
|
|
50
50
|
program
|
|
51
51
|
.command('issue')
|
|
52
|
-
.description('Cria um work item (epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
|
|
52
|
+
.description('Cria um work item (initiative/epic/feature/story/task...), opcionalmente como sub-issue, e adiciona ao board')
|
|
53
53
|
.requiredOption('--title <title>', 'Título (sem o prefixo de tipo, ex.: [FEATURE])')
|
|
54
|
-
.option('--type <type>', 'Tipo: epic, feature, story, task, bug, spike ou rfc', 'feature')
|
|
54
|
+
.option('--type <type>', 'Tipo: initiative, epic, feature, story, task, bug, spike ou rfc', 'feature')
|
|
55
55
|
.option('--parent <n>', 'Número da issue pai (cria como sub-issue dela)')
|
|
56
56
|
.option('--body <text>', 'Descrição')
|
|
57
57
|
.option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3')
|
|
@@ -61,6 +61,18 @@ program
|
|
|
61
61
|
await issue(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
program
|
|
65
|
+
.command('initiative')
|
|
66
|
+
.description('Atalho de `issue --type initiative` (nó raiz que agrupa Epics)')
|
|
67
|
+
.requiredOption('--title <title>', 'Título da initiative (sem o prefixo [INITIATIVE])')
|
|
68
|
+
.option('--body <text>', 'Descrição da initiative')
|
|
69
|
+
.option('--priority <p>', 'Prioridade: P0, P1, P2 ou P3 (adiciona label)')
|
|
70
|
+
.option('--area <area>', 'Área: Frontend, Backend, Mobile, Infra, DevOps ou Data')
|
|
71
|
+
.action(async (options) => {
|
|
72
|
+
const { initiative } = await import('../src/commands/initiative.mjs');
|
|
73
|
+
await initiative(options).catch(err => { console.error(err.message); process.exit(1); });
|
|
74
|
+
});
|
|
75
|
+
|
|
64
76
|
program
|
|
65
77
|
.command('feature')
|
|
66
78
|
.description('Atalho de `issue --type feature`')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spec-wave/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"@octokit/graphql": "^9.0.1",
|
|
23
23
|
"@octokit/rest": "^22.0.0",
|
|
24
24
|
"chalk": "^5.4.1",
|
|
25
|
-
"commander": "^13.1.0"
|
|
25
|
+
"commander": "^13.1.0",
|
|
26
|
+
"js-yaml": "^4.1.0"
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
4
|
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
5
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
6
|
import { slugify } from '../lib/slugify.mjs';
|
|
7
|
+
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
7
8
|
|
|
8
|
-
const SYSTEM_PROMPT = `Você é um Tech Lead experiente. Gere um plano técnico (plan.md) completo e detalhado
|
|
9
|
+
const SYSTEM_PROMPT = `Você é um Tech Lead experiente. Gere um plano técnico (plan.md) completo e detalhado, baseado ESTRITAMENTE no spec.md fornecido.
|
|
9
10
|
|
|
10
|
-
O plano deve conter
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
# Segurança
|
|
16
|
-
# Testes
|
|
17
|
-
|
|
11
|
+
O plano deve conter EXATAMENTE estas seções em português, nesta ordem:
|
|
12
|
+
# Estratégia Técnica
|
|
13
|
+
- Abordagem Arquitetural, Decisões-Chave e uma Matriz de Rastreabilidade (tabela) ligando cada Critério de Aceite do spec a um componente técnico.
|
|
14
|
+
# Detalhamento da Implementação
|
|
15
|
+
- Subseções: ## Backend, ## Banco de Dados, ## Frontend, ## Infraestrutura.
|
|
16
|
+
# Segurança e Conformidade
|
|
17
|
+
# Estratégia de Testes
|
|
18
|
+
- Unitários, Integração e E2E.
|
|
19
|
+
# Rollback e Monitoramento
|
|
20
|
+
- Plano de Rollback, Métricas Observadas e Alertas.
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
Regras OBRIGATÓRIAS:
|
|
23
|
+
- TODA mudança de banco, endpoint de API ou componente de UI DEVE referenciar um Critério de Aceite específico do spec.md (rastreabilidade).
|
|
24
|
+
- Use APENAS as tecnologias e serviços listados no tech_context fornecido. Não invente APIs ou serviços inexistentes.
|
|
25
|
+
- Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
|
|
26
|
+
- Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
|
|
22
27
|
|
|
23
28
|
export async function generatePlan({ issueNumber }) {
|
|
24
29
|
const token = await resolveToken();
|
|
@@ -38,11 +43,28 @@ export async function generatePlan({ issueNumber }) {
|
|
|
38
43
|
const featureDir = `docs/features/${slug}`;
|
|
39
44
|
const filePath = `${featureDir}/plan.md`;
|
|
40
45
|
|
|
46
|
+
// Read existing spec.md if available (spec é gerada antes do plano)
|
|
47
|
+
const specPath = `${featureDir}/spec.md`;
|
|
48
|
+
const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
|
|
49
|
+
|
|
50
|
+
// Tech context (RFC-002 §4): estático + dinâmico + override do corpo da issue.
|
|
51
|
+
const tech = buildTechContext({ issueBody: issue.body || '' });
|
|
52
|
+
|
|
53
|
+
// Payload estruturado (RFC-002 §5.2): spec_content + tech_context.
|
|
54
|
+
const payload = {
|
|
55
|
+
spec_content: specContent || '(spec.md ainda não gerado — baseie-se na descrição da Feature)',
|
|
56
|
+
feature_title: issue.title,
|
|
57
|
+
feature_description: issue.body || '(sem descrição)',
|
|
58
|
+
tech_context: {
|
|
59
|
+
static: tech.merged,
|
|
60
|
+
dynamic: tech.dynamic,
|
|
61
|
+
overrides: tech.overrides,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const userContent = `Gere o plan.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
65
|
+
|
|
41
66
|
console.log(`Gerando plan.md para: ${issue.title}`);
|
|
42
|
-
const content = await generateDocument(
|
|
43
|
-
SYSTEM_PROMPT,
|
|
44
|
-
`Feature: ${issue.title}\n\nDescrição:\n${issue.body || '(sem descrição)'}`
|
|
45
|
-
);
|
|
67
|
+
const content = await generateDocument(SYSTEM_PROMPT, userContent);
|
|
46
68
|
|
|
47
69
|
mkdirSync(featureDir, { recursive: true });
|
|
48
70
|
writeFileSync(filePath, content, 'utf-8');
|
|
@@ -63,8 +85,8 @@ export async function generatePlan({ issueNumber }) {
|
|
|
63
85
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
64
86
|
`📋 **plan.md gerado automaticamente!**\n\n` +
|
|
65
87
|
`📄 Arquivo: [\`${filePath}\`](${filePath})\n\n` +
|
|
66
|
-
`Revise o plano e, quando estiver pronto, mova o card para
|
|
67
|
-
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:
|
|
88
|
+
`Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
|
|
89
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\``
|
|
68
90
|
);
|
|
69
91
|
|
|
70
92
|
console.log(`plan.md criado em: ${filePath}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { mkdirSync, writeFileSync
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
4
|
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
5
|
import { generateDocument } from '../lib/claude.mjs';
|
|
@@ -7,17 +7,23 @@ import { slugify } from '../lib/slugify.mjs';
|
|
|
7
7
|
|
|
8
8
|
const SYSTEM_PROMPT = `Você é um Product Manager experiente. Gere uma especificação funcional (spec.md) completa para a Feature descrita pelo usuário.
|
|
9
9
|
|
|
10
|
-
O spec deve conter
|
|
11
|
-
#
|
|
10
|
+
O spec deve conter EXATAMENTE estas seções em português, nesta ordem:
|
|
11
|
+
# Visão Geral
|
|
12
|
+
- Objetivo, Personas e Critérios de Sucesso como bullets.
|
|
12
13
|
# Regras de Negócio
|
|
13
14
|
# Fluxos
|
|
15
|
+
- Subseções: ## Fluxo Principal (Happy Path), ## Fluxos Alternativos, ## Cenários de Erro.
|
|
14
16
|
# Critérios de Aceite
|
|
15
|
-
|
|
17
|
+
- OBRIGATORIAMENTE no formato Gherkin, dentro de um bloco \`\`\`gherkin com Given/When/Then. Um cenário por critério.
|
|
16
18
|
# Dependências
|
|
19
|
+
- Subdivida em Internas e Externas.
|
|
20
|
+
# Requisitos Não-Funcionais
|
|
21
|
+
- Performance, Segurança e Usabilidade.
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
Regras:
|
|
24
|
+
- NÃO invente regras de negócio. Se faltar informação, marque explicitamente com "[TODO: requer esclarecimento do PO]".
|
|
25
|
+
- Seja específico e detalhado em cada seção.
|
|
26
|
+
- Responda APENAS com o conteúdo do spec.md, sem texto adicional.`;
|
|
21
27
|
|
|
22
28
|
export async function generateSpec({ issueNumber }) {
|
|
23
29
|
const token = await resolveToken();
|
|
@@ -37,15 +43,18 @@ export async function generateSpec({ issueNumber }) {
|
|
|
37
43
|
const featureDir = `docs/features/${slug}`;
|
|
38
44
|
const filePath = `${featureDir}/spec.md`;
|
|
39
45
|
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// Payload estruturado (RFC-002 §5.1): metadata + entrada de negócio.
|
|
47
|
+
const payload = {
|
|
48
|
+
metadata: {
|
|
49
|
+
feature_title: issue.title,
|
|
50
|
+
feature_description: issue.body || '(sem descrição)',
|
|
51
|
+
labels: (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name)),
|
|
52
|
+
},
|
|
53
|
+
business_input: {
|
|
54
|
+
raw: issue.body || '(sem descrição)',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const userContent = `Gere o spec.md a partir deste payload JSON:\n\n${JSON.stringify(payload, null, 2)}`;
|
|
49
58
|
|
|
50
59
|
console.log(`Gerando spec.md para: ${issue.title}`);
|
|
51
60
|
const content = await generateDocument(SYSTEM_PROMPT, userContent);
|
|
@@ -69,8 +78,8 @@ export async function generateSpec({ issueNumber }) {
|
|
|
69
78
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
70
79
|
`📋 **spec.md gerado automaticamente!**\n\n` +
|
|
71
80
|
`📄 Arquivo: [\`${filePath}\`](${filePath})\n\n` +
|
|
72
|
-
`Revise a especificação e, quando estiver pronto, mova o card para
|
|
73
|
-
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:
|
|
81
|
+
`Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
|
|
82
|
+
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\``
|
|
74
83
|
);
|
|
75
84
|
|
|
76
85
|
console.log(`spec.md criado em: ${filePath}`);
|
package/src/commands/init.mjs
CHANGED
|
@@ -122,7 +122,7 @@ export async function init(options) {
|
|
|
122
122
|
labelSpinner.start('Criando labels...');
|
|
123
123
|
try {
|
|
124
124
|
await setupLabels(token, owner, repo, labelSpinner);
|
|
125
|
-
labelSpinner.stop('Labels criadas (
|
|
125
|
+
labelSpinner.stop('Labels criadas (16 labels)');
|
|
126
126
|
} catch (err) {
|
|
127
127
|
labelSpinner.stop('');
|
|
128
128
|
p.log.error(`Erro ao criar labels: ${err.message}`);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { issue } from './issue.mjs';
|
|
2
|
+
|
|
3
|
+
// `initiative` é um atalho de `issue --type initiative`. A Initiative é o nó raiz
|
|
4
|
+
// da hierarquia (Initiative → Epic → Feature → Story → Task) e agrupa Epics.
|
|
5
|
+
// Toda a lógica vive em issue.mjs.
|
|
6
|
+
export async function initiative(options) {
|
|
7
|
+
return issue({ ...options, type: 'initiative' });
|
|
8
|
+
}
|
package/src/commands/issue.mjs
CHANGED
|
@@ -179,8 +179,13 @@ export async function issue(options) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const parentLine = parent ? ` (sub-issue de #${parent.number})` : '';
|
|
182
|
+
const hints = {
|
|
183
|
+
Feature: `Próximo: \`/spec-wave plan ${created.number}\` para o planejamento técnico.`,
|
|
184
|
+
Initiative: `Próximo: crie Epics sob esta Initiative com \`spec-wave issue --type epic --parent ${created.number} --title "..."\`.`,
|
|
185
|
+
Epic: `Próximo: crie Features sob este Epic com \`spec-wave feature --parent ${created.number} --title "..."\`.`,
|
|
186
|
+
};
|
|
182
187
|
p.outro(
|
|
183
188
|
`${chalk.green('✓')} ${type} #${created.number} criado em "${INITIAL_STAGE}"${parentLine}.\n` +
|
|
184
|
-
` ${type
|
|
189
|
+
` ${hints[type] || ''}`
|
|
185
190
|
);
|
|
186
191
|
}
|
|
@@ -67,8 +67,8 @@ export async function validate({ issueNumber }) {
|
|
|
67
67
|
await commentOnIssue(
|
|
68
68
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
69
69
|
`✅ **Validação concluída com sucesso!**\n\n` +
|
|
70
|
-
`- [\`${
|
|
71
|
-
`- [\`${
|
|
70
|
+
`- [\`${specPath}\`](${specPath}) ✓\n` +
|
|
71
|
+
`- [\`${planPath}\`](${planPath}) ✓\n\n` +
|
|
72
72
|
`A Feature está pronta para decomposição. Mova o card para **📋 Backlog Técnico** ou use:\n` +
|
|
73
73
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:decompose"\n\`\`\``
|
|
74
74
|
);
|
package/src/config.mjs
CHANGED
|
@@ -36,8 +36,8 @@ export function getProvider(value) {
|
|
|
36
36
|
export const STATUS_OPTIONS = [
|
|
37
37
|
{ name: '📥 Backlog', color: 'GRAY' },
|
|
38
38
|
{ name: '🎯 Priorizado', color: 'BLUE' },
|
|
39
|
-
{ name: '📋 Plan', color: 'YELLOW' },
|
|
40
39
|
{ name: '📋 Spec', color: 'YELLOW' },
|
|
40
|
+
{ name: '📋 Plan', color: 'YELLOW' },
|
|
41
41
|
{ name: '✅ Ready', color: 'GREEN' },
|
|
42
42
|
{ name: '📋 Backlog Técnico', color: 'BLUE' },
|
|
43
43
|
{ name: '🚧 Desenvolvimento', color: 'ORANGE' },
|
|
@@ -53,6 +53,7 @@ export const CUSTOM_FIELDS = [
|
|
|
53
53
|
name: 'Work Item Type',
|
|
54
54
|
dataType: 'SINGLE_SELECT',
|
|
55
55
|
options: [
|
|
56
|
+
{ name: 'Initiative', color: 'PINK', description: 'Agrupamento estratégico de Epics' },
|
|
56
57
|
{ name: 'Epic', color: 'PURPLE', description: 'Objetivo estratégico' },
|
|
57
58
|
{ name: 'Feature', color: 'BLUE', description: 'Capacidade funcional' },
|
|
58
59
|
{ name: 'Story', color: 'GREEN', description: 'Necessidade do usuário' },
|
|
@@ -110,6 +111,7 @@ export const WORK_ITEM_TYPES = CUSTOM_FIELDS
|
|
|
110
111
|
.options.map(o => o.name);
|
|
111
112
|
|
|
112
113
|
export const TYPE_LABELS = [
|
|
114
|
+
{ name: '[INITIATIVE]', color: 'C5DEF5', description: 'Agrupamento estratégico de Epics' },
|
|
113
115
|
{ name: '[EPIC]', color: '7B61FF', description: 'Objetivo estratégico' },
|
|
114
116
|
{ name: '[FEATURE]', color: '0075CA', description: 'Capacidade funcional' },
|
|
115
117
|
{ name: '[STORY]', color: '0E8A16', description: 'Necessidade do usuário' },
|
|
@@ -127,8 +129,8 @@ export const PRIORITY_LABELS = [
|
|
|
127
129
|
];
|
|
128
130
|
|
|
129
131
|
export const TRIGGER_LABELS = [
|
|
130
|
-
{ name: 'spec-wave:plan', color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
|
|
131
132
|
{ name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
|
|
133
|
+
{ name: 'spec-wave:plan', color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
|
|
132
134
|
{ name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
|
|
133
135
|
{ name: 'spec-wave:decompose', color: 'BFD4F2', description: 'Decompor em Stories e Tasks' },
|
|
134
136
|
];
|
|
@@ -147,14 +149,24 @@ export const ISSUE_TEMPLATE_FILES = [
|
|
|
147
149
|
'spec-template.md',
|
|
148
150
|
];
|
|
149
151
|
|
|
152
|
+
export const REQUIRED_SPEC_SECTIONS = [
|
|
153
|
+
'Visão Geral',
|
|
154
|
+
'Critérios de Aceite',
|
|
155
|
+
'Requisitos Não-Funcionais',
|
|
156
|
+
];
|
|
157
|
+
|
|
150
158
|
export const REQUIRED_PLAN_SECTIONS = [
|
|
151
|
-
'
|
|
152
|
-
'
|
|
153
|
-
'
|
|
154
|
-
'Testes',
|
|
159
|
+
'Estratégia Técnica',
|
|
160
|
+
'Detalhamento da Implementação',
|
|
161
|
+
'Segurança e Conformidade',
|
|
162
|
+
'Estratégia de Testes',
|
|
163
|
+
'Rollback e Monitoramento',
|
|
155
164
|
];
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
// Arquivos de configuração versionados gerados pelo `init` em .github/config/.
|
|
167
|
+
// O tech_context.yml (RFC-002 §4) é a fonte de verdade estática que o
|
|
168
|
+
// generate-plan lê para embasar o plano técnico. O scaffold em setup/files.mjs
|
|
169
|
+
// só cria o arquivo se ainda não existir, para não sobrescrever ajustes manuais.
|
|
170
|
+
export const CONFIG_FILES = [
|
|
171
|
+
'tech_context.yml',
|
|
160
172
|
];
|
package/src/lib/claude.mjs
CHANGED
|
@@ -23,17 +23,40 @@ function resolveAi() {
|
|
|
23
23
|
return { provider: meta.value, model, secret: meta.secret };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
|
|
27
|
+
// sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
|
|
28
|
+
export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
27
29
|
const ai = resolveAi();
|
|
28
|
-
|
|
30
|
+
const temperature = opts.temperature ?? 0.2;
|
|
31
|
+
// Modelos de reasoning (ex.: deepseek-r1) consomem tokens "pensando" antes da
|
|
32
|
+
// resposta, então o teto precisa ser maior para o plano não vir truncado.
|
|
33
|
+
const maxTokens = opts.maxTokens ?? 8192;
|
|
34
|
+
console.log(`Provider de IA: ${ai.provider} · modelo: ${ai.model} · temperature: ${temperature} · max_tokens: ${maxTokens}`);
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
const raw = ai.provider === 'openrouter'
|
|
37
|
+
? await generateWithOpenRouter(systemPrompt, userContent, ai, temperature, maxTokens)
|
|
38
|
+
: await generateWithAnthropic(systemPrompt, userContent, ai, temperature, maxTokens);
|
|
39
|
+
|
|
40
|
+
return stripOuterFence(raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Remove blocos de raciocínio que alguns modelos (ex.: deepseek-r1) embutem no
|
|
44
|
+
// content. A OpenRouter normalmente separa em `reasoning`, mas isto é uma rede
|
|
45
|
+
// de segurança caso o <think> venha junto do conteúdo final.
|
|
46
|
+
function stripReasoning(text) {
|
|
47
|
+
return text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Alguns modelos (ex.: deepseek-r1) envolvem o documento inteiro numa fence
|
|
51
|
+
// ```markdown ... ```. Removemos só o wrapper externo (a primeira e a última
|
|
52
|
+
// linha de cerca), preservando fences internas (gherkin, typescript, etc.).
|
|
53
|
+
function stripOuterFence(text) {
|
|
54
|
+
const t = (text || '').trim();
|
|
55
|
+
const m = t.match(/^```[a-zA-Z]*\n([\s\S]*)\n```$/);
|
|
56
|
+
return m ? m[1].trim() : t;
|
|
34
57
|
}
|
|
35
58
|
|
|
36
|
-
async function generateWithAnthropic(systemPrompt, userContent, ai) {
|
|
59
|
+
async function generateWithAnthropic(systemPrompt, userContent, ai, temperature, maxTokens) {
|
|
37
60
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
38
61
|
if (!apiKey) {
|
|
39
62
|
throw new Error(
|
|
@@ -45,7 +68,8 @@ async function generateWithAnthropic(systemPrompt, userContent, ai) {
|
|
|
45
68
|
const client = new Anthropic({ apiKey });
|
|
46
69
|
const message = await client.messages.create({
|
|
47
70
|
model: ai.model,
|
|
48
|
-
max_tokens:
|
|
71
|
+
max_tokens: maxTokens,
|
|
72
|
+
temperature,
|
|
49
73
|
messages: [{ role: 'user', content: userContent }],
|
|
50
74
|
system: systemPrompt,
|
|
51
75
|
});
|
|
@@ -53,7 +77,7 @@ async function generateWithAnthropic(systemPrompt, userContent, ai) {
|
|
|
53
77
|
return message.content[0].text;
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
async function generateWithOpenRouter(systemPrompt, userContent, ai) {
|
|
80
|
+
async function generateWithOpenRouter(systemPrompt, userContent, ai, temperature, maxTokens) {
|
|
57
81
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
58
82
|
if (!apiKey) {
|
|
59
83
|
throw new Error(
|
|
@@ -72,7 +96,8 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai) {
|
|
|
72
96
|
},
|
|
73
97
|
body: JSON.stringify({
|
|
74
98
|
model: ai.model,
|
|
75
|
-
max_tokens:
|
|
99
|
+
max_tokens: maxTokens,
|
|
100
|
+
temperature,
|
|
76
101
|
messages: [
|
|
77
102
|
{ role: 'system', content: systemPrompt },
|
|
78
103
|
{ role: 'user', content: userContent },
|
|
@@ -86,7 +111,7 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai) {
|
|
|
86
111
|
}
|
|
87
112
|
|
|
88
113
|
const data = await res.json();
|
|
89
|
-
const content = data?.choices?.[0]?.message?.content;
|
|
114
|
+
const content = stripReasoning(data?.choices?.[0]?.message?.content || '');
|
|
90
115
|
if (!content) {
|
|
91
116
|
throw new Error(`OpenRouter retornou resposta vazia: ${JSON.stringify(data)}`);
|
|
92
117
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
// Subsistema de Tech Context (RFC-002 §4).
|
|
6
|
+
//
|
|
7
|
+
// Monta o contexto técnico que o `generate-plan` injeta na chamada de IA, no
|
|
8
|
+
// formato do payload RFC §5.2: { static, dynamic, overrides }. Roda no checkout
|
|
9
|
+
// do GitHub Action, então lê tudo do filesystem local (cwd = raiz do repo-alvo).
|
|
10
|
+
|
|
11
|
+
const TECH_CONTEXT_PATH = '.github/config/tech_context.yml';
|
|
12
|
+
|
|
13
|
+
// Diretórios onde migrations costumam viver (várias stacks).
|
|
14
|
+
const MIGRATION_DIRS = [
|
|
15
|
+
'migrations',
|
|
16
|
+
'db/migrations',
|
|
17
|
+
'src/migrations',
|
|
18
|
+
'prisma/migrations',
|
|
19
|
+
'database/migrations',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const MAX_MIGRATIONS = 10;
|
|
23
|
+
|
|
24
|
+
// §4.1 — Fonte de verdade estática. Ausência não é erro: segue com {} e avisa.
|
|
25
|
+
function readStaticContext(cwd) {
|
|
26
|
+
const filePath = path.join(cwd, TECH_CONTEXT_PATH);
|
|
27
|
+
if (!existsSync(filePath)) {
|
|
28
|
+
console.warn(
|
|
29
|
+
`⚠️ ${TECH_CONTEXT_PATH} não encontrado. ` +
|
|
30
|
+
`O plano será gerado sem contexto técnico estático. ` +
|
|
31
|
+
`Rode \`spec-wave init\` para gerar o scaffold.`
|
|
32
|
+
);
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return yaml.load(readFileSync(filePath, 'utf-8')) || {};
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.warn(`⚠️ Falha ao parsear ${TECH_CONTEXT_PATH}: ${err.message}. Ignorando.`);
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// §4.2 — Augmentação dinâmica: migrations recentes + versões exatas de pacotes.
|
|
44
|
+
function readDynamicContext(cwd) {
|
|
45
|
+
return {
|
|
46
|
+
recent_migrations: listRecentMigrations(cwd),
|
|
47
|
+
current_packages: readPackageVersions(cwd),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function listRecentMigrations(cwd) {
|
|
52
|
+
for (const dir of MIGRATION_DIRS) {
|
|
53
|
+
const abs = path.join(cwd, dir);
|
|
54
|
+
if (!existsSync(abs)) continue;
|
|
55
|
+
try {
|
|
56
|
+
const entries = readdirSync(abs)
|
|
57
|
+
.map((name) => {
|
|
58
|
+
const full = path.join(abs, name);
|
|
59
|
+
return { name, mtime: statSync(full).mtimeMs };
|
|
60
|
+
})
|
|
61
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
62
|
+
.slice(0, MAX_MIGRATIONS)
|
|
63
|
+
.map((e) => `${dir}/${e.name}`);
|
|
64
|
+
if (entries.length) return entries;
|
|
65
|
+
} catch {
|
|
66
|
+
// diretório ilegível → tenta o próximo
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readPackageVersions(cwd) {
|
|
73
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
74
|
+
if (!existsSync(pkgPath)) return {};
|
|
75
|
+
try {
|
|
76
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
77
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// §4.3 — Override do corpo da issue. Extrai a seção markdown exatamente titulada
|
|
84
|
+
// `## Tech Override` e parseia o bloco como YAML. Retorna {} se ausente.
|
|
85
|
+
export function parseTechOverride(issueBody) {
|
|
86
|
+
if (!issueBody) return {};
|
|
87
|
+
const lines = issueBody.split(/\r?\n/);
|
|
88
|
+
const start = lines.findIndex((l) => l.trim() === '## Tech Override');
|
|
89
|
+
if (start === -1) return {};
|
|
90
|
+
|
|
91
|
+
// Captura até o próximo heading de mesmo nível (ou superior) ou fim do corpo.
|
|
92
|
+
const body = [];
|
|
93
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
94
|
+
if (/^#{1,2}\s/.test(lines[i])) break;
|
|
95
|
+
body.push(lines[i]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Tolera o YAML vir dentro de uma fence ```yaml ... ```.
|
|
99
|
+
let raw = body.join('\n').trim();
|
|
100
|
+
const fence = raw.match(/```(?:ya?ml)?\s*\n([\s\S]*?)```/);
|
|
101
|
+
if (fence) raw = fence[1];
|
|
102
|
+
if (!raw.trim()) return {};
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return yaml.load(raw) || {};
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`⚠️ Falha ao parsear a seção '## Tech Override': ${err.message}. Ignorando.`);
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Deep-merge: override vence; objetos são mesclados recursivamente, escalares e
|
|
113
|
+
// arrays são substituídos.
|
|
114
|
+
function deepMerge(base, override) {
|
|
115
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
|
116
|
+
const out = { ...base };
|
|
117
|
+
for (const key of Object.keys(override)) {
|
|
118
|
+
out[key] = key in base ? deepMerge(base[key], override[key]) : override[key];
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPlainObject(v) {
|
|
124
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Monta o tech context completo. `cwd` default = process.cwd() (checkout do Action).
|
|
128
|
+
export function buildTechContext({ issueBody = '', cwd = process.cwd() } = {}) {
|
|
129
|
+
const staticContext = readStaticContext(cwd);
|
|
130
|
+
const dynamic = readDynamicContext(cwd);
|
|
131
|
+
const overrides = parseTechOverride(issueBody);
|
|
132
|
+
const merged = deepMerge(staticContext, overrides);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
static: staticContext,
|
|
136
|
+
dynamic,
|
|
137
|
+
overrides,
|
|
138
|
+
merged,
|
|
139
|
+
// String pronta para injetar no prompt: contexto efetivo + efêmeros.
|
|
140
|
+
yaml: yaml.dump({ ...merged, _dynamic: dynamic }, { lineWidth: 120 }),
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/setup/files.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { upsertFile, isRepoInitialized } from '../api/github-rest.mjs';
|
|
4
|
+
import { upsertFile, getFileContent, isRepoInitialized } from '../api/github-rest.mjs';
|
|
5
5
|
|
|
6
6
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
|
|
@@ -60,4 +60,24 @@ export async function setupFiles(token, owner, repo, spinner) {
|
|
|
60
60
|
spinner.message(`Criando arquivo ${i + 1}/${filesToCreate.length}: ${file.path}`);
|
|
61
61
|
await upsertFile(token, owner, repo, file.path, file.content, file.message);
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
// Arquivos de config criados apenas se ainda não existirem, para não
|
|
65
|
+
// sobrescrever ajustes manuais (tech_context.yml é editado pelo time).
|
|
66
|
+
const configFiles = [
|
|
67
|
+
{
|
|
68
|
+
path: '.github/config/tech_context.yml',
|
|
69
|
+
content: readTemplate('config', 'tech_context.yml'),
|
|
70
|
+
message: 'chore: scaffold tech_context.yml [spec-wave]',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const file of configFiles) {
|
|
75
|
+
const existing = await getFileContent(token, owner, repo, file.path);
|
|
76
|
+
if (existing !== null) {
|
|
77
|
+
spinner.message(`Mantendo ${file.path} existente (não sobrescrito)`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
spinner.message(`Criando arquivo de config: ${file.path}`);
|
|
81
|
+
await upsertFile(token, owner, repo, file.path, file.content, file.message);
|
|
82
|
+
}
|
|
63
83
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# tech_context.yml — Fonte de verdade estática do sistema (RFC-002 §4)
|
|
2
|
+
#
|
|
3
|
+
# O comando `generate-plan` lê este arquivo para embasar o plano técnico, usando
|
|
4
|
+
# APENAS as tecnologias e serviços aqui declarados. Mantenha-o atualizado.
|
|
5
|
+
#
|
|
6
|
+
# Augmentação dinâmica (automática, não precisa editar): o agente também lê as
|
|
7
|
+
# migrations recentes e as versões exatas de pacotes (package.json) em tempo de
|
|
8
|
+
# execução. Para desvios pontuais, adicione uma seção `## Tech Override` no corpo
|
|
9
|
+
# da issue da Feature — ela é mesclada (deep-merge) sobre esta config.
|
|
10
|
+
|
|
11
|
+
system_info:
|
|
12
|
+
name: "Order Management System"
|
|
13
|
+
stack:
|
|
14
|
+
backend: "Node.js (NestJS v10)"
|
|
15
|
+
frontend: "React 18 with Vite"
|
|
16
|
+
database: "PostgreSQL 15"
|
|
17
|
+
cache: "Redis 7"
|
|
18
|
+
messaging: "RabbitMQ"
|
|
19
|
+
infra: "Kubernetes (EKS) + Helm"
|
|
20
|
+
architecture: "Microservices via API Gateway"
|
|
21
|
+
|
|
22
|
+
security:
|
|
23
|
+
auth_protocol: "JWT (Access/Refresh)"
|
|
24
|
+
rbac_roles: ["ADMIN", "MANAGER", "GARCOM", "COZINHA"]
|
|
25
|
+
|
|
26
|
+
database_schemas:
|
|
27
|
+
- table: "users"
|
|
28
|
+
columns: "id, name, email, role, password_hash"
|
|
29
|
+
- table: "tables"
|
|
30
|
+
columns: "id, number, status, capacity"
|
|
31
|
+
- table: "products"
|
|
32
|
+
columns: "id, name, category, price, stock"
|
|
33
|
+
|
|
34
|
+
existing_services:
|
|
35
|
+
- name: "Inventory API"
|
|
36
|
+
endpoint: "/api/inventory/{productId}/availability"
|
|
37
|
+
auth: "mTLS"
|
|
38
|
+
docs: "https://docs.internal/inventory"
|
|
39
|
+
|
|
40
|
+
internal_libraries:
|
|
41
|
+
- "shared-logger"
|
|
42
|
+
- "db-client (TypeORM)"
|
|
@@ -6,30 +6,46 @@ labels: "[FEATURE]"
|
|
|
6
6
|
assignees: ""
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
#
|
|
9
|
+
# Estratégia Técnica
|
|
10
10
|
|
|
11
|
-
<!--
|
|
11
|
+
- **Abordagem Arquitetural:** <!-- ex.: CQRS, Event Sourcing, REST -->
|
|
12
|
+
- **Decisões-Chave:** <!-- Justificativa das tecnologias/padrões escolhidos -->
|
|
13
|
+
- **Matriz de Rastreabilidade:** <!-- Cada Critério de Aceite do spec mapeado para um componente técnico -->
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
| Critério de Aceite | Componente Técnico |
|
|
16
|
+
|--------------------|--------------------|
|
|
17
|
+
| | |
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
# Detalhamento da Implementação
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
## Backend
|
|
18
22
|
|
|
19
|
-
<!--
|
|
23
|
+
<!-- Endpoints, DTOs, controllers, serviços, casos de uso, jobs/filas -->
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
## Banco de Dados
|
|
22
26
|
|
|
23
|
-
<!--
|
|
27
|
+
<!-- Novas tabelas, migrations, índices -->
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
## Frontend
|
|
26
30
|
|
|
27
|
-
<!--
|
|
31
|
+
<!-- Componentes/telas, gerenciamento de estado, rotas e guards -->
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
## Infraestrutura
|
|
30
34
|
|
|
31
|
-
<!--
|
|
35
|
+
<!-- ConfigMaps/Secrets, pipeline CI/CD, feature flags e estratégia de rollout -->
|
|
32
36
|
|
|
33
|
-
#
|
|
37
|
+
# Segurança e Conformidade
|
|
34
38
|
|
|
35
|
-
<!--
|
|
39
|
+
<!-- Autenticação/autorização (quais papéis acessam?), criptografia (em repouso/trânsito), logging e auditoria -->
|
|
40
|
+
|
|
41
|
+
# Estratégia de Testes
|
|
42
|
+
|
|
43
|
+
- **Unitários:** <!-- Escopo e frameworks -->
|
|
44
|
+
- **Integração:** <!-- Escopo e mocks -->
|
|
45
|
+
- **E2E:** <!-- Caminhos críticos -->
|
|
46
|
+
|
|
47
|
+
# Rollback e Monitoramento
|
|
48
|
+
|
|
49
|
+
- **Plano de Rollback:** <!-- Rollback de banco, revert de código -->
|
|
50
|
+
- **Métricas Observadas:** <!-- Dashboards (New Relic/Datadog) -->
|
|
51
|
+
- **Alertas:** <!-- Thresholds e caminhos de escalonamento -->
|
|
@@ -6,9 +6,11 @@ labels: "[FEATURE]"
|
|
|
6
6
|
assignees: ""
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
#
|
|
9
|
+
# Visão Geral
|
|
10
10
|
|
|
11
|
-
<!-- O que esta Feature entrega e qual problema resolve? -->
|
|
11
|
+
- **Objetivo:** <!-- O que esta Feature entrega e qual problema resolve? -->
|
|
12
|
+
- **Personas:** <!-- Quais usuários são afetados? (ex.: Garçom, Cozinha) -->
|
|
13
|
+
- **Critérios de Sucesso:** <!-- Resultados mensuráveis -->
|
|
12
14
|
|
|
13
15
|
# Regras de Negócio
|
|
14
16
|
|
|
@@ -16,17 +18,37 @@ assignees: ""
|
|
|
16
18
|
|
|
17
19
|
# Fluxos
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
## Fluxo Principal (Happy Path)
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
<!-- Passo a passo do caminho feliz -->
|
|
24
|
+
|
|
25
|
+
## Fluxos Alternativos
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
- [ ]
|
|
27
|
+
<!-- Variações do fluxo principal -->
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
## Cenários de Erro
|
|
27
30
|
|
|
28
31
|
<!-- O que acontece quando algo dá errado? Como o sistema se comporta? -->
|
|
29
32
|
|
|
33
|
+
# Critérios de Aceite
|
|
34
|
+
|
|
35
|
+
<!-- Use o formato Gherkin (Given/When/Then). Um cenário por critério. -->
|
|
36
|
+
|
|
37
|
+
```gherkin
|
|
38
|
+
Feature: [Nome da Feature]
|
|
39
|
+
Scenario: [Título do cenário]
|
|
40
|
+
Given [pré-condição]
|
|
41
|
+
When [ação]
|
|
42
|
+
Then [resultado esperado]
|
|
43
|
+
```
|
|
44
|
+
|
|
30
45
|
# Dependências
|
|
31
46
|
|
|
32
|
-
|
|
47
|
+
- **Internas:** <!-- Serviços/APIs dentro do sistema -->
|
|
48
|
+
- **Externas:** <!-- Sistemas de terceiros -->
|
|
49
|
+
|
|
50
|
+
# Requisitos Não-Funcionais
|
|
51
|
+
|
|
52
|
+
- **Performance:** <!-- ex.: tempo de resposta < 200ms -->
|
|
53
|
+
- **Segurança:** <!-- ex.: RBAC obrigatório -->
|
|
54
|
+
- **Usabilidade:** <!-- ex.: responsivo em mobile -->
|