@spec-wave/cli 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spec-wave.mjs +34 -3
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +10 -1
- package/src/agent/errors.mjs +57 -3
- package/src/agent/openrouter-agent.mjs +12 -2
- package/src/api/auth.mjs +217 -9
- package/src/api/github-graphql.mjs +33 -0
- package/src/commands/code-review.mjs +186 -31
- package/src/commands/decompose.mjs +199 -14
- package/src/commands/doctor.mjs +195 -35
- package/src/commands/generate-bug.mjs +18 -15
- package/src/commands/generate-plan.mjs +93 -5
- package/src/commands/generate-spec.mjs +7 -4
- package/src/commands/repair-stage.mjs +232 -0
- package/src/commands/update.mjs +13 -5
- package/src/config.mjs +43 -0
- package/src/lib/board.mjs +44 -0
- package/src/lib/claude.mjs +59 -9
- package/src/lib/critique.mjs +186 -14
- package/src/lib/decomposition-doc.mjs +59 -7
- package/src/lib/dependencies.mjs +49 -0
- package/src/lib/flow-run.mjs +134 -5
- package/src/lib/prompt-loader.mjs +30 -3
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/move/SKILL.md +29 -0
- package/src/plugin/skills/plan/SKILL.md +13 -0
- package/src/plugin/skills/spec/model-prompt.critique.md +58 -0
- package/src/setup/labels.mjs +10 -6
- package/src/templates/workflows/code-review.yml +34 -1
- package/src/templates/workflows/decompose.yml +12 -1
- package/src/templates/workflows/generate-bug.yml +8 -1
- package/src/templates/workflows/generate-plan.yml +16 -1
- package/src/templates/workflows/generate-spec.yml +16 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Reparo de Etapa — a saída para quando a AUTOMAÇÃO erra.
|
|
2
|
+
//
|
|
3
|
+
// O fluxo normal é `move`, e nele a Etapa só avança: essa regra existe porque
|
|
4
|
+
// board que retrocede sozinho vira board em que ninguém confia. Mas a automação
|
|
5
|
+
// erra. Uma corrida entre o `decompose-apply` e o `code-review` pôs duas Stories
|
|
6
|
+
// fundacionais e duas Tasks em 🎉 Done no instante em que nasceram, e o `move`
|
|
7
|
+
// recusou desfazer — a única saída foi mutação GraphQL crua no campo do Project.
|
|
8
|
+
//
|
|
9
|
+
// Isso é pior que ter o comando: quem consegue montar a mutação à mão consegue
|
|
10
|
+
// fazer qualquer coisa no board, sem confirmação e sem deixar rastro. Aqui o
|
|
11
|
+
// preço é explícito e o registro é obrigatório:
|
|
12
|
+
//
|
|
13
|
+
// • --yes e --reason são exigidos (sem os dois, recusa);
|
|
14
|
+
// • em terminal interativo, ainda pede a confirmação digitada;
|
|
15
|
+
// • cada reparo vira um comentário na issue dizendo quem, de onde e por quê.
|
|
16
|
+
//
|
|
17
|
+
// Não existe label nem workflow para isto de propósito: reparo é ato humano
|
|
18
|
+
// deliberado, não automação.
|
|
19
|
+
import * as p from '@clack/prompts';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import { resolveToken } from '../api/auth.mjs';
|
|
22
|
+
import { getIssue, commentOnIssue } from '../api/github-rest.mjs';
|
|
23
|
+
import { verifyTokenScopes } from '../api/auth.mjs';
|
|
24
|
+
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
25
|
+
import { loadProjectConfig, resolveField, setItemStage, resolveStageName } from '../lib/board.mjs';
|
|
26
|
+
import { loadConfig } from '../lib/project-root.mjs';
|
|
27
|
+
import { resolveProgressName } from './move.mjs';
|
|
28
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Lista de issues a reparar (função PURA).
|
|
32
|
+
*
|
|
33
|
+
* Aceita "529", "#529", "529,530" e "#529, #530" — o incidente que motivou o
|
|
34
|
+
* comando pegou sete itens de uma vez, e obrigar a sete invocações seria pedir
|
|
35
|
+
* para alguém desistir no meio.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|number} input
|
|
38
|
+
* @returns {{ numbers: number[], error: string|null }}
|
|
39
|
+
*/
|
|
40
|
+
export function parseIssueList(input) {
|
|
41
|
+
const raw = String(input ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
42
|
+
if (raw.length === 0) return { numbers: [], error: 'Informe ao menos uma issue (ex.: 529 ou 529,530).' };
|
|
43
|
+
const numbers = [];
|
|
44
|
+
for (const item of raw) {
|
|
45
|
+
const n = parseInt(item.replace('#', ''), 10);
|
|
46
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
47
|
+
return { numbers: [], error: `Issue inválida: "${item}". Use o número da issue, ex.: 529 ou #529.` };
|
|
48
|
+
}
|
|
49
|
+
if (!numbers.includes(n)) numbers.push(n);
|
|
50
|
+
}
|
|
51
|
+
return { numbers, error: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* O reparo pode prosseguir? (função PURA — é a trava do comando).
|
|
56
|
+
*
|
|
57
|
+
* Recusa sem `--yes` OU sem `--reason` com conteúdo. A mensagem diz o comando
|
|
58
|
+
* completo: quem chegou aqui está consertando um estrago e não deve ter que
|
|
59
|
+
* caçar a sintaxe.
|
|
60
|
+
*
|
|
61
|
+
* @param {{ yes?: boolean, reason?: string }} options
|
|
62
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
63
|
+
*/
|
|
64
|
+
export function checkRepairGuards({ yes = false, reason = '' } = {}) {
|
|
65
|
+
const motivo = String(reason || '').trim();
|
|
66
|
+
if (!yes || !motivo) {
|
|
67
|
+
const faltando = [!yes ? '--yes' : null, !motivo ? '--reason "<motivo>"' : null].filter(Boolean);
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
error:
|
|
71
|
+
`Reparo de Etapa exige ${faltando.join(' e ')}.\n` +
|
|
72
|
+
'A Etapa só avança no fluxo normal — reparar é exceção, e fica registrado na issue.\n' +
|
|
73
|
+
'Ex.: spec-wave repair-stage 529,530 ready --yes --reason "corrida do decompose-apply com o code-review"',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, error: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Comentário de auditoria do reparo (função PURA).
|
|
81
|
+
*
|
|
82
|
+
* Sem isto o reparo é indistinguível de uma mutação manual — e a razão de o
|
|
83
|
+
* comando existir é justamente deixar rastro.
|
|
84
|
+
*/
|
|
85
|
+
export function renderRepairComment({ from, to, status, reason, actor, version }) {
|
|
86
|
+
return [
|
|
87
|
+
'🛠️ **Etapa reparada manualmente** (`spec-wave repair-stage`)',
|
|
88
|
+
'',
|
|
89
|
+
`- **De:** ${from || '(sem etapa)'}`,
|
|
90
|
+
`- **Para:** ${to}${status ? ` · Status **${status}**` : ''}`,
|
|
91
|
+
`- **Por:** ${actor ? `@${actor}` : '(usuário do token)'}`,
|
|
92
|
+
`- **Motivo:** ${String(reason).trim()}`,
|
|
93
|
+
'',
|
|
94
|
+
`_A Etapa não retrocede pelo fluxo normal; este reparo foi explícito${version ? ` · spec-wave ${version}` : ''}._`,
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function repairStage(issuesArg, stageArg, options = {}) {
|
|
99
|
+
const { numbers, error: listError } = parseIssueList(issuesArg);
|
|
100
|
+
if (listError) {
|
|
101
|
+
p.log.error(listError);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { stage, error: stageError } = resolveStageName(stageArg);
|
|
107
|
+
if (stageError) {
|
|
108
|
+
p.log.error(stageError);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Sem --status o Status não é tocado: no reparo, mexer no que não foi pedido
|
|
114
|
+
// é justamente o que se está consertando.
|
|
115
|
+
let status = null;
|
|
116
|
+
if (options.status !== undefined && options.status !== null && String(options.status).trim() !== '') {
|
|
117
|
+
const resolved = resolveProgressName(options.status);
|
|
118
|
+
if (resolved.error) {
|
|
119
|
+
p.log.error(resolved.error);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
status = resolved.status;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const guards = checkRepairGuards(options);
|
|
127
|
+
if (!guards.ok && !options.dryRun) {
|
|
128
|
+
p.log.error(guards.error);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
134
|
+
const { config, root } = loadConfig();
|
|
135
|
+
const owner = envOwner || config?.owner;
|
|
136
|
+
const repo = envRepo || config?.repo;
|
|
137
|
+
if (!owner || !repo) {
|
|
138
|
+
p.log.error(
|
|
139
|
+
'Não foi possível determinar owner/repo.\n' +
|
|
140
|
+
`Rode dentro de um repositório com ${CONFIG_FILE} (\`spec-wave init\`) ou defina GITHUB_REPOSITORY=owner/repo.`
|
|
141
|
+
);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let token;
|
|
147
|
+
try {
|
|
148
|
+
token = await resolveToken();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
p.log.error(err.message);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
p.intro(chalk.bold(
|
|
156
|
+
`spec-wave repair-stage ${numbers.map(n => `#${n}`).join(', ')} → ${stage}${options.dryRun ? ' (dry-run)' : ''}`
|
|
157
|
+
));
|
|
158
|
+
|
|
159
|
+
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
160
|
+
if (projectError) {
|
|
161
|
+
p.log.error(`${projectError} — nada a reparar. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
166
|
+
const statusField = status ? await resolveField(token, project, 'Status').catch(() => null) : null;
|
|
167
|
+
|
|
168
|
+
// Lê tudo ANTES de escrever qualquer coisa: reparo parcial por issue
|
|
169
|
+
// inexistente no meio da lista é o tipo de surpresa que ninguém quer aqui.
|
|
170
|
+
const alvos = [];
|
|
171
|
+
for (const n of numbers) {
|
|
172
|
+
try {
|
|
173
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
174
|
+
alvos.push({ number: n, issue, type: detectIssueType(issue) });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
p.log.error(`Não foi possível ler a issue #${n}: ${err.message}`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const alvo of alvos) {
|
|
183
|
+
p.log.info(`#${alvo.number} ${alvo.type ? `(${alvo.type}) ` : ''}${alvo.issue.title}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (options.dryRun) {
|
|
187
|
+
p.log.warn(
|
|
188
|
+
`Dry-run: ${alvos.length} item(ns) seriam movidos para "${stage}"` +
|
|
189
|
+
`${status ? ` / Status "${status}"` : ''}, com comentário de auditoria em cada issue.`
|
|
190
|
+
);
|
|
191
|
+
p.outro('Nada foi alterado.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Confirmação digitada, além do --yes: em terminal, a chance de o comando ter
|
|
196
|
+
// vindo de um histórico ou de um copiar-colar é real.
|
|
197
|
+
if (process.stdin.isTTY) {
|
|
198
|
+
const esperado = `repair ${alvos.map(a => `#${a.number}`).join(' ')}`;
|
|
199
|
+
const digitado = await p.text({
|
|
200
|
+
message: `Confirme digitando: ${esperado}`,
|
|
201
|
+
placeholder: esperado,
|
|
202
|
+
});
|
|
203
|
+
if (p.isCancel(digitado) || String(digitado).trim() !== esperado) {
|
|
204
|
+
p.cancel('Reparo cancelado — nada foi alterado.');
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Login do token para o registro. Best-effort: falhar aqui não pode impedir o
|
|
211
|
+
// reparo, mas o comentário fica sem o "quem".
|
|
212
|
+
const actor = await verifyTokenScopes(token).then(i => i.login).catch(() => null);
|
|
213
|
+
const version = process.env.npm_package_version || null;
|
|
214
|
+
|
|
215
|
+
let reparados = 0;
|
|
216
|
+
for (const alvo of alvos) {
|
|
217
|
+
try {
|
|
218
|
+
const { from } = await setItemStage(
|
|
219
|
+
token, project, etapaField, statusField, alvo.issue.node_id, stage, status);
|
|
220
|
+
reparados += 1;
|
|
221
|
+
p.log.success(`#${alvo.number}: ${from || '(sem etapa)'} → ${chalk.bold(stage)}${status ? ` / ${status}` : ''}`);
|
|
222
|
+
await commentOnIssue(token, owner, repo, alvo.number, renderRepairComment({
|
|
223
|
+
from, to: stage, status, reason: options.reason, actor, version,
|
|
224
|
+
})).catch(err => p.log.warn(`#${alvo.number}: reparo aplicado, mas o registro falhou: ${err.message}`));
|
|
225
|
+
} catch (err) {
|
|
226
|
+
p.log.error(`#${alvo.number}: falha ao reparar — ${err.message}`);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
p.outro(`${chalk.green('✓')} ${reparados}/${alvos.length} item(ns) reparado(s) e registrado(s).`);
|
|
232
|
+
}
|
package/src/commands/update.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
7
|
-
import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
|
|
7
|
+
import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS, allLabelsFor } from '../config.mjs';
|
|
8
8
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
9
9
|
import {
|
|
10
10
|
getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
|
|
@@ -80,14 +80,22 @@ function applySkill(job) {
|
|
|
80
80
|
* 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
|
|
81
81
|
* namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
|
|
82
82
|
*
|
|
83
|
+
* As labels de modelo (`spec-wave:model:<apelido>`) entram pelo `fileAi`: são
|
|
84
|
+
* derivadas de `ai.modelAliases`, e tratá-las como desconhecidas fazia o update
|
|
85
|
+
* APAGAR justamente o override de modelo por issue — o mecanismo que destrava
|
|
86
|
+
* uma Feature que não gera plano no modelo default. Passar o bloco `ai` também
|
|
87
|
+
* é o que faz o update CRIÁ-LAS, em vez de exigir `gh label create` à mão.
|
|
88
|
+
*
|
|
83
89
|
* @param {Array<{name,color,description}>} existing labels do repo
|
|
90
|
+
* @param {object} [fileAi] bloco `ai` do .spec-wave.json
|
|
84
91
|
* @returns {{ missing: object[], changed: object[], orphan: object[] }}
|
|
85
92
|
*/
|
|
86
|
-
export function diffLabels(existing) {
|
|
93
|
+
export function diffLabels(existing, fileAi) {
|
|
94
|
+
const wanted = allLabelsFor(fileAi);
|
|
87
95
|
const byName = new Map((existing || []).map(l => [l.name, l]));
|
|
88
96
|
const missing = [];
|
|
89
97
|
const changed = [];
|
|
90
|
-
for (const label of
|
|
98
|
+
for (const label of wanted) {
|
|
91
99
|
const cur = byName.get(label.name);
|
|
92
100
|
if (!cur) {
|
|
93
101
|
missing.push(label);
|
|
@@ -98,7 +106,7 @@ export function diffLabels(existing) {
|
|
|
98
106
|
changed.push(label);
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
|
-
const known = new Set(
|
|
109
|
+
const known = new Set(wanted.map(l => l.name));
|
|
102
110
|
const orphan = (existing || []).filter(l => l.name?.startsWith('spec-wave:') && !known.has(l.name));
|
|
103
111
|
return { missing, changed, orphan };
|
|
104
112
|
}
|
|
@@ -266,7 +274,7 @@ export async function update(options = {}) {
|
|
|
266
274
|
if (remote === null) repoFiles.push({ ...f, reason: 'ausente', local });
|
|
267
275
|
else if (remote !== local) repoFiles.push({ ...f, reason: 'desatualizado', local });
|
|
268
276
|
}
|
|
269
|
-
labelDiff = diffLabels(await listLabels(tk, owner, repo));
|
|
277
|
+
labelDiff = diffLabels(await listLabels(tk, owner, repo), config?.ai);
|
|
270
278
|
repoChecked = true;
|
|
271
279
|
s.stop('Repositório comparado.');
|
|
272
280
|
} catch (err) {
|
package/src/config.mjs
CHANGED
|
@@ -372,6 +372,49 @@ export const TRIGGER_LABELS = [
|
|
|
372
372
|
|
|
373
373
|
export const ALL_LABELS = [...TYPE_LABELS, ...PRIORITY_LABELS, ...TRIGGER_LABELS];
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Labels de override de modelo, derivadas de `ai.modelAliases` (função PURA).
|
|
377
|
+
*
|
|
378
|
+
* Estas NÃO cabem em ALL_LABELS: o conjunto depende do .spec-wave.json de cada
|
|
379
|
+
* repo. Tratá-las como estáticas produziu dois defeitos simétricos:
|
|
380
|
+
*
|
|
381
|
+
* • o `update` (que remove toda `spec-wave:*` fora da lista conhecida) e o
|
|
382
|
+
* `doctor` acusavam TODO alias configurado como label descontinuada, com o
|
|
383
|
+
* conselho de apagá-la — apagar quebra o override de modelo por issue, que é
|
|
384
|
+
* justamente o que destrava uma Feature que não gera plano no modelo default;
|
|
385
|
+
* • o `init`/`update` nunca as criavam, então usar o mecanismo exigia
|
|
386
|
+
* `gh label create` à mão, um por alias.
|
|
387
|
+
*
|
|
388
|
+
* É a mesma armadilha que já tinha apagado a `spec-wave:dev-agent` (ver
|
|
389
|
+
* LABEL_DEV_AGENT), com um agravante: ali bastou registrar a label na lista, e
|
|
390
|
+
* aqui a lista não pode ser fixa.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} [modelAliases] bloco `ai.modelAliases` do .spec-wave.json
|
|
393
|
+
* @returns {Array<{name: string, color: string, description: string}>}
|
|
394
|
+
*/
|
|
395
|
+
export function modelLabels(modelAliases) {
|
|
396
|
+
if (!modelAliases || typeof modelAliases !== 'object') return [];
|
|
397
|
+
return Object.entries(modelAliases)
|
|
398
|
+
.filter(([alias, model]) => alias.trim() && typeof model === 'string' && model.trim())
|
|
399
|
+
.map(([alias, model]) => ({
|
|
400
|
+
name: `${MODEL_LABEL_PREFIX}${alias}`,
|
|
401
|
+
color: '5319E7',
|
|
402
|
+
description: `Gera esta issue com ${model.trim()}`,
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Labels do fluxo + as de modelo deste repo (função PURA).
|
|
408
|
+
*
|
|
409
|
+
* É este o conjunto que `update`, `doctor` e `init` devem usar — ALL_LABELS
|
|
410
|
+
* sozinha é incompleta em qualquer repo que configure `ai.modelAliases`.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} [fileAi] bloco `ai` do .spec-wave.json
|
|
413
|
+
*/
|
|
414
|
+
export function allLabelsFor(fileAi) {
|
|
415
|
+
return [...ALL_LABELS, ...modelLabels(fileAi?.modelAliases)];
|
|
416
|
+
}
|
|
417
|
+
|
|
375
418
|
/**
|
|
376
419
|
* Nomes das labels de uma issue (função PURA).
|
|
377
420
|
*
|
package/src/lib/board.mjs
CHANGED
|
@@ -147,6 +147,50 @@ export async function advanceToStage(token, project, etapaField, statusField, no
|
|
|
147
147
|
return true;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Escreve a Etapa IGNORANDO a regra de avanço — reparo, não fluxo.
|
|
152
|
+
*
|
|
153
|
+
* `advanceToStage` continua sendo o único caminho do fluxo, e continua sem
|
|
154
|
+
* escape hatch: a Etapa só avança. Esta função existe porque a automação erra —
|
|
155
|
+
* uma corrida entre o `decompose-apply` e o `code-review` já pôs duas Stories e
|
|
156
|
+
* duas Tasks em 🎉 Done no nascimento — e a única saída era mutação GraphQL
|
|
157
|
+
* crua: sem trava, sem confirmação e sem rastro. Um caminho com as três é
|
|
158
|
+
* melhor que a mão livre.
|
|
159
|
+
*
|
|
160
|
+
* Quem chama (`repair-stage`) é responsável pela confirmação e pelo registro na
|
|
161
|
+
* issue. Aqui só se escreve.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} token token com scope project
|
|
164
|
+
* @param {object} project bloco project (precisa de .id)
|
|
165
|
+
* @param {{id,options}|null} etapaField campo "Etapa" (ver resolveField)
|
|
166
|
+
* @param {{id,options}|null} statusField campo nativo "Status"
|
|
167
|
+
* @param {string} nodeId node id da issue
|
|
168
|
+
* @param {string} targetStage etapa de destino (precisa existir em STAGE_ORDER)
|
|
169
|
+
* @param {string} [targetStatus] valor do Status; omitido = não mexe no Status
|
|
170
|
+
* @returns {Promise<{ from: string|null }>} etapa em que o item estava
|
|
171
|
+
*/
|
|
172
|
+
export async function setItemStage(token, project, etapaField, statusField, nodeId, targetStage, targetStatus) {
|
|
173
|
+
if (!etapaField?.id) throw new Error('Campo "Etapa" não encontrado no Project — nada a reparar.');
|
|
174
|
+
if (STAGE_ORDER.indexOf(targetStage) === -1) {
|
|
175
|
+
throw new Error(`Etapa "${targetStage}" não faz parte do fluxo (${STAGE_ORDER.join(' → ')}).`);
|
|
176
|
+
}
|
|
177
|
+
const optionId = etapaField.options?.[targetStage];
|
|
178
|
+
if (!optionId) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`A opção "${targetStage}" não existe no campo Etapa deste Project. ` +
|
|
181
|
+
'Rode `spec-wave refresh --config` para sincronizar os ids.'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const itemId = await addProjectItem(token, project.id, nodeId);
|
|
185
|
+
const from = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
|
|
186
|
+
await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
|
|
187
|
+
if (statusField?.id && targetStatus) {
|
|
188
|
+
const statusOption = statusField.options?.[targetStatus];
|
|
189
|
+
if (statusOption) await setItemSingleSelect(token, project.id, itemId, statusField.id, statusOption);
|
|
190
|
+
}
|
|
191
|
+
return { from };
|
|
192
|
+
}
|
|
193
|
+
|
|
150
194
|
/**
|
|
151
195
|
* Define APENAS o Status nativo (Todo/In Progress/Done) de um item, sem tocar
|
|
152
196
|
* na Etapa — usado para marcar progresso dentro da etapa atual.
|
package/src/lib/claude.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '../config.mjs';
|
|
7
7
|
import { lintLanguage } from './output-lint.mjs';
|
|
8
8
|
import { runAgent } from '../agent/index.mjs';
|
|
9
|
-
import { TruncatedOutputError, isTruncationReason } from '../agent/errors.mjs';
|
|
9
|
+
import { TruncatedOutputError, MaxTurnsError, isTruncationReason } from '../agent/errors.mjs';
|
|
10
10
|
import { computeCost } from './usage-report.mjs';
|
|
11
11
|
import { findConfigPath } from './project-root.mjs';
|
|
12
12
|
|
|
@@ -17,12 +17,21 @@ import { findConfigPath } from './project-root.mjs';
|
|
|
17
17
|
// branco. Ajustável por `ai.maxTokens` / `ai.maxTokensByAction`.
|
|
18
18
|
export const DEFAULT_MAX_TOKENS = 32768;
|
|
19
19
|
|
|
20
|
-
// Teto padrão POR AÇÃO, aplicado abaixo de `ai.maxTokens` na precedência.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
20
|
+
// Teto padrão POR AÇÃO, aplicado abaixo de `ai.maxTokens` na precedência.
|
|
21
|
+
//
|
|
22
|
+
// A crítica JÁ TEVE um default próprio de 8192, com o raciocínio de que a saída
|
|
23
|
+
// (uma lista JSON de findings) é muito menor que um spec/plan. O raciocínio
|
|
24
|
+
// estava certo sobre a SAÍDA e errado sobre o CUSTO: em modelo que raciocina por
|
|
25
|
+
// padrão, o pensamento consome o mesmo teto antes de a primeira linha da
|
|
26
|
+
// resposta sair. O corte vira TruncatedOutputError (os dois backends falham
|
|
27
|
+
// alto, e isso está certo) — mas o efeito prático é a crítica não concluir numa
|
|
28
|
+
// etapa cujo propósito é justamente barrar problema, e a rodada se perde.
|
|
29
|
+
//
|
|
30
|
+
// `max_tokens` é TETO, não consumo: deixar a crítica herdar DEFAULT_MAX_TOKENS
|
|
31
|
+
// não encarece nada em modelo sem raciocínio, e é o que evita o corte no que
|
|
32
|
+
// raciocina. A tabela fica aqui, vazia, para o caso de alguma ação futura
|
|
33
|
+
// precisar mesmo de um default próprio.
|
|
34
|
+
export const MAX_TOKENS_BY_ACTION = {};
|
|
26
35
|
|
|
27
36
|
/**
|
|
28
37
|
* Resolve o alias de modelo vindo de label (função PURA).
|
|
@@ -143,6 +152,27 @@ function resolveAi(action, { labels = [], modelOverride } = {}) {
|
|
|
143
152
|
return resolveAiConfig({ env: process.env, fileAi: readFileAi(), action, labels, modelOverride });
|
|
144
153
|
}
|
|
145
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Avisa quando a label de modelo existe na issue mas não resolve.
|
|
157
|
+
*
|
|
158
|
+
* `resolveModelLabel` já devolvia o erro, e NINGUÉM o lia: um apelido errado
|
|
159
|
+
* fazia a label ser ignorada em silêncio, a precedência caía para o modelo do
|
|
160
|
+
* .spec-wave.json e o run seguia com o modelo errado. Quem aplicou a label para
|
|
161
|
+
* reprocessar num modelo mais forte não tinha como perceber — o único sinal era
|
|
162
|
+
* o `origem:` da linha acima, que ninguém compara.
|
|
163
|
+
*/
|
|
164
|
+
function warnUnresolvedModelLabel(ai) {
|
|
165
|
+
const alias = ai.labelAlias;
|
|
166
|
+
if (!alias?.error) return;
|
|
167
|
+
const motivo = alias.error === 'ambíguo'
|
|
168
|
+
? `há mais de uma label ${MODEL_LABEL_PREFIX}* na issue (${alias.alias})`
|
|
169
|
+
: `o apelido "${alias.alias}" não existe em ai.modelAliases do .spec-wave.json`;
|
|
170
|
+
console.warn(
|
|
171
|
+
`⚠️ Label de modelo IGNORADA: ${motivo}. ` +
|
|
172
|
+
`Seguindo com ${ai.model} (origem: ${ai.modelSource}).`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
146
176
|
// Retry de falha transitória do provedor. Sem isto, um corpo cortado numa
|
|
147
177
|
// geração de 90s derruba o Action inteiro e a issue fica sem spec — o custo de
|
|
148
178
|
// esperar alguns segundos é irrisório perto de refazer o ciclo à mão.
|
|
@@ -186,7 +216,7 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
|
|
|
186
216
|
// `agent/errors.mjs` quando os backends portados passaram a precisar deles.
|
|
187
217
|
// Re-exportados para não quebrar os 5 consumidores e os testes que importam
|
|
188
218
|
// deste módulo.
|
|
189
|
-
export { TruncatedOutputError, isTruncationReason };
|
|
219
|
+
export { TruncatedOutputError, MaxTurnsError, isTruncationReason };
|
|
190
220
|
|
|
191
221
|
// Os parâmetros de sampling foram REMOVIDOS a partir do Claude Opus 4.7 (vale
|
|
192
222
|
// para 4.8 e 5, Sonnet 5, Fable 5 e Mythos 5): enviar temperature/top_p/top_k
|
|
@@ -247,6 +277,7 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
|
247
277
|
`temperature: ${sendTemperature ? temperature : 'n/a (removida neste modelo)'} · ` +
|
|
248
278
|
`max_tokens: ${maxTokens}`
|
|
249
279
|
);
|
|
280
|
+
warnUnresolvedModelLabel(ai);
|
|
250
281
|
|
|
251
282
|
// Acumuladores de uso desta invocação (1 ou 2 chamadas, com o retry de lint).
|
|
252
283
|
let inputTokens = 0;
|
|
@@ -262,6 +293,11 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
|
|
|
262
293
|
// repositório antes de escrever o documento. É o que os blocos
|
|
263
294
|
// `requires-tools` dos model-prompt sempre descreveram.
|
|
264
295
|
tools: opts.tools ?? ['Read', 'Glob', 'Grep'],
|
|
296
|
+
// Teto de turnos do PROMPT (frontmatter `maxTurns`, default
|
|
297
|
+
// DEFAULT_PROMPT_MAX_TURNS). Sem repassar aqui, o valor declarado no
|
|
298
|
+
// prompt nunca chegava ao motor e o MAX_TURNS dos backends prevalecia —
|
|
299
|
+
// um teto ficava documentado num lugar e vigorando em outro.
|
|
300
|
+
maxTurns: opts.maxTurns,
|
|
265
301
|
};
|
|
266
302
|
const { text, usage } = await withRetry(`Geração via ${ai.provider}`, () =>
|
|
267
303
|
generateViaEngine(system, userContent, ai, callOpts));
|
|
@@ -362,6 +398,7 @@ export async function generateStructured(systemPrompt, userContent, opts = {}) {
|
|
|
362
398
|
`Provider de IA: ${ai.provider} · modelo: ${ai.model} (origem: ${ai.modelSource}) · ` +
|
|
363
399
|
`saída estruturada: ${schema.name}${strict ? ' (strict)' : ''} · max_tokens: ${maxTokens}`
|
|
364
400
|
);
|
|
401
|
+
warnUnresolvedModelLabel(ai);
|
|
365
402
|
|
|
366
403
|
const callOpts = {
|
|
367
404
|
temperature: sendTemperature ? temperature : undefined,
|
|
@@ -451,7 +488,7 @@ export function extractOpenRouterUsage(usage) {
|
|
|
451
488
|
* @returns {Promise<{text: string, json?: object, usage: object}>}
|
|
452
489
|
*/
|
|
453
490
|
async function generateViaEngine(
|
|
454
|
-
systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict, action, tools },
|
|
491
|
+
systemPrompt, userContent, ai, { temperature, maxTokens, maxTurns, schema, strict, action, tools },
|
|
455
492
|
) {
|
|
456
493
|
const result = await runAgent(userContent, {
|
|
457
494
|
provider: ai.provider,
|
|
@@ -463,6 +500,7 @@ async function generateViaEngine(
|
|
|
463
500
|
userId: 'spec-wave',
|
|
464
501
|
...(tools ? { tools } : {}),
|
|
465
502
|
...(maxTokens ? { maxTokens } : {}),
|
|
503
|
+
...(maxTurns ? { maxTurns } : {}),
|
|
466
504
|
...(temperature !== undefined ? { temperature } : {}),
|
|
467
505
|
...(schema
|
|
468
506
|
? { responseSchema: { ...schema, strict: strict === true } }
|
|
@@ -492,6 +530,18 @@ async function generateViaEngine(
|
|
|
492
530
|
|
|
493
531
|
const text = stripReasoning(result.outputText || '');
|
|
494
532
|
if (!text) {
|
|
533
|
+
// Teto de turnos tem causa e remédio próprios, e NÃO é transitório: o
|
|
534
|
+
// modelo gastou todos os turnos em tool calls sem nunca escrever o
|
|
535
|
+
// documento, e repetir reproduz isso. Ver MaxTurnsError.
|
|
536
|
+
if (result.resultSubtype === 'error_max_turns') {
|
|
537
|
+
throw new MaxTurnsError({
|
|
538
|
+
provider: ai.provider,
|
|
539
|
+
model: ai.model,
|
|
540
|
+
turns: result.numTurns,
|
|
541
|
+
action: action || null,
|
|
542
|
+
toolCalls: result.toolCalls || [],
|
|
543
|
+
});
|
|
544
|
+
}
|
|
495
545
|
const err = new Error(
|
|
496
546
|
`O provider ${ai.provider} retornou resposta sem texto ` +
|
|
497
547
|
`(subtype=${result.resultSubtype}, turnos=${result.numTurns}).`
|