@spec-wave/cli 0.14.0 → 0.16.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 +1 -0
- package/bin/spec-wave.mjs +44 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +206 -2
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +352 -62
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +69 -7
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
package/src/api/github-rest.mjs
CHANGED
|
@@ -4,6 +4,15 @@ function makeOctokit(token) {
|
|
|
4
4
|
return new Octokit({ auth: token });
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
// Para as consultas em que 404 é um RESULTADO ESPERADO ("a branch ainda não
|
|
8
|
+
// existe", "não há o que comparar") e não um erro: o logger padrão do Octokit
|
|
9
|
+
// imprime a linha `GET ... - 404` no stderr, que no meio de um spinner parece
|
|
10
|
+
// falha. Mesmo padrão do doctor.mjs, onde 404/403 também são respostas válidas.
|
|
11
|
+
const silentLog = { debug() {}, info() {}, warn() {}, error() {} };
|
|
12
|
+
function makeQuietOctokit(token) {
|
|
13
|
+
return new Octokit({ auth: token, log: silentLog });
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
export async function getOwnerNodeId(token, owner) {
|
|
8
17
|
const octokit = makeOctokit(token);
|
|
9
18
|
try {
|
|
@@ -87,6 +96,189 @@ export async function upsertFile(token, owner, repo, path, content, message) {
|
|
|
87
96
|
});
|
|
88
97
|
}
|
|
89
98
|
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Git Data API — commit ATÔMICO + Pull Request
|
|
101
|
+
//
|
|
102
|
+
// O upsertFile acima faz UM COMMIT POR ARQUIVO direto na branch default: oito
|
|
103
|
+
// arquivos = oito commits, e uma falha no quinto deixa o repositório num estado
|
|
104
|
+
// intermediário que ninguém pediu. Aqui a árvore inteira é montada primeiro e só
|
|
105
|
+
// então nascem o commit e a ref — antes da createRef/updateRef nada é
|
|
106
|
+
// ALCANÇÁVEL (os objetos existem no banco, mas nenhuma ref aponta para eles),
|
|
107
|
+
// então uma falha no meio não muda o repositório observável.
|
|
108
|
+
//
|
|
109
|
+
// Três pegadinhas da API, todas documentadas porque custam tempo de depuração:
|
|
110
|
+
// • createTree SEM `base_tree` produz uma árvore com SÓ os arquivos enviados —
|
|
111
|
+
// o commit resultante APAGA todo o resto do repositório, e a API responde 201.
|
|
112
|
+
// • getRef/getCommit/updateRef querem o ref SEM o prefixo `refs/`
|
|
113
|
+
// ('heads/main'); createRef quer COM ('refs/heads/x'). Trocar dá 404/422 sem
|
|
114
|
+
// explicação.
|
|
115
|
+
// • pulls.list exige `head` qualificado com o owner ('owner:branch'): sem o
|
|
116
|
+
// prefixo a API IGNORA o filtro e devolve todos os PRs abertos.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lê a ponta de uma branch: sha do commit e sha da ÁRVORE desse commit.
|
|
121
|
+
*
|
|
122
|
+
* A árvore é o que interessa para o `base_tree` e para detectar "nada mudou":
|
|
123
|
+
* git é endereçado por conteúdo, então árvore igual = conteúdo igual, sem
|
|
124
|
+
* precisar pedir diff.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<{commitSha: string, treeSha: string}|null>} null quando a
|
|
127
|
+
* branch não existe (404) — o chamador decide criar vs. atualizar a ref.
|
|
128
|
+
*/
|
|
129
|
+
export async function getBranchHead(token, owner, repo, branch) {
|
|
130
|
+
const octokit = makeQuietOctokit(token); // 404 = branch inexistente, não erro
|
|
131
|
+
let commitSha;
|
|
132
|
+
try {
|
|
133
|
+
const ref = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
|
|
134
|
+
commitSha = ref.data.object.sha;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.status === 404) return null;
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
const commit = await octokit.rest.git.getCommit({ owner, repo, commit_sha: commitSha });
|
|
140
|
+
return { commitSha, treeSha: commit.data.tree.sha };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compara `base...branch`.
|
|
145
|
+
*
|
|
146
|
+
* 'behind'/'identical' significa que a branch não tem nenhum commit que a base
|
|
147
|
+
* já não tenha — sinal típico de branch de um PR já mergeado com squash. Serve
|
|
148
|
+
* só para AVISAR antes de empilhar um commit numa branch obsoleta; nunca para
|
|
149
|
+
* decidir um force-push.
|
|
150
|
+
*
|
|
151
|
+
* @returns {Promise<'identical'|'ahead'|'behind'|'diverged'|null>} null se a
|
|
152
|
+
* branch não existe ou a comparação falha (é só um aviso — não pode
|
|
153
|
+
* derrubar o fluxo).
|
|
154
|
+
*/
|
|
155
|
+
export async function compareBranches(token, owner, repo, base, branch) {
|
|
156
|
+
const octokit = makeQuietOctokit(token); // 404 = branch inexistente, não erro
|
|
157
|
+
try {
|
|
158
|
+
const res = await octokit.rest.repos.compareCommitsWithBasehead({
|
|
159
|
+
owner, repo, basehead: `${base}...${branch}`,
|
|
160
|
+
});
|
|
161
|
+
return res.data.status;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Grava N arquivos em UM único commit numa branch (criando-a a partir de `base`
|
|
169
|
+
* quando ainda não existe).
|
|
170
|
+
*
|
|
171
|
+
* Idempotente de propósito: rodar `update --branch fix/x` duas vezes não gera um
|
|
172
|
+
* segundo commit. Quando a branch já existe o pai é a PONTA DELA — não a base —
|
|
173
|
+
* então a atualização da ref é sempre fast-forward e `force` fica em false (uma
|
|
174
|
+
* corrida com outro push falha em vez de sobrescrever trabalho alheio).
|
|
175
|
+
*
|
|
176
|
+
* Se a árvore montada tiver o MESMO sha da árvore do pai, nada mudou de fato e o
|
|
177
|
+
* commit é pulado: commit vazio num PR só confunde quem revisa.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} opts
|
|
180
|
+
* @param {string} opts.branch branch de trabalho (head do PR)
|
|
181
|
+
* @param {string} opts.base branch de partida quando `branch` ainda não existe
|
|
182
|
+
* @param {Array<{path: string, content: string}>} opts.files conteúdo FINAL de cada arquivo
|
|
183
|
+
* @param {string} opts.message mensagem do commit único
|
|
184
|
+
* @returns {Promise<{branch, base, commitSha, createdBranch, unchanged, baseSha}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function commitFilesToBranch(token, owner, repo, { branch, base, files, message }) {
|
|
187
|
+
const octokit = makeOctokit(token);
|
|
188
|
+
if (!files?.length) throw new Error('commitFilesToBranch: nenhum arquivo a enviar.');
|
|
189
|
+
|
|
190
|
+
const baseHead = await getBranchHead(token, owner, repo, base);
|
|
191
|
+
if (!baseHead) throw new Error(`Branch base "${base}" não encontrada em ${owner}/${repo}.`);
|
|
192
|
+
|
|
193
|
+
const head = await getBranchHead(token, owner, repo, branch);
|
|
194
|
+
const parent = head ?? baseHead;
|
|
195
|
+
|
|
196
|
+
// `content` inline: a própria API cria o blob, poupando um createBlob por
|
|
197
|
+
// arquivo. Vale só para texto UTF-8 — todos os nossos templates são.
|
|
198
|
+
// mode: 100644 = arquivo comum (100755 executável, 040000 subárvore).
|
|
199
|
+
const tree = await octokit.rest.git.createTree({
|
|
200
|
+
owner,
|
|
201
|
+
repo,
|
|
202
|
+
base_tree: parent.treeSha, // SEM isto o commit apagaria o repositório inteiro
|
|
203
|
+
tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (tree.data.sha === parent.treeSha) {
|
|
207
|
+
return {
|
|
208
|
+
branch, base, commitSha: parent.commitSha,
|
|
209
|
+
createdBranch: false, unchanged: true, baseSha: baseHead.commitSha,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const commit = await octokit.rest.git.createCommit({
|
|
214
|
+
owner, repo, message, tree: tree.data.sha, parents: [parent.commitSha],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (head) {
|
|
218
|
+
await octokit.rest.git.updateRef({
|
|
219
|
+
owner, repo, ref: `heads/${branch}`, sha: commit.data.sha, force: false,
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
await octokit.rest.git.createRef({
|
|
223
|
+
owner, repo, ref: `refs/heads/${branch}`, sha: commit.data.sha,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
branch, base, commitSha: commit.data.sha,
|
|
229
|
+
createdBranch: !head, unchanged: false, baseSha: baseHead.commitSha,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* PR ABERTO cuja head é `branch` neste mesmo repositório.
|
|
235
|
+
*
|
|
236
|
+
* @returns {Promise<{number, url, title, base}|null>}
|
|
237
|
+
*/
|
|
238
|
+
export async function findOpenPR(token, owner, repo, branch) {
|
|
239
|
+
const octokit = makeOctokit(token);
|
|
240
|
+
const res = await octokit.rest.pulls.list({
|
|
241
|
+
owner, repo, state: 'open', head: `${owner}:${branch}`, per_page: 1,
|
|
242
|
+
});
|
|
243
|
+
const pr = res.data[0];
|
|
244
|
+
return pr
|
|
245
|
+
? { number: pr.number, url: pr.html_url, title: pr.title, base: pr.base.ref }
|
|
246
|
+
: null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Abre o PR da branch — ou devolve o que já está aberto para ela.
|
|
251
|
+
*
|
|
252
|
+
* Rodar o update duas vezes não pode virar dois PRs. O findOpenPR cobre o caso
|
|
253
|
+
* normal; o catch do 422 cobre a corrida e o "No commits between base and head"
|
|
254
|
+
* (branch sem nada novo em relação à base → não existe PR a abrir → null).
|
|
255
|
+
* Qualquer outro 422 propaga com a mensagem real da API, senão uma base inválida
|
|
256
|
+
* viraria um silencioso "nada a revisar".
|
|
257
|
+
*
|
|
258
|
+
* @returns {Promise<{number, url, created}|null>}
|
|
259
|
+
*/
|
|
260
|
+
export async function ensurePullRequest(
|
|
261
|
+
token, owner, repo, { branch, base, title, body, draft = false }
|
|
262
|
+
) {
|
|
263
|
+
const octokit = makeOctokit(token);
|
|
264
|
+
const existing = await findOpenPR(token, owner, repo, branch);
|
|
265
|
+
if (existing) return { ...existing, created: false };
|
|
266
|
+
try {
|
|
267
|
+
const res = await octokit.rest.pulls.create({
|
|
268
|
+
owner, repo, title, body, head: branch, base, draft,
|
|
269
|
+
});
|
|
270
|
+
return { number: res.data.number, url: res.data.html_url, created: true };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err.status !== 422) throw err;
|
|
273
|
+
const again = await findOpenPR(token, owner, repo, branch);
|
|
274
|
+
if (again) return { ...again, created: false };
|
|
275
|
+
const detail = err.response?.data?.errors?.map(e => e.message).filter(Boolean).join('; ')
|
|
276
|
+
|| err.message;
|
|
277
|
+
if (/No commits between/i.test(detail)) return null;
|
|
278
|
+
throw new Error(detail);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
90
282
|
// `id` é o database id da issue — exigido pela API de dependências
|
|
91
283
|
// (blocked_by), que não aceita number nem node id.
|
|
92
284
|
export async function createIssue(token, owner, repo, title, body, labels) {
|
|
@@ -182,6 +374,14 @@ export async function updateComment(token, owner, repo, commentId, body) {
|
|
|
182
374
|
});
|
|
183
375
|
}
|
|
184
376
|
|
|
377
|
+
// Fecha (ou reabre) uma issue. Usado pela triagem de Bug: rejeitar e marcar
|
|
378
|
+
// como duplicata tiram o item das filas SEM mexer na Etapa — ela nunca
|
|
379
|
+
// retrocede, e um bug rejeitado não avançou para lugar nenhum.
|
|
380
|
+
export async function setIssueState(token, owner, repo, issueNumber, state) {
|
|
381
|
+
const octokit = makeOctokit(token);
|
|
382
|
+
await octokit.rest.issues.update({ owner, repo, issue_number: issueNumber, state });
|
|
383
|
+
}
|
|
384
|
+
|
|
185
385
|
// Marca uma issue como bloqueada por outra (relação nativa do GitHub).
|
|
186
386
|
// `blockingIssueId` é o DATABASE id da issue bloqueadora (não o number nem o
|
|
187
387
|
// node id — ver createIssue). Erros propagam: o chamador usa como fallback.
|
|
@@ -235,10 +435,14 @@ export async function getPR(token, owner, repo, prNumber) {
|
|
|
235
435
|
return res.data;
|
|
236
436
|
}
|
|
237
437
|
|
|
238
|
-
|
|
438
|
+
// `ref` é opcional (compatível com os chamadores de 4 argumentos): o modo PR do
|
|
439
|
+
// `update` precisa ler o .spec-wave.json na BASE, não no que a API escolher.
|
|
440
|
+
export async function getFileContent(token, owner, repo, path, ref) {
|
|
239
441
|
const octokit = makeOctokit(token);
|
|
240
442
|
try {
|
|
241
|
-
const res = await octokit.rest.repos.getContent({
|
|
443
|
+
const res = await octokit.rest.repos.getContent({
|
|
444
|
+
owner, repo, path, ...(ref ? { ref } : {}),
|
|
445
|
+
});
|
|
242
446
|
return Buffer.from(res.data.content, 'base64').toString('utf-8');
|
|
243
447
|
} catch (err) {
|
|
244
448
|
if (err.status === 404) return null;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { issue } from './issue.mjs';
|
|
2
|
+
|
|
3
|
+
// `bug` é um atalho de `issue --type bug`, no mesmo padrão de `feature` e
|
|
4
|
+
// `initiative`. A etapa de nascimento (🐞 Triagem, ou ✅ Ready quando P0) é
|
|
5
|
+
// decidida por initialStageForType — ver config.mjs.
|
|
6
|
+
export async function bug(options) {
|
|
7
|
+
return issue({ ...options, type: 'bug' });
|
|
8
|
+
}
|
|
@@ -52,16 +52,40 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
|
|
|
52
52
|
// tipo porque cada um tem destino próprio: Tasks → Done, Stories → Code Review,
|
|
53
53
|
// Feature → Code Review (só quando todas as Stories estiverem prontas). Retorna
|
|
54
54
|
// { feature:{number,nodeId,title}|null, stories:Map, tasks:Map }.
|
|
55
|
+
/**
|
|
56
|
+
* Como o PR trata uma issue referenciada, pelo tipo (função PURA).
|
|
57
|
+
*
|
|
58
|
+
* 'bug' → unidade de review PRÓPRIA: move o Bug e NÃO toca na Feature-pai.
|
|
59
|
+
* Um defeito em review não deve arrastar a Feature inteira para
|
|
60
|
+
* Code Review — ela pode ter Stories ainda em desenvolvimento.
|
|
61
|
+
* 'unit' → Feature/Story/Task: sobe pela cadeia até a Feature (fluxo atual).
|
|
62
|
+
* 'ignore' → Spike, RFC, Epic e desconhecidos.
|
|
63
|
+
*
|
|
64
|
+
* @param {string|null} type
|
|
65
|
+
* @returns {'bug'|'unit'|'ignore'}
|
|
66
|
+
*/
|
|
67
|
+
export function classifyReviewTarget(type) {
|
|
68
|
+
if (type === 'Bug') return 'bug';
|
|
69
|
+
if (type === 'Feature' || type === 'Story' || type === 'Task') return 'unit';
|
|
70
|
+
return 'ignore';
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
56
74
|
const stories = new Map();
|
|
57
75
|
const tasks = new Map();
|
|
76
|
+
const bugs = new Map();
|
|
58
77
|
const addStory = (n, nodeId, title) => { if (n && nodeId && !stories.has(n)) stories.set(n, { nodeId, title }); };
|
|
59
78
|
const addTask = (n, nodeId, title) => { if (n && nodeId && !tasks.has(n)) tasks.set(n, { nodeId, title }); };
|
|
60
79
|
|
|
61
80
|
let issue;
|
|
62
|
-
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks }; }
|
|
81
|
+
try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks, bugs }; }
|
|
63
82
|
const type = detectIssueType(issue);
|
|
64
|
-
|
|
83
|
+
const kind = classifyReviewTarget(type);
|
|
84
|
+
if (kind === 'ignore') return { feature: null, stories, tasks, bugs };
|
|
85
|
+
if (kind === 'bug') {
|
|
86
|
+
bugs.set(issue.number, { nodeId: issue.node_id, title: issue.title });
|
|
87
|
+
return { feature: null, stories, tasks, bugs };
|
|
88
|
+
}
|
|
65
89
|
|
|
66
90
|
const featureIssue = await resolveFeatureIssue(token, owner, repo, issueNumber);
|
|
67
91
|
const feature = featureIssue
|
|
@@ -93,7 +117,7 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
|
|
|
93
117
|
for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
|
|
94
118
|
}
|
|
95
119
|
}
|
|
96
|
-
return { feature, stories, tasks };
|
|
120
|
+
return { feature, stories, tasks, bugs };
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
// A Feature só avança quando TODAS as suas Stories já estiverem em Code Review
|
|
@@ -155,7 +179,7 @@ export async function codeReview({ prNumber }) {
|
|
|
155
179
|
// Stories → 👀 Code Review; Feature → Code Review só quando TODAS as suas
|
|
156
180
|
// Stories já estiverem em Code Review.
|
|
157
181
|
for (const num of issueNums) {
|
|
158
|
-
const { feature, stories, tasks } = await collectReviewUnit(token, owner, repo, num);
|
|
182
|
+
const { feature, stories, tasks, bugs } = await collectReviewUnit(token, owner, repo, num);
|
|
159
183
|
|
|
160
184
|
// Tasks → Done (Status Done).
|
|
161
185
|
for (const [n, info] of tasks) {
|
|
@@ -191,6 +215,23 @@ export async function codeReview({ prNumber }) {
|
|
|
191
215
|
}
|
|
192
216
|
}
|
|
193
217
|
|
|
218
|
+
// Bugs → Code Review, sem Feature-pai envolvida.
|
|
219
|
+
for (const [n, info] of bugs) {
|
|
220
|
+
if (seen.has(n)) continue;
|
|
221
|
+
seen.add(n);
|
|
222
|
+
try {
|
|
223
|
+
const moved = await advanceToStage(projectToken, project, etapaField, statusField, info.nodeId, CODE_REVIEW_STAGE, TODO_STATUS);
|
|
224
|
+
if (moved) {
|
|
225
|
+
updated.push(`#${n} ${info.title} → ${CODE_REVIEW_STAGE}`);
|
|
226
|
+
console.log(`Bug #${n} → "${CODE_REVIEW_STAGE}" / Status "${TODO_STATUS}".`);
|
|
227
|
+
} else {
|
|
228
|
+
console.log(`Bug #${n} já está em "${CODE_REVIEW_STAGE}" ou etapa posterior — mantido (não retrocede).`);
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.warn(`Falha ao atualizar #${n}: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
194
235
|
// Feature: só avança se todas as suas Stories já estão em Code Review+.
|
|
195
236
|
if (feature && !featuresChecked.has(feature.number)) {
|
|
196
237
|
featuresChecked.add(feature.number);
|
|
@@ -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 { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
|
|
36
37
|
import {
|
|
37
38
|
renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
|
|
38
39
|
} from '../lib/decomposition-doc.mjs';
|
|
@@ -207,60 +208,17 @@ function commitFile(filePath, content, message) {
|
|
|
207
208
|
git('git push');
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{
|
|
215
|
-
"stories": [
|
|
216
|
-
{
|
|
217
|
-
"title": "Título curto da story (apenas a parte 'quero', sem prefixo)",
|
|
218
|
-
"userStory": "Como <perfil>, quero <objetivo>, para <benefício>",
|
|
219
|
-
"body": "Descrição complementar da story com contexto e critérios de aceite relevantes",
|
|
220
|
-
"dependsOn": [0],
|
|
221
|
-
"tasks": [
|
|
222
|
-
{
|
|
223
|
-
"title": "Título técnico curto da task (sem prefixo)",
|
|
224
|
-
"body": "Descrição técnica detalhada"
|
|
225
|
-
}
|
|
226
|
-
]
|
|
227
|
-
}
|
|
228
|
-
]
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
Regras:
|
|
232
|
-
- "title" deve ser CURTO (máx. ~60 caracteres): apenas a parte "quero" da user story, sem o "Como" nem o "para", e sem prefixo. Ex.: "visualizar meus repositórios em layout responsivo"
|
|
233
|
-
- "userStory" deve trazer a user story completa no formato "Como <perfil>, quero <objetivo>, para <benefício>"
|
|
234
|
-
- "body" é texto complementar (contexto, critérios de aceite); não repita o título
|
|
235
|
-
- Cada Story deve ter 2–5 Tasks associadas
|
|
236
|
-
- Tasks devem ser atividades técnicas concretas, com "title" curto e "body" detalhado
|
|
237
|
-
- Gere entre 3 e 7 Stories por Feature
|
|
238
|
-
- Ordene as stories na sequência de implementação — a ORDEM da lista importa
|
|
239
|
-
- "dependsOn" (opcional): índices 0-based das stories ANTERIORES na lista das quais esta story depende. Referencie apenas índices menores que o da própria story. Use [] quando a story puder ser feita em paralelo (sem dependências); se omitido, assume-se dependência da story anterior (sequencial)`;
|
|
240
|
-
|
|
241
|
-
const RFC_SYSTEM_PROMPT = `Você é um Tech Lead experiente. A partir do RFC fornecido (proposta técnica/de processo), gere a lista de Tasks técnicas concretas necessárias para implementá-lo.
|
|
242
|
-
|
|
243
|
-
Responda APENAS com JSON válido neste formato:
|
|
244
|
-
{
|
|
245
|
-
"tasks": [
|
|
246
|
-
{
|
|
247
|
-
"title": "Título técnico curto da task (sem prefixo)",
|
|
248
|
-
"body": "Descrição técnica detalhada (o que fazer, áreas/arquivos afetados, critério de pronto)"
|
|
249
|
-
}
|
|
250
|
-
]
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
Regras:
|
|
254
|
-
- "title" CURTO (máx. ~60 caracteres), sem prefixo.
|
|
255
|
-
- "body" detalhado e acionável.
|
|
256
|
-
- Gere entre 3 e 10 Tasks concretas que, juntas, cubram o RFC.`;
|
|
211
|
+
// Os prompts vivem em `src/plugin/skills/decompose/model-prompt.{feature,rfc}.md`
|
|
212
|
+
// (sobrescrevíveis em `.spec-wave/prompts/decompose.feature.md`). O contrato JSON é o
|
|
213
|
+
// `json.shape` do frontmatter — quem parseia a saída e quem instrui o modelo
|
|
214
|
+
// passam a ler a MESMA declaração, em vez de duas cópias que podiam divergir.
|
|
257
215
|
|
|
258
216
|
// ---------------------------------------------------------------------------
|
|
259
217
|
// Etapa 1: rascunho (label spec-wave:decompose)
|
|
260
218
|
// ---------------------------------------------------------------------------
|
|
261
219
|
|
|
262
220
|
async function draftDecomposition(ctx) {
|
|
263
|
-
const { token, owner, repo, issue, issueNumber, type, labels, usage, docDir, docPath, docRel } = ctx;
|
|
221
|
+
const { token, owner, repo, issue, issueNumber, type, labels, usage, root, docDir, docPath, docRel } = ctx;
|
|
264
222
|
const number = parseInt(issueNumber, 10);
|
|
265
223
|
const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
|
|
266
224
|
const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
|
|
@@ -292,8 +250,12 @@ async function draftDecomposition(ctx) {
|
|
|
292
250
|
`\n## plan.md\n${planContent || '(plan.md não encontrado)'}`,
|
|
293
251
|
].join('\n');
|
|
294
252
|
|
|
253
|
+
const decomposePrompt = loadPrompt(
|
|
254
|
+
type === 'RFC' ? 'decompose/rfc' : 'decompose/feature',
|
|
255
|
+
{ cwd: root },
|
|
256
|
+
);
|
|
295
257
|
const generated = parseModelJson(await generateDocument(
|
|
296
|
-
|
|
258
|
+
toolFreeSystemPrompt(decomposePrompt),
|
|
297
259
|
userContent,
|
|
298
260
|
{ action: 'decompose', labels, usage }
|
|
299
261
|
));
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { homedir, tmpdir } from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { CONFIG_FILE } from '../config.mjs';
|
|
22
|
+
import { CONFIG_FILE, LABEL_DEV_AGENT } from '../config.mjs';
|
|
23
23
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
24
24
|
|
|
25
25
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -82,7 +82,7 @@ export function renderAgentConfig({ owner, repo }) {
|
|
|
82
82
|
# Schema completo: https://github.com/${AGENT_REPO}#configuração
|
|
83
83
|
|
|
84
84
|
repo = "${owner}/${repo}"
|
|
85
|
-
queue_label = "
|
|
85
|
+
queue_label = "${LABEL_DEV_AGENT}"
|
|
86
86
|
|
|
87
87
|
# Defaults do agente (descomente para ajustar):
|
|
88
88
|
#poll_interval_secs = 60 # consulta à fila quando ocioso
|
|
@@ -412,7 +412,7 @@ async function finishSetup({ cfg, home, binPath, configPath, options, configActi
|
|
|
412
412
|
p.outro(
|
|
413
413
|
`${chalk.green('✓')} Agente pronto.\n` +
|
|
414
414
|
` Rodar agora: ${chalk.cyan('spec-wave dev-agent --run')}\n` +
|
|
415
|
-
|
|
415
|
+
` Enfileirar: aplique a label ${LABEL_DEV_AGENT} numa issue [FEATURE] já decomposta.`
|
|
416
416
|
);
|
|
417
417
|
}
|
|
418
418
|
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
|
|
|
12
12
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
13
13
|
import {
|
|
14
14
|
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
|
|
15
|
-
ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
15
|
+
RETIRED_STAGES, ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
16
|
+
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS,
|
|
16
17
|
} from '../config.mjs';
|
|
17
18
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
18
19
|
import {
|
|
@@ -284,7 +285,12 @@ async function checkConfig(ctx) {
|
|
|
284
285
|
export function inspectBoardHygiene({ boardStages = null, repoLabels = null } = {}) {
|
|
285
286
|
const canonical = STATUS_OPTIONS.map(s => s.name);
|
|
286
287
|
const known = new Set(canonical);
|
|
287
|
-
const
|
|
288
|
+
const retired = new Map(RETIRED_STAGES.map(s => [s.name, s]));
|
|
289
|
+
const foreign = boardStages ? boardStages.filter(s => !known.has(s)) : [];
|
|
290
|
+
// Uma etapa aposentada também é "fora do fluxo", mas a orientação ao usuário
|
|
291
|
+
// é diferente da de uma coluna inventada — por isso saem em listas separadas.
|
|
292
|
+
const retiredStages = foreign.filter(s => retired.has(s)).map(s => retired.get(s));
|
|
293
|
+
const unknownStages = foreign.filter(s => !retired.has(s));
|
|
288
294
|
const missingStages = boardStages ? canonical.filter(s => !boardStages.includes(s)) : [];
|
|
289
295
|
|
|
290
296
|
const knownLabels = new Set(ALL_LABELS.map(l => l.name));
|
|
@@ -294,7 +300,29 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null } =
|
|
|
294
300
|
const missingLabels = repoLabels
|
|
295
301
|
? ALL_LABELS.map(l => l.name).filter(n => !repoLabels.includes(n))
|
|
296
302
|
: [];
|
|
297
|
-
return { unknownStages, missingStages, orphanLabels, missingLabels };
|
|
303
|
+
return { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* O board comporta a trilha do Bug? (função PURA)
|
|
308
|
+
*
|
|
309
|
+
* Um Bug percorre 🐞 Triagem → ✅ Ready → … → 🚀 Deploy → 🎉 Done (RFC-004 §4).
|
|
310
|
+
* Se o board não tem alguma dessas colunas, o item não tem onde parar: o campo
|
|
311
|
+
* "Etapa" só aceita as opções que existem, então a escrita falha em silêncio e
|
|
312
|
+
* o bug fica sem etapa — invisível em toda tela que filtra por etapa.
|
|
313
|
+
*
|
|
314
|
+
* Só reporta quando há Bug aberto: um board sem bugs não precisa da trilha.
|
|
315
|
+
*
|
|
316
|
+
* @param {object} params
|
|
317
|
+
* @param {string[]|null} [params.boardStages] opções do campo Etapa
|
|
318
|
+
* @param {number} [params.openBugCount] bugs abertos no repositório
|
|
319
|
+
* @returns {{ missingTrackStages: string[] }}
|
|
320
|
+
*/
|
|
321
|
+
export function inspectBugTrack({ boardStages = null, openBugCount = 0 } = {}) {
|
|
322
|
+
if (!boardStages || openBugCount <= 0) return { missingTrackStages: [] };
|
|
323
|
+
return {
|
|
324
|
+
missingTrackStages: STAGE_TRACKS.Bug.filter(s => !boardStages.includes(s)),
|
|
325
|
+
};
|
|
298
326
|
}
|
|
299
327
|
|
|
300
328
|
async function checkBoardHygiene(ctx) {
|
|
@@ -315,6 +343,7 @@ async function checkBoardHygiene(ctx) {
|
|
|
315
343
|
}
|
|
316
344
|
|
|
317
345
|
let repoLabels = null;
|
|
346
|
+
let openBugCount = 0;
|
|
318
347
|
if (ctx.token && cfg.owner && cfg.repo) {
|
|
319
348
|
try {
|
|
320
349
|
const res = await makeOctokit(ctx.token)
|
|
@@ -323,9 +352,17 @@ async function checkBoardHygiene(ctx) {
|
|
|
323
352
|
} catch {
|
|
324
353
|
// labels não verificáveis agora — segue só com o board
|
|
325
354
|
}
|
|
355
|
+
try {
|
|
356
|
+
const bugs = await makeOctokit(ctx.token).paginate('GET /repos/{owner}/{repo}/issues', {
|
|
357
|
+
owner: cfg.owner, repo: cfg.repo, labels: '[BUG]', state: 'open', per_page: 100,
|
|
358
|
+
});
|
|
359
|
+
openBugCount = bugs.filter(i => !i.pull_request).length;
|
|
360
|
+
} catch {
|
|
361
|
+
// sem acesso às issues — o check da trilha do Bug fica de fora
|
|
362
|
+
}
|
|
326
363
|
}
|
|
327
364
|
|
|
328
|
-
const { unknownStages, missingStages, orphanLabels, missingLabels } =
|
|
365
|
+
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels } =
|
|
329
366
|
inspectBoardHygiene({ boardStages, repoLabels });
|
|
330
367
|
|
|
331
368
|
const notes = [];
|
|
@@ -343,10 +380,32 @@ async function checkBoardHygiene(ctx) {
|
|
|
343
380
|
'Mova os itens para uma etapa do fluxo e remova a coluna.'
|
|
344
381
|
);
|
|
345
382
|
}
|
|
383
|
+
for (const stage of retiredStages) {
|
|
384
|
+
status = 'warn';
|
|
385
|
+
notes.push(
|
|
386
|
+
`Etapa descontinuada ainda no board: ${stage.name} (removida do fluxo na v${stage.removedIn}). ` +
|
|
387
|
+
`Mova os itens dela para ${stage.replacedBy} e apague a coluna nas configurações do campo ` +
|
|
388
|
+
'"Etapa"; depois rode `refresh --config` para tirar o id do .spec-wave.json.'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
346
391
|
if (missingStages.length > 0) {
|
|
347
392
|
status = 'warn';
|
|
348
|
-
notes.push(
|
|
393
|
+
notes.push(
|
|
394
|
+
`Etapas do RFC-001 ausentes no board: ${missingStages.join(', ')} — rode ` +
|
|
395
|
+
'`refresh --stages --dry-run` para ver o plano, ou crie a coluna à mão nas configurações ' +
|
|
396
|
+
'do campo "Etapa" e rode `refresh --config`.'
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const { missingTrackStages } = inspectBugTrack({ boardStages, openBugCount });
|
|
400
|
+
if (missingTrackStages.length > 0) {
|
|
401
|
+
status = 'warn';
|
|
402
|
+
notes.push(
|
|
403
|
+
`${openBugCount} Bug(s) aberto(s), mas o board não tem: ${missingTrackStages.join(', ')}. ` +
|
|
404
|
+
'Um Bug movido para uma etapa inexistente fica SEM Etapa e some das telas — ' +
|
|
405
|
+
'rode `refresh --stages`.'
|
|
406
|
+
);
|
|
349
407
|
}
|
|
408
|
+
|
|
350
409
|
if (repoLabels) {
|
|
351
410
|
if (orphanLabels.length > 0) {
|
|
352
411
|
status = 'warn';
|
|
@@ -504,10 +563,22 @@ async function checkAi(ctx) {
|
|
|
504
563
|
);
|
|
505
564
|
} else {
|
|
506
565
|
notes.push(
|
|
507
|
-
`Saída estruturada da crítica:
|
|
566
|
+
`Saída estruturada da crítica: tool call forçado (${provider.value}) ` +
|
|
508
567
|
`· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
|
|
509
568
|
);
|
|
510
569
|
}
|
|
570
|
+
// O backend anthropic não chama a API — sobe o Claude Code CLI como
|
|
571
|
+
// subprocesso, que não existe no runner dos workflows (só há setup-node).
|
|
572
|
+
if (provider.value === 'anthropic') {
|
|
573
|
+
problems.push(
|
|
574
|
+
'O provider `anthropic` NÃO roda nos GitHub Actions: ele sobe o Claude Code CLI como ' +
|
|
575
|
+
'subprocesso, ausente no runner. As Actions de spec/plan/decompose vão falhar. ' +
|
|
576
|
+
'Troque para `"provider": "openrouter"` no .spec-wave.json (e adicione o secret ' +
|
|
577
|
+
'OPENROUTER_API_KEY), ou instale o Claude Code no workflow e defina ' +
|
|
578
|
+
'SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1. Localmente o anthropic funciona.'
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
511
582
|
if (process.env[provider.secret]) {
|
|
512
583
|
notes.push(`${provider.secret} presente no ambiente local.`);
|
|
513
584
|
} else {
|