@spec-wave/cli 0.21.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.
@@ -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(
@@ -8,7 +8,7 @@ import {
8
8
  REQUIRED_PLAN_SECTIONS, REQUIRED_SPEC_SECTIONS, REQUIRED_BUG_SECTIONS, labelNames,
9
9
  } from '../config.mjs';
10
10
  import { findIncompleteDocSigns } from '../lib/doc-completeness.mjs';
11
- import { bugDocPaths, findMissingSections } from '../lib/bug-doc.mjs';
11
+ import { bugDocPaths, describeMissingSections } from '../lib/bug-doc.mjs';
12
12
  import { detectIssueType } from '../lib/issue-type.mjs';
13
13
  import { loadConfig } from '../lib/project-root.mjs';
14
14
 
@@ -47,8 +47,8 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
47
47
  );
48
48
  } else {
49
49
  const content = readFileSync(fileAbs, 'utf-8');
50
- for (const section of findMissingSections(content, REQUIRED_BUG_SECTIONS)) {
51
- errors.push(`❌ Seção obrigatória ausente no bug.md: **${section}**`);
50
+ for (const faltante of describeMissingSections(content, REQUIRED_BUG_SECTIONS)) {
51
+ errors.push(renderMissingSection('bug.md', faltante));
52
52
  }
53
53
  for (const problem of findIncompleteDocSigns(content)) {
54
54
  errors.push(`❌ \`bug.md\` parece incompleto: ${problem}`);
@@ -79,6 +79,22 @@ async function validateBug({ token, owner, repo, issue, issueNumber, root }) {
79
79
  console.log('bug.md validado.');
80
80
  }
81
81
 
82
+ /**
83
+ * Linha de erro de seção ausente (função PURA).
84
+ *
85
+ * Dizer só o que falta obriga o humano a comparar título a título; dizer o que
86
+ * EXISTE no lugar resolve em segundos o caso real (um "# Rollout e
87
+ * Monitoramento" onde se esperava "# Rollback e Monitoramento", com o conteúdo
88
+ * certo embaixo).
89
+ */
90
+ export function renderMissingSection(doc, { section, found }) {
91
+ const base = `❌ Seção obrigatória ausente no ${doc}: **${section}**`;
92
+ return found
93
+ ? `${base} — encontrei \`# ${found}\`, esperava \`# ${section}\`. ` +
94
+ 'Se o conteúdo é o certo, basta renomear o título.'
95
+ : base;
96
+ }
97
+
82
98
  export async function validate({ issueNumber }) {
83
99
  const token = await resolveToken();
84
100
  const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
@@ -133,8 +149,8 @@ export async function validate({ issueNumber }) {
133
149
  errors.push('❌ `plan.md` não encontrado em `' + `${featureRel}/plan.md` + '`');
134
150
  } else {
135
151
  const planContent = readFileSync(planPath, 'utf-8');
136
- for (const section of findMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
137
- errors.push(`❌ Seção obrigatória ausente no plan.md: **${section}**`);
152
+ for (const faltante of describeMissingSections(planContent, REQUIRED_PLAN_SECTIONS)) {
153
+ errors.push(renderMissingSection('plan.md', faltante));
138
154
  }
139
155
  for (const problem of findIncompleteDocSigns(planContent)) {
140
156
  errors.push(`❌ \`plan.md\` parece incompleto: ${problem}`);
@@ -147,8 +163,8 @@ export async function validate({ issueNumber }) {
147
163
  errors.push('❌ `spec.md` não encontrado em `' + `${featureRel}/spec.md` + '`');
148
164
  } else {
149
165
  const specContent = readFileSync(specPath, 'utf-8');
150
- for (const section of findMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
151
- errors.push(`❌ Seção obrigatória ausente no spec.md: **${section}**`);
166
+ for (const faltante of describeMissingSections(specContent, REQUIRED_SPEC_SECTIONS)) {
167
+ errors.push(renderMissingSection('spec.md', faltante));
152
168
  }
153
169
  // Seções presentes não garantem documento completo: um corte dentro da
154
170
  // última seção passa na checagem acima (foi o caso da EP2-F13).
@@ -165,18 +181,23 @@ export async function validate({ issueNumber }) {
165
181
  token, owner, repo, parseInt(issueNumber, 10),
166
182
  `⚠️ **Validação falhou — Feature não está pronta.**\n\n` +
167
183
  errors.join('\n') +
168
- `\n\nCorreija os problemas e adicione novamente a label \`spec-wave:ready\`.`
184
+ `\n\nCorrija os problemas e adicione novamente a label \`spec-wave:ready\`.` +
185
+ `\n\nSe os documentos precisam mesmo ser REGERADOS (e não só corrigidos), aplique ` +
186
+ `você a label \`spec-wave:spec\` — ela **sobrescreve** o \`spec.md\`, inclusive o que ` +
187
+ 'foi revisado à mão.'
169
188
  );
170
- // Quando as ÚNICAS falhas são os portões humanos (crítica grave / revisão
171
- // exigida), os documentos existem e estão estruturalmente válidos — só
172
- // precisam de correção manual. Nesse caso NÃO devolvemos a feature para a
173
- // etapa de spec (spec-wave:spec).
174
- const humanGates = (critiqueFailed ? 1 : 0) + (needsHuman ? 1 : 0);
175
- const onlyHumanGates = humanGates > 0 && errors.length === humanGates;
176
- if (!onlyHumanGates) {
177
- // Send back to spec stage
178
- await addLabel(token, owner, repo, parseInt(issueNumber, 10), 'spec-wave:spec');
179
- }
189
+ // A reprova NÃO aplica mais `spec-wave:spec` sozinha.
190
+ //
191
+ // `spec-wave:spec` é label de GATILHO: aplicá-la manda o generate-spec
192
+ // sobrescrever o spec.md. Uma reprova por título trocado ("# Rollout" onde
193
+ // se esperava "# Rollback", com o conteúdo certo embaixo) apagaria uma spec
194
+ // revisada à mão por causa de uma palavra. Isso não aconteceu até hoje por
195
+ // um acidente: o GitHub não dispara `labeled` para eventos do GITHUB_TOKEN,
196
+ // então a label ficava inerte. Quem trocasse por um PAT — o que a própria
197
+ // documentação sugere para alcançar Projects de organização — perderia o
198
+ // documento sem nunca ter pedido isso.
199
+ //
200
+ // Regenerar é decisão humana, e o comentário acima diz como e o que custa.
180
201
  console.error('Validação falhou:', errors.join(', '));
181
202
  process.exit(1);
182
203
  }
package/src/config.mjs CHANGED
@@ -372,6 +372,12 @@ export const LABEL_CRITIQUE_FAILED = 'spec-wave:critique-failed';
372
372
  export const LABEL_DECOMPOSED = 'spec-wave:decomposed';
373
373
  export const LABEL_DECOMPOSE_READY = 'spec-wave:decompose-ready';
374
374
  export const LABEL_NEEDS_HUMAN = 'spec-wave:needs-human';
375
+ // Selo de que spec+plan passaram pelo `validate`. É o que distingue uma Feature
376
+ // pronta de uma que só foi movida à mão para ✅ Ready.
377
+ export const LABEL_PLAN_APPROVED = 'spec-wave:plan-approved';
378
+ // Gatilhos que REGERAM documento: presentes na issue, indicam etapa pendente.
379
+ export const LABEL_SPEC = 'spec-wave:spec';
380
+ export const LABEL_PLAN = 'spec-wave:plan';
375
381
  // Achado GRAVE que o Tech Leader aceitou como risco conhecido. Fica na issue
376
382
  // depois que a crítica passa: sem ela, o risco aceito some da vista assim que a
377
383
  // rodada seguinte roda limpa, e ninguém mais sabe que houve uma decisão.
@@ -382,11 +388,11 @@ export const LABEL_RISK_ACCEPTED = 'spec-wave:risk-accepted';
382
388
  export const LABEL_CRITIQUE = 'spec-wave:critique';
383
389
 
384
390
  export const TRIGGER_LABELS = [
385
- { name: 'spec-wave:spec', color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
386
- { name: 'spec-wave:plan', color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
391
+ { name: LABEL_SPEC, color: 'BFD4F2', description: 'Gerar spec.md via GitHub Action' },
392
+ { name: LABEL_PLAN, color: 'BFD4F2', description: 'Gerar plan.md via GitHub Action' },
387
393
  { name: LABEL_CRITIQUE, color: 'BFD4F2', description: 'Re-criticar o plan.md COMO ESTÁ, sem regerar' },
388
394
  { name: 'spec-wave:ready', color: '0E8A16', description: 'Validar spec+plan e mover para Ready' },
389
- { name: 'spec-wave:plan-approved', color: '0E8A16', description: 'Spec+plan validados com sucesso' },
395
+ { name: LABEL_PLAN_APPROVED, color: '0E8A16', description: 'Spec+plan validados com sucesso' },
390
396
  { name: LABEL_DECOMPOSE, color: 'BFD4F2', description: 'Gerar/re-criticar o rascunho da decomposição (decomposition.md)' },
391
397
  { name: LABEL_DECOMPOSE_APPLY, color: 'BFD4F2', description: 'Aplicar o decomposition.md revisado: criar Stories e Tasks' },
392
398
  { name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
package/src/lib/board.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Extraídos de code-review.mjs/qa.mjs para uso também pelos comandos de CLI
3
3
  // (task/story/order). Ver a distinção Etapa × Status em config.mjs.
4
4
  import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
- import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES } from '../config.mjs';
5
+ import { CONFIG_FILE, STAGE_ORDER, STATUS_OPTIONS, WORK_ITEM_TYPES, STAGE_DONE } from '../config.mjs';
6
6
  import { loadConfig } from './project-root.mjs';
7
7
 
8
8
  /**
@@ -116,6 +116,39 @@ export function resolveStageName(input) {
116
116
  return { stage: null, error: `Etapa "${input}" não existe. Use uma destas: ${list()}.` };
117
117
  }
118
118
 
119
+ /**
120
+ * Índice do board por número de issue (função PURA).
121
+ *
122
+ * @param {Array<{number:number}>} items saída de listProjectItems
123
+ * @returns {Map<number, object>}
124
+ */
125
+ export function indexBoardItems(items) {
126
+ return new Map((items || []).filter(i => i?.number).map(i => [i.number, i]));
127
+ }
128
+
129
+ /**
130
+ * As Features que ainda têm trabalho (função PURA).
131
+ *
132
+ * "Ainda tem trabalho" = issue aberta e Etapa diferente de 🎉 Done. É este o
133
+ * conjunto do `order` sem argumento: o mapa de quem depende de quem quando o
134
+ * trabalho de uma onda inteira está espalhado por várias Features.
135
+ *
136
+ * O tipo sai do campo "Work Item Type", com FALLBACK no prefixo do título: o
137
+ * campo ficou vazio em todo item criado pelo apply até a v0.21.0, e um board com
138
+ * esse buraco não pode virar um mapa vazio em silêncio.
139
+ *
140
+ * @param {Array<object>} items saída de listProjectItems
141
+ * @returns {Array<object>} Features, em ordem crescente de número
142
+ */
143
+ export function selectOpenFeatures(items) {
144
+ return (items || [])
145
+ .filter(i => i?.number
146
+ && String(i.state || '').toUpperCase() !== 'CLOSED'
147
+ && (i.fields?.['Work Item Type'] === 'Feature' || /^\s*\[FEATURE\]/i.test(i.title || ''))
148
+ && i.fields?.Etapa !== STAGE_DONE)
149
+ .sort((a, b) => a.number - b.number);
150
+ }
151
+
119
152
  /**
120
153
  * Preenche o "Work Item Type" do item quando ele está VAZIO (best-effort).
121
154
  *
@@ -49,3 +49,74 @@ export function findMissingSections(content, sections) {
49
49
  const text = String(content || '');
50
50
  return (sections || []).filter(section => !text.includes(`# ${section}`));
51
51
  }
52
+
53
+ /**
54
+ * Títulos presentes no documento (função PURA).
55
+ */
56
+ export function listHeadings(content) {
57
+ return [...String(content || '').matchAll(/^#{1,6}[ \t]+(.+?)[ \t]*$/gm)].map(m => m[1].trim());
58
+ }
59
+
60
+ // Comparação tolerante: sem acento, sem pontuação, caixa única.
61
+ function normalizeHeading(value) {
62
+ return String(value ?? '')
63
+ .normalize('NFD')
64
+ .replace(/[\u0300-\u036f]/g, '')
65
+ .replace(/[^\p{Letter}\p{Number}]+/gu, ' ')
66
+ .trim()
67
+ .toLowerCase();
68
+ }
69
+
70
+ function bigrams(value) {
71
+ const s = normalizeHeading(value).replace(/ /g, '');
72
+ const out = new Set();
73
+ for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Semelhança entre dois títulos, 0..1 (função PURA — coeficiente de Dice).
79
+ */
80
+ export function headingSimilarity(a, b) {
81
+ const A = bigrams(a);
82
+ const B = bigrams(b);
83
+ if (A.size === 0 || B.size === 0) return normalizeHeading(a) === normalizeHeading(b) ? 1 : 0;
84
+ let comuns = 0;
85
+ for (const g of A) if (B.has(g)) comuns += 1;
86
+ return (2 * comuns) / (A.size + B.size);
87
+ }
88
+
89
+ // Abaixo disto são dois títulos diferentes, não um errado. "Rollout e
90
+ // Monitoramento" × "Rollback e Monitoramento" fica bem acima; "Riscos" ×
91
+ // "Rollback e Monitoramento", bem abaixo.
92
+ const SIMILARIDADE_MINIMA = 0.6;
93
+
94
+ /**
95
+ * O que falta E o que existe no lugar (função PURA).
96
+ *
97
+ * `findMissingSections` só sabe dizer o que não achou, e foi isso que fez uma
98
+ * Feature ser reprovada por ter escrito "# Rollout e Monitoramento" no lugar de
99
+ * "# Rollback e Monitoramento" — com todo o conteúdo certo embaixo. A mensagem
100
+ * dizia "seção ausente" e o humano tinha que caçar a diferença de uma palavra.
101
+ *
102
+ * Só sugere um título que NÃO satisfaz nenhuma seção obrigatória: senão o
103
+ * "Riscos" legítimo do documento vira sugestão para o "Rollback" que falta.
104
+ *
105
+ * @returns {Array<{section: string, found: string|null}>}
106
+ */
107
+ export function describeMissingSections(content, sections) {
108
+ const faltando = findMissingSections(content, sections);
109
+ if (faltando.length === 0) return [];
110
+ const presentes = (sections || []).filter(s => !faltando.includes(s)).map(normalizeHeading);
111
+ const candidatos = listHeadings(content)
112
+ .filter(h => !presentes.includes(normalizeHeading(h)));
113
+ return faltando.map(section => {
114
+ let melhor = null;
115
+ let score = SIMILARIDADE_MINIMA;
116
+ for (const h of candidatos) {
117
+ const s = headingSimilarity(h, section);
118
+ if (s >= score) { score = s; melhor = h; }
119
+ }
120
+ return { section, found: melhor };
121
+ });
122
+ }
@@ -193,6 +193,21 @@ export function isTransientProviderError(err) {
193
193
  .test(err.message || '');
194
194
  }
195
195
 
196
+ /**
197
+ * O teto de turnos merece UMA repetição? (função PURA)
198
+ *
199
+ * Só quando o erro se classificou como exploração (ver MaxTurnsError): aí a
200
+ * falha é variância entre execuções, e a repetição costuma custar uma fração do
201
+ * run que falhou. Loop degenerado continua sem retry — repetir o determinístico
202
+ * foi o que custou 55 minutos de Action.
203
+ *
204
+ * UMA, e não `attempts`: o ganho observado está na segunda tentativa; da
205
+ * terceira em diante o padrão vira "gastar caro para confirmar o óbvio".
206
+ */
207
+ export function shouldRetryMaxTurns(err) {
208
+ return Boolean(err?.maxTurns && err.exploration);
209
+ }
210
+
196
211
  export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs = RETRY_BASE_MS } = {}) {
197
212
  let lastErr;
198
213
  for (let attempt = 1; attempt <= attempts; attempt++) {
@@ -200,11 +215,12 @@ export async function withRetry(label, fn, { attempts = RETRY_ATTEMPTS, baseMs =
200
215
  return await fn();
201
216
  } catch (err) {
202
217
  lastErr = err;
203
- if (attempt === attempts || !isTransientProviderError(err)) break;
218
+ const repeteTeto = attempt === 1 && shouldRetryMaxTurns(err);
219
+ if (attempt === attempts || (!isTransientProviderError(err) && !repeteTeto)) break;
204
220
  const delayMs = baseMs * 2 ** (attempt - 1); // 2s, 4s, 8s…
205
221
  console.warn(
206
- `${label}: falha transitória na tentativa ${attempt}/${attempts} (${err.message}) ` +
207
- `repetindo em ${delayMs / 1000}s.`
222
+ `${label}: ${repeteTeto ? 'teto de turnos gasto explorando' : 'falha transitória'} na ` +
223
+ `tentativa ${attempt}/${attempts} (${err.message}) — repetindo em ${delayMs / 1000}s.`
208
224
  );
209
225
  await sleep(delayMs);
210
226
  }
@@ -530,9 +546,9 @@ async function generateViaEngine(
530
546
 
531
547
  const text = stripReasoning(result.outputText || '');
532
548
  if (!text) {
533
- // Teto de turnos tem causa e remédio próprios, e NÃO é transitório: o
534
- // modelo gastou todos os turnos em tool calls sem nunca escrever o
535
- // documento, e repetir reproduz isso. Ver MaxTurnsError.
549
+ // Teto de turnos tem causa e remédio próprios. é repetível quando as
550
+ // tool calls foram de EXPLORAÇÃO (variância); loop degenerado não é.
551
+ // Quem classifica é o próprio erro. Ver MaxTurnsError.
536
552
  if (result.resultSubtype === 'error_max_turns') {
537
553
  throw new MaxTurnsError({
538
554
  provider: ai.provider,
@@ -540,6 +556,7 @@ async function generateViaEngine(
540
556
  turns: result.numTurns,
541
557
  action: action || null,
542
558
  toolCalls: result.toolCalls || [],
559
+ toolSignatures: result.toolSignatures || [],
543
560
  });
544
561
  }
545
562
  const err = new Error(