@spec-wave/cli 0.18.1 → 0.19.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,10 +1,16 @@
1
+ import { appendFileSync } from 'node:fs';
1
2
  import { resolveToken } from '../api/auth.mjs';
2
3
  import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
3
- import { addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue } from '../api/github-graphql.mjs';
4
+ import {
5
+ addProjectItem, getIssueParent, listSubIssues, getItemSingleSelectValue, getPRClosingIssues,
6
+ } from '../api/github-graphql.mjs';
4
7
  import { detectIssueType } from '../lib/issue-type.mjs';
5
8
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
6
9
  import { loadConfig } from '../lib/project-root.mjs';
7
- import { STATUS_OPTIONS, STAGE_ORDER, STAGE_DONE, PROGRESS_TODO, PROGRESS_DONE, isManualStageType } from '../config.mjs';
10
+ import {
11
+ STATUS_OPTIONS, STAGE_ORDER, STAGE_DONE, PROGRESS_TODO, PROGRESS_DONE, isManualStageType,
12
+ LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY, labelNames,
13
+ } from '../config.mjs';
8
14
 
9
15
  // Ao abrir o PR: Stories/Feature → Etapa "👀 Code Review" (Status "Todo");
10
16
  // Tasks → Etapa "🎉 Done" (Status "Done"), pois a implementação da task terminou.
@@ -13,18 +19,76 @@ const DONE_STAGE = STAGE_DONE;
13
19
  const TODO_STATUS = PROGRESS_TODO;
14
20
  const DONE_STATUS = PROGRESS_DONE;
15
21
 
