@spec-wave/cli 0.20.0 → 0.23.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 +2 -2
- package/package.json +1 -1
- package/src/agent/anthropic-agent.mjs +3 -1
- package/src/agent/errors.mjs +59 -13
- package/src/agent/openrouter-agent.mjs +5 -1
- package/src/api/github-graphql.mjs +127 -7
- package/src/api/github-rest.mjs +9 -2
- package/src/commands/code-review.mjs +15 -4
- package/src/commands/decompose.mjs +172 -23
- package/src/commands/doctor.mjs +88 -8
- package/src/commands/move.mjs +58 -1
- package/src/commands/order.mjs +200 -7
- package/src/commands/qa.mjs +8 -2
- package/src/commands/repair-stage.mjs +6 -1
- package/src/commands/story.mjs +6 -1
- package/src/commands/task.mjs +6 -1
- package/src/commands/triage.mjs +4 -1
- package/src/commands/validate.mjs +39 -18
- package/src/config.mjs +47 -3
- package/src/lib/board.mjs +87 -3
- package/src/lib/bug-doc.mjs +71 -0
- package/src/lib/claude.mjs +23 -6
- package/src/lib/decomposition-doc.mjs +66 -14
- package/src/lib/dependencies.mjs +14 -4
- package/src/lib/implement-board.mjs +15 -11
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/decompose/SKILL.md +3 -3
- package/src/plugin/skills/order/SKILL.md +8 -4
- package/src/plugin/skills/ready/SKILL.md +1 -1
- package/src/templates/skill/SKILL.md +4 -4
- package/src/templates/workflows/code-review.yml +9 -2
- package/src/templates/workflows/critique.yml +19 -2
- package/src/templates/workflows/decompose.yml +19 -2
- package/src/templates/workflows/generate-bug.yml +19 -2
- package/src/templates/workflows/generate-plan.yml +19 -2
- package/src/templates/workflows/generate-spec.yml +19 -2
- package/src/templates/workflows/qa.yml +5 -1
- package/src/templates/workflows/validate.yml +19 -2
|
@@ -49,9 +49,16 @@ import {
|
|
|
49
49
|
|
|
50
50
|
// Adiciona a issue ao board na Etapa ✅ Ready / Status Todo. Best-effort; a
|
|
51
51
|
// Etapa nunca retrocede (advanceToStage não toca itens já adiante).
|
|
52
|
-
|
|
52
|
+
//
|
|
53
|
+
// `itemType` é o tipo do que ACABOU de ser criado ('Story'/'Task'/'Feature') —
|
|
54
|
+
// aqui não há dúvida sobre ele, e sem essa passagem o Work Item Type das issues
|
|
55
|
+
// nascidas do apply ficava vazio para sempre (ver ensureWorkItemType).
|
|
56
|
+
async function moveToReady(token, project, fields, nodeId, itemType) {
|
|
53
57
|
if (!project?.id) return;
|
|
54
|
-
await advanceToStage(
|
|
58
|
+
await advanceToStage(
|
|
59
|
+
token, project, fields.etapaField, fields.statusField, nodeId, STAGE_READY, PROGRESS_TODO,
|
|
60
|
+
{ typeField: fields.typeField, itemType },
|
|
61
|
+
);
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/**
|
|
@@ -196,6 +203,25 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
|
|
|
196
203
|
return { skip: false, reason: '' };
|
|
197
204
|
}
|
|
198
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Milestone que os filhos herdam do pai (função PURA — testável).
|
|
208
|
+
*
|
|
209
|
+
* Uma Story/Task nasce para entregar a Feature que a gerou: separá-las da release
|
|
210
|
+
* do pai faria a mesma entrega aparecer em dois grupos — e o milestone é o que o
|
|
211
|
+
* fluxo usa como release (é o milestone fechado que sinaliza o deploy). Mesma
|
|
212
|
+
* regra que a reprovação de QA já aplica ao registrar um bug filho.
|
|
213
|
+
*
|
|
214
|
+
* Devolve o NÚMERO (o que `createIssue` aceita) ou `undefined` quando o pai não
|
|
215
|
+
* tem milestone — aí não há release a herdar, e o filho nasce sem, como antes.
|
|
216
|
+
*
|
|
217
|
+
* @param {{ milestone?: { number?: number, title?: string } | null }} [issue]
|
|
218
|
+
* @returns {number|undefined}
|
|
219
|
+
*/
|
|
220
|
+
export function resolveInheritedMilestone(issue) {
|
|
221
|
+
const number = issue?.milestone?.number;
|
|
222
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
199
225
|
// Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
|
|
200
226
|
// spec/plan; RFC ganha o seu, já que não passa por spec/plan.
|
|
201
227
|
function resolveDocDir(root, issue, type) {
|
|
@@ -457,8 +483,8 @@ async function applyDecomposition(ctx) {
|
|
|
457
483
|
|
|
458
484
|
// Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
|
|
459
485
|
// `strict`: no apply, board inalcançável ABORTA antes da primeira criação.
|
|
460
|
-
const { project, etapaField, statusField } = await resolveBoard(ctx, { strict: true });
|
|
461
|
-
const applyCtx = { ...ctx, project, etapaField, statusField };
|
|
486
|
+
const { project, etapaField, statusField, typeField } = await resolveBoard(ctx, { strict: true });
|
|
487
|
+
const applyCtx = { ...ctx, project, etapaField, statusField, typeField };
|
|
462
488
|
|
|
463
489
|
// As falhas de board voltam em vez de subir na hora: a exceção precisa esperar
|
|
464
490
|
// a contabilidade de labels abaixo. Lançar aqui deixaria o gatilho
|
|
@@ -495,7 +521,16 @@ async function applyDecomposition(ctx) {
|
|
|
495
521
|
|
|
496
522
|
await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
|
|
497
523
|
.catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
|
|
498
|
-
|
|
524
|
+
// Best-effort como as duas vizinhas: as issues já existem, e derrubar o run
|
|
525
|
+
// porque a REMOÇÃO de uma label falhou (404 de label já removida por um run
|
|
526
|
+
// concorrente, 5xx da API) transformava um apply concluído em vermelho — e o
|
|
527
|
+
// vermelho manda o humano reaplicar o gatilho, que é o caminho para duplicar
|
|
528
|
+
// dezenas de issues. A label que sobra é visível na issue e removível à mão.
|
|
529
|
+
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY)
|
|
530
|
+
.catch(err => console.warn(
|
|
531
|
+
`Decomposição aplicada, mas a label ${LABEL_DECOMPOSE_APPLY} não pôde ser removida ` +
|
|
532
|
+
`(${err.message}). Remova-a à mão: gh issue edit ${number} --remove-label "${LABEL_DECOMPOSE_APPLY}".`
|
|
533
|
+
));
|
|
499
534
|
await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
|
|
500
535
|
|
|
501
536
|
// Por último, com as labels já consistentes: escrita de board incompleta
|
|
@@ -519,7 +554,10 @@ export function renderBoardFailures(failures) {
|
|
|
519
554
|
`${linhas}\n\n` +
|
|
520
555
|
`Motivo: \`${motivo}\`\n\n` +
|
|
521
556
|
'Verifique o `GH_PROJECT_TOKEN` (scope `project` e acesso ao Project da organização) e ' +
|
|
522
|
-
'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue
|
|
557
|
+
'reposicione os itens com `npx @spec-wave/cli@latest repair-stage <issue>`.\n\n' +
|
|
558
|
+
'O run termina em VERMELHO de propósito — a decomposição em si CONCLUIU. ' +
|
|
559
|
+
`Não reaplique \`${LABEL_DECOMPOSE_APPLY}\`: as issues acima já existem e o apply ` +
|
|
560
|
+
'recomeça do zero; o que falta é só a Etapa, e quem repara isso é o `repair-stage`.'
|
|
523
561
|
);
|
|
524
562
|
}
|
|
525
563
|
|
|
@@ -541,9 +579,59 @@ function failIfBoardIncomplete(failures) {
|
|
|
541
579
|
throw err;
|
|
542
580
|
}
|
|
543
581
|
|
|
582
|
+
// Linha do comentário registrando o milestone herdado ('' quando não há).
|
|
583
|
+
function milestoneLine(issue, milestone) {
|
|
584
|
+
if (!milestone) return '';
|
|
585
|
+
return `🏁 Milestone herdado do pai: **${issue.milestone?.title ?? `#${milestone}`}**.\n\n`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Resolve as dependências externas do rascunho ANTES de criar qualquer issue.
|
|
590
|
+
*
|
|
591
|
+
* `#N` só entra no documento se a issue JÁ existe (fase 1 da proposta), e é
|
|
592
|
+
* aqui que isso é cobrado. Falhar cedo é a única opção honesta: descobrir no
|
|
593
|
+
* meio do laço que a #412 não existe deixaria metade das Stories criadas e a
|
|
594
|
+
* outra metade não — o estado que o apply inteiro foi desenhado para evitar.
|
|
595
|
+
*
|
|
596
|
+
* `addBlockedBy` exige o DATABASE id da bloqueadora (não aceita number nem node
|
|
597
|
+
* id), e é por isso que resolver custa uma leitura por issue distinta.
|
|
598
|
+
*
|
|
599
|
+
* @returns {Promise<Map<number, {number:number, id:number, title:string}>>}
|
|
600
|
+
*/
|
|
601
|
+
async function resolveExternalDeps({ token, owner, repo }, doc) {
|
|
602
|
+
const numeros = [...new Set(
|
|
603
|
+
(doc.stories || []).flatMap(s => s.dependsOnIssues || [])
|
|
604
|
+
)].sort((a, b) => a - b);
|
|
605
|
+
const resolvidas = new Map();
|
|
606
|
+
const faltando = [];
|
|
607
|
+
for (const n of numeros) {
|
|
608
|
+
try {
|
|
609
|
+
const issue = await getIssue(token, owner, repo, n);
|
|
610
|
+
resolvidas.set(n, { number: issue.number, id: issue.id, title: issue.title });
|
|
611
|
+
} catch (err) {
|
|
612
|
+
faltando.push(`#${n} (${err.status === 404 ? 'não existe' : err.message})`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (faltando.length > 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`o rascunho depende de issue(s) que não consegui ler: ${faltando.join(', ')}. ` +
|
|
618
|
+
'Em "**Depende de:**", `#N` precisa ser uma issue que JÁ existe — corrija o ' +
|
|
619
|
+
'decomposition.md (ou aplique antes a Feature que cria essa Story).'
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return resolvidas;
|
|
623
|
+
}
|
|
624
|
+
|
|
544
625
|
async function createStoriesFromDoc(ctx, doc) {
|
|
545
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project,
|
|
626
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
627
|
+
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
546
628
|
const featureNodeId = issue.node_id;
|
|
629
|
+
// Herdado uma vez e reusado por todas as Stories e Tasks deste apply.
|
|
630
|
+
const milestone = resolveInheritedMilestone(issue);
|
|
631
|
+
if (milestone) console.log(`Milestone herdado da ${ctx.type}: ${issue.milestone?.title ?? `#${milestone}`}.`);
|
|
632
|
+
// Antes da primeira criação: rascunho que aponta para issue inexistente é
|
|
633
|
+
// erro de escrita, e o lugar de reprovar é aqui.
|
|
634
|
+
const externas = await resolveExternalDeps(ctx, doc);
|
|
547
635
|
const created = [];
|
|
548
636
|
const createdStories = []; // issues criadas, na ordem dos índices das stories
|
|
549
637
|
const generatedTexts = []; // títulos+corpos para o lint de idioma final
|
|
@@ -560,12 +648,18 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
560
648
|
.filter(Boolean)
|
|
561
649
|
.join('\n\n') || '_(sem descrição)_';
|
|
562
650
|
|
|
563
|
-
//
|
|
564
|
-
|
|
651
|
+
// Irmãs (índices já validados pelo parser, sempre para trás) + externas (já
|
|
652
|
+
// resolvidas acima). As duas viram a MESMA linha no corpo e a mesma relação
|
|
653
|
+
// blocked_by: para quem lê a issue, a fronteira da Feature não existe.
|
|
654
|
+
const depIssues = [
|
|
655
|
+
...story.dependsOn.map(idx => createdStories[idx]),
|
|
656
|
+
...(story.dependsOnIssues || []).map(n => externas.get(n)),
|
|
657
|
+
].filter(Boolean);
|
|
565
658
|
const depLine = formatDependencyLine(depIssues.map(d => d.number));
|
|
566
659
|
if (depLine) storyBody += `\n\n${depLine}`;
|
|
567
660
|
|
|
568
|
-
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
|
|
661
|
+
const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]'], { milestone });
|
|
662
|
+
ctx.createdItems.push(createdStory.number);
|
|
569
663
|
// Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
|
|
570
664
|
// arquivo no fim do apply.
|
|
571
665
|
story.issue = createdStory.number;
|
|
@@ -588,7 +682,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
588
682
|
console.warn(` Story #${createdStory.number} criada, mas falhou ao vincular à Feature: ${err.message}`);
|
|
589
683
|
}
|
|
590
684
|
try {
|
|
591
|
-
await moveToReady(projectToken, project,
|
|
685
|
+
await moveToReady(projectToken, project, fields, createdStory.nodeId, 'Story');
|
|
592
686
|
} catch (err) {
|
|
593
687
|
console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
|
|
594
688
|
boardFailures.push({ number: createdStory.number, kind: 'Story', reason: err.message });
|
|
@@ -598,7 +692,8 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
598
692
|
console.log(` Criando task: ${task.title}`);
|
|
599
693
|
const taskTitle = `[TASK] ${task.title}`;
|
|
600
694
|
const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
|
|
601
|
-
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
695
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
|
|
696
|
+
ctx.createdItems.push(createdTask.number);
|
|
602
697
|
task.issue = createdTask.number;
|
|
603
698
|
generatedTexts.push(taskTitle, taskBody);
|
|
604
699
|
try {
|
|
@@ -607,7 +702,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
607
702
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular à Story: ${err.message}`);
|
|
608
703
|
}
|
|
609
704
|
try {
|
|
610
|
-
await moveToReady(projectToken, project,
|
|
705
|
+
await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
|
|
611
706
|
} catch (err) {
|
|
612
707
|
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
613
708
|
boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
|
|
@@ -616,8 +711,8 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
616
711
|
}
|
|
617
712
|
|
|
618
713
|
try {
|
|
619
|
-
await moveToReady(projectToken, project,
|
|
620
|
-
if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
|
|
714
|
+
await moveToReady(projectToken, project, fields, featureNodeId, ctx.type);
|
|
715
|
+
if (project?.id && fields.etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
|
|
621
716
|
} catch (err) {
|
|
622
717
|
console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
|
|
623
718
|
boardFailures.push({ number: parseInt(issueNumber, 10), kind: 'Feature', reason: err.message });
|
|
@@ -630,6 +725,7 @@ async function createStoriesFromDoc(ctx, doc) {
|
|
|
630
725
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
631
726
|
`🔀 **Decomposição aplicada!**\n\n` +
|
|
632
727
|
`A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
|
|
728
|
+
milestoneLine(issue, milestone) +
|
|
633
729
|
posicionamento +
|
|
634
730
|
formatItemsLintWarning(generatedTexts)
|
|
635
731
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
@@ -685,8 +781,11 @@ async function commentStoryOrder({ token, owner, repo, issueNumber, doc, created
|
|
|
685
781
|
}
|
|
686
782
|
|
|
687
783
|
async function createTasksFromDoc(ctx, doc) {
|
|
688
|
-
const { token, projectToken, owner, repo, issue, issueNumber, project,
|
|
784
|
+
const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
|
|
785
|
+
const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
|
|
689
786
|
const parentNodeId = issue.node_id;
|
|
787
|
+
const milestone = resolveInheritedMilestone(issue);
|
|
788
|
+
if (milestone) console.log(`Milestone herdado do RFC: ${issue.milestone?.title ?? `#${milestone}`}.`);
|
|
690
789
|
const created = [];
|
|
691
790
|
const generatedTexts = [];
|
|
692
791
|
const boardFailures = [];
|
|
@@ -695,7 +794,8 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
695
794
|
console.log(`Criando task: ${task.title}`);
|
|
696
795
|
const taskTitle = `[TASK] ${task.title}`;
|
|
697
796
|
const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
|
|
698
|
-
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
|
|
797
|
+
const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
|
|
798
|
+
ctx.createdItems.push(createdTask.number);
|
|
699
799
|
task.issue = createdTask.number;
|
|
700
800
|
created.push({ title: taskTitle, url: createdTask.url });
|
|
701
801
|
generatedTexts.push(taskTitle, taskBody);
|
|
@@ -706,7 +806,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
706
806
|
console.warn(` Task #${createdTask.number} criada, mas falhou ao vincular ao RFC: ${err.message}`);
|
|
707
807
|
}
|
|
708
808
|
try {
|
|
709
|
-
await moveToReady(projectToken, project,
|
|
809
|
+
await moveToReady(projectToken, project, fields, createdTask.nodeId, 'Task');
|
|
710
810
|
} catch (err) {
|
|
711
811
|
console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
|
|
712
812
|
boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
|
|
@@ -720,6 +820,7 @@ async function createTasksFromDoc(ctx, doc) {
|
|
|
720
820
|
await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
|
|
721
821
|
`🔀 **Decomposição do RFC aplicada!**\n\n` +
|
|
722
822
|
`A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
|
|
823
|
+
milestoneLine(issue, milestone) +
|
|
723
824
|
posicionamento +
|
|
724
825
|
formatItemsLintWarning(generatedTexts)
|
|
725
826
|
).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
|
|
@@ -757,6 +858,7 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
|
|
|
757
858
|
|
|
758
859
|
let etapaField = null;
|
|
759
860
|
let statusField = null;
|
|
861
|
+
let typeField = null;
|
|
760
862
|
if (project?.id) {
|
|
761
863
|
try {
|
|
762
864
|
etapaField = await resolveField(projectToken, project, 'Etapa');
|
|
@@ -770,14 +872,58 @@ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
|
|
|
770
872
|
if (strict) throw new BoardUnreachableError(`campo Status não resolvido (${err.message})`);
|
|
771
873
|
console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
|
|
772
874
|
}
|
|
875
|
+
// NUNCA strict: o tipo é organização, não visibilidade. Um board sem o campo
|
|
876
|
+
// "Work Item Type" continua sendo um board válido — sem Etapa, não.
|
|
877
|
+
try {
|
|
878
|
+
typeField = await resolveField(projectToken, project, 'Work Item Type');
|
|
879
|
+
} catch (err) {
|
|
880
|
+
console.warn(`Não foi possível resolver campo Work Item Type do board: ${err.message}`);
|
|
881
|
+
}
|
|
773
882
|
}
|
|
774
|
-
return { project, etapaField, statusField };
|
|
883
|
+
return { project, etapaField, statusField, typeField };
|
|
775
884
|
}
|
|
776
885
|
|
|
777
886
|
// ---------------------------------------------------------------------------
|
|
778
887
|
// Entrada
|
|
779
888
|
// ---------------------------------------------------------------------------
|
|
780
889
|
|
|
890
|
+
/**
|
|
891
|
+
* Comentário de falha do apply (função PURA).
|
|
892
|
+
*
|
|
893
|
+
* O conselho depende de UMA coisa: já existe issue criada por este run?
|
|
894
|
+
*
|
|
895
|
+
* • Não → o retry é seguro e é o que se quer: reaplique o gatilho.
|
|
896
|
+
* • Sim → o apply recomeça do zero, e o único freio é o guard de idempotência
|
|
897
|
+
* (label `spec-wave:decomposed` ou sub-issues do tipo-alvo). Se a falha foi
|
|
898
|
+
* ANTES de a label ser aplicada e o vínculo de sub-issue também não pegou,
|
|
899
|
+
* reaplicar duplica tudo o que a lista abaixo mostra. Mandar "adicione a
|
|
900
|
+
* label de novo" nesse estado — que é o que este comentário fazia sempre —
|
|
901
|
+
* é o conselho errado exatamente quando ele custa mais caro.
|
|
902
|
+
*
|
|
903
|
+
* @param {object} params
|
|
904
|
+
* @param {string} params.trigger label que disparou o run
|
|
905
|
+
* @param {string} params.message mensagem do erro
|
|
906
|
+
* @param {number[]} [params.createdItems] issues já criadas por este run
|
|
907
|
+
* @param {'apply'|'draft'} [params.mode]
|
|
908
|
+
*/
|
|
909
|
+
export function renderApplyFailureComment({ trigger, message, createdItems = [], mode = 'apply' }) {
|
|
910
|
+
const cabecalho =
|
|
911
|
+
`❌ **Falha no decompose (${mode === 'apply' ? 'aplicação' : 'rascunho'}).**\n\n` +
|
|
912
|
+
`\`\`\`\n${message}\n\`\`\`\n\n`;
|
|
913
|
+
if (createdItems.length === 0) {
|
|
914
|
+
return cabecalho +
|
|
915
|
+
`A label \`${trigger}\` foi removida para destravar o gatilho — ` +
|
|
916
|
+
'adicione-a de novo para tentar outra vez.';
|
|
917
|
+
}
|
|
918
|
+
const lista = createdItems.map(n => `#${n}`).join(', ');
|
|
919
|
+
return cabecalho +
|
|
920
|
+
`⚠️ **${createdItems.length} issue(s) JÁ foram criadas por este run:** ${lista}.\n\n` +
|
|
921
|
+
`A label \`${trigger}\` foi removida para destravar o gatilho, mas **não a reaplique sem ` +
|
|
922
|
+
'conferir**: o apply recomeça do zero e, se o guard de idempotência não tiver pegado ' +
|
|
923
|
+
`(label \`${LABEL_DECOMPOSED}\` ou sub-issues do tipo-alvo), essas issues são recriadas. ` +
|
|
924
|
+
'Complete o que falta à mão, ou apague as issues acima antes de tentar de novo.';
|
|
925
|
+
}
|
|
926
|
+
|
|
781
927
|
export async function decompose({ issueNumber, apply = false }) {
|
|
782
928
|
const token = await resolveToken();
|
|
783
929
|
// PROJECT_TOKEN deve ter scope "project" para atualizar GitHub Projects v2.
|
|
@@ -858,6 +1004,10 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
858
1004
|
const ctx = {
|
|
859
1005
|
token, projectToken, owner, repo, issue, issueNumber, type, labels, comments,
|
|
860
1006
|
root, runMode, docDir, docPath, docRel: `${docRel}/${DECOMPOSITION_FILE}`,
|
|
1007
|
+
// Números das issues já criadas por este run. Compartilhado por referência
|
|
1008
|
+
// com o applyCtx: o catch externo precisa saber se houve criação para não
|
|
1009
|
+
// aconselhar um retry que duplicaria itens.
|
|
1010
|
+
createdItems: [],
|
|
861
1011
|
escalationModel: config?.ai?.escalationModel || null,
|
|
862
1012
|
maxCritiqueAttempts:
|
|
863
1013
|
Number.isInteger(config?.ai?.maxCritiqueAttempts) && config.ai.maxCritiqueAttempts > 0
|
|
@@ -874,10 +1024,9 @@ export async function decompose({ issueNumber, apply = false }) {
|
|
|
874
1024
|
if (!err.blocked) {
|
|
875
1025
|
await removeLabel(token, owner, repo, number, trigger).catch(() => {});
|
|
876
1026
|
await commentOnIssue(token, owner, repo, number,
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
'adicione-a de novo para tentar outra vez.'
|
|
1027
|
+
renderApplyFailureComment({
|
|
1028
|
+
trigger, message: err.message, createdItems: ctx.createdItems, mode,
|
|
1029
|
+
})
|
|
881
1030
|
).catch(() => {});
|
|
882
1031
|
}
|
|
883
1032
|
throw err;
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -16,7 +16,8 @@ import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
|
|
|
16
16
|
import {
|
|
17
17
|
CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
|
|
18
18
|
RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
|
|
19
|
-
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS,
|
|
19
|
+
DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
|
|
20
|
+
modelLabels,
|
|
20
21
|
} from '../config.mjs';
|
|
21
22
|
import { findConfigPath } from '../lib/project-root.mjs';
|
|
22
23
|
import {
|
|
@@ -284,7 +285,10 @@ async function checkConfig(ctx) {
|
|
|
284
285
|
* @param {object} params
|
|
285
286
|
* @param {string[]} [params.boardStages] opções reais de Etapa no project
|
|
286
287
|
* @param {string[]} [params.repoLabels] nomes das labels do repo
|
|
287
|
-
* @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[],
|
|
288
|
+
* @returns {{ unknownStages: string[], missingStages: string[], orphanLabels: string[],
|
|
289
|
+
* missingLabels: string[], missingModelLabels: string[] }}
|
|
290
|
+
* `missingLabels` traz só as do fluxo; as de modelo saem separadas
|
|
291
|
+
* porque a orientação é outra (ver o uso em checkBoardHygiene).
|
|
288
292
|
*/
|
|
289
293
|
/**
|
|
290
294
|
* Os ids de opção do .spec-wave.json batem com os do Project? (função PURA)
|
|
@@ -314,6 +318,35 @@ export function inspectStageIds({ configOptions = null, boardOptions = null } =
|
|
|
314
318
|
return { staleIds, checked: true };
|
|
315
319
|
}
|
|
316
320
|
|
|
321
|
+
/**
|
|
322
|
+
* O que falta em `ai.modelAliases` para chegar ao conjunto recomendado (função PURA).
|
|
323
|
+
*
|
|
324
|
+
* Sem apelido nenhum, a label `spec-wave:model:<apelido>` não resolve nada: o
|
|
325
|
+
* override por issue — o jeito de reprocessar UMA Feature difícil num modelo
|
|
326
|
+
* mais forte sem mexer na config do repo inteiro — fica inerte, e o doctor só
|
|
327
|
+
* dizia que estava inerte, sem dizer o que colar para destravá-lo.
|
|
328
|
+
*
|
|
329
|
+
* Sugere o MERGE (configurado + faltante), nunca a substituição: apelido já
|
|
330
|
+
* configurado pode estar em uso numa issue aberta, e mandar sobrescrever o
|
|
331
|
+
* bloco quebraria essa issue em silêncio.
|
|
332
|
+
*
|
|
333
|
+
* @param {object} params
|
|
334
|
+
* @param {string} [params.provider] valor de `ai.provider`
|
|
335
|
+
* @param {object|null} [params.aliases] bloco `ai.modelAliases` atual
|
|
336
|
+
* @returns {{ missing: Array<{alias: string, model: string}>, snippet: string }}
|
|
337
|
+
*/
|
|
338
|
+
export function suggestModelAliases({ provider = undefined, aliases = null } = {}) {
|
|
339
|
+
const recommended = recommendedModelAliases(provider);
|
|
340
|
+
const have = new Set(Object.keys(aliases || {}));
|
|
341
|
+
const missing = Object.entries(recommended)
|
|
342
|
+
.filter(([alias]) => !have.has(alias))
|
|
343
|
+
.map(([alias, model]) => ({ alias, model }));
|
|
344
|
+
if (missing.length === 0) return { missing, snippet: '' };
|
|
345
|
+
const merged = { ...(aliases || {}) };
|
|
346
|
+
for (const { alias, model } of missing) merged[alias] = model;
|
|
347
|
+
return { missing, snippet: `"modelAliases": ${JSON.stringify(merged, null, 2)}` };
|
|
348
|
+
}
|
|
349
|
+
|
|
317
350
|
export function inspectBoardHygiene({ boardStages = null, repoLabels = null, modelAliases = null } = {}) {
|
|
318
351
|
const canonical = STATUS_OPTIONS.map(s => s.name);
|
|
319
352
|
const known = new Set(canonical);
|
|
@@ -342,10 +375,18 @@ export function inspectBoardHygiene({ boardStages = null, repoLabels = null, mod
|
|
|
342
375
|
? repoLabels.filter(n => n.startsWith('spec-wave:') && !knownLabels.has(n)
|
|
343
376
|
&& (modelAliases !== null || !n.startsWith(MODEL_LABEL_PREFIX)))
|
|
344
377
|
: [];
|
|
345
|
-
|
|
346
|
-
|
|
378
|
+
// Ausentes saem em duas listas: uma label do fluxo que falta é um repo
|
|
379
|
+
// desatualizado; uma `spec-wave:model:<apelido>` que falta é um apelido
|
|
380
|
+
// configurado que NINGUÉM consegue usar — a label não existe para ser aplicada
|
|
381
|
+
// na issue, então o override de modelo por execução está morto na origem.
|
|
382
|
+
const allMissing = repoLabels
|
|
383
|
+
? wantedLabels.filter(l => !repoLabels.includes(l.name)).map(l => l.name)
|
|
347
384
|
: [];
|
|
348
|
-
|
|
385
|
+
const missingModelLabels = allMissing.filter(n => n.startsWith(MODEL_LABEL_PREFIX));
|
|
386
|
+
const missingLabels = allMissing.filter(n => !n.startsWith(MODEL_LABEL_PREFIX));
|
|
387
|
+
return {
|
|
388
|
+
unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels,
|
|
389
|
+
};
|
|
349
390
|
}
|
|
350
391
|
|
|
351
392
|
/**
|
|
@@ -407,7 +448,7 @@ async function checkBoardHygiene(ctx) {
|
|
|
407
448
|
}
|
|
408
449
|
}
|
|
409
450
|
|
|
410
|
-
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels } =
|
|
451
|
+
const { unknownStages, retiredStages, missingStages, orphanLabels, missingLabels, missingModelLabels } =
|
|
411
452
|
inspectBoardHygiene({ boardStages, repoLabels, modelAliases: cfg?.ai?.modelAliases ?? null });
|
|
412
453
|
|
|
413
454
|
const notes = [];
|
|
@@ -481,8 +522,32 @@ async function checkBoardHygiene(ctx) {
|
|
|
481
522
|
status = 'warn';
|
|
482
523
|
notes.push(`Labels do fluxo ausentes no repo: ${missingLabels.join(', ')} — rode \`update\`.`);
|
|
483
524
|
}
|
|
484
|
-
|
|
485
|
-
|
|
525
|
+
// Apelido configurado sem label é uma promessa que o repo não cumpre: a skill
|
|
526
|
+
// manda aplicar `spec-wave:model:<apelido>` na issue e o `gh issue edit` falha
|
|
527
|
+
// com "label not found" — ou, pior, cria a label sem cor nem descrição.
|
|
528
|
+
if (missingModelLabels.length > 0) {
|
|
529
|
+
status = 'warn';
|
|
530
|
+
const specs = modelLabels(cfg?.ai?.modelAliases);
|
|
531
|
+
const exemplo = specs.find(l => l.name === missingModelLabels[0]);
|
|
532
|
+
notes.push(
|
|
533
|
+
`Labels de modelo ausentes no repo: ${missingModelLabels.join(', ')} — o apelido está em ` +
|
|
534
|
+
'`ai.modelAliases`, mas a label não existe, então não há como aplicá-la numa issue e o ' +
|
|
535
|
+
'override de modelo por execução fica indisponível. Rode ' +
|
|
536
|
+
'`npx @spec-wave/cli@latest update` para criar todas' +
|
|
537
|
+
(exemplo
|
|
538
|
+
? `, ou uma a uma: \`gh label create "${exemplo.name}" --color ${exemplo.color} ` +
|
|
539
|
+
`--description "${exemplo.description}"\`.`
|
|
540
|
+
: '.')
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (orphanLabels.length === 0 && missingLabels.length === 0 && missingModelLabels.length === 0) {
|
|
544
|
+
// Conta as de modelo à parte — dizer só "N do fluxo" omitia que as labels
|
|
545
|
+
// de apelido também foram conferidas.
|
|
546
|
+
const modelCount = modelLabels(cfg?.ai?.modelAliases).length;
|
|
547
|
+
notes.push(
|
|
548
|
+
`Labels: ${ALL_LABELS.length} do fluxo` +
|
|
549
|
+
`${modelCount > 0 ? ` + ${modelCount} de modelo` : ''} presentes, nenhuma descontinuada.`
|
|
550
|
+
);
|
|
486
551
|
}
|
|
487
552
|
} else {
|
|
488
553
|
notes.push('Labels do repo não verificáveis agora (sem token ou sem acesso).');
|
|
@@ -595,11 +660,19 @@ async function checkAi(ctx) {
|
|
|
595
660
|
const aliases = fileAi.modelAliases && Object.keys(fileAi.modelAliases).length > 0
|
|
596
661
|
? fileAi.modelAliases
|
|
597
662
|
: null;
|
|
663
|
+
const suggestion = suggestModelAliases({ provider: provider.value, aliases });
|
|
664
|
+
// Onde colar e o que rodar depois: o bloco sozinho não cria as labels, e sem
|
|
665
|
+
// a label criada no repo o override continua sem existir para quem abre a issue.
|
|
666
|
+
const howTo = (
|
|
667
|
+
`Cole no bloco \`ai\` do ${CONFIG_FILE}, commite e rode ` +
|
|
668
|
+
'`npx @spec-wave/cli@latest update` para criar as labels correspondentes:'
|
|
669
|
+
);
|
|
598
670
|
if (!aliases) {
|
|
599
671
|
notes.push(
|
|
600
672
|
`Apelidos de modelo: nenhum (\`ai.modelAliases\` ausente) — labels ` +
|
|
601
673
|
`\`${MODEL_LABEL_PREFIX}<apelido>\` serão ignoradas.`
|
|
602
674
|
);
|
|
675
|
+
notes.push(`Sugestão de apelidos para o provider ${provider.value}. ${howTo}\n${suggestion.snippet}`);
|
|
603
676
|
} else {
|
|
604
677
|
notes.push(
|
|
605
678
|
`Apelidos de modelo (\`${MODEL_LABEL_PREFIX}<apelido>\`): ` +
|
|
@@ -616,6 +689,13 @@ async function checkAi(ctx) {
|
|
|
616
689
|
`${wrongShape.map(([a, m]) => `${a}=${m}`).join(', ')}.`
|
|
617
690
|
);
|
|
618
691
|
}
|
|
692
|
+
if (suggestion.missing.length > 0) {
|
|
693
|
+
notes.push(
|
|
694
|
+
`Apelidos recomendados ainda não configurados: ` +
|
|
695
|
+
`${suggestion.missing.map(({ alias, model }) => `${alias}=${model}`).join(', ')}. ` +
|
|
696
|
+
`${howTo}\n${suggestion.snippet}`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
619
699
|
}
|
|
620
700
|
|
|
621
701
|
// Saída estruturada da crítica: sem structured output confiável, a validação
|
package/src/commands/move.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { loadConfig } from '../lib/project-root.mjs';
|
|
|
21
21
|
import {
|
|
22
22
|
CONFIG_FILE, PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE,
|
|
23
23
|
isManualStageType, MANUAL_STAGE_TYPES, isStageInTrack, STAGE_TRACKS,
|
|
24
|
+
STAGE_READY, LABEL_PLAN_APPROVED, LABEL_SPEC, LABEL_PLAN, LABEL_CRITIQUE_FAILED,
|
|
25
|
+
LABEL_NEEDS_HUMAN, allowsSpecPlan, labelNames,
|
|
24
26
|
} from '../config.mjs';
|
|
25
27
|
|
|
26
28
|
const PROGRESS_VALUES = [PROGRESS_TODO, PROGRESS_IN_PROGRESS, PROGRESS_DONE];
|
|
@@ -49,6 +51,51 @@ export function resolveProgressName(input) {
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* O que o board sabe e quem move à mão pode não saber (função PURA).
|
|
56
|
+
*
|
|
57
|
+
* `advanceToStage` já protege a ORDEM das etapas ("a Etapa nunca retrocede"),
|
|
58
|
+
* mas nada olhava os PORTÕES: mover uma Feature para ✅ Ready sem
|
|
59
|
+
* `spec-wave:plan-approved` põe na fila do time um item cuja validação não
|
|
60
|
+
* passou — e com `spec-wave:spec` ainda na issue, que é o rastro de uma reprova
|
|
61
|
+
* recente. Foi o que aconteceu com a #43.
|
|
62
|
+
*
|
|
63
|
+
* AVISA, não bloqueia: a decisão pode ser deliberada (documentos corrigidos à
|
|
64
|
+
* mão, validação que não se quer rodar de novo), e o comando existe justamente
|
|
65
|
+
* para os casos que o fluxo não cobre. O que não pode é ser silencioso.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params
|
|
68
|
+
* @param {string|null} params.type tipo do work item
|
|
69
|
+
* @param {string} params.stage etapa de destino
|
|
70
|
+
* @param {Array<string|{name:string}>} [params.labels] labels da issue
|
|
71
|
+
* @returns {string[]} avisos, na ordem de gravidade
|
|
72
|
+
*/
|
|
73
|
+
export function readinessWarnings({ type, stage, labels = [] } = {}) {
|
|
74
|
+
if (stage !== STAGE_READY) return [];
|
|
75
|
+
const nomes = labelNames(labels);
|
|
76
|
+
const avisos = [];
|
|
77
|
+
|
|
78
|
+
// Bug, RFC e Spike não passam por spec/plan — cobrar plan-approved deles
|
|
79
|
+
// seria inventar um portão que o fluxo não tem.
|
|
80
|
+
if (allowsSpecPlan(type) && !nomes.includes(LABEL_PLAN_APPROVED)) {
|
|
81
|
+
avisos.push(
|
|
82
|
+
`não tem \`${LABEL_PLAN_APPROVED}\`: spec+plan não passaram pelo \`validate\`. ` +
|
|
83
|
+
`Para validar, aplique \`spec-wave:ready\` na issue.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
for (const gatilho of [LABEL_SPEC, LABEL_PLAN]) {
|
|
87
|
+
if (nomes.includes(gatilho)) {
|
|
88
|
+
avisos.push(`ainda tem \`${gatilho}\` — há geração de documento pendente ou uma reprova recente.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const portao of [LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN]) {
|
|
92
|
+
if (nomes.includes(portao)) {
|
|
93
|
+
avisos.push(`tem \`${portao}\` — a crítica reprovou e ninguém liberou.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return avisos;
|
|
97
|
+
}
|
|
98
|
+
|
|
52
99
|
export async function move({ issue: issueArg, stage: stageArg, status: statusArg }) {
|
|
53
100
|
const issueNumber = parseInt(String(issueArg).replace('#', ''), 10);
|
|
54
101
|
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
@@ -126,6 +173,11 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
126
173
|
);
|
|
127
174
|
}
|
|
128
175
|
|
|
176
|
+
// Portões do fluxo: aviso antes de escrever, com a issue já em mãos.
|
|
177
|
+
for (const aviso of readinessWarnings({ type, stage, labels: issue.labels })) {
|
|
178
|
+
p.log.warn(`#${issueNumber} ${aviso}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
129
181
|
const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
|
|
130
182
|
if (projectError) {
|
|
131
183
|
p.log.error(`${projectError} — board não atualizado. Rode \`spec-wave init\` (ou \`spec-wave refresh --config\`).`);
|
|
@@ -134,11 +186,16 @@ export async function move({ issue: issueArg, stage: stageArg, status: statusArg
|
|
|
134
186
|
}
|
|
135
187
|
const etapaField = await resolveField(token, project, 'Etapa').catch(() => null);
|
|
136
188
|
const statusField = await resolveField(token, project, 'Status').catch(() => null);
|
|
189
|
+
// O `move` também REPARA o Work Item Type quando ele está vazio (o `apply` não
|
|
190
|
+
// o escrevia, então há board com dezenas de itens sem tipo). Só no vazio, e
|
|
191
|
+
// mesmo quando a Etapa não avança — ver ensureWorkItemType.
|
|
192
|
+
const typeField = await resolveField(token, project, 'Work Item Type').catch(() => null);
|
|
137
193
|
|
|
138
194
|
let moved;
|
|
139
195
|
try {
|
|
140
196
|
moved = await advanceToStage(
|
|
141
|
-
token, project, etapaField, statusField, issue.node_id, stage, status
|
|
197
|
+
token, project, etapaField, statusField, issue.node_id, stage, status,
|
|
198
|
+
{ typeField, itemType: type });
|
|
142
199
|
} catch (err) {
|
|
143
200
|
p.log.error(`Falha ao atualizar o board: ${err.message}`);
|
|
144
201
|
process.exitCode = 1;
|