@spec-wave/cli 0.13.0 → 0.14.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 +39 -6
- package/bin/spec-wave.mjs +14 -1
- package/package.json +1 -1
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +43 -19
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +143 -27
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
|
@@ -20,6 +20,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { CONFIG_FILE } from '../config.mjs';
|
|
23
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
23
24
|
|
|
24
25
|
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const TEMPLATES_DIR = path.join(__dir, '..', 'templates', 'agent');
|
|
@@ -180,8 +181,8 @@ function writeInstalledTag(home, tag) {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
function readRepoConfig(cwd) {
|
|
183
|
-
const configPath =
|
|
184
|
-
if (!
|
|
184
|
+
const configPath = findConfigPath(cwd);
|
|
185
|
+
if (!configPath) {
|
|
185
186
|
return { error: `${CONFIG_FILE} não encontrado. Rode \`spec-wave init\` neste repositório primeiro.` };
|
|
186
187
|
}
|
|
187
188
|
try {
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -10,8 +10,15 @@ import chalk from 'chalk';
|
|
|
10
10
|
import { Octokit } from '@octokit/rest';
|
|
11
11
|
import { resolveToken, verifyTokenScopes } from '../api/auth.mjs';
|
|
12
12
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
|
|
15
|
+
ALL_LABELS, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX, DEFAULT_MAX_CRITIQUE_ATTEMPTS,
|
|
16
|
+
} from '../config.mjs';
|
|
17
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_MAX_TOKENS, MAX_TOKENS_BY_ACTION, supportsStrictSchema,
|
|
20
|
+
} from '../lib/claude.mjs';
|
|
21
|
+
import { CLI_VERSION } from '../lib/templates.mjs';
|
|
15
22
|
|
|
16
23
|
// Mesmo padrão de instanciação de github-rest.mjs, mas com o logger mudo:
|
|
17
24
|
// aqui 404/403 são resultados esperados dos checks, não erros a logar.
|
|
@@ -199,11 +206,13 @@ async function checkGhAccount(ctx) {
|
|
|
199
206
|
|
|
200
207
|
async function checkConfig(ctx) {
|
|
201
208
|
const name = `Configuração (${CONFIG_FILE})`;
|
|
202
|
-
if (!
|
|
209
|
+
if (!ctx.configPath) {
|
|
203
210
|
return {
|
|
204
211
|
name,
|
|
205
212
|
status: 'fail',
|
|
206
|
-
detail:
|
|
213
|
+
detail:
|
|
214
|
+
`${CONFIG_FILE} não encontrado em ${ctx.cwd} nem em nenhum diretório acima. ` +
|
|
215
|
+
'Rode `npx @spec-wave/cli@latest init`.',
|
|
207
216
|
};
|
|
208
217
|
}
|
|
209
218
|
if (ctx.cfgError) {
|
|
@@ -211,6 +220,9 @@ async function checkConfig(ctx) {
|
|
|
211
220
|
}
|
|
212
221
|
const { cfg } = ctx;
|
|
213
222
|
const notes = [`Repositório: ${cfg.owner ?? '?'}/${cfg.repo ?? '?'}.`];
|
|
223
|
+
if (ctx.root && ctx.root !== ctx.cwd) {
|
|
224
|
+
notes.push(`Raiz do projeto: ${ctx.root} (o comando foi rodado de um subdiretório).`);
|
|
225
|
+
}
|
|
214
226
|
|
|
215
227
|
const fields = cfg.project?.fields || {};
|
|
216
228
|
const missingFields = ['Etapa', 'Status'].filter((f) => !fields[f]);
|
|
@@ -254,6 +266,109 @@ async function checkConfig(ctx) {
|
|
|
254
266
|
return { name, status: 'ok', detail: notes.join('\n') };
|
|
255
267
|
}
|
|
256
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Higiene do board e das labels (função PURA — testável sem rede).
|
|
271
|
+
*
|
|
272
|
+
* Dois problemas que passavam em silêncio:
|
|
273
|
+
* • colunas de Etapa fora de STATUS_OPTIONS. A checagem de retrocesso compara a
|
|
274
|
+
* etapa atual com a ordem canônica; um item numa coluna desconhecida não tem
|
|
275
|
+
* posição na ordem, e o board perde a garantia de fluxo só-para-frente;
|
|
276
|
+
* • labels `spec-wave:*` descontinuadas. O `update` só criava e atualizava, então
|
|
277
|
+
* uma label removida do config ficava para sempre (caso da `spec-wave:force`).
|
|
278
|
+
*
|
|
279
|
+
* @param {object} params
|
|
280
|
+
* @param {string[]} [params.boardStages] opções reais de Etapa no project
|
|
281
|
+
* @param {string[]} [params.repoLabels] nomes das labels do repo
|
|
282
|
+
* @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[], missingLabels: string[] }}
|
|
283
|
+
*/
|
|
284
|
+
export function inspectBoardHygiene({ boardStages = null, repoLabels = null } = {}) {
|
|
285
|
+
const canonical = STATUS_OPTIONS.map(s => s.name);
|
|
286
|
+
const known = new Set(canonical);
|
|
287
|
+
const unknownStages = boardStages ? boardStages.filter(s => !known.has(s)) : [];
|
|
288
|
+
const missingStages = boardStages ? canonical.filter(s => !boardStages.includes(s)) : [];
|
|
289
|
+
|
|
290
|
+
const knownLabels = new Set(ALL_LABELS.map(l => l.name));
|
|
291
|
+
const orphanLabels = repoLabels
|
|
292
|
+
? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n))
|
|
293
|
+
: [];
|
|
294
|
+
const missingLabels = repoLabels
|
|
295
|
+
? ALL_LABELS.map(l => l.name).filter(n => !repoLabels.includes(n))
|
|
296
|
+
: [];
|
|
297
|
+
return { unknownStages, missingStages, orphanLabels, missingLabels };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function checkBoardHygiene(ctx) {
|
|
301
|
+
const name = 'Higiene do board e das labels';
|
|
302
|
+
const { cfg } = ctx;
|
|
303
|
+
if (!cfg?.project?.id) {
|
|
304
|
+
return { name, status: 'warn', detail: 'Sem project no config — verificação pulada.' };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let boardStages = null;
|
|
308
|
+
const snapshot = ctx.projectSnapshot;
|
|
309
|
+
if (snapshot?.fields?.['Etapa']?.options) {
|
|
310
|
+
boardStages = Object.keys(snapshot.fields['Etapa'].options);
|
|
311
|
+
} else if (cfg.project.fields?.['Etapa']?.options) {
|
|
312
|
+
// Sem snapshot (sem token/rede), o config é a melhor aproximação — foi nele
|
|
313
|
+
// que a coluna extra "📋 Backlog Técnico" ficou pendurada.
|
|
314
|
+
boardStages = Object.keys(cfg.project.fields['Etapa'].options);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let repoLabels = null;
|
|
318
|
+
if (ctx.token && cfg.owner && cfg.repo) {
|
|
319
|
+
try {
|
|
320
|
+
const res = await makeOctokit(ctx.token)
|
|
321
|
+
.paginate('GET /repos/{owner}/{repo}/labels', { owner: cfg.owner, repo: cfg.repo, per_page: 100 });
|
|
322
|
+
repoLabels = res.map(l => l.name);
|
|
323
|
+
} catch {
|
|
324
|
+
// labels não verificáveis agora — segue só com o board
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const { unknownStages, missingStages, orphanLabels, missingLabels } =
|
|
329
|
+
inspectBoardHygiene({ boardStages, repoLabels });
|
|
330
|
+
|
|
331
|
+
const notes = [];
|
|
332
|
+
let status = 'ok';
|
|
333
|
+
if (boardStages) {
|
|
334
|
+
notes.push(`Etapas no board: ${boardStages.length}/${STATUS_OPTIONS.length} do RFC-001.`);
|
|
335
|
+
} else {
|
|
336
|
+
notes.push('Opções de Etapa não legíveis — verificação de colunas pulada.');
|
|
337
|
+
}
|
|
338
|
+
if (unknownStages.length > 0) {
|
|
339
|
+
status = 'warn';
|
|
340
|
+
notes.push(
|
|
341
|
+
`Colunas fora do fluxo canônico: ${unknownStages.join(', ')}. Um item nelas não tem ` +
|
|
342
|
+
'posição na ordem das etapas, e a garantia de "a Etapa nunca retrocede" não se aplica. ' +
|
|
343
|
+
'Mova os itens para uma etapa do fluxo e remova a coluna.'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (missingStages.length > 0) {
|
|
347
|
+
status = 'warn';
|
|
348
|
+
notes.push(`Etapas do RFC-001 ausentes no board: ${missingStages.join(', ')} — rode \`refresh\`.`);
|
|
349
|
+
}
|
|
350
|
+
if (repoLabels) {
|
|
351
|
+
if (orphanLabels.length > 0) {
|
|
352
|
+
status = 'warn';
|
|
353
|
+
notes.push(
|
|
354
|
+
`Labels \`spec-wave:*\` descontinuadas ainda no repo: ${orphanLabels.join(', ')}. ` +
|
|
355
|
+
'Nenhum comando as honra — rode `npx @spec-wave/cli@latest update` para removê-las.'
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (missingLabels.length > 0) {
|
|
359
|
+
status = 'warn';
|
|
360
|
+
notes.push(`Labels do fluxo ausentes no repo: ${missingLabels.join(', ')} — rode \`update\`.`);
|
|
361
|
+
}
|
|
362
|
+
if (orphanLabels.length === 0 && missingLabels.length === 0) {
|
|
363
|
+
notes.push(`Labels: ${ALL_LABELS.length} do fluxo presentes, nenhuma descontinuada.`);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
notes.push('Labels do repo não verificáveis agora (sem token ou sem acesso).');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { name, status, detail: notes.join('\n') };
|
|
370
|
+
}
|
|
371
|
+
|
|
257
372
|
async function checkRepoAccess(ctx) {
|
|
258
373
|
const name = 'Acesso ao repositório';
|
|
259
374
|
const { cfg } = ctx;
|
|
@@ -307,9 +422,92 @@ async function checkAi(ctx) {
|
|
|
307
422
|
const byAction = fileAi.maxTokensByAction && Object.keys(fileAi.maxTokensByAction).length > 0
|
|
308
423
|
? ` · por ação: ${Object.entries(fileAi.maxTokensByAction).map(([a, t]) => `${a}=${t}`).join(', ')}`
|
|
309
424
|
: '';
|
|
310
|
-
|
|
425
|
+
const actionDefaults = Object.entries(MAX_TOKENS_BY_ACTION)
|
|
426
|
+
.filter(([a]) => !fileAi.maxTokensByAction?.[a] && !fileAi.maxTokens)
|
|
427
|
+
.map(([a, t]) => `${a}=${t} (default)`)
|
|
428
|
+
.join(', ');
|
|
429
|
+
notes.push(
|
|
430
|
+
`Teto de saída: ${maxTokensNote}${byAction}${actionDefaults ? ` · ${actionDefaults}` : ''}.`
|
|
431
|
+
);
|
|
311
432
|
|
|
312
433
|
let status = 'ok';
|
|
434
|
+
|
|
435
|
+
// Escalada da crítica: sem escalationModel, a segunda tentativa apenas repete o
|
|
436
|
+
// MESMO modelo — a escalada existe no fluxo mas não muda nada.
|
|
437
|
+
const maxAttempts = Number.isInteger(fileAi.maxCritiqueAttempts) && fileAi.maxCritiqueAttempts > 0
|
|
438
|
+
? fileAi.maxCritiqueAttempts
|
|
439
|
+
: DEFAULT_MAX_CRITIQUE_ATTEMPTS;
|
|
440
|
+
const critiqueModel = fileAi.models?.critique || model;
|
|
441
|
+
if (!fileAi.escalationModel) {
|
|
442
|
+
status = 'warn';
|
|
443
|
+
notes.push(
|
|
444
|
+
`Escalada da crítica: nenhuma (\`ai.escalationModel\` ausente) — na tentativa 2 a crítica ` +
|
|
445
|
+
`repete o mesmo modelo (\`${critiqueModel}\`). Teto: ${maxAttempts} tentativas → ` +
|
|
446
|
+
`label \`${LABEL_NEEDS_HUMAN}\`.`
|
|
447
|
+
);
|
|
448
|
+
} else if (fileAi.escalationModel === critiqueModel) {
|
|
449
|
+
status = 'warn';
|
|
450
|
+
notes.push(
|
|
451
|
+
`Escalada da crítica: \`ai.escalationModel\` é igual ao modelo da crítica ` +
|
|
452
|
+
`(\`${critiqueModel}\`) — escalar não troca de modelo. Teto: ${maxAttempts} tentativas.`
|
|
453
|
+
);
|
|
454
|
+
} else {
|
|
455
|
+
notes.push(
|
|
456
|
+
`Escalada da crítica: \`${fileAi.escalationModel}\` a partir da tentativa 2 · ` +
|
|
457
|
+
`teto de ${maxAttempts} tentativas → label \`${LABEL_NEEDS_HUMAN}\`.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (Number.isInteger(fileAi.maxCritiqueAttempts) === false && fileAi.maxCritiqueAttempts !== undefined) {
|
|
461
|
+
status = 'warn';
|
|
462
|
+
notes.push(
|
|
463
|
+
`\`ai.maxCritiqueAttempts\` = ${JSON.stringify(fileAi.maxCritiqueAttempts)} não é um inteiro ` +
|
|
464
|
+
`positivo — usando o default (${DEFAULT_MAX_CRITIQUE_ATTEMPTS}).`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Apelidos de modelo por label (spec-wave:model:<apelido>).
|
|
469
|
+
const aliases = fileAi.modelAliases && Object.keys(fileAi.modelAliases).length > 0
|
|
470
|
+
? fileAi.modelAliases
|
|
471
|
+
: null;
|
|
472
|
+
if (!aliases) {
|
|
473
|
+
notes.push(
|
|
474
|
+
`Apelidos de modelo: nenhum (\`ai.modelAliases\` ausente) — labels ` +
|
|
475
|
+
`\`${MODEL_LABEL_PREFIX}<apelido>\` serão ignoradas.`
|
|
476
|
+
);
|
|
477
|
+
} else {
|
|
478
|
+
notes.push(
|
|
479
|
+
`Apelidos de modelo (\`${MODEL_LABEL_PREFIX}<apelido>\`): ` +
|
|
480
|
+
`${Object.entries(aliases).map(([a, m]) => `${a}=${m}`).join(', ')}.`
|
|
481
|
+
);
|
|
482
|
+
// Slug da OpenRouter tem "/" (anthropic/claude-…); id da Anthropic, não.
|
|
483
|
+
const wrongShape = Object.entries(aliases).filter(([, m]) => (
|
|
484
|
+
provider.value === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
|
|
485
|
+
));
|
|
486
|
+
if (wrongShape.length > 0) {
|
|
487
|
+
status = 'warn';
|
|
488
|
+
notes.push(
|
|
489
|
+
`Apelidos com formato incompatível com o provider ${provider.value}: ` +
|
|
490
|
+
`${wrongShape.map(([a, m]) => `${a}=${m}`).join(', ')}.`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Saída estruturada da crítica: sem structured output confiável, a validação
|
|
496
|
+
// do schema queima os retries antes de falhar.
|
|
497
|
+
if (provider.value === 'openrouter' && !fileAi.models?.critique) {
|
|
498
|
+
status = 'warn';
|
|
499
|
+
notes.push(
|
|
500
|
+
`Saída estruturada da crítica: o provider é openrouter e \`ai.models.critique\` não está ` +
|
|
501
|
+
`definido — \`${model}\` pode não ser roteado para um endpoint com \`response_format\`, ` +
|
|
502
|
+
'e a crítica falha na primeira execução. Aponte `ai.models.critique` para um modelo com ' +
|
|
503
|
+
'saída estruturada.'
|
|
504
|
+
);
|
|
505
|
+
} else {
|
|
506
|
+
notes.push(
|
|
507
|
+
`Saída estruturada da crítica: ${provider.value === 'anthropic' ? 'tool call forçado' : 'response_format json_schema'} ` +
|
|
508
|
+
`· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
313
511
|
if (process.env[provider.secret]) {
|
|
314
512
|
notes.push(`${provider.secret} presente no ambiente local.`);
|
|
315
513
|
} else {
|
|
@@ -377,7 +575,9 @@ export function checkSpecKit(ctx) {
|
|
|
377
575
|
|
|
378
576
|
async function checkWorkflows(ctx) {
|
|
379
577
|
const name = 'Workflows do Actions';
|
|
380
|
-
|
|
578
|
+
// Ancorado na raiz do projeto, não no cwd: rodar o doctor de um subdiretório
|
|
579
|
+
// reportava ".github/workflows/ não encontrado" mesmo com tudo instalado.
|
|
580
|
+
const dir = path.join(ctx.root || ctx.cwd, '.github', 'workflows');
|
|
381
581
|
if (!existsSync(dir)) {
|
|
382
582
|
return {
|
|
383
583
|
name,
|
|
@@ -394,7 +594,33 @@ async function checkWorkflows(ctx) {
|
|
|
394
594
|
detail: `Workflows faltando em .github/workflows/: ${missing.join(', ')} — rode \`npx @spec-wave/cli@latest update\`.`,
|
|
395
595
|
};
|
|
396
596
|
}
|
|
397
|
-
|
|
597
|
+
|
|
598
|
+
// Versão da CLI fixada nos workflows: um `@latest` deixa uma release mudar o
|
|
599
|
+
// comportamento de pipelines já em andamento.
|
|
600
|
+
const unpinned = [];
|
|
601
|
+
const otherVersion = [];
|
|
602
|
+
for (const file of WORKFLOW_FILES) {
|
|
603
|
+
const content = readFileSync(path.join(dir, file), 'utf-8');
|
|
604
|
+
if (/@spec-wave\/cli@latest/.test(content)) unpinned.push(file);
|
|
605
|
+
else if (!content.includes(`@spec-wave/cli@${CLI_VERSION}`)) otherVersion.push(file);
|
|
606
|
+
}
|
|
607
|
+
const notes = [`Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.`];
|
|
608
|
+
let status = 'ok';
|
|
609
|
+
if (unpinned.length > 0) {
|
|
610
|
+
status = 'warn';
|
|
611
|
+
notes.push(
|
|
612
|
+
`Chamando \`@spec-wave/cli@latest\` (sem versão fixada): ${unpinned.join(', ')}. ` +
|
|
613
|
+
'Uma release da CLI muda o comportamento de pipelines em andamento — rode `update`.'
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (otherVersion.length > 0) {
|
|
617
|
+
status = 'warn';
|
|
618
|
+
notes.push(
|
|
619
|
+
`Fixados em outra versão que não a ${CLI_VERSION}: ${otherVersion.join(', ')} — rode \`update\` ` +
|
|
620
|
+
'para fazer o bump explícito.'
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
return { name, status, detail: notes.join('\n') };
|
|
398
624
|
}
|
|
399
625
|
|
|
400
626
|
// ── Comando ─────────────────────────────────────────────────────────────────
|
|
@@ -404,11 +630,14 @@ export async function doctor() {
|
|
|
404
630
|
|
|
405
631
|
// Contexto compartilhado entre os checks (token, config, resultados parciais).
|
|
406
632
|
const ctx = { cwd: process.cwd() };
|
|
407
|
-
|
|
633
|
+
// Procurado subindo na árvore: rodar o doctor de um subdiretório deve
|
|
634
|
+
// diagnosticar o repositório, não reportar "não inicializado".
|
|
635
|
+
ctx.configPath = findConfigPath(ctx.cwd);
|
|
636
|
+
ctx.root = ctx.configPath ? path.dirname(ctx.configPath) : null;
|
|
408
637
|
ctx.cfg = null;
|
|
409
638
|
ctx.cfgError = null;
|
|
410
639
|
try {
|
|
411
|
-
if (
|
|
640
|
+
if (ctx.configPath) {
|
|
412
641
|
ctx.cfg = JSON.parse(readFileSync(ctx.configPath, 'utf-8'));
|
|
413
642
|
}
|
|
414
643
|
} catch (err) {
|
|
@@ -421,6 +650,7 @@ export async function doctor() {
|
|
|
421
650
|
checkGhAccount,
|
|
422
651
|
checkConfig,
|
|
423
652
|
checkRepoAccess,
|
|
653
|
+
checkBoardHygiene,
|
|
424
654
|
checkAi,
|
|
425
655
|
checkSpecKit,
|
|
426
656
|
checkWorkflows,
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
3
4
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
|
|
7
|
+
} from '../api/github-rest.mjs';
|
|
5
8
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
6
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE,
|
|
11
|
+
LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
|
|
12
|
+
} from '../config.mjs';
|
|
7
13
|
import { generateDocument } from '../lib/claude.mjs';
|
|
8
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
|
|
16
|
+
} from '../lib/critique.mjs';
|
|
9
17
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
10
18
|
import { slugify } from '../lib/slugify.mjs';
|
|
19
|
+
import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
|
|
11
20
|
import { buildTechContext } from '../lib/tech-context.mjs';
|
|
12
21
|
|
|
13
22
|
// Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
|
|
@@ -41,9 +50,82 @@ Regras OBRIGATÓRIAS:
|
|
|
41
50
|
- Forneça detalhes acionáveis: caminhos exatos de endpoints, nomes de DTOs, constraints de banco.
|
|
42
51
|
- Responda APENAS com o conteúdo do plan.md, sem texto adicional.`;
|
|
43
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Roda a crítica do plan e aplica as labels de bloqueio.
|
|
55
|
+
*
|
|
56
|
+
* Extraída do fluxo principal por dois motivos:
|
|
57
|
+
* • o `commentOnIssue` do resultado estava DENTRO do mesmo try do runCritique,
|
|
58
|
+
* então uma falha ao comentar pulava o addLabel(critique-failed) e a crítica
|
|
59
|
+
* grave deixava de bloquear em silêncio;
|
|
60
|
+
* • a escalada de modelo e o teto de tentativas precisam do contador derivado
|
|
61
|
+
* dos comentários da issue.
|
|
62
|
+
*
|
|
63
|
+
* Nunca lança: o plan já foi commitado e o humano precisa dele para corrigir.
|
|
64
|
+
*/
|
|
65
|
+
async function critiquePlan({
|
|
66
|
+
token, owner, repo, issueNumber, spec, plan, techContextYaml, labels, config, usage,
|
|
67
|
+
}) {
|
|
68
|
+
if (labels.includes(LABEL_NEEDS_HUMAN)) {
|
|
69
|
+
console.log(`Crítica pulada: a issue tem a label ${LABEL_NEEDS_HUMAN} (revisão humana pendente).`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const maxAttempts = Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
74
|
+
? config.ai.maxCritiqueAttempts
|
|
75
|
+
: DEFAULT_MAX_CRITIQUE_ATTEMPTS;
|
|
76
|
+
const escalationModel = config?.ai?.escalationModel || null;
|
|
77
|
+
|
|
78
|
+
const comments = await listIssueComments(token, owner, repo, issueNumber).catch(err => {
|
|
79
|
+
console.warn(`Não foi possível listar comentários: ${err.message} — contador de tentativas em 1.`);
|
|
80
|
+
return [];
|
|
81
|
+
});
|
|
82
|
+
const { attempt, blocked } = resolveCritiqueAttempt({ comments, labels, kind: 'plan', maxAttempts });
|
|
83
|
+
|
|
84
|
+
if (blocked) {
|
|
85
|
+
console.log(`Teto de ${maxAttempts} tentativas de crítica atingido — exigindo revisão humana.`);
|
|
86
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
87
|
+
renderNeedsHumanComment({ kind: 'plan', attempt, maxAttempts, escalationModel })).catch(() => {});
|
|
88
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_NEEDS_HUMAN).catch(() => {});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const model = attempt > 1 ? (escalationModel || undefined) : undefined;
|
|
93
|
+
if (model) console.log(`Tentativa ${attempt}: escalando a crítica para ${model}.`);
|
|
94
|
+
|
|
95
|
+
let critique;
|
|
96
|
+
try {
|
|
97
|
+
critique = await runCritique({
|
|
98
|
+
kind: 'plan', spec, plan, techContextYaml, attempt, maxAttempts, model, labels, usage,
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.warn(`Crítica adversarial não concluiu: ${err.message}`);
|
|
102
|
+
await commentOnIssue(token, owner, repo, issueNumber,
|
|
103
|
+
`⚠️ **A crítica adversarial não concluiu** (erro abaixo) — o \`plan.md\` foi gerado, ` +
|
|
104
|
+
'mas **não foi auditado**. Revise-o com atenção extra.\n\n' +
|
|
105
|
+
`\`\`\`\n${err.message}\n\`\`\``
|
|
106
|
+
).catch(() => {});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// O comentário fica FORA do try da label: falhar ao comentar não pode impedir
|
|
111
|
+
// o bloqueio de uma crítica grave.
|
|
112
|
+
await commentOnIssue(token, owner, repo, issueNumber, critique.markdown)
|
|
113
|
+
.catch(err => console.warn(`Falha ao comentar a crítica: ${err.message}`));
|
|
114
|
+
|
|
115
|
+
if (critique.grave) {
|
|
116
|
+
await addLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED);
|
|
117
|
+
console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Crítica limpa remove o bloqueio anterior — é isso que zera o contador de
|
|
121
|
+
// tentativas na rodada seguinte (ver resolveCritiqueAttempt).
|
|
122
|
+
await removeLabel(token, owner, repo, issueNumber, LABEL_CRITIQUE_FAILED).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
44
125
|
export async function generatePlan({ issueNumber }) {
|
|
45
126
|
const token = await resolveToken();
|
|
46
127
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
128
|
+
const { config, root } = loadConfig();
|
|
47
129
|
|
|
48
130
|
if (!owner || !repo) {
|
|
49
131
|
throw new Error(
|
|
@@ -58,6 +140,8 @@ export async function generatePlan({ issueNumber }) {
|
|
|
58
140
|
|
|
59
141
|
// plan.md é artefato de Feature — não se aplica a Spike/RFC/Bug.
|
|
60
142
|
const type = detectIssueType(issue);
|
|
143
|
+
// Alimentam o override de modelo por label (spec-wave:model:<apelido>).
|
|
144
|
+
const issueLabels = labelNames(issue);
|
|
61
145
|
if (!allowsSpecPlan(type)) {
|
|
62
146
|
console.log(`Issue #${issueNumber} é ${type}: plan.md não é gerado para ${SPEC_PLAN_EXCLUDED_TYPES.join('/')}.`);
|
|
63
147
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:plan');
|
|
@@ -70,11 +154,15 @@ export async function generatePlan({ issueNumber }) {
|
|
|
70
154
|
}
|
|
71
155
|
|
|
72
156
|
const slug = slugify(issue.title);
|
|
73
|
-
|
|
74
|
-
|
|
157
|
+
// Caminho RELATIVO para links/commit; ABSOLUTO ancorado na raiz para o fs — o
|
|
158
|
+
// config é procurado subindo na árvore, e os documentos moram junto dele.
|
|
159
|
+
const featureRel = `docs/features/${slug}`;
|
|
160
|
+
const featureDir = resolveFromRoot(root, featureRel);
|
|
161
|
+
const filePath = path.join(featureDir, 'plan.md');
|
|
162
|
+
const fileRel = `${featureRel}/plan.md`;
|
|
75
163
|
|
|
76
164
|
// Read existing spec.md if available (spec é gerada antes do plano)
|
|
77
|
-
const specPath =
|
|
165
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
78
166
|
const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
|
|
79
167
|
|
|
80
168
|
// Tech context (RFC-002 §4): estático + dinâmico + override do corpo da issue.
|
|
@@ -100,6 +188,7 @@ export async function generatePlan({ issueNumber }) {
|
|
|
100
188
|
console.log(`Gerando plan.md para: ${issue.title}`);
|
|
101
189
|
const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
|
|
102
190
|
action: 'plan',
|
|
191
|
+
labels: issueLabels,
|
|
103
192
|
lint: { lang: TARGET_LANGUAGE },
|
|
104
193
|
withReport: true,
|
|
105
194
|
usage: usageEntries,
|
|
@@ -124,36 +213,22 @@ export async function generatePlan({ issueNumber }) {
|
|
|
124
213
|
await commentOnIssue(
|
|
125
214
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
126
215
|
`📋 **plan.md gerado automaticamente!**\n\n` +
|
|
127
|
-
`📄 Arquivo: [\`${
|
|
216
|
+
`📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
|
|
128
217
|
`Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
|
|
129
218
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
|
|
130
219
|
formatLintWarning(lintFindings)
|
|
131
220
|
);
|
|
132
221
|
|
|
133
222
|
// Crítica adversarial: audita o plan recém-comitado contra spec +
|
|
134
|
-
// tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10), critique.markdown);
|
|
144
|
-
if (critique.grave) {
|
|
145
|
-
await addLabel(token, owner, repo, parseInt(issueNumber, 10), LABEL_CRITIQUE_FAILED);
|
|
146
|
-
console.log(`Crítica adversarial apontou findings GRAVES — label ${LABEL_CRITIQUE_FAILED} aplicada.`);
|
|
147
|
-
}
|
|
148
|
-
} catch (err) {
|
|
149
|
-
console.warn(`Crítica adversarial indisponível: ${err.message}`);
|
|
150
|
-
await commentOnIssue(
|
|
151
|
-
token, owner, repo, parseInt(issueNumber, 10),
|
|
152
|
-
`⚠️ crítica adversarial indisponível (erro: ${err.message})`
|
|
153
|
-
).catch(() => {});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
console.log(`plan.md criado em: ${filePath}`);
|
|
223
|
+
// tech_context. NUNCA desfaz o plan — falha da crítica vira só um aviso, e o
|
|
224
|
+
// humano segue com a ferramenta que precisa para corrigir.
|
|
225
|
+
await critiquePlan({
|
|
226
|
+
token, owner, repo, issueNumber: parseInt(issueNumber, 10),
|
|
227
|
+
spec: specContent, plan: content, techContextYaml: tech.yaml,
|
|
228
|
+
labels: issueLabels, config, usage: usageEntries,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
console.log(`plan.md criado em: ${fileRel}`);
|
|
157
232
|
} catch (err) {
|
|
158
233
|
// Mesmo beco sem saída do generate-spec: o gatilho é `issues: [labeled]`,
|
|
159
234
|
// então com a label ainda aplicada re-adicioná-la não dispara nada. Remove
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { resolveToken } from '../api/auth.mjs';
|
|
4
5
|
import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
|
|
5
6
|
import { generateDocument } from '../lib/claude.mjs';
|
|
6
7
|
import { recordUsage } from '../lib/usage-report.mjs';
|
|
7
8
|
import { slugify } from '../lib/slugify.mjs';
|
|
9
|
+
import { loadConfig, resolveFromRoot } from '../lib/project-root.mjs';
|
|
8
10
|
import { detectIssueType } from '../lib/issue-type.mjs';
|
|
9
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE, labelNames,
|
|
13
|
+
} from '../config.mjs';
|
|
10
14
|
|
|
11
15
|
// Aviso anexado ao comentário quando o lint de idioma ainda reprova após o
|
|
12
16
|
// retry automático do generateDocument (excertos ao redor de cada vazamento).
|
|
@@ -44,6 +48,7 @@ Regras:
|
|
|
44
48
|
export async function generateSpec({ issueNumber }) {
|
|
45
49
|
const token = await resolveToken();
|
|
46
50
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
51
|
+
const { root } = loadConfig();
|
|
47
52
|
|
|
48
53
|
if (!owner || !repo) {
|
|
49
54
|
throw new Error(
|
|
@@ -59,6 +64,8 @@ export async function generateSpec({ issueNumber }) {
|
|
|
59
64
|
// spec.md/plan.md são artefatos funcionais de Feature — não se aplicam a
|
|
60
65
|
// Spike/RFC/Bug. Pula a geração, remove o trigger e avisa na issue.
|
|
61
66
|
const type = detectIssueType(issue);
|
|
67
|
+
// Alimentam o override de modelo por label (spec-wave:model:<apelido>).
|
|
68
|
+
const issueLabels = labelNames(issue);
|
|
62
69
|
if (!allowsSpecPlan(type)) {
|
|
63
70
|
console.log(`Issue #${issueNumber} é ${type}: spec.md não é gerada para ${SPEC_PLAN_EXCLUDED_TYPES.join('/')}.`);
|
|
64
71
|
await removeLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
|
|
@@ -71,8 +78,12 @@ export async function generateSpec({ issueNumber }) {
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
const slug = slugify(issue.title);
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
// Caminho RELATIVO para links/commit; ABSOLUTO ancorado na raiz para o fs — o
|
|
82
|
+
// config é procurado subindo na árvore, e os documentos moram junto dele.
|
|
83
|
+
const featureRel = `docs/features/${slug}`;
|
|
84
|
+
const featureDir = resolveFromRoot(root, featureRel);
|
|
85
|
+
const filePath = path.join(featureDir, 'spec.md');
|
|
86
|
+
const fileRel = `${featureRel}/spec.md`;
|
|
76
87
|
|
|
77
88
|
// Payload estruturado (RFC-002 §5.1): metadata + entrada de negócio.
|
|
78
89
|
const payload = {
|
|
@@ -94,6 +105,7 @@ export async function generateSpec({ issueNumber }) {
|
|
|
94
105
|
console.log(`Gerando spec.md para: ${issue.title}`);
|
|
95
106
|
const { content, lintFindings } = await generateDocument(SYSTEM_PROMPT, userContent, {
|
|
96
107
|
action: 'spec',
|
|
108
|
+
labels: issueLabels,
|
|
97
109
|
lint: { lang: TARGET_LANGUAGE },
|
|
98
110
|
withReport: true,
|
|
99
111
|
usage: usageEntries,
|
|
@@ -118,13 +130,13 @@ export async function generateSpec({ issueNumber }) {
|
|
|
118
130
|
await commentOnIssue(
|
|
119
131
|
token, owner, repo, parseInt(issueNumber, 10),
|
|
120
132
|
`📋 **spec.md gerado automaticamente!**\n\n` +
|
|
121
|
-
`📄 Arquivo: [\`${
|
|
133
|
+
`📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
|
|
122
134
|
`Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
|
|
123
135
|
`\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
|
|
124
136
|
formatLintWarning(lintFindings)
|
|
125
137
|
);
|
|
126
138
|
|
|
127
|
-
console.log(`spec.md criado em: ${
|
|
139
|
+
console.log(`spec.md criado em: ${fileRel}`);
|
|
128
140
|
} catch (err) {
|
|
129
141
|
// Sem isto a label de gatilho fica aplicada — e como o workflow dispara em
|
|
130
142
|
// `issues: [labeled]`, re-adicionar uma label já presente não emite evento:
|