@spec-wave/cli 0.16.0 → 0.16.1
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 +3 -3
- package/package.json +1 -1
- package/src/api/github-graphql.mjs +23 -1
- package/src/commands/decompose.mjs +12 -24
- package/src/commands/generate-plan.mjs +13 -24
- package/src/commands/generate-spec.mjs +12 -24
- package/src/commands/refresh.mjs +42 -16
- package/src/lib/flow-run.mjs +145 -0
- package/src/lib/project-root.mjs +9 -2
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/decompose/SKILL.md +7 -1
- package/src/plugin/skills/plan/SKILL.md +7 -2
- package/src/plugin/skills/spec/SKILL.md +22 -4
- package/src/plugin/skills/workflow/SKILL.md +5 -1
package/bin/spec-wave.mjs
CHANGED
|
@@ -163,7 +163,7 @@ program
|
|
|
163
163
|
|
|
164
164
|
program
|
|
165
165
|
.command('generate-plan')
|
|
166
|
-
.description('Gera plan.md para uma Feature
|
|
166
|
+
.description('Gera plan.md para uma Feature — roda no GitHub Action ou localmente')
|
|
167
167
|
.requiredOption('--issue-number <n>', 'Número da issue no GitHub')
|
|
168
168
|
.action(async (options) => {
|
|
169
169
|
const { generatePlan } = await import('../src/commands/generate-plan.mjs');
|
|
@@ -172,7 +172,7 @@ program
|
|
|
172
172
|
|
|
173
173
|
program
|
|
174
174
|
.command('generate-spec')
|
|
175
|
-
.description('Gera spec.md para uma Feature
|
|
175
|
+
.description('Gera spec.md para uma Feature — roda no GitHub Action ou localmente')
|
|
176
176
|
.requiredOption('--issue-number <n>', 'Número da issue no GitHub')
|
|
177
177
|
.action(async (options) => {
|
|
178
178
|
const { generateSpec } = await import('../src/commands/generate-spec.mjs');
|
|
@@ -199,7 +199,7 @@ program
|
|
|
199
199
|
|
|
200
200
|
program
|
|
201
201
|
.command('decompose')
|
|
202
|
-
.description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado
|
|
202
|
+
.description('Gera o rascunho da decomposição em decomposition.md; com --apply, cria as Stories/Tasks a partir do rascunho revisado — roda no Action ou localmente')
|
|
203
203
|
.requiredOption('--issue-number <n>', 'Número da issue no GitHub')
|
|
204
204
|
.option('--apply', 'Aplica o decomposition.md já revisado: cria as issues (sem esta flag, apenas gera/critica o rascunho)')
|
|
205
205
|
.action(async (options) => {
|
package/package.json
CHANGED
|
@@ -33,6 +33,23 @@ export async function createProject(token, ownerId, title) {
|
|
|
33
33
|
return { projectId: project.id, projectNumber: project.number, projectUrl: project.url, statusFieldId: statusField?.id };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Reescreve as opções de um campo single-select.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ O `id` de cada opção é OBRIGATÓRIO para as que já existem. Sem ele o
|
|
40
|
+
* GitHub CRIA uma opção nova com o mesmo nome e id diferente — e todo item do
|
|
41
|
+
* board que apontava para a opção antiga fica SEM VALOR, em silêncio.
|
|
42
|
+
*
|
|
43
|
+
* Foi exatamente o que aconteceu no smoke test do RFC-004: as 11 etapas
|
|
44
|
+
* existentes foram preservadas por NOME, os ids mudaram todos, e a Feature que
|
|
45
|
+
* estava em 🎯 Priorizado apareceu sem Etapa. Preservar o nome não preserva
|
|
46
|
+
* nada — o que liga um item à opção é o id.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} token
|
|
49
|
+
* @param {string} fieldId
|
|
50
|
+
* @param {Array<{name: string, color: string, id?: string}>} options
|
|
51
|
+
* Opções na ordem final. Com `id` = preserva a existente; sem `id` = cria.
|
|
52
|
+
*/
|
|
36
53
|
export async function updateStatusField(token, fieldId, options) {
|
|
37
54
|
const client = makeClient(token);
|
|
38
55
|
await client(`
|
|
@@ -51,7 +68,12 @@ export async function updateStatusField(token, fieldId, options) {
|
|
|
51
68
|
}
|
|
52
69
|
`, {
|
|
53
70
|
fieldId,
|
|
54
|
-
options: options.map(o => ({
|
|
71
|
+
options: options.map(o => ({
|
|
72
|
+
...(o.id ? { id: o.id } : {}),
|
|
73
|
+
name: o.name,
|
|
74
|
+
color: o.color,
|
|
75
|
+
description: '',
|
|
76
|
+
})),
|
|
55
77
|
});
|
|
56
78
|
}
|
|
57
79
|
|
|
@@ -33,6 +33,7 @@ import { lintLanguage } from '../lib/output-lint.mjs';
|
|
|
33
33
|
import { slugify } from '../lib/slugify.mjs';
|
|
34
34
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
35
35
|
import { loadConfig } from '../lib/project-root.mjs';
|
|
36
|
+
import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
|
|
36
37
|
import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
37
38
|
import {
|
|
38
39
|
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
@@ -194,18 +195,11 @@ function formatItemsLintWarning(texts) {
|
|
|
194
195
|
return `\n\n⚠️ possíveis artefatos de idioma nos itens gerados: ${excerpts}`;
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
// Grava e commita o rascunho
|
|
198
|
-
// com
|
|
199
|
-
function commitFile(filePath, content, message) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const git = (cmd) => execSync(cmd, { stdio: 'inherit' });
|
|
203
|
-
git('git config user.email "spec-wave[bot]@github.com"');
|
|
204
|
-
git('git config user.name "spec-wave[bot]"');
|
|
205
|
-
git(`git add "${filePath}"`);
|
|
206
|
-
git(`git commit -m "${message}"`);
|
|
207
|
-
git('git pull --rebase');
|
|
208
|
-
git('git push');
|
|
198
|
+
// Grava e commita o rascunho. O modo (actions|local) decide identidade do git e
|
|
199
|
+
// o que fazer com falha de push — ver `lib/flow-run.mjs`.
|
|
200
|
+
function commitFile(filePath, content, message, mode) {
|
|
201
|
+
const published = commitGenerated({ filePath, content, message, mode });
|
|
202
|
+
if (published.warning) console.warn(`⚠️ ${published.warning}`);
|
|
209
203
|
}
|
|
210
204
|
|
|
211
205
|
// Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
|
|
@@ -218,7 +212,7 @@ function commitFile(filePath, content, message) {
|
|
|
218
212
|
// ---------------------------------------------------------------------------
|
|
219
213
|
|
|
220
214
|
async function draftDecomposition(ctx) {
|
|
221
|
-
const { token, owner, repo, issue, issueNumber, type, labels, usage, root, docDir, docPath, docRel } = ctx;
|
|
215
|
+
const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
|
|
222
216
|
const number = parseInt(issueNumber, 10);
|
|
223
217
|
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
224
218
|
const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
|
|
@@ -267,7 +261,7 @@ async function draftDecomposition(ctx) {
|
|
|
267
261
|
stories: generated.stories || [],
|
|
268
262
|
tasks: generated.tasks || [],
|
|
269
263
|
});
|
|
270
|
-
commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]
|
|
264
|
+
commitFile(docPath, markdown, `docs: rascunho de decomposição de ${docRel} [spec-wave]`, runMode);
|
|
271
265
|
console.log(`Rascunho commitado em ${docRel}.`);
|
|
272
266
|
}
|
|
273
267
|
|
|
@@ -589,15 +583,9 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
589
583
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
590
584
|
// Fallback para GITHUB_TOKEN (só funciona em repos pessoais sem org restrictions).
|
|
591
585
|
const projectToken = process.env.PROJECT_TOKEN || token;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
throw new Error(
|
|
596
|
-
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
597
|
-
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
598
|
-
' GITHUB_REPOSITORY=owner/repo spec-wave decompose --issue-number 1'
|
|
599
|
-
);
|
|
600
|
-
}
|
|
586
|
+
// Roda nos dois modos: no Action (disparado por label) e na sessão local.
|
|
587
|
+
const { owner, repo, mode: runMode } = resolveFlowContext({ command: 'decompose' });
|
|
588
|
+
console.log(`Modo de execução: ${runMode}`);
|
|
601
589
|
|
|
602
590
|
const number = parseInt(issueNumber, 10);
|
|
603
591
|
const issue = await getIssue(token, owner, repo, number);
|
|
@@ -669,7 +657,7 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
669
657
|
const usageEntries = [];
|
|
670
658
|
const ctx = {
|
|
671
659
|
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
672
|
-
root, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
660
|
+
root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
673
661
|
escalationModel: config?.ai?.escalationModel || null,
|
|
674
662
|
maxCritiqueAttempts:
|
|
675
663
|
Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
3
|
import { resolveToken } from '../api/auth.mjs';
|
|
5
4
|
import {
|
|
6
5
|
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
|
|
@@ -16,7 +15,8 @@ import {
|
|
|
16
15
|
} from '../lib/critique.mjs';
|
|
17
16
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
18
17
|
import { slugify } from '../lib/slugify.mjs';
|
|
19
|
-
import {
|
|
18
|
+
import { resolveFromRoot } from '../lib/project-root.mjs';
|
|
19
|
+
import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
|
|
20
20
|
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
21
21
|
import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
22
22
|
|
|
@@ -109,16 +109,9 @@ async function critiquePlan({
|
|
|
109
109
|
|
|
110
110
|
export async function generatePlan({ issueNumber }) {
|
|
111
111
|
const token = await resolveToken();
|
|
112
|
-
|
|
113
|
-
const {
|
|
114
|
-
|
|
115
|
-
if (!owner || !repo) {
|
|
116
|
-
throw new Error(
|
|
117
|
-
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
118
|
-
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
119
|
-
' GITHUB_REPOSITORY=owner/repo spec-wave generate-plan --issue-number 1'
|
|
120
|
-
);
|
|
121
|
-
}
|
|
112
|
+
// Roda nos dois modos: no Action (disparado por label) e na sessão local.
|
|
113
|
+
const { owner, repo, root, config, mode } = resolveFlowContext({ command: 'generate-plan' });
|
|
114
|
+
console.log(`Modo de execução: ${mode}`);
|
|
122
115
|
|
|
123
116
|
console.log(`Buscando issue #${issueNumber}...`);
|
|
124
117
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
@@ -180,17 +173,13 @@ export async function generatePlan({ issueNumber }) {
|
|
|
180
173
|
usage: usageEntries,
|
|
181
174
|
});
|
|
182
175
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
git(`git add "${filePath}"`);
|
|
191
|
-
git(`git commit -m "docs: generate plan.md for ${slug} [spec-wave]"`);
|
|
192
|
-
git('git pull --rebase');
|
|
193
|
-
git('git push');
|
|
176
|
+
const published = commitGenerated({
|
|
177
|
+
filePath,
|
|
178
|
+
content,
|
|
179
|
+
message: `docs: generate plan.md for ${slug} [spec-wave]`,
|
|
180
|
+
mode,
|
|
181
|
+
});
|
|
182
|
+
if (published.warning) console.warn(`⚠️ ${published.warning}`);
|
|
194
183
|
|
|
195
184
|
// Remove trigger label
|
|
196
185
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { execSync } from 'node:child_process';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
2
|
import { resolveToken } from '../api/auth.mjs';
|
|
5
3
|
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
6
4
|
import { generateDocument } from '../lib/claude.mjs';
|
|
7
5
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
8
6
|
import { slugify } from '../lib/slugify.mjs';
|
|
9
|
-
import {
|
|
7
|
+
import { resolveFromRoot } from '../lib/project-root.mjs';
|
|
8
|
+
import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
|
|
10
9
|
import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
11
10
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
12
11
|
import {
|
|
@@ -30,16 +29,9 @@ function formatLintWarning(lintFindings) {
|
|
|
30
29
|
|
|
31
30
|
export async function generateSpec({ issueNumber }) {
|
|
32
31
|
const token = await resolveToken();
|
|
33
|
-
|
|
34
|
-
const { root } =
|
|
35
|
-
|
|
36
|
-
if (!owner || !repo) {
|
|
37
|
-
throw new Error(
|
|
38
|
-
'GITHUB_REPOSITORY env var não definida.\n' +
|
|
39
|
-
'Este comando roda no GitHub Actions. Para testar localmente:\n' +
|
|
40
|
-
' GITHUB_REPOSITORY=owner/repo spec-wave generate-spec --issue-number 1'
|
|
41
|
-
);
|
|
42
|
-
}
|
|
32
|
+
// Roda nos dois modos: no Action (disparado por label) e na sessão local.
|
|
33
|
+
const { owner, repo, root, mode } = resolveFlowContext({ command: 'generate-spec' });
|
|
34
|
+
console.log(`Modo de execução: ${mode}`);
|
|
43
35
|
|
|
44
36
|
console.log(`Buscando issue #${issueNumber}...`);
|
|
45
37
|
const issue = await getIssue(token, owner, repo, parseInt(issueNumber, 10));
|
|
@@ -95,17 +87,13 @@ export async function generateSpec({ issueNumber }) {
|
|
|
95
87
|
usage: usageEntries,
|
|
96
88
|
});
|
|
97
89
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
git(`git add "${filePath}"`);
|
|
106
|
-
git(`git commit -m "docs: generate spec.md for ${slug} [spec-wave]"`);
|
|
107
|
-
git('git pull --rebase');
|
|
108
|
-
git('git push');
|
|
90
|
+
const published = commitGenerated({
|
|
91
|
+
filePath,
|
|
92
|
+
content,
|
|
93
|
+
message: `docs: generate spec.md for ${slug} [spec-wave]`,
|
|
94
|
+
mode,
|
|
95
|
+
});
|
|
96
|
+
if (published.warning) console.warn(`⚠️ ${published.warning}`);
|
|
109
97
|
|
|
110
98
|
// Remove trigger label
|
|
111
99
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
|
package/src/commands/refresh.mjs
CHANGED
|
@@ -18,28 +18,38 @@ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json')
|
|
|
18
18
|
* board mais as etapas canônicas que faltam, cada uma na posição canônica. Nada
|
|
19
19
|
* é removido — nem coluna inventada, nem etapa descontinuada.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* ⚠️ E cada opção existente leva seu **id** junto. O contrato da API é o que
|
|
22
|
+
* torna isso obrigatório: `updateProjectV2Field` SUBSTITUI o conjunto de
|
|
23
|
+
* opções, e o que liga um item do board a uma opção é o ID — não o nome. Uma
|
|
24
|
+
* opção reenviada sem id é RECRIADA com id novo, e todo item que estava nela
|
|
25
|
+
* fica sem Etapa, em silêncio.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* A versão anterior preservava só o nome. No smoke test do RFC-004 isso apagou
|
|
28
|
+
* a Etapa de uma Feature que estava em 🎯 Priorizado, enquanto o comando
|
|
29
|
+
* relatava "nenhuma é removida" — verdade sobre os nomes, mentira sobre o
|
|
30
|
+
* board.
|
|
31
|
+
*
|
|
32
|
+
* @param {Record<string, string>} current opções hoje no board: nome → id
|
|
28
33
|
* @param {Array<{name: string, color: string}>} canonical STATUS_OPTIONS
|
|
29
|
-
* @returns {{ missing: string[], preserved: string[],
|
|
34
|
+
* @returns {{ missing: string[], preserved: string[],
|
|
35
|
+
* ordered: Array<{name: string, color: string, id?: string}> }}
|
|
30
36
|
*/
|
|
31
37
|
export function planStageSync(current, canonical) {
|
|
32
|
-
const
|
|
38
|
+
const atual = current || {};
|
|
39
|
+
const idDe = (name) => atual[name];
|
|
33
40
|
const canonicalNames = new Set(canonical.map(o => o.name));
|
|
34
41
|
|
|
35
|
-
const missing = canonical.filter(o => !
|
|
42
|
+
const missing = canonical.filter(o => !idDe(o.name)).map(o => o.name);
|
|
36
43
|
// Colunas que o board tem e o fluxo canônico não conhece (inventadas ou
|
|
37
44
|
// descontinuadas). Vão para o fim, preservando a ordem relativa que tinham.
|
|
38
|
-
const preserved = (
|
|
45
|
+
const preserved = Object.keys(atual).filter(name => !canonicalNames.has(name));
|
|
39
46
|
|
|
40
47
|
const ordered = [
|
|
41
|
-
...canonical.map(o =>
|
|
42
|
-
|
|
48
|
+
...canonical.map(o => {
|
|
49
|
+
const id = idDe(o.name);
|
|
50
|
+
return id ? { name: o.name, color: o.color, id } : { name: o.name, color: o.color };
|
|
51
|
+
}),
|
|
52
|
+
...preserved.map(name => ({ name, color: 'GRAY', id: atual[name] })),
|
|
43
53
|
];
|
|
44
54
|
return { missing, preserved, ordered };
|
|
45
55
|
}
|
|
@@ -61,7 +71,9 @@ async function syncStages(token, snapshot, options) {
|
|
|
61
71
|
return false;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
// Mapa nome → id: é o id que preserva o vínculo dos itens com a opção.
|
|
75
|
+
const current = etapa.options || {};
|
|
76
|
+
const nomesAntes = Object.keys(current);
|
|
65
77
|
const { missing, preserved, ordered } = planStageSync(current, STATUS_OPTIONS);
|
|
66
78
|
|
|
67
79
|
if (missing.length === 0) {
|
|
@@ -74,7 +86,7 @@ async function syncStages(token, snapshot, options) {
|
|
|
74
86
|
(preserved.length
|
|
75
87
|
? `${chalk.dim('=')} preservar (fora do fluxo canônico): ${preserved.join(', ')}\n`
|
|
76
88
|
: '') +
|
|
77
|
-
`${chalk.dim('=')} preservar (canônicas já presentes): ${
|
|
89
|
+
`${chalk.dim('=')} preservar (canônicas já presentes): ${nomesAntes.length - preserved.length}\n\n` +
|
|
78
90
|
chalk.dim(`Resultado: ${ordered.length} opções. Nenhuma é removida.`),
|
|
79
91
|
'Plano para o campo "Etapa"'
|
|
80
92
|
);
|
|
@@ -115,8 +127,13 @@ async function syncStages(token, snapshot, options) {
|
|
|
115
127
|
p.log.warn(`Campo atualizado, mas a verificação falhou: ${err.message}`);
|
|
116
128
|
return true;
|
|
117
129
|
}
|
|
118
|
-
const
|
|
119
|
-
const
|
|
130
|
+
const depois = after?.fields?.['Etapa']?.options || {};
|
|
131
|
+
const now = Object.keys(depois);
|
|
132
|
+
const lost = nomesAntes.filter(name => !now.includes(name));
|
|
133
|
+
// A verificação que importa: o ID de cada opção preexistente tem que ser o
|
|
134
|
+
// MESMO. Conferir só o nome era o que deixava passar o pior desfecho — as 12
|
|
135
|
+
// opções presentes, todos os ids trocados, e o board inteiro sem Etapa.
|
|
136
|
+
const recriadas = nomesAntes.filter(name => depois[name] && depois[name] !== current[name]);
|
|
120
137
|
const stillMissing = STATUS_OPTIONS.map(o => o.name).filter(name => !now.includes(name));
|
|
121
138
|
spinner.stop(`Campo "Etapa" com ${now.length} opções.`);
|
|
122
139
|
|
|
@@ -128,6 +145,15 @@ async function syncStages(token, snapshot, options) {
|
|
|
128
145
|
process.exitCode = 1;
|
|
129
146
|
return false;
|
|
130
147
|
}
|
|
148
|
+
if (recriadas.length > 0) {
|
|
149
|
+
p.log.error(
|
|
150
|
+
`Opções RECRIADAS com id novo: ${recriadas.join(', ')}. Os itens que estavam nelas ` +
|
|
151
|
+
'perderam a Etapa — reposicione-os no board. Isto é um bug do comando, não do seu ' +
|
|
152
|
+
'Project: reporte com a saída acima.'
|
|
153
|
+
);
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
131
157
|
if (stillMissing.length > 0) {
|
|
132
158
|
p.log.warn(`Etapas canônicas ainda ausentes: ${stillMissing.join(', ')}.`);
|
|
133
159
|
return true;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Modo de execução do fluxo: GitHub Actions ou sessão local.
|
|
2
|
+
//
|
|
3
|
+
// `generate-spec`, `generate-plan` e `decompose` nasceram como comandos de
|
|
4
|
+
// Action e exigiam `GITHUB_REPOSITORY`, recusando qualquer execução fora do
|
|
5
|
+
// runner. Mas eles são só comandos — o que os prendia ao CI era a resolução de
|
|
6
|
+
// owner/repo, não o trabalho em si. Agora o MESMO comando roda nos dois lugares:
|
|
7
|
+
// dentro do Action, disparado por label, ou na sua sessão do agente.
|
|
8
|
+
//
|
|
9
|
+
// O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), não por flag: um
|
|
10
|
+
// comando com dois nomes para a mesma coisa envelhece mal.
|
|
11
|
+
//
|
|
12
|
+
// **O comportamento é deliberadamente IDÊNTICO nos dois modos** — gera, commita,
|
|
13
|
+
// dá pull --rebase, faz push, comenta na issue e avança a Etapa. O board é a
|
|
14
|
+
// fonte de verdade do RFC-001 independentemente de onde a geração rodou; um modo
|
|
15
|
+
// local que não sincronizasse o board deixaria o próximo passo do fluxo cego.
|
|
16
|
+
//
|
|
17
|
+
// Duas diferenças existem, e as duas são de SEGURANÇA, não de resultado:
|
|
18
|
+
//
|
|
19
|
+
// 1. IDENTIDADE DO GIT. O Action roda `git config user.email "spec-wave[bot]"`
|
|
20
|
+
// sem `--global`, o que grava em `.git/config`. Num runner descartável isso
|
|
21
|
+
// é inócuo; no seu clone, mudaria o autor de TODOS os seus commits futuros
|
|
22
|
+
// naquele repositório. Localmente a sua identidade é preservada.
|
|
23
|
+
// 2. FALHA DE PUSH. No Action, não conseguir publicar é falha do job. Local, o
|
|
24
|
+
// arquivo já está gerado e commitado — perder isso porque o remoto andou
|
|
25
|
+
// seria pior que avisar e deixar você resolver o push.
|
|
26
|
+
|
|
27
|
+
import { execSync } from 'node:child_process';
|
|
28
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { resolveRepoContext } from './project-root.mjs';
|
|
31
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
32
|
+
|
|
33
|
+
/** Rodando dentro do GitHub Actions? (função PURA) */
|
|
34
|
+
export function isActionsRun(env = process.env) {
|
|
35
|
+
return env.GITHUB_ACTIONS === 'true';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 'actions' | 'local' (função PURA) */
|
|
39
|
+
export function executionMode(env = process.env) {
|
|
40
|
+
return isActionsRun(env) ? 'actions' : 'local';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve owner/repo + modo, ou lança com a instrução certa para cada contexto.
|
|
45
|
+
*
|
|
46
|
+
* Nos Actions o `GITHUB_REPOSITORY` vem do runner; local, o `.spec-wave.json`
|
|
47
|
+
* gravado pelo `init` é a fonte. `resolveRepoContext` já cobre os dois.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} [opts]
|
|
50
|
+
* @param {string} [opts.cwd]
|
|
51
|
+
* @param {string} [opts.command] nome do comando, para a mensagem de erro
|
|
52
|
+
* @param {object} [opts.env]
|
|
53
|
+
* @returns {{owner: string, repo: string, root: string|null, config: object|null, mode: 'actions'|'local'}}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveFlowContext({ cwd = process.cwd(), command = 'este comando', env = process.env } = {}) {
|
|
56
|
+
// Repassa o `env` recebido: sem isso o modo local fica intestável dentro do
|
|
57
|
+
// GitHub Actions, que define GITHUB_REPOSITORY em toda execução.
|
|
58
|
+
const { owner, repo, root, config } = resolveRepoContext(cwd, env);
|
|
59
|
+
const mode = executionMode(env);
|
|
60
|
+
|
|
61
|
+
if (!owner || !repo) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
64
|
+
(mode === 'actions'
|
|
65
|
+
? 'No GitHub Actions, o runner define GITHUB_REPOSITORY — verifique o workflow.'
|
|
66
|
+
: `Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`), ` +
|
|
67
|
+
`ou defina GITHUB_REPOSITORY=owner/repo:\n` +
|
|
68
|
+
` GITHUB_REPOSITORY=owner/repo spec-wave ${command} --issue-number 1`)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return { owner, repo, root, config, mode };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Grava um arquivo gerado e o publica: commit + pull --rebase + push.
|
|
76
|
+
*
|
|
77
|
+
* Era o mesmo bloco copiado em `generate-spec`, `generate-plan` e `decompose`,
|
|
78
|
+
* com a identidade do bot embutida. Centralizado aqui para que a diferença
|
|
79
|
+
* entre os modos exista num lugar só.
|
|
80
|
+
*
|
|
81
|
+
* O commit é escopado ao caminho (`git commit -- <arquivo>`): sem isso, qualquer
|
|
82
|
+
* coisa que você já tivesse no index entraria junto no commit do spec-wave —
|
|
83
|
+
* irrelevante num runner limpo, nada irrelevante no seu clone.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} params
|
|
86
|
+
* @param {string} params.filePath caminho absoluto do arquivo
|
|
87
|
+
* @param {string} params.content
|
|
88
|
+
* @param {string} params.message mensagem de commit
|
|
89
|
+
* @param {'actions'|'local'} params.mode
|
|
90
|
+
* @returns {{committed: boolean, pushed: boolean, warning: string|null}}
|
|
91
|
+
*/
|
|
92
|
+
export function commitGenerated({ filePath, content, message, mode }) {
|
|
93
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
94
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
95
|
+
|
|
96
|
+
const git = (cmd, opts = {}) => execSync(cmd, { stdio: 'inherit', ...opts });
|
|
97
|
+
const gitQuiet = (cmd) => execSync(cmd, { stdio: 'pipe' }).toString().trim();
|
|
98
|
+
|
|
99
|
+
if (mode === 'actions') {
|
|
100
|
+
// Runner descartável: identidade do bot é o que se quer no histórico.
|
|
101
|
+
git('git config user.email "spec-wave[bot]@github.com"');
|
|
102
|
+
git('git config user.name "spec-wave[bot]"');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
git(`git add "${filePath}"`);
|
|
106
|
+
|
|
107
|
+
// Nada mudou (regerar conteúdo idêntico) → `git commit` sairia 1 e derrubaria
|
|
108
|
+
// o comando depois de o trabalho estar feito.
|
|
109
|
+
let hasChanges = true;
|
|
110
|
+
try {
|
|
111
|
+
execSync(`git diff --cached --quiet -- "${filePath}"`, { stdio: 'pipe' });
|
|
112
|
+
hasChanges = false;
|
|
113
|
+
} catch {
|
|
114
|
+
hasChanges = true;
|
|
115
|
+
}
|
|
116
|
+
if (!hasChanges) {
|
|
117
|
+
return { committed: false, pushed: false, warning: 'conteúdo idêntico ao já versionado — nada a commitar' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
git(`git commit -m "${message}" -- "${filePath}"`);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
git('git pull --rebase');
|
|
124
|
+
git('git push');
|
|
125
|
+
return { committed: true, pushed: true, warning: null };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (mode === 'actions') throw err;
|
|
128
|
+
// Local: o arquivo está gerado e commitado. Derrubar o comando aqui
|
|
129
|
+
// esconderia esse fato atrás de um erro de rede/divergência.
|
|
130
|
+
const branch = (() => {
|
|
131
|
+
try {
|
|
132
|
+
return gitQuiet('git rev-parse --abbrev-ref HEAD');
|
|
133
|
+
} catch {
|
|
134
|
+
return 'seu branch';
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
return {
|
|
138
|
+
committed: true,
|
|
139
|
+
pushed: false,
|
|
140
|
+
warning:
|
|
141
|
+
`commit feito em ${branch}, mas o push falhou (${err.message.split('\n')[0]}). ` +
|
|
142
|
+
'O arquivo está salvo e versionado — publique quando resolver.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/lib/project-root.mjs
CHANGED
|
@@ -76,12 +76,19 @@ export function resolveFromRoot(root, ...parts) {
|
|
|
76
76
|
*
|
|
77
77
|
* Era o mesmo bloco copiado em story/task/order/qa/code-review/validate.
|
|
78
78
|
*
|
|
79
|
+
* `env` é INJETÁVEL, e precisa ser: ler `process.env` aqui dentro tornava
|
|
80
|
+
* impossível testar o caminho local. Quem chamava com `env: {}` para simular
|
|
81
|
+
* "sem GITHUB_REPOSITORY" recebia o valor real assim mesmo — o teste passava na
|
|
82
|
+
* máquina do dev (onde a env não existe) e falhava no GitHub Actions (onde o
|
|
83
|
+
* runner a define).
|
|
84
|
+
*
|
|
79
85
|
* @param {string} [cwd=process.cwd()]
|
|
86
|
+
* @param {object} [env=process.env] ambiente; injetável para teste
|
|
80
87
|
* @returns {{ owner: string|undefined, repo: string|undefined,
|
|
81
88
|
* root: string|null, config: object|null, error: string|null }}
|
|
82
89
|
*/
|
|
83
|
-
export function resolveRepoContext(cwd = process.cwd()) {
|
|
84
|
-
const [envOwner, envRepo] = (
|
|
90
|
+
export function resolveRepoContext(cwd = process.cwd(), env = process.env) {
|
|
91
|
+
const [envOwner, envRepo] = (env.GITHUB_REPOSITORY || '').split('/');
|
|
85
92
|
const { config, root, error } = loadConfig(cwd);
|
|
86
93
|
return {
|
|
87
94
|
owner: envOwner || config?.owner,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spec-wave",
|
|
3
3
|
"displayName": "Spec Wave",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.1",
|
|
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",
|
|
@@ -38,8 +38,11 @@ spec-wave:decompose-apply
|
|
|
38
38
|
|
|
39
39
|
1. **Pré-requisito.** Feature: confirme que está em **✅ Ready** (spec e plan validados — skill **ready**). RFC: basta a descrição estar completa.
|
|
40
40
|
|
|
41
|
-
2. **Etapa 1 — gerar o rascunho
|
|
41
|
+
2. **Etapa 1 — gerar o rascunho**, no modo que preferir (mesmo resultado; o modo é detectado pelo ambiente):
|
|
42
42
|
```bash
|
|
43
|
+
# local — resultado nesta sessão
|
|
44
|
+
npx @spec-wave/cli@latest decompose --issue-number <número>
|
|
45
|
+
# ou Action — assíncrono
|
|
43
46
|
gh issue edit <número> --add-label "spec-wave:decompose"
|
|
44
47
|
```
|
|
45
48
|
Informe: "Rascunho iniciado — vai commitar o `decomposition.md` e passar pela crítica. **Nenhuma issue é criada nesta etapa.**"
|
|
@@ -54,6 +57,9 @@ spec-wave:decompose-apply
|
|
|
54
57
|
|
|
55
58
|
4. **Etapa 2 — aplicar o rascunho aprovado**, só depois da revisão:
|
|
56
59
|
```bash
|
|
60
|
+
# local
|
|
61
|
+
npx @spec-wave/cli@latest decompose --issue-number <número> --apply
|
|
62
|
+
# ou Action
|
|
57
63
|
gh issue edit <número> --add-label "spec-wave:decompose-apply"
|
|
58
64
|
```
|
|
59
65
|
Aplicar essa label **é** a aprovação humana — não há nova crítica.
|
|
@@ -12,7 +12,9 @@ allowed-tools:
|
|
|
12
12
|
|
|
13
13
|
# spec-wave plan — plano técnico (2º documento)
|
|
14
14
|
|
|
15
|
-
> **Regra fundamental: nunca
|
|
15
|
+
> **Regra fundamental: nunca escreva o `plan.md` à mão.** Quem gera é o spec-wave — pelo Action ou pela CLI local. Exceção: revisar/melhorar um plano já gerado.
|
|
16
|
+
|
|
17
|
+
**Dois modos, mesmo resultado.** `npx @spec-wave/cli@latest generate-plan --issue-number <n>` roda **agora**, nesta sessão; a label `spec-wave:plan` roda no Action. O modo é detectado pelo ambiente. Ambos geram, commitam, fazem push, criticam e comentam na issue. Local exige a chave de IA no seu ambiente. Veja a skill **spec** para a tabela completa.
|
|
16
18
|
|
|
17
19
|
**Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
|
|
18
20
|
|
|
@@ -24,8 +26,11 @@ O plano segue o schema do **RFC-002 §3.2**: **Estratégia Técnica** (com Matri
|
|
|
24
26
|
|
|
25
27
|
2. **Garanta o `tech_context`.** Verifique se `.github/config/tech_context.yml` existe (Read). **Se não existir, ajude a criar AGORA** — o passo a passo está em `reference/tech-context.md`, ao lado deste arquivo. Garanta que esteja **commitado e pushado** antes de aplicar a label: o Action lê o arquivo do repositório, não do seu disco local.
|
|
26
28
|
|
|
27
|
-
3.
|
|
29
|
+
3. **Acione**, no modo escolhido:
|
|
28
30
|
```bash
|
|
31
|
+
# local — resultado nesta sessão
|
|
32
|
+
npx @spec-wave/cli@latest generate-plan --issue-number <número>
|
|
33
|
+
# ou Action — assíncrono
|
|
29
34
|
gh issue edit <número> --add-label "spec-wave:plan"
|
|
30
35
|
```
|
|
31
36
|
|
|
@@ -9,7 +9,18 @@ allowed-tools:
|
|
|
9
9
|
|
|
10
10
|
# spec-wave spec — especificação funcional (1º documento)
|
|
11
11
|
|
|
12
|
-
> **Regra fundamental: nunca
|
|
12
|
+
> **Regra fundamental: nunca escreva o `spec.md` à mão.** Quem gera é o spec-wave — pelo Action ou pela CLI local. É isso que garante que o arquivo seja commitado e referenciado na issue. Exceção: revisar/melhorar um `spec.md` já gerado (aí sim use Edit).
|
|
13
|
+
|
|
14
|
+
## Dois modos, mesmo resultado
|
|
15
|
+
|
|
16
|
+
| Modo | Como acionar | Quando |
|
|
17
|
+
|------|--------------|--------|
|
|
18
|
+
| **Action** | aplicar a label `spec-wave:spec` | fluxo assíncrono; roda no CI, você acompanha pela issue |
|
|
19
|
+
| **Local** | `npx @spec-wave/cli@latest generate-spec --issue-number <n>` | você quer o documento **agora**, nesta sessão, e iterar em cima dele |
|
|
20
|
+
|
|
21
|
+
O modo é detectado pelo ambiente — fora do Actions, a CLI roda em modo local automaticamente. Os dois geram o arquivo, commitam, dão push, comentam na issue e removem a label de gatilho. **Pergunte ao usuário qual ele quer** se não estiver claro; na dúvida numa sessão interativa, prefira o local (o resultado aparece em segundos, em vez de exigir acompanhar o Action).
|
|
22
|
+
|
|
23
|
+
> Local exige a chave de IA no seu ambiente (`OPENROUTER_API_KEY` ou `ANTHROPIC_API_KEY`) e um `.spec-wave.json` no repositório. O provider `anthropic` **só** funciona local — no Action ele precisaria do Claude Code como subprocesso, que o runner não tem.
|
|
13
24
|
|
|
14
25
|
**Contexto:** leia `.spec-wave.json` (Read). Ausente → skill **setup**.
|
|
15
26
|
|
|
@@ -19,12 +30,19 @@ allowed-tools:
|
|
|
19
30
|
|
|
20
31
|
> **Apenas Features.** Para **Spike, RFC e Bug** o Action **pula** a geração, remove a label e comenta. Não use esta skill nesses tipos.
|
|
21
32
|
|
|
22
|
-
2.
|
|
33
|
+
2. **Escolha o modo** (veja a tabela acima) e acione:
|
|
34
|
+
|
|
35
|
+
**Local** — resultado nesta sessão:
|
|
23
36
|
```bash
|
|
24
|
-
|
|
37
|
+
npx @spec-wave/cli@latest generate-spec --issue-number <número>
|
|
25
38
|
```
|
|
39
|
+
O comando imprime `Modo de execução: local`, gera, commita e faz push.
|
|
26
40
|
|
|
27
|
-
|
|
41
|
+
**Action** — assíncrono:
|
|
42
|
+
```bash
|
|
43
|
+
gh issue edit <número> --add-label "spec-wave:spec"
|
|
44
|
+
```
|
|
45
|
+
Informe: "Label `spec-wave:spec` adicionada. O Action `generate-spec.yml` vai gerar o `spec.md`. Acompanhe em Actions → Generate Spec."
|
|
28
46
|
|
|
29
47
|
4. Quando concluir, ofereça revisar o arquivo em `docs/features/<slug>/spec.md`. O slug vem do título: `[FEATURE] Cadastro de Pedidos com PIX` → `cadastro-de-pedidos-com-pix`.
|
|
30
48
|
|
|
@@ -27,7 +27,11 @@ Toda a CLI é invocada como `npx @spec-wave/cli@latest <comando>`.
|
|
|
27
27
|
|
|
28
28
|
## Regras fundamentais
|
|
29
29
|
|
|
30
|
-
1. **Nunca
|
|
30
|
+
1. **Nunca escreva `spec.md` ou `plan.md` à mão.** Quem gera é o spec-wave — e ele roda de **dois modos**, com o mesmo resultado:
|
|
31
|
+
- **Action:** aplique a label de gatilho (`spec-wave:spec`, `spec-wave:plan`, `spec-wave:decompose`);
|
|
32
|
+
- **Local:** `npx @spec-wave/cli@latest generate-spec|generate-plan|decompose --issue-number <n>` na sua sessão.
|
|
33
|
+
|
|
34
|
+
O modo é detectado pelo ambiente (`GITHUB_ACTIONS`), sem flag. Os dois geram, commitam, fazem push, comentam na issue e sincronizam o board — o board é a fonte de verdade independentemente de onde rodou. Local exige a chave de IA no seu ambiente e um `.spec-wave.json`. Exceção à regra: revisar/melhorar um documento já gerado.
|
|
31
35
|
2. **Nunca crie Story ou Task avulsa.** Elas nascem do `decompose`, já em **✅ Ready** e vinculadas ao pai. Criadas à mão caem em 📥 Backlog e **não aparecem em tela nenhuma** da UI (o inbox do PM lista Features, a tela do Dev lê 🚧 Desenvolvimento, a fila do TL lê ✅ Ready).
|
|
32
36
|
3. **Nunca use `gh issue create`** para work items — não adiciona ao Project, a issue fica sem Etapa e some das telas. Use `npx @spec-wave/cli@latest issue`.
|
|
33
37
|
4. **A Etapa só avança, nunca retrocede.** O campo **Status** (Todo / In Progress / Done) mede o progresso *dentro* da Etapa e reinicia a cada avanço. Prefira `move`, `task start|done` e `story review` a mutações GraphQL manuais — os comandos embutem essas regras.
|