16
- // Extrai números de issues referenciadas no corpo do PR (Closes #N, Fixes #N, #N solto).
17
- function extractIssueNumbers(body) {
22
+ // Vínculo EXPLÍCITO: o verbo é obrigatório. `Relacionado: #10` e `#10` solto
23
+ // deixaram de contar — referência cruzada em PR é prática comum (uma PR de
24
+ // documentação citando três Features moveu as três para 👀 Code Review, e uma
25
+ // delas nem plano tinha). Aceita `#N`, `owner/repo#N` e a URL completa da issue.
26
+ const EXPLICIT_LINK_RE =
27
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|implement(?:s|ed)?)\b\s*:?\s+(?:https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/(\d+)|[\w.-]+\/[\w.-]+#(\d+)|#(\d+))/gi;
28
+
29
+ // Forma antiga, atrás do knob `codeReview.linkMode: "any-mention"`.
30
+ const ANY_MENTION_RE = /(?:closes?|fixes?|resolves?)\s+#(\d+)|(?<![/\w#])#(\d+)/gi;
31
+
32
+ // Blocos de código não são vínculo: um `Closes #12` dentro de um exemplo de
33
+ // commit message é documentação, não promessa.
34
+ function stripCodeBlocks(body) {
35
+ return String(body)
36
+ .replace(/^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1[ \t]*$/gm, '\n')
37
+ .replace(/`[^`\n]*`/g, ' ');
38
+ }
39
+
40
+ /**
41
+ * Números de issues que o PR declara implementar (função PURA — testável).
42
+ *
43
+ * @param {string} body corpo do PR
44
+ * @param {object} [opts]
45
+ * @param {'explicit'|'any-mention'} [opts.linkMode='explicit']
46
+ * @returns {number[]} na ordem em que aparecem, sem repetição
47
+ */
48
+ export function extractIssueNumbers(body, { linkMode = 'explicit' } = {}) {
18
49
  if (!body) return [];
19
50
  const nums = new Set();
20
- const re = /(?:closes?|fixes?|resolves?)\s+#(\d+)|(?<![/\w#])#(\d+)/gi;
21
- for (const m of body.matchAll(re)) {
22
- const n = parseInt(m[1] || m[2], 10);
51
+ const text = stripCodeBlocks(body);
52
+ const re = linkMode === 'any-mention' ? ANY_MENTION_RE : EXPLICIT_LINK_RE;
53
+ re.lastIndex = 0;
54
+ for (const m of text.matchAll(re)) {
55
+ const n = parseInt(m.slice(1).find(Boolean) || '', 10);
23
56
  if (n) nums.add(n);
24
57
  }
25
58
  return [...nums];
26
59
  }
27
60
 
61
+ /**
62
+ * Recorte temporal da subárvore (função PURA — testável).
63
+ *
64
+ * Uma sub-issue criada DEPOIS que o PR foi aberto não faz parte daquele PR. Foi
65
+ * assim que o `decompose-apply` da #10 e o `code-review` da PR #528 se
66
+ * atropelaram: o comando leu a subárvore no meio da criação das issues e moveu
67
+ * o que existia naquele instante — duas Stories fundacionais e duas Tasks
68
+ * nasceram concluídas.
69
+ *
70
+ * `pr.created_at` é a referência (e não o relógio do run) porque é determinístico:
71
+ * reexecutar o mesmo PR dá o mesmo recorte.
72
+ *
73
+ * @param {Array<{number,createdAt?}>} subs sub-issues como vêm do listSubIssues
74
+ * @param {string|null} notAfter ISO-8601; null desliga o recorte
75
+ * @returns {{ kept: Array, skipped: Array }}
76
+ */
77
+ export function partitionByCreation(subs = [], notAfter = null) {
78
+ if (!notAfter) return { kept: [...subs], skipped: [] };
79
+ const limit = Date.parse(notAfter);
80
+ if (Number.isNaN(limit)) return { kept: [...subs], skipped: [] };
81
+ const kept = [];
82
+ const skipped = [];
83
+ for (const sub of subs) {
84
+ const at = sub?.createdAt ? Date.parse(sub.createdAt) : NaN;
85
+ // Sem data legível não dá para afirmar que nasceu depois — mantém.
86
+ if (Number.isNaN(at) || at <= limit) kept.push(sub);
87
+ else skipped.push(sub);
88
+ }
89
+ return { kept, skipped };
90
+ }
91
+
28
92
  // A partir de qualquer issue (Feature/Story/Task), sobe a hierarquia e retorna a Feature.
29
93
  async function resolveFeatureIssue(token, owner, repo, issueNumber) {
30
94
  let issue;
@@ -70,21 +134,32 @@ export function classifyReviewTarget(type) {
70
134
  return 'ignore';
71
135
  }
72
136
 
73
- async function collectReviewUnit(token, owner, repo, issueNumber) {
137
+ // Labels que dizem "há uma decomposição em curso nesta Feature". Descer para a
138
+ // subárvore no meio disso é a corrida do item 3 — melhor não tocar em nada e
139
+ // deixar o humano reexecutar.
140
+ const DECOMPOSING_LABELS = [LABEL_DECOMPOSE, LABEL_DECOMPOSE_APPLY];
141
+
142
+ function isDecomposing(issue) {
143
+ return labelNames(issue?.labels || []).some(n => DECOMPOSING_LABELS.includes(n));
144
+ }
145
+
146
+ async function collectReviewUnit(token, owner, repo, issueNumber, { notAfter = null } = {}) {
74
147
  const stories = new Map();
75
148
  const tasks = new Map();
76
149
  const bugs = new Map();
150
+ const skipped = [];
77
151
  const addStory = (n, nodeId, title) => { if (n && nodeId && !stories.has(n)) stories.set(n, { nodeId, title }); };
78
152
  const addTask = (n, nodeId, title) => { if (n && nodeId && !tasks.has(n)) tasks.set(n, { nodeId, title }); };
153
+ const empty = (extra = {}) => ({ feature: null, stories, tasks, bugs, skipped, deferred: null, ...extra });
79
154
 
80
155
  let issue;
81
- try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return { feature: null, stories, tasks, bugs }; }
156
+ try { issue = await getIssue(token, owner, repo, issueNumber); } catch { return empty(); }
82
157
  const type = detectIssueType(issue);
83
158
  const kind = classifyReviewTarget(type);
84
- if (kind === 'ignore') return { feature: null, stories, tasks, bugs };
159
+ if (kind === 'ignore') return empty();
85
160
  if (kind === 'bug') {
86
161
  bugs.set(issue.number, { nodeId: issue.node_id, title: issue.title });
87
- return { feature: null, stories, tasks, bugs };
162
+ return empty({ bugs });
88
163
  }
89
164
 
90
165
  const featureIssue = await resolveFeatureIssue(token, owner, repo, issueNumber);
@@ -92,32 +167,42 @@ async function collectReviewUnit(token, owner, repo, issueNumber) {
92
167
  ? { number: featureIssue.number, nodeId: featureIssue.node_id, title: featureIssue.title }
93
168
  : null;
94
169
 
170
+ // Decomposição em curso: as sub-issues estão sendo criadas AGORA. Mover o que
171
+ // já existe deixa o board mentindo sobre metade da árvore.
172
+ if (isDecomposing(issue) || (featureIssue && isDecomposing(featureIssue))) {
173
+ return empty({ deferred: (featureIssue || issue).number });
174
+ }
175
+
95
176
  // Spikes (tipos manuais) nunca são movidos automaticamente — pulados aqui.
96
177
  const isManual = (sub) => isManualStageType(detectIssueType({ title: sub.title, labels: sub.labels }));
97
178
 
179
+ // Desce um nível aplicando o recorte temporal, registrando o que ficou de fora.
180
+ const children = async (nodeId) => {
181
+ const all = await listSubIssues(token, nodeId).catch(() => []);
182
+ const { kept, skipped: out } = partitionByCreation(all, notAfter);
183
+ skipped.push(...out);
184
+ return kept;
185
+ };
186
+
98
187
  if (type === 'Feature') {
99
188
  // Referência direta à Feature: toda a subárvore (Stories + Tasks).
100
- const subs = await listSubIssues(token, issue.node_id).catch(() => []);
101
- for (const st of subs) {
189
+ for (const st of await children(issue.node_id)) {
102
190
  if (isManual(st)) continue;
103
191
  addStory(st.number, st.nodeId, st.title);
104
- const tks = await listSubIssues(token, st.nodeId).catch(() => []);
105
- for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
192
+ for (const t of await children(st.nodeId)) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
106
193
  }
107
194
  } else if (type === 'Story') {
108
195
  addStory(issue.number, issue.node_id, issue.title);
109
- const tks = await listSubIssues(token, issue.node_id).catch(() => []);
110
- for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
196
+ for (const t of await children(issue.node_id)) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
111
197
  } else { // Task → inclui a Story pai e as Tasks irmãs.
112
198
  addTask(issue.number, issue.node_id, issue.title);
113
199
  const parent = await getIssueParent(token, issue.node_id).catch(() => null);
114
200
  if (parent && detectIssueType({ title: parent.title }) === 'Story') {
115
201
  addStory(parent.number, parent.nodeId, parent.title);
116
- const tks = await listSubIssues(token, parent.nodeId).catch(() => []);
117
- for (const t of tks) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
202
+ for (const t of await children(parent.nodeId)) { if (isManual(t)) continue; addTask(t.number, t.nodeId, t.title); }
118
203
  }
119
204
  }
120
- return { feature, stories, tasks, bugs };
205
+ return { feature, stories, tasks, bugs, skipped, deferred: null };
121
206
  }
122
207
 
123
208
  // A Feature só avança quando TODAS as suas Stories já estiverem em Code Review
@@ -137,7 +222,7 @@ async function allStoriesReadyForReview(readToken, projToken, project, etapaFiel
137
222
  return true;
138
223
  }
139
224
 
140
- export async function codeReview({ prNumber }) {
225
+ export async function codeReview({ prNumber, resolveOnly = false }) {
141
226
  const token = await resolveToken();
142
227
  const projectToken = process.env.PROJECT_TOKEN || token;
143
228
  const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
@@ -153,10 +238,37 @@ export async function codeReview({ prNumber }) {
153
238
  }
154
239
 
155
240
  const pr = await getPR(token, owner, repo, parseInt(prNumber, 10));
156
- const issueNums = extractIssueNumbers(pr.body || '');
241
+ const linkMode = cfg?.codeReview?.linkMode === 'any-mention' ? 'any-mention' : 'explicit';
242
+
243
+ // Duas fontes de vínculo, nesta ordem: o que o GitHub reconhece nativamente
244
+ // (o mesmo que fecha a issue no merge, e que cobre o vínculo feito pela UI) e
245
+ // o que está escrito no corpo. `Implements #N` só existe na segunda.
246
+ const linked = await getPRClosingIssues(token, owner, repo, parseInt(prNumber, 10)).catch(err => {
247
+ console.warn(`Não foi possível ler os vínculos nativos do PR: ${err.message}`);
248
+ return [];
249
+ });
250
+ const issueNums = [...new Set([...linked, ...extractIssueNumbers(pr.body || '', { linkMode })])];
157
251
 
158
252
  if (issueNums.length === 0) {
159
- console.log('PR sem referências a issues — nenhuma Feature atualizada.');
253
+ console.log(
254
+ linkMode === 'explicit'
255
+ ? 'PR sem vínculo explícito (Closes/Fixes/Resolves/Implements #N) — board não atualizado. ' +
256
+ 'Menção solta a uma issue é referência cruzada, não implementação.'
257
+ : 'PR sem referências a issues — nenhuma Feature atualizada.'
258
+ );
259
+ if (resolveOnly) emitResolved(null);
260
+ return;
261
+ }
262
+
263
+ // `--resolve-only`: só descobre a Feature-alvo e imprime, para o workflow
264
+ // poder declarar a concurrency por ITEM antes de tocar no board (o job que
265
+ // move fica atrás da mesma trava do decompose).
266
+ if (resolveOnly) {
267
+ for (const num of issueNums) {
268
+ const feature = await resolveFeatureIssue(token, owner, repo, num).catch(() => null);
269
+ if (feature) { emitResolved(feature.number); return; }
270
+ }
271
+ emitResolved(issueNums[0]);
160
272
  return;
161
273
  }
162
274
 
@@ -174,12 +286,26 @@ export async function codeReview({ prNumber }) {
174
286
  const seen = new Set();
175
287
  const updated = [];
176
288
  const featuresChecked = new Set();
289
+ const deferred = new Set();
290
+ const ignored = [];
177
291
 
178
292
  // Regras ao abrir o PR: Tasks → 🎉 Done (implementação concluída);
179
293
  // Stories → 👀 Code Review; Feature → Code Review só quando TODAS as suas
180
294
  // Stories já estiverem em Code Review.
181
295
  for (const num of issueNums) {
182
- const { feature, stories, tasks, bugs } = await collectReviewUnit(token, owner, repo, num);
296
+ const unit = await collectReviewUnit(token, owner, repo, num, { notAfter: pr.created_at });
297
+ const { feature, stories, tasks, bugs } = unit;
298
+ if (unit.deferred) {
299
+ deferred.add(unit.deferred);
300
+ console.log(
301
+ `#${unit.deferred} está com decomposição em andamento — board não atualizado para esta unidade.`
302
+ );
303
+ continue;
304
+ }
305
+ for (const sub of unit.skipped) {
306
+ ignored.push(`#${sub.number} ${sub.title}`);
307
+ console.log(`#${sub.number} criada depois da abertura do PR (${sub.createdAt}) — fora desta review.`);
308
+ }
183
309
 
184
310
  // Tasks → Done (Status Done).
185
311
  for (const [n, info] of tasks) {
@@ -255,14 +381,43 @@ export async function codeReview({ prNumber }) {
255
381
  }
256
382
  }
257
383
 
258
- if (updated.length > 0) {
259
- await commentOnIssue(
260
- token, owner, repo, parseInt(prNumber, 10),
261
- `🔍 **Code Review iniciado**\n\n` +
262
- `Board atualizado (Tasks → **${DONE_STAGE}**; Story → **${CODE_REVIEW_STAGE}**; Feature só avança quando todas as Stories concluírem):\n\n` +
263
- updated.map(f => `- ${f}`).join('\n')
264
- ).catch(() => {});
384
+ if (updated.length > 0 || deferred.size > 0) {
385
+ const partes = [];
386
+ if (updated.length > 0) {
387
+ partes.push(
388
+ `🔍 **Code Review iniciado**\n\n` +
389
+ `Board atualizado (Tasks **${DONE_STAGE}**; Story → **${CODE_REVIEW_STAGE}**; Feature só avança quando todas as Stories concluírem):\n\n` +
390
+ updated.map(f => `- ${f}`).join('\n')
391
+ );
392
+ }
393
+ // O que ficou de fora precisa ser dito: silêncio aqui vira "o board está
394
+ // certo" na cabeça de quem lê, que é exatamente o erro que se quer evitar.
395
+ if (ignored.length > 0) {
396
+ partes.push(
397
+ `_Fora desta review (criadas depois da abertura do PR):_\n\n${ignored.map(i => `- ${i}`).join('\n')}`
398
+ );
399
+ }
400
+ if (deferred.size > 0) {
401
+ partes.push(
402
+ `⏳ **Board não atualizado** para ${[...deferred].map(n => `#${n}`).join(', ')}: ` +
403
+ 'há decomposição em andamento. Depois que ela terminar, rode ' +
404
+ `\`npx @spec-wave/cli@latest code-review --pr-number ${prNumber}\`.`
405
+ );
406
+ }
407
+ await commentOnIssue(token, owner, repo, parseInt(prNumber, 10), partes.join('\n\n')).catch(() => {});
265
408
  }
266
409
 
267
410
  console.log(`code-review: ${updated.length} item(ns) atualizado(s) no board.`);
268
411
  }
412
+
413
+ // Saída do `--resolve-only`: linha legível + `feature=<n>` no GITHUB_OUTPUT,
414
+ // que é o que o job seguinte usa como chave de concurrency.
415
+ function emitResolved(number) {
416
+ const value = number ? String(number) : '';
417
+ console.log(`feature=${value}`);
418
+ const out = process.env.GITHUB_OUTPUT;
419
+ if (out) {
420
+ try { appendFileSync(out, `feature=${value}\n`); }
421
+ catch (err) { console.warn(`Falha ao escrever em GITHUB_OUTPUT: ${err.message}`); }
422
+ }
423
+ }
@@ -23,7 +23,7 @@ import { resolveToken } from '../api/auth.mjs';
23
23
  import {
24
24
  getIssue, createIssue, removeLabel, addLabel, commentOnIssue, addBlockedBy, listIssueComments,
25
25
  } from '../api/github-rest.mjs';
26
- import { addSubIssue, listSubIssues } from '../api/github-graphql.mjs';
26
+ import { addSubIssue, listSubIssues, getProjectSnapshot } from '../api/github-graphql.mjs';
27
27
  import { loadProjectConfig, resolveField, advanceToStage } from '../lib/board.mjs';
28
28
  import { generateDocument } from '../lib/claude.mjs';
29
29
  import {
@@ -37,7 +37,7 @@ import { slugify } from '../lib/slugify.mjs';
37
37
  import { detectIssueType } from '../lib/issue-type.mjs';
38
38
  import { loadConfig } from '../lib/project-root.mjs';
39
39
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
40
- import { loadPrompt, toolFreeSystemPrompt } from '../lib/prompt-loader.mjs';
40
+ import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
41
41
  import {
42
42
  renderDecompositionDoc, parseDecompositionDoc, DECOMPOSITION_FILE,
43
43
  } from '../lib/decomposition-doc.mjs';
@@ -54,6 +54,24 @@ async function moveToReady(token, project, etapaField, statusField, nodeId) {
54
54
  await advanceToStage(token, project, etapaField, statusField, nodeId, STAGE_READY, PROGRESS_TODO);
55
55
  }
56
56
 
57
+ /**
58
+ * Board inalcançável no apply — parada ANTES de criar qualquer issue.
59
+ *
60
+ * Separado de DecomposeBlockedError porque o remédio é outro: aqui nada foi
61
+ * criado e o problema é de credencial/config, não do conteúdo do rascunho.
62
+ */
63
+ class BoardUnreachableError extends Error {
64
+ constructor(motivo) {
65
+ super(
66
+ `Board inalcançável: ${motivo}. Nenhuma issue foi criada — ` +
67
+ 'sem escrita no board elas nasceriam sem Etapa e ficariam invisíveis em todas as telas. ' +
68
+ 'Verifique se o GH_PROJECT_TOKEN tem scope `project` e acesso ao Project da organização.'
69
+ );
70
+ this.name = 'BoardUnreachableError';
71
+ this.boardUnreachable = true;
72
+ }
73
+ }
74
+
57
75
  /**
58
76
  * Parada por decisão do fluxo (crítica grave, crítica indisponível, teto de
59
77
  * tentativas): o comentário já foi postado, o catch externo não deve duplicá-lo,
@@ -252,9 +270,9 @@ async function draftDecomposition(ctx) {
252
270
  { cwd: root },
253
271
  );
254
272
  const generated = parseModelJson(await generateDocument(
255
- toolFreeSystemPrompt(decomposePrompt),
273
+ systemPromptWithTools(decomposePrompt),
256
274
  userContent,
257
- { action: 'decompose', labels, usage }
275
+ { action: 'decompose', labels, usage, maxTurns: decomposePrompt.maxTurns }
258
276
  ));
259
277
 
260
278
  markdown = renderDecompositionDoc({
@@ -438,16 +456,89 @@ async function applyDecomposition(ctx) {
438
456
  }
439
457
 
440
458
  // Campos do board só são resolvidos aqui: a etapa de rascunho não toca o board.
441
- const { project, etapaField, statusField } = await resolveBoard(ctx);
459
+ // `strict`: no apply, board inalcançável ABORTA antes da primeira criação.
460
+ const { project, etapaField, statusField } = await resolveBoard(ctx, { strict: true });
442
461
  const applyCtx = { ...ctx, project, etapaField, statusField };
443
462
 
444
- if (doc.kind === 'tasks') await createTasksFromDoc(applyCtx, doc);
445
- else await createStoriesFromDoc(applyCtx, doc);
463
+ // As falhas de board voltam em vez de subir na hora: a exceção precisa esperar
464
+ // a contabilidade de labels abaixo. Lançar aqui deixaria o gatilho
465
+ // `spec-wave:decompose-apply` preso na issue — e `issues: [labeled]` não
466
+ // redispara com a label ainda aplicada, o mesmo beco sem saída que o
467
+ // generate-plan já evita.
468
+ const boardFailures = doc.kind === 'tasks'
469
+ ? await createTasksFromDoc(applyCtx, doc)
470
+ : await createStoriesFromDoc(applyCtx, doc);
471
+
472
+ // O arquivo passa a REGISTRAR o que foi criado, em vez de continuar
473
+ // descrevendo só a proposta. Sem isto, rescopar as issues à mão (o que é
474
+ // legítimo e acontece) deixa o decomposition.md como registro enganoso, e nada
475
+ // no fluxo percebe: o guard `spec-wave:decomposed` impede duplicar, não
476
+ // desatualizar.
477
+ //
478
+ // Depois da criação de propósito: se este commit falhar, as issues já existem
479
+ // e o pior caso é o comportamento anterior (arquivo sem anotação), avisado no
480
+ // log. O contrário — anotar antes e falhar na criação — inventaria issues.
481
+ try {
482
+ commitFile(
483
+ docPath,
484
+ renderDecompositionDoc({ ...doc, appliedAt: new Date().toISOString() }),
485
+ `docs: registra as issues criadas em ${docRel} [spec-wave]`,
486
+ ctx.runMode,
487
+ );
488
+ console.log(`${docRel} anotado com as issues criadas.`);
489
+ } catch (err) {
490
+ console.warn(
491
+ `⚠️ Issues criadas, mas ${docRel} não foi anotado (${err.message}). ` +
492
+ 'O arquivo segue descrevendo a proposta, não o que existe.'
493
+ );
494
+ }
446
495
 
447
496
  await addLabel(token, owner, repo, number, LABEL_DECOMPOSED)
448
497
  .catch(err => console.warn(`Falha ao aplicar a label ${LABEL_DECOMPOSED}: ${err.message}`));
449
498
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_APPLY);
450
499
  await removeLabel(token, owner, repo, number, LABEL_DECOMPOSE_READY).catch(() => {});
500
+
501
+ // Por último, com as labels já consistentes: escrita de board incompleta
502
+ // termina o run em VERMELHO.
503
+ failIfBoardIncomplete(boardFailures);
504
+ }
505
+
506
+ /**
507
+ * Bloco do comentário quando itens ficaram sem Etapa (função PURA).
508
+ *
509
+ * O comentário é postado ANTES de a exceção subir: quem for reparar precisa da
510
+ * lista, e ela não pode depender de alguém abrir o log do Action.
511
+ */
512
+ export function renderBoardFailures(failures) {
513
+ const linhas = failures.map(f => `- ${f.kind} #${f.number}`).join('\n');
514
+ const motivo = failures[0]?.reason || 'erro desconhecido';
515
+ return (
516
+ `⚠️ **${failures.length} item(ns) ficaram SEM Etapa no board** — eles existem, mas ` +
517
+ 'não aparecem em nenhuma tela (o inbox do PM lista Features, a tela do Dev lê ' +
518
+ '🚧 Desenvolvimento, a fila do TL lê ✅ Ready):\n\n' +
519
+ `${linhas}\n\n` +
520
+ `Motivo: \`${motivo}\`\n\n` +
521
+ '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>`.'
523
+ );
524
+ }
525
+
526
+ /**
527
+ * Escrita do board incompleta = run VERMELHO.
528
+ *
529
+ * O apply criava as issues e engolia TODA falha de board, terminando com exit 0:
530
+ * 27 issues nasceram sem Etapa, invisíveis em todas as telas, com o Action ✓ —
531
+ * só se descobriu rodando `order` por acaso. É o mesmo princípio que o fluxo já
532
+ * aplica à crítica ("um pipeline bloqueado nunca fica verde") e ao truncamento
533
+ * ("documento cortado vale menos que documento nenhum").
534
+ */
535
+ function failIfBoardIncomplete(failures) {
536
+ if (failures.length === 0) return;
537
+ const err = new DecomposeBlockedError(
538
+ `${failures.length} item(ns) criados ficaram sem Etapa no board — ` +
539
+ 'as issues existem mas estão invisíveis nas telas. Ver o comentário na issue para reparar.'
540
+ );
541
+ throw err;
451
542
  }
452
543
 
453
544
  async function createStoriesFromDoc(ctx, doc) {
@@ -456,6 +547,9 @@ async function createStoriesFromDoc(ctx, doc) {
456
547
  const created = [];
457
548
  const createdStories = []; // issues criadas, na ordem dos índices das stories
458
549
  const generatedTexts = []; // títulos+corpos para o lint de idioma final
550
+ // Item que não consegue Etapa fica invisível em todas as telas. Coletar em vez
551
+ // de só avisar é o que permite o run terminar VERMELHO — ver reportBoardFailures.
552
+ const boardFailures = [];
459
553
 
460
554
  for (let i = 0; i < doc.stories.length; i++) {
461
555
  const story = doc.stories[i];
@@ -472,6 +566,9 @@ async function createStoriesFromDoc(ctx, doc) {
472
566
  if (depLine) storyBody += `\n\n${depLine}`;
473
567
 
474
568
  const createdStory = await createIssue(token, owner, repo, storyTitle, storyBody, ['[STORY]']);
569
+ // Anota no doc em memória: é daqui que sai o `**Issue:** #N` gravado no
570
+ // arquivo no fim do apply.
571
+ story.issue = createdStory.number;
475
572
  created.push({ title: storyTitle, url: createdStory.url });
476
573
  createdStories.push(createdStory);
477
574
  generatedTexts.push(storyTitle, storyBody);
@@ -494,6 +591,7 @@ async function createStoriesFromDoc(ctx, doc) {
494
591
  await moveToReady(projectToken, project, etapaField, statusField, createdStory.nodeId);
495
592
  } catch (err) {
496
593
  console.warn(` Falha ao mover story #${createdStory.number} para "${STAGE_READY}": ${err.message}`);
594
+ boardFailures.push({ number: createdStory.number, kind: 'Story', reason: err.message });
497
595
  }
498
596
 
499
597
  for (const task of story.tasks || []) {
@@ -501,6 +599,7 @@ async function createStoriesFromDoc(ctx, doc) {
501
599
  const taskTitle = `[TASK] ${task.title}`;
502
600
  const taskBody = `${task.body}\n\n_Story pai: ${createdStory.url}_`;
503
601
  const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
602
+ task.issue = createdTask.number;
504
603
  generatedTexts.push(taskTitle, taskBody);
505
604
  try {
506
605
  await addSubIssue(token, createdStory.nodeId, createdTask.nodeId);
@@ -511,6 +610,7 @@ async function createStoriesFromDoc(ctx, doc) {
511
610
  await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
512
611
  } catch (err) {
513
612
  console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
613
+ boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
514
614
  }
515
615
  }
516
616
  }
@@ -520,16 +620,21 @@ async function createStoriesFromDoc(ctx, doc) {
520
620
  if (project?.id && etapaField) console.log(`Feature movida para "${STAGE_READY}" no board.`);
521
621
  } catch (err) {
522
622
  console.warn(`Falha ao mover Feature para "${STAGE_READY}": ${err.message}`);
623
+ boardFailures.push({ number: parseInt(issueNumber, 10), kind: 'Feature', reason: err.message });
523
624
  }
524
625
 
525
626
  const list = created.map(s => `- ${s.url} — ${s.title}`).join('\n');
627
+ const posicionamento = boardFailures.length === 0
628
+ ? `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).`
629
+ : renderBoardFailures(boardFailures);
526
630
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
527
631
  `🔀 **Decomposição aplicada!**\n\n` +
528
632
  `A partir de \`${docRel}\` foram criados ${created.length} stories e suas tasks:\n\n${list}\n\n` +
529
- `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement ${issueNumber}\` (Stories em ordem de dependência).` +
633
+ posicionamento +
530
634
  formatItemsLintWarning(generatedTexts)
531
- );
635
+ ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
532
636
  console.log(`Decomposição aplicada: ${created.length} stories criadas.`);
637
+ return boardFailures;
533
638
  }
534
639
 
535
640
  async function createTasksFromDoc(ctx, doc) {
@@ -537,12 +642,14 @@ async function createTasksFromDoc(ctx, doc) {
537
642
  const parentNodeId = issue.node_id;
538
643
  const created = [];
539
644
  const generatedTexts = [];
645
+ const boardFailures = [];
540
646
 
541
647
  for (const task of doc.tasks) {
542
648
  console.log(`Criando task: ${task.title}`);
543
649
  const taskTitle = `[TASK] ${task.title}`;
544
650
  const taskBody = `${task.body}\n\n_RFC pai: ${issue.html_url || `#${issueNumber}`}_`;
545
651
  const createdTask = await createIssue(token, owner, repo, taskTitle, taskBody, ['[TASK]']);
652
+ task.issue = createdTask.number;
546
653
  created.push({ title: taskTitle, url: createdTask.url });
547
654
  generatedTexts.push(taskTitle, taskBody);
548
655
 
@@ -555,34 +662,65 @@ async function createTasksFromDoc(ctx, doc) {
555
662
  await moveToReady(projectToken, project, etapaField, statusField, createdTask.nodeId);
556
663
  } catch (err) {
557
664
  console.warn(` Falha ao mover task #${createdTask.number} para "${STAGE_READY}": ${err.message}`);
665
+ boardFailures.push({ number: createdTask.number, kind: 'Task', reason: err.message });
558
666
  }
559
667
  }
560
668
 
561
669
  const list = created.map(t => `- ${t.url} — ${t.title}`).join('\n');
670
+ const posicionamento = boardFailures.length === 0
671
+ ? `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.`
672
+ : renderBoardFailures(boardFailures);
562
673
  await commentOnIssue(token, owner, repo, parseInt(issueNumber, 10),
563
674
  `🔀 **Decomposição do RFC aplicada!**\n\n` +
564
675
  `A partir de \`${docRel}\` foram criadas ${created.length} tasks:\n\n${list}\n\n` +
565
- `Tudo posicionado em **✅ Ready**. Inicie o desenvolvimento com \`npx @spec-wave/cli@latest implement <task>\`.` +
676
+ posicionamento +
566
677
  formatItemsLintWarning(generatedTexts)
567
- );
678
+ ).catch(err => console.warn(`Falha ao comentar a decomposição: ${err.message}`));
568
679
  console.log(`Decomposição aplicada: ${created.length} tasks criadas.`);
680
+ return boardFailures;
569
681
  }
570
682
 
571
683
  // Projeto + campos Etapa/Status do board (reutilizados em todos os itens).
572
- async function resolveBoard({ projectToken, root }) {
684
+ async function resolveBoard({ projectToken, root }, { strict = false } = {}) {
573
685
  const { project, error: projectError } = loadProjectConfig({ cwd: root || process.cwd() });
574
- if (projectError) console.warn(`${projectError} — itens criados não serão posicionados no board.`);
686
+ if (projectError) {
687
+ if (strict) throw new BoardUnreachableError(projectError);
688
+ console.warn(`${projectError} — itens criados não serão posicionados no board.`);
689
+ }
690
+
691
+ // PREFLIGHT do apply: resolver o NODE do project antes de criar qualquer issue.
692
+ //
693
+ // `resolveField` não serve como preflight: ele devolve do cache do config
694
+ // (`project.fields`) sem tocar a rede, então um token sem acesso ao Project
695
+ // passava aqui limpo e só falhava na primeira mutação — depois de 27 issues
696
+ // criadas. Elas nascem sem Etapa, e sem Etapa somem de TODAS as telas (o inbox
697
+ // do PM lista Features, a tela do Dev lê 🚧 Desenvolvimento, a fila do TL lê
698
+ // ✅ Ready). Vinte e sete issues existindo e nenhuma aparecendo, com o Action
699
+ // verde. `getProjectSnapshot` faz `node(id:)` e é exatamente o que falha
700
+ // quando o token não alcança o Project da organização.
701
+ if (strict && project?.id) {
702
+ try {
703
+ await getProjectSnapshot(projectToken, project.id);
704
+ } catch (err) {
705
+ throw new BoardUnreachableError(
706
+ `o Project ${project.id} não pôde ser resolvido com o token disponível (${err.message})`
707
+ );
708
+ }
709
+ }
710
+
575
711
  let etapaField = null;
576
712
  let statusField = null;
577
713
  if (project?.id) {
578
714
  try {
579
715
  etapaField = await resolveField(projectToken, project, 'Etapa');
580
716
  } catch (err) {
717
+ if (strict) throw new BoardUnreachableError(`campo Etapa não resolvido (${err.message})`);
581
718
  console.warn(`Não foi possível resolver campo Etapa do board: ${err.message}`);
582
719
  }
583
720
  try {
584
721
  statusField = await resolveField(projectToken, project, 'Status');
585
722
  } catch (err) {
723
+ if (strict) throw new BoardUnreachableError(`campo Status não resolvido (${err.message})`);
586
724
  console.warn(`Não foi possível resolver campo Status do board: ${err.message}`);
587
725
  }
588
726
  }