@spec-wave/cli 0.21.0 → 0.24.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.
@@ -203,6 +203,25 @@ export function shouldSkipDecompose({ labels = [], subIssues = [], type } = {})
203
203
  return { skip: false, reason: '' };
204
204
  }
205
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
+
206
225
  // Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
207
226
  // spec/plan; RFC ganha o seu, já que não passa por spec/plan.
208
227
  function resolveDocDir(root, issue, type) {
@@ -560,10 +579,59 @@ function failIfBoardIncomplete(failures) {
560
579
  throw err;
561
580
  }
562
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
+
563
625
  async function createStoriesFromDoc(ctx, doc) {
564
626
  const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
565
627
  const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
566
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);
567
635
  const created = [];
568
636
  const createdStories = []; // issues criadas, na ordem dos índices das stories
569
637
  const generatedTexts = []; // títulos+corpos para o lint de idioma final
@@ -580,12 +648,17 @@ async function createStoriesFromDoc(ctx, doc) {
580
648
  .filter(Boolean)
581
649
  .join('\n\n') || '_(sem descrição)_';
582
650
 
583
- // dependsOnvem validado pelo parser (0-based, para trás).
584
- const depIssues = story.dependsOn.map(idx => createdStories[idx]).filter(Boolean);
651
+ // Irmãs (índices 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);
585
658
  const depLine = formatDependencyLine(depIssues.map(d => d.number));
586
659
  if (depLine) storyBody += `\n\n${depLine}`;
587
660
 
588
- const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
661
+ const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]'], { milestone });
589
662
  ctx.createdItems.push(createdStory.number);
590
663
  // Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
591
664
  // arquivo no fim do apply.
@@ -619,7 +692,7 @@ async function createStoriesFromDoc(ctx, doc) {
619
692
  console.log(` Criando task: ${task.title}`);
620
693
  const taskTitle = `[TASK] ${task.title}`;
621
694
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
622
- const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
695
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
623
696
  ctx.createdItems.push(createdTask.number);
624
697
  task.issue = createdTask.number;
625
698
  generatedTexts.push(taskTitle, taskBody);
@@ -652,6 +725,7 @@ async function createStoriesFromDoc(ctx, doc) {
652
725
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
653
726
  `🔀 **Decomposição aplicada!**\n\n` +
654
727
  `A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
728
+ milestoneLine(issue, milestone) +
655
729
  posicionamento +
656
730
  formatItemsLintWarning(generatedTexts)
657
731
  ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
@@ -710,6 +784,8 @@ async function createTasksFromDoc(ctx, doc) {
710
784
  const { token, projectToken, owner, repo, issue, issueNumber, project, docRel } = ctx;
711
785
  const fields = { etapaField: ctx.etapaField, statusField: ctx.statusField, typeField: ctx.typeField };
712
786
  const parentNodeId = issue.node_id;
787
+ const milestone = resolveInheritedMilestone(issue);
788
+ if (milestone) console.log(`Milestone herdado do RFC: ${issue.milestone?.title ?? `#${milestone}`}.`);
713
789
  const created = [];
714
790
  const generatedTexts = [];
715
791
  const boardFailures = [];
@@ -718,7 +794,7 @@ async function createTasksFromDoc(ctx, doc) {
718
794
  console.log(`Criando task: ${task.title}`);
719
795
  const taskTitle = `[TASK] ${task.title}`;
720
796
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
721
- const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
797
+ const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]'], { milestone });
722
798
  ctx.createdItems.push(createdTask.number);
723
799
  task.issue = createdTask.number;
724
800
  created.push({ title: taskTitle, url: createdTask.url });
@@ -744,6 +820,7 @@ async function createTasksFromDoc(ctx, doc) {
744
820
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
745
821
  `🔀 **Decomposição do RFC aplicada!**\n\n` +
746
822
  `A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
823
+ milestoneLine(issue, milestone) +
747
824
  posicionamento +
748
825
  formatItemsLintWarning(generatedTexts)
749
826
  ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
@@ -14,7 +14,7 @@ import {
14
14
  } from '../api/auth.mjs';
15
15
  import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
16
16
  import {
17
- CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, STATUS_OPTIONS,
17
+ CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, AI_PROVIDERS, STATUS_OPTIONS,
18
18
  RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
19
19
  DEFAULT_MAX_CRITIQUE_ATTEMPTS, STAGE_TRACKS, AI_ACTIONS, recommendedModelAliases,
20
20
  modelLabels,
@@ -679,8 +679,9 @@ async function checkAi(ctx) {
679
679
  `${Object.entries(aliases).map(([a, m]) => `${a}=${m}`).join(', ')}.`
680
680
  );
681
681
  // Slug da OpenRouter tem "/" (anthropic/claude-…); id da Anthropic, não.
682
+ // O critério é o BACKEND: `claude-oauth` fala com a mesma API do `anthropic`.
682
683
  const wrongShape = Object.entries(aliases).filter(([, m]) => (
683
- provider.value === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
684
+ provider.backend === 'openrouter' ? !String(m).includes('/') : String(m).includes('/')
684
685
  ));
685
686
  if (wrongShape.length > 0) {
686
687
  status = 'warn';
@@ -700,7 +701,7 @@ async function checkAi(ctx) {
700
701
 
701
702
  // Saída estruturada da crítica: sem structured output confiável, a validação
702
703
  // do schema queima os retries antes de falhar.
703
- if (provider.value === 'openrouter' && !fileAi.models?.critique) {
704
+ if (provider.backend === 'openrouter' && !fileAi.models?.critique) {
704
705
  status = 'warn';
705
706
  notes.push(
706
707
  `Saída estruturada da crítica: o provider é openrouter e \`ai.models.critique\` não está ` +
@@ -708,21 +709,27 @@ async function checkAi(ctx) {
708
709
  'e a crítica falha na primeira execução. Aponte `ai.models.critique` para um modelo com ' +
709
710
  'saída estruturada.'
710
711
  );
712
+ } else if (provider.backend === 'anthropic') {
713
+ // No backend anthropic quem garante o schema é o próprio Claude Code
714
+ // (`outputFormat: json_schema`), repetindo o turno até casar — não há
715
+ // tool call forçado nem `strict` de endpoint para reportar.
716
+ notes.push(
717
+ `Saída estruturada da crítica: json_schema do Claude Code (${provider.value}) ` +
718
+ '— o CLI repete o turno até a resposta casar com o schema.'
719
+ );
711
720
  } else {
712
721
  notes.push(
713
722
  `Saída estruturada da crítica: tool call forçado (${provider.value}) ` +
714
723
  `· strict=${supportsStrictSchema(critiqueModel) ? 'sim' : 'não'} neste modelo.`
715
724
  );
716
725
  }
717
- // O backend anthropic não chama a API — sobe o Claude Code CLI como
718
- // subprocesso, que não existe no runner dos workflows (só setup-node).
719
- if (provider.value === 'anthropic') {
720
- problems.push(
721
- 'O provider `anthropic` NÃO roda nos GitHub Actions: ele sobe o Claude Code CLI como ' +
722
- 'subprocesso, ausente no runner. As Actions de spec/plan/decompose vão falhar. ' +
723
- 'Troque para `"provider": "openrouter"` no .spec-wave.json (e adicione o secret ' +
724
- 'OPENROUTER_API_KEY), ou instale o Claude Code no workflow e defina ' +
725
- 'SPEC_WAVE_ALLOW_ANTHROPIC_IN_CI=1. Localmente o anthropic funciona.'
726
+ // O backend anthropic sobe o Claude Code CLI como subprocesso — e o binário
727
+ // vem embarcado no SDK, então o runner não precisa de instalação extra.
728
+ if (provider.backend === 'anthropic') {
729
+ notes.push(
730
+ `Backend anthropic (${provider.value}): o Claude Code sobe como subprocesso a partir do ` +
731
+ 'binário embarcado no @anthropic-ai/claude-agent-sdk nada a instalar no runner além do ' +
732
+ 'próprio CLI. falha se o pacote for instalado com `--omit=optional`.'
726
733
  );
727
734
  }
728
735
 
@@ -955,10 +962,19 @@ async function checkWorkflows(ctx) {
955
962
  // comportamento de pipelines já em andamento.
956
963
  const unpinned = [];
957
964
  const otherVersion = [];
965
+ // O secret do provider escolhido chega ao runner? A YAML é que decide: um
966
+ // repo configurado com um provider cujo secret o workflow não encaminha morre
967
+ // com "Faltam credenciais" no meio do fluxo, e o único conserto é `update`.
968
+ const provider = getProvider(ctx.cfg?.ai?.provider) || getProvider(DEFAULT_PROVIDER);
969
+ const missingSecret = [];
958
970
  for (const file of WORKFLOW_FILES) {
959
971
  const content = readFileSync(path.join(dir, file), 'utf-8');
960
972
  if (/@spec-wave\/cli@latest/.test(content)) unpinned.push(file);
961
973
  else if (!content.includes(`@spec-wave/cli@${CLI_VERSION}`)) otherVersion.push(file);
974
+ // Identifica os workflows de IA pelo que eles próprios declaram, em vez de
975
+ // manter uma segunda lista para sair de sincronia com WORKFLOW_FILES.
976
+ const usesAi = AI_PROVIDERS.some(pr => content.includes(`secrets.${pr.secret}`));
977
+ if (usesAi && !content.includes(`secrets.${provider.secret}`)) missingSecret.push(file);
962
978
  }
963
979
  const notes = [`Os ${WORKFLOW_FILES.length} workflows do spec-wave estão presentes.`];
964
980
  let status = 'ok';
@@ -976,6 +992,16 @@ async function checkWorkflows(ctx) {
976
992
  'para fazer o bump explícito.'
977
993
  );
978
994
  }
995
+ if (missingSecret.length > 0) {
996
+ status = 'fail';
997
+ notes.push(
998
+ `Não encaminham \`${provider.secret}\` (secret do provider ${provider.value}): ` +
999
+ `${missingSecret.join(', ')}. Esses workflows vão falhar com "Faltam credenciais" — ` +
1000
+ 'rode `npx @spec-wave/cli@latest update`.'
1001
+ );
1002
+ } else {
1003
+ notes.push(`Todos encaminham \`${provider.secret}\` (provider ${provider.value}).`);
1004
+ }
979
1005
  return { name, status, detail: notes.join('\n') };
980
1006
  }
981
1007
 
@@ -220,6 +220,11 @@ export async function init(options) {
220
220
  ? ` 1. Commite o ${CONFIG_FILE} quando quiser (git add ${CONFIG_FILE} && git commit)\n`
221
221
  : '') +
222
222
  ` ${configWritten ? '2' : '1'}. Adicione ${providerMeta.secret} como secret no repositório (provider: ${providerMeta.label})\n` +
223
+ // O valor do token da assinatura não é copiável de lugar nenhum: só existe
224
+ // depois de rodar o comando que o gera.
225
+ (providerMeta.secret === 'CLAUDE_CODE_OAUTH_TOKEN'
226
+ ? ` ${chalk.dim('Gere o valor com: claude setup-token')}\n`
227
+ : '') +
223
228
  ` ${configWritten ? '3' : '2'}. Configure o board view para agrupar por "Etapa"\n` +
224
229
  ` ${configWritten ? '4' : '3'}. Crie uma Feature com o prefixo [FEATURE] no título\n` +
225
230
  ` ${configWritten ? '5' : '4'}. Use a skill spec-wave para guiar o fluxo\n\n` +
@@ -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\`).`);
@@ -1,24 +1,176 @@
1
- // Ordena as Stories de uma Feature pelas dependências (topológica, Kahn —
1
+ // Ordena as Stories pelas dependências (topológica, Kahn —
2
2
  // ver orderStories em src/lib/dependencies.mjs). As dependências vêm de duas
3
3
  // fontes, mescladas: a linha "Depende de: #N" no corpo da Story e a relação
4
4
  // nativa blocked_by do GitHub.
5
5
  //
6
+ // Dependência para FORA da Feature não entra na ordenação (não há como saber
7
+ // onde a Story de outra Feature entra nesta sequência), mas é EXIBIDA: era lida,
8
+ // descartada em silêncio por orderStories e ignorada de novo no aviso de
9
+ // fora-de-ordem. Quem montava o mapa de trabalho de dois devs tinha que
10
+ // reconstruir isso à mão, lendo os decomposition.md.
11
+ //
12
+ // Dois escopos: `order <feature>` (uma Feature) e `order` sem argumento — o
13
+ // mapa de TODAS as Features com trabalho no board, onde as dependências entre
14
+ // Features deixam de ser "externas" e entram no mesmo grafo.
15
+ //
6
16
  // Comando LOCAL — rodado pelo dev no terminal. owner/repo vêm da env
7
17
  // GITHUB_REPOSITORY quando existir, senão do .spec-wave.json (gravado pelo init).
8
18
  import * as p from '@clack/prompts';
9
19
  import chalk from 'chalk';
10
20
  import { resolveToken } from '../api/auth.mjs';
11
21
  import { getIssue, listBlockedBy } from '../api/github-rest.mjs';
12
- import { addProjectItem, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
22
+ import {
23
+ addProjectItem, listSubIssues, getItemSingleSelectValue, listProjectItems,
24
+ } from '../api/github-graphql.mjs';
13
25
  import { detectIssueType } from '../lib/issue-type.mjs';
14
26
  import { parseDependencies, orderStories } from '../lib/dependencies.mjs';
15
- import { loadProjectConfig, resolveField } from '../lib/board.mjs';
27
+ import {
28
+ loadProjectConfig, resolveField, indexBoardItems, selectOpenFeatures,
29
+ } from '../lib/board.mjs';
16
30
  import { resolveRepoContext } from '../lib/project-root.mjs';
17
31
  import { CONFIG_FILE, STAGE_ORDER, STAGE_DEVELOPMENT, STAGE_DONE } from '../config.mjs';
18
32
 
19
- export async function order({ feature: featureArg }) {
33
+ /**
34
+ * Rótulo curto de uma Feature para a coluna do mapa (função PURA).
35
+ */
36
+ export function featureLabel(feature) {
37
+ const titulo = String(feature?.title || '').replace(/^\s*\[FEATURE\]\s*/i, '').trim();
38
+ const curto = titulo.length > 28 ? `${titulo.slice(0, 27)}…` : titulo;
39
+ return `#${feature?.number} ${curto}`.trim();
40
+ }
41
+
42
+ /**
43
+ * O mapa de execução de várias Features (função PURA).
44
+ *
45
+ * Uma ordem SÓ, não uma lista por Feature: é isso que responde "o que os dois
46
+ * devs pegam agora" — a pergunta que obrigava a abrir nove decomposition.md e
47
+ * reconstruir o grafo à mão. A coluna da Feature vem junto porque, num grafo de
48
+ * várias, saber a que Feature cada Story pertence é metade da informação.
49
+ *
50
+ * @param {object} params
51
+ * @param {number[]} params.sorted ordem topológica (números de Story)
52
+ * @param {Map<number, {title:string, dependsOn:number[]}>} params.byNumber
53
+ * @param {Map<number, object>} params.featureOf Story → Feature dona
54
+ * @param {Map<number, string|null>} params.stageOf Story → Etapa
55
+ * @returns {string}
56
+ */
57
+ export function renderBoardOrder({ sorted, byNumber, featureOf, stageOf }) {
58
+ return (sorted || []).map((n, i) => {
59
+ const s = byNumber.get(n);
60
+ const etapa = stageOf.get(n) || '—';
61
+ const deps = (s?.dependsOn || []).filter(d => byNumber.has(d));
62
+ const dep = deps.length > 0 ? ` ← depende de ${deps.map(d => `#${d}`).join(', ')}` : '';
63
+ return `${String(i + 1).padStart(2)}. #${n} ${s?.title || ''}\n` +
64
+ ` ${featureLabel(featureOf.get(n))} · Etapa: ${etapa}${dep}`;
65
+ }).join('\n');
66
+ }
67
+
68
+ /**
69
+ * `spec-wave order` sem argumento: o grafo de TODAS as Features com trabalho.
70
+ *
71
+ * Fonte do conjunto é o BOARD, não os arquivos — `decomposition.md` registra o
72
+ * que foi proposto, a issue é o que existe. Uma query paginada de itens do
73
+ * project resolve Etapa e tipo de todo mundo de uma vez, no lugar do laço de
74
+ * duas chamadas por Story que o modo de uma Feature ainda usa.
75
+ */
76
+ async function orderBoard({ token, owner, repo }) {
77
+ const { project, error: projectError } = loadProjectConfig();
78
+ if (projectError) {
79
+ p.log.error(
80
+ `${projectError} — sem board não há como saber quais Features têm trabalho. ` +
81
+ 'Use `spec-wave order <feature>` para ordenar uma Feature específica.'
82
+ );
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+
87
+ let itens;
88
+ try {
89
+ itens = await listProjectItems(token, project.id);
90
+ } catch (err) {
91
+ p.log.error(`Não foi possível ler os itens do Project: ${err.message}`);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ const indice = indexBoardItems(itens);
96
+ const features = selectOpenFeatures(itens);
97
+ if (features.length === 0) {
98
+ p.log.info(`Nenhuma Feature aberta fora de "${STAGE_DONE}" no board — nada a ordenar.`);
99
+ p.outro('Nada a fazer.');
100
+ return;
101
+ }
102
+
103
+ const enriched = [];
104
+ const featureOf = new Map();
105
+ const stageOf = new Map();
106
+ const semStories = [];
107
+ for (const feature of features) {
108
+ const subs = await listSubIssues(token, feature.nodeId).catch(() => []);
109
+ const stories = subs.filter(sub =>
110
+ detectIssueType({ title: sub.title, labels: sub.labels }) === 'Story');
111
+ if (stories.length === 0) { semStories.push(feature); continue; }
112
+ for (const story of stories) {
113
+ // Story concluída não entra: o mapa é do que falta fazer.
114
+ if (indice.get(story.number)?.fields?.Etapa === STAGE_DONE) continue;
115
+ const fromBody = parseDependencies(story.body);
116
+ const fromBlockedBy = (await listBlockedBy(token, owner, repo, story.number).catch(() => []))
117
+ .map(b => b.number);
118
+ enriched.push({
119
+ number: story.number,
120
+ title: story.title,
121
+ dependsOn: [...new Set([...fromBody, ...fromBlockedBy])],
122
+ });
123
+ featureOf.set(story.number, feature);
124
+ stageOf.set(story.number, indice.get(story.number)?.fields?.Etapa || null);
125
+ }
126
+ }
127
+
128
+ if (enriched.length === 0) {
129
+ p.log.info(`${features.length} Feature(s) aberta(s), nenhuma com Story pendente.`);
130
+ p.outro('Nada a fazer.');
131
+ return;
132
+ }
133
+
134
+ const byNumber = new Map(enriched.map(s => [s.number, s]));
135
+ const { order: sorted, cycle, external } = orderStories(
136
+ enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
137
+
138
+ if (cycle.length > 0) {
139
+ p.log.warn(
140
+ chalk.yellow.bold('⚠ CICLO DE DEPENDÊNCIAS detectado!') + '\n' +
141
+ `Stories envolvidas (ou bloqueadas pelo ciclo): ${cycle.map(n => `#${n}`).join(', ')}.\n` +
142
+ 'Elas ficaram fora da ordem abaixo. Num grafo de várias Features o ciclo pode ' +
143
+ 'ATRAVESSAR a fronteira delas — confira as linhas "Depende de" dos dois lados.'
144
+ );
145
+ }
146
+
147
+ p.note(
148
+ renderBoardOrder({ sorted, byNumber, featureOf, stageOf }),
149
+ `Ordem de execução — ${sorted.length} Story(ies) de ${features.length} Feature(s)`
150
+ );
151
+
152
+ // Aqui "fora do conjunto" não é mais "de outra Feature" (essas agora estão
153
+ // DENTRO): é Story já concluída, Feature em Done ou issue de outro board.
154
+ if (external.size > 0) {
155
+ const linhas = [...external.entries()].map(([n, deps]) =>
156
+ ` #${n} ← ${deps.map(d => `#${d}`).join(', ')}`);
157
+ p.note(
158
+ linhas.join('\n'),
159
+ 'Dependências fora do conjunto (concluídas, de Feature em Done ou de outro board)'
160
+ );
161
+ }
162
+
163
+ if (semStories.length > 0) {
164
+ p.log.info(`Sem Stories (ainda não decompostas): ${semStories.map(f => `#${f.number}`).join(', ')}.`);
165
+ }
166
+ p.outro(`${chalk.green('✓')} ${sorted.length} de ${enriched.length} story(ies) ordenada(s).`);
167
+ }
168
+
169
+ export async function order({ feature: featureArg } = {}) {
170
+ const semArgumento = featureArg === undefined || featureArg === null
171
+ || String(featureArg).trim() === '';
20
172
  const featureNumber = parseInt(String(featureArg).replace('#', ''), 10);
21
- if (!Number.isInteger(featureNumber) || featureNumber <= 0) {
173
+ if (!semArgumento && (!Number.isInteger(featureNumber) || featureNumber <= 0)) {
22
174
  p.log.error(`Feature inválida: "${featureArg}". Use o número da issue, ex.: 12 ou #12.`);
23
175
  process.exitCode = 1;
24
176
  return;
@@ -43,6 +195,11 @@ export async function order({ feature: featureArg }) {
43
195
  return;
44
196
  }
45
197
 
198
+ if (semArgumento) {
199
+ p.intro(chalk.bold('spec-wave order — todas as Features com trabalho'));
200
+ return await orderBoard({ token, owner, repo });
201
+ }
202
+
46
203
  p.intro(chalk.bold(`spec-wave order #${featureNumber}`));
47
204
 
48
205
  // 1. Lê a Feature e valida o tipo.
@@ -116,7 +273,19 @@ export async function order({ feature: featureArg }) {
116
273
 
117
274
  // 5. Ordenação topológica (nunca lança; ciclo vem em `cycle`).
118
275
  const byNumber = new Map(enriched.map(s => [s.number, s]));
119
- const { order: sorted, cycle } = orderStories(enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
276
+ const { order: sorted, cycle, external } = orderStories(
277
+ enriched.map(({ number, dependsOn }) => ({ number, dependsOn })));
278
+
279
+ // Uma leitura por issue externa DISTINTA — o estado dela é o que diz se o
280
+ // bloqueio ainda vale.
281
+ const foraDoConjunto = [...new Set([...external.values()].flat())];
282
+ const externasInfo = new Map();
283
+ await Promise.all(foraDoConjunto.map(async (n) => {
284
+ const issue = await getIssue(token, owner, repo, n).catch(() => null);
285
+ externasInfo.set(n, issue
286
+ ? { title: issue.title, state: issue.state, aberta: issue.state === 'open' }
287
+ : { title: '(não foi possível ler)', state: null, aberta: true });
288
+ }));
120
289
 
121
290
  if (cycle.length > 0) {
122
291
  p.log.warn(
@@ -136,6 +305,18 @@ export async function order({ feature: featureArg }) {
136
305
  };
137
306
  p.note(sorted.map(line).join('\n'), `Ordem de execução das Stories da Feature #${featureNumber}`);
138
307
 
308
+ if (external.size > 0) {
309
+ const linhas = [...external.entries()].map(([n, deps]) => {
310
+ const detalhe = deps.map(d => {
311
+ const info = externasInfo.get(d);
312
+ const marca = info?.aberta ? chalk.yellow('aberta') : chalk.green('fechada');
313
+ return `#${d} (${marca}) ${info?.title || ''}`.trim();
314
+ }).join('\n ');
315
+ return ` #${n} ${byNumber.get(n)?.title || ''}\n ← ${detalhe}`;
316
+ });
317
+ p.note(linhas.join('\n'), 'Bloqueadas por fora desta Feature');
318
+ }
319
+
139
320
  // 6. Aviso final: dependente já em Desenvolvimento+ com dependência não-Done.
140
321
  const devIdx = STAGE_ORDER.indexOf(STAGE_DEVELOPMENT);
141
322
  const outOfOrder = [];
@@ -144,7 +325,19 @@ export async function order({ feature: featureArg }) {
144
325
  const idx = stage ? STAGE_ORDER.indexOf(stage) : -1;
145
326
  if (idx === -1 || idx < devIdx) continue; // ainda não chegou em Desenvolvimento
146
327
  for (const d of s.dependsOn) {
147
- if (!byNumber.has(d)) continue; // dependência externa ao conjunto — sem Etapa conhecida
328
+ if (!byNumber.has(d)) {
329
+ // Externa: não tem Etapa neste conjunto, mas tem ESTADO — e uma issue
330
+ // aberta bloqueando quem já está em Desenvolvimento é a mesma falha que
331
+ // o aviso abaixo denuncia, só que atravessando a fronteira.
332
+ const info = externasInfo.get(d);
333
+ if (info?.aberta) {
334
+ outOfOrder.push(
335
+ `#${s.number} já está em "${stage}", mas depende de #${d}, de outra Feature, ` +
336
+ 'que continua ABERTA.'
337
+ );
338
+ }
339
+ continue;
340
+ }
148
341
  const depStage = stageOf.get(d);
149
342
  if (depStage !== STAGE_DONE) {
150
343
  outOfOrder.push(