@spec-wave/cli 0.26.0 → 0.27.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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-rest.mjs +25 -0
  3. package/src/commands/decompose.mjs +166 -39
  4. package/src/commands/doctor.mjs +190 -3
  5. package/src/commands/generate-bug.mjs +22 -16
  6. package/src/commands/generate-plan.mjs +72 -23
  7. package/src/commands/generate-spec.mjs +19 -15
  8. package/src/commands/implement.mjs +40 -20
  9. package/src/commands/run.mjs +51 -30
  10. package/src/commands/validate.mjs +84 -17
  11. package/src/config.mjs +18 -0
  12. package/src/lib/artifact-pr.mjs +272 -0
  13. package/src/lib/artifact-publish.mjs +169 -0
  14. package/src/lib/doc-availability.mjs +23 -1
  15. package/src/lib/doc-source.mjs +162 -0
  16. package/src/lib/flow-run.mjs +9 -218
  17. package/src/lib/next-step.mjs +27 -4
  18. package/src/lib/pr-branch.mjs +10 -0
  19. package/src/lib/repo-links.mjs +8 -2
  20. package/src/plugin/.claude-plugin/plugin.json +1 -1
  21. package/src/plugin/skills/bug/SKILL.md +2 -2
  22. package/src/plugin/skills/decompose/SKILL.md +4 -4
  23. package/src/plugin/skills/plan/SKILL.md +1 -1
  24. package/src/plugin/skills/run/SKILL.md +3 -1
  25. package/src/plugin/skills/spec/SKILL.md +4 -4
  26. package/src/plugin/skills/workflow/SKILL.md +2 -2
  27. package/src/templates/skill/SKILL.md +7 -7
  28. package/src/templates/workflows/code-review.yml +13 -2
  29. package/src/templates/workflows/critique.yml +1 -1
  30. package/src/templates/workflows/decompose.yml +13 -2
  31. package/src/templates/workflows/generate-bug.yml +17 -6
  32. package/src/templates/workflows/generate-plan.yml +20 -7
  33. package/src/templates/workflows/generate-spec.yml +20 -7
  34. package/src/templates/workflows/qa.yml +13 -0
@@ -18,8 +18,9 @@ import { recordUsage } from '../lib/usage-report.mjs';
18
18
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
19
19
  import { detectIssueType } from '../lib/issue-type.mjs';
20
20
  import { bugDocPaths } from '../lib/bug-doc.mjs';
21
- import { commitGenerated, resolveFlowContext } from '../lib/flow-run.mjs';
22
- import { docBlobUrl } from '../lib/repo-links.mjs';
21
+ import { resolveFlowContext } from '../lib/flow-run.mjs';
22
+ import { publishArtifact } from '../lib/artifact-publish.mjs';
23
+ import { renderPrLine } from '../lib/artifact-pr.mjs';
23
24
  import {
24
25
  runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
25
26
  } from '../lib/critique.mjs';
@@ -77,7 +78,7 @@ export async function generateBug({ issueNumber }) {
77
78
  return;
78
79
  }
79
80
 
80
- const { slug, dirRel, fileRel, fileAbs } = bugDocPaths(issue.title, root);
81
+ const { fileRel } = bugDocPaths(issue.title, root);
81
82
 
82
83
  const comments = await listIssueComments(token, owner, repo, n).catch(() => []);
83
84
  const report = buildReport(issue, comments);
@@ -111,16 +112,19 @@ export async function generateBug({ issueNumber }) {
111
112
  // ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
112
113
  const content = unwrapGeneratedDoc(bruto);
113
114
 
114
- // Publica pelo mesmo caminho do generate-spec/plan/decompose: commit
115
- // escopado ao arquivo, identidade do bot no runner e repetição quando
116
- // outro run rouba a ponta da branch. O bloco de git solto que existia aqui
117
- // não tinha nada disso em particular, o `git commit` sem `-- <arquivo>`
118
- // varria para dentro do commit qualquer coisa que estivesse no index.
119
- const published = commitGenerated({
120
- filePath: fileAbs,
115
+ // Publica pelo mesmo caminho do generate-spec/plan/decompose: branch própria
116
+ // do documento, commit único e Pull Request. O bloco de git solto que
117
+ // existia aqui commitava direto na branch default sem sequer escopar o
118
+ // commit ao arquivovarria para dentro qualquer coisa no index.
119
+ const published = await publishArtifact({
120
+ token, owner, repo,
121
+ doc: 'bug',
122
+ issueNumber: n,
123
+ issueTitle: issue.title,
124
+ issueUrl: issue.html_url,
125
+ pathRel: fileRel,
121
126
  content,
122
- message: `docs: generate bug.md for ${slug} [spec-wave]`,
123
- mode,
127
+ nextLabel: 'spec-wave:ready',
124
128
  });
125
129
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
126
130
 
@@ -136,14 +140,16 @@ export async function generateBug({ issueNumber }) {
136
140
  await commentOnIssue(
137
141
  token, owner, repo, n,
138
142
  '🐞 **bug.md gerado automaticamente!**\n\n' +
139
- `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root, config })})\n\n` +
140
- 'Revise a **causa raiz** e o **teste de regressão** — são as duas seções que decidem se ' +
141
- 'a correção ataca o defeito ou o sintoma. Quando estiver pronto, valide com:\n' +
143
+ `📄 Arquivo: [\`${fileRel}\`](${published.blobUrl})\n\n` +
144
+ `${renderPrLine(published)}\n\n` +
145
+ 'Revise a **causa raiz** e o **teste de regressão** no Pull Request são as duas seções ' +
146
+ 'que decidem se a correção ataca o defeito ou o sintoma. Depois do merge, valide com:\n' +
142
147
  `\`\`\`\ngh issue edit ${n} --add-label "spec-wave:ready"\n\`\`\`` +
143
148
  (critique?.blocked ? '\n\n⛔ A crítica adversarial encontrou problemas graves (acima).' : '')
144
149
  );
145
150
 
146
- console.log(`bug.md criado em: ${fileRel} (dir: ${dirRel})`);
151
+ console.log(`bug.md publicado em ${published.branch} (${fileRel}).`);
152
+ return { pr: published.pr, branch: published.branch };
147
153
  } catch (err) {
148
154
  // Mesmo motivo do generate-spec: sem remover a label, re-aplicá-la não
149
155
  // emite evento e a issue vira beco sem saída.
@@ -2,13 +2,13 @@ import path from 'node:path';
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import {
5
- getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments,
5
+ getIssue, removeLabel, addLabel, commentOnIssue, listIssueComments, getRepoDefaultBranch,
6
6
  } from '../api/github-rest.mjs';
7
7
  import { detectIssueType } from '../lib/issue-type.mjs';
8
8
  import {
9
9
  allowsSpecPlan, SPEC_PLAN_EXCLUDED_TYPES, TARGET_LANGUAGE,
10
10
  LABEL_CRITIQUE, LABEL_CRITIQUE_FAILED, LABEL_NEEDS_HUMAN, LABEL_RISK_ACCEPTED,
11
- DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
11
+ LABEL_SPEC, DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
12
12
  } from '../config.mjs';
13
13
  import { generateDocument } from '../lib/claude.mjs';
14
14
  import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
@@ -18,9 +18,11 @@ import {
18
18
  } from '../lib/critique.mjs';
19
19
  import { recordUsage } from '../lib/usage-report.mjs';
20
20
  import { slugify } from '../lib/slugify.mjs';
21
- import { resolveFromRoot } from '../lib/project-root.mjs';
22
- import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
23
- import { docBlobUrl } from '../lib/repo-links.mjs';
21
+ import { resolveFlowContext } from '../lib/flow-run.mjs';
22
+ import { publishArtifact } from '../lib/artifact-publish.mjs';
23
+ import { loadArtifact } from '../lib/doc-source.mjs';
24
+ import { awaitingMergeBlock, renderPrLine } from '../lib/artifact-pr.mjs';
25
+ import { isAwaitingMerge } from '../lib/doc-source.mjs';
24
26
  import { buildTechContext } from '../lib/tech-context.mjs';
25
27
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
26
28
 
@@ -155,13 +157,39 @@ export async function generatePlan({ issueNumber }) {
155
157
  // Caminho RELATIVO para links/commit; ABSOLUTO ancorado na raiz para o fs — o
156
158
  // config é procurado subindo na árvore, e os documentos moram junto dele.
157
159
  const featureRel = `docs/features/${slug}`;
158
- const featureDir = resolveFromRoot(root, featureRel);
159
- const filePath = path.join(featureDir, 'plan.md');
160
160
  const fileRel = `${featureRel}/plan.md`;
161
161
 
162
- // Read existing spec.md if available (spec é gerada antes do plano)
163
- const specPath = path.join(featureDir, 'spec.md');
164
- const specContent = existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null;
162
+ // A spec é a ENTRADA do plano, e agora ela pode estar em três lugares: no
163
+ // clone, na branch base, ou num Pull Request que ninguém mergeou ainda.
164
+ const specRel = `${featureRel}/spec.md`;
165
+ const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
166
+ const spec = await loadArtifact({
167
+ token, owner, repo, root, pathRel: specRel, doc: 'spec',
168
+ issueNumber: parseInt(issueNumber, 10), base,
169
+ });
170
+
171
+ // Portão duro. Este comando TOLERAVA a spec ausente e seguia com um
172
+ // placeholder no payload — o que, com a publicação por Pull Request, viraria o
173
+ // modo de falha caro: PR da spec aberto, plano gerado sobre o vazio, nenhum
174
+ // erro. Degradar em silêncio custa uma chamada de IA e produz um documento que
175
+ // parece bom. Recusar custa uma label.
176
+ if (isAwaitingMerge(spec.state) || spec.content == null) {
177
+ const n = parseInt(issueNumber, 10);
178
+ const motivo = isAwaitingMerge(spec.state)
179
+ ? awaitingMergeBlock({ pathRel: specRel, state: spec.state, pr: spec.pr, branch: spec.ref })
180
+ : {
181
+ message: `\`${specRel}\` não existe — o plano depende da spec.`,
182
+ unblock: `Aplique \`${LABEL_SPEC}\` para gerar a spec primeiro.`,
183
+ };
184
+ await removeLabel(token, owner, repo, n, 'spec-wave:plan').catch(() => {});
185
+ await commentOnIssue(token, owner, repo, n,
186
+ `⏸️ **plan.md não gerado:** ${motivo.message}\n\n${motivo.unblock}\n\n` +
187
+ 'Gerar o plano sem a spec produziria um documento plausível e errado, ' +
188
+ 'com a chamada de IA já paga.'
189
+ ).catch(() => {});
190
+ throw new Error(motivo.message);
191
+ }
192
+ const specContent = spec.content;
165
193
 
166
194
  // Tech context (RFC-002 §4): estático + dinâmico + override do corpo da issue.
167
195
  const tech = buildTechContext({ issueBody: issue.body || '' });
@@ -199,11 +227,16 @@ export async function generatePlan({ issueNumber }) {
199
227
  // ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
200
228
  const content = unwrapGeneratedDoc(bruto);
201
229
 
202
- const published = commitGenerated({
203
- filePath,
230
+ const published = await publishArtifact({
231
+ token, owner, repo,
232
+ doc: 'plan',
233
+ issueNumber: parseInt(issueNumber, 10),
234
+ issueTitle: issue.title,
235
+ issueUrl: issue.html_url,
236
+ pathRel: fileRel,
204
237
  content,
205
- message: `docs: generate plan.md for ${slug} [spec-wave]`,
206
- mode,
238
+ base,
239
+ nextLabel: 'spec-wave:ready',
207
240
  });
208
241
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
209
242
 
@@ -214,8 +247,9 @@ export async function generatePlan({ issueNumber }) {
214
247
  await commentOnIssue(
215
248
  token, owner, repo, parseInt(issueNumber, 10),
216
249
  `📋 **plan.md gerado automaticamente!**\n\n` +
217
- `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root, config })})\n\n` +
218
- `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
250
+ `📄 Arquivo: [\`${fileRel}\`](${published.blobUrl})\n\n` +
251
+ `${renderPrLine(published)}\n\n` +
252
+ `Revise o plano **no Pull Request** e faça o merge. Depois, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
219
253
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
220
254
  formatLintWarning(lintFindings)
221
255
  );
@@ -229,7 +263,8 @@ export async function generatePlan({ issueNumber }) {
229
263
  labels: issueLabels, config, usage: usageEntries,
230
264
  });
231
265
 
232
- console.log(`plan.md criado em: ${fileRel}`);
266
+ console.log(`plan.md publicado em ${published.branch} (${fileRel}).`);
267
+ return { pr: published.pr, branch: published.branch };
233
268
  } catch (err) {
234
269
  // Mesmo beco sem saída do generate-spec: o gatilho é `issues: [labeled]`,
235
270
  // então com a label ainda aplicada re-adicioná-la não dispara nada. Remove
@@ -361,9 +396,20 @@ export async function critique({ issueNumber, file, kind, failOnGrave = false })
361
396
  return;
362
397
  }
363
398
 
364
- const featureDir = resolveFromRoot(root, `docs/features/${slugify(issue.title)}`);
365
- const planPath = path.join(featureDir, 'plan.md');
366
- if (!existsSync(planPath)) {
399
+ const featureRel = `docs/features/${slugify(issue.title)}`;
400
+ const planRel = `${featureRel}/plan.md`;
401
+ const specRel = `${featureRel}/spec.md`;
402
+ const base = await getRepoDefaultBranch(token, owner, repo).catch(() => null);
403
+ const ler = (doc, pathRel) => loadArtifact({
404
+ token, owner, repo, root, pathRel, doc, issueNumber: n, base,
405
+ });
406
+
407
+ // Este é o caminho de RECUPERAÇÃO do `critique-failed`: o revisor corrige o
408
+ // plano e pede outra crítica. Com a publicação por Pull Request, o plano que
409
+ // ele acabou de corrigir está na branch do PR — ler só o disco responderia
410
+ // "nada a criticar" e removeria o gatilho, fechando a única saída do ciclo.
411
+ const plan = await ler('plan', planRel);
412
+ if (plan.content == null) {
367
413
  console.log('plan.md ainda não existe — gere o plano antes de criticá-lo.');
368
414
  await removeLabel(token, owner, repo, n, LABEL_CRITIQUE).catch(() => {});
369
415
  await commentOnIssue(token, owner, repo, n,
@@ -371,13 +417,16 @@ export async function critique({ issueNumber, file, kind, failOnGrave = false })
371
417
  'Aplique `spec-wave:plan` para gerá-lo.').catch(() => {});
372
418
  return;
373
419
  }
374
- const specPath = path.join(featureDir, 'spec.md');
420
+ if (plan.state === 'pending-pr') {
421
+ console.log(`Criticando o plan.md do PR #${plan.pr?.number} (ainda não mergeado).`);
422
+ }
423
+ const spec = await ler('spec', specRel);
375
424
 
376
425
  const issueLabels = labelNames(issue.labels || []);
377
426
  await critiquePlan({
378
427
  token, owner, repo, issueNumber: n,
379
- spec: existsSync(specPath) ? readFileSync(specPath, 'utf-8') : null,
380
- plan: readFileSync(planPath, 'utf-8'),
428
+ spec: spec.content,
429
+ plan: plan.content,
381
430
  techContextYaml: buildTechContext({ issueBody: issue.body || '' }).yaml,
382
431
  labels: issueLabels, config, usage: [],
383
432
  });
@@ -1,13 +1,12 @@
1
- import path from 'node:path';
2
1
  import { resolveToken } from '../api/auth.mjs';
3
2
  import { getIssue, removeLabel, commentOnIssue } from '../api/github-rest.mjs';
4
3
  import { generateDocument } from '../lib/claude.mjs';
5
4
  import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
6
5
  import { recordUsage } from '../lib/usage-report.mjs';
7
6
  import { slugify } from '../lib/slugify.mjs';
8
- import { resolveFromRoot } from '../lib/project-root.mjs';
9
- import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
10
- import { docBlobUrl } from '../lib/repo-links.mjs';
7
+ import { resolveFlowContext } from '../lib/flow-run.mjs';
8
+ import { publishArtifact } from '../lib/artifact-publish.mjs';
9
+ import { renderPrLine } from '../lib/artifact-pr.mjs';
11
10
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
12
11
  import { detectIssueType } from '../lib/issue-type.mjs';
13
12
  import {
@@ -56,11 +55,9 @@ export async function generateSpec({ issueNumber }) {
56
55
  }
57
56
 
58
57
  const slug = slugify(issue.title);
59
- // Caminho RELATIVO para links/commit; ABSOLUTO ancorado na raiz para o fs o
60
- // config é procurado subindo na árvore, e os documentos moram junto dele.
58
+ // o caminho RELATIVO: a publicação é via API, e nada é gravado no working
59
+ // tree ver o cabeçalho de lib/artifact-publish.mjs.
61
60
  const featureRel = `docs/features/${slug}`;
62
- const featureDir = resolveFromRoot(root, featureRel);
63
- const filePath = path.join(featureDir, 'spec.md');
64
61
  const fileRel = `${featureRel}/spec.md`;
65
62
 
66
63
  // Payload estruturado (RFC-002 §5.1): metadata + entrada de negócio.
@@ -96,11 +93,15 @@ export async function generateSpec({ issueNumber }) {
96
93
  // ver lib/unwrap-doc.mjs. O que vai para o repositório é o documento.
97
94
  const content = unwrapGeneratedDoc(bruto);
98
95
 
99
- const published = commitGenerated({
100
- filePath,
96
+ const published = await publishArtifact({
97
+ token, owner, repo,
98
+ doc: 'spec',
99
+ issueNumber: parseInt(issueNumber, 10),
100
+ issueTitle: issue.title,
101
+ issueUrl: issue.html_url,
102
+ pathRel: fileRel,
101
103
  content,
102
- message: `docs: generate spec.md for ${slug} [spec-wave]`,
103
- mode,
104
+ nextLabel: 'spec-wave:plan',
104
105
  });
105
106
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
106
107
 
@@ -111,13 +112,16 @@ export async function generateSpec({ issueNumber }) {
111
112
  await commentOnIssue(
112
113
  token, owner, repo, parseInt(issueNumber, 10),
113
114
  `📋 **spec.md gerado automaticamente!**\n\n` +
114
- `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root })})\n\n` +
115
- `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
115
+ `📄 Arquivo: [\`${fileRel}\`](${published.blobUrl})\n\n` +
116
+ `${renderPrLine(published)}\n\n` +
117
+ `Revise a especificação **no Pull Request** e faça o merge. Depois, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
116
118
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
117
119
  formatLintWarning(lintFindings)
118
120
  );
119
121
 
120
- console.log(`spec.md criado em: ${fileRel}`);
122
+ console.log(`spec.md publicado em ${published.branch} (${fileRel}).`);
123
+ // Devolvido para o `run` saber que o próximo passo depende de um merge.
124
+ return { pr: published.pr, branch: published.branch };
121
125
  } catch (err) {
122
126
  // Sem isto a label de gatilho fica aplicada — e como o workflow dispara em
123
127
  // `issues: [labeled]`, re-adicionar uma label já presente não emite evento:
@@ -13,6 +13,7 @@ import { listSubIssues, getIssueParent, addProjectItem, getItemSingleSelectValue
13
13
  import { detectIssueType } from '../lib/issue-type.mjs';
14
14
  import { bugDocPaths } from '../lib/bug-doc.mjs';
15
15
  import { missingDocMessage, existsOnRemote } from '../lib/doc-availability.mjs';
16
+ import { loadArtifact } from '../lib/doc-source.mjs';
16
17
  import { buildBugContext } from '../lib/bug-context.mjs';
17
18
  import { slugify } from '../lib/slugify.mjs';
18
19
  import { parseDependencies, orderStories, formatDependencyLine } from '../lib/dependencies.mjs';
@@ -45,6 +46,32 @@ async function resolveFeature(token, startNodeId) {
45
46
  return null;
46
47
  }
47
48
 
49
+ /**
50
+ * Onde está o documento que não veio no clone — incluindo Pull Request aberto.
51
+ *
52
+ * `existsOnRemote` sozinho consulta só a branch base, então um documento
53
+ * recém-gerado (que vive na branch do PR até alguém mergear) era reportado como
54
+ * "não existe no repositório". Mentira cara: manda o executor refazer do zero um
55
+ * trabalho que está pronto, esperando revisão.
56
+ *
57
+ * Best-effort como a sonda que substitui: nunca lança, nunca bloqueia.
58
+ */
59
+ async function probeMissingDoc({ token, owner, repo, root, pathRel, doc, issueNumber, fallback }) {
60
+ if (issueNumber) {
61
+ const achado = await loadArtifact({ token, owner, repo, root, pathRel, doc, issueNumber })
62
+ .catch(() => null);
63
+ if (achado?.state === 'pending-pr' || achado?.state === 'branch-only') {
64
+ return missingDocMessage({
65
+ pathRel, onRemote: false, fallback, pr: achado.pr, branch: achado.ref,
66
+ });
67
+ }
68
+ if (achado?.state === 'remote') return missingDocMessage({ pathRel, onRemote: true, fallback });
69
+ if (achado?.state === 'missing') return missingDocMessage({ pathRel, onRemote: false, fallback });
70
+ }
71
+ const onRemote = await existsOnRemote({ getFileContent, token, owner, repo, pathRel });
72
+ return missingDocMessage({ pathRel, onRemote, fallback });
73
+ }
74
+
48
75
  // Lê spec.md/plan.md de um docs/features/<slug> se existirem.
49
76
  function readSpecPlan(featureDir) {
50
77
  const specPath = path.join(featureDir, 'spec.md');
@@ -463,12 +490,9 @@ async function implementBug({ token, owner, repo, config, bug, dryRun, repoRoot
463
490
  bugDoc = readFileSync(fileAbs, 'utf-8');
464
491
  p.log.info(`bug.md encontrado em ${chalk.cyan(fileRel)}.`);
465
492
  } else {
466
- const onRemote = await existsOnRemote({
467
- getFileContent, token, owner, repo, pathRel: fileRel,
468
- });
469
- p.log.warn(missingDocMessage({
470
- pathRel: fileRel,
471
- onRemote,
493
+ p.log.warn(await probeMissingDoc({
494
+ token, owner, repo, root: repoRoot,
495
+ pathRel: fileRel, doc: 'bug', issueNumber: bug.number,
472
496
  fallback: 'o contexto assume a investigação inteira.',
473
497
  }));
474
498
  }
@@ -626,12 +650,9 @@ async function implementFeature({ token, owner, repo, config, feature, featureDi
626
650
  specPlan = readSpecPlan(featureDir);
627
651
  } else {
628
652
  const specRel = `docs/features/${slugify(feature.title)}/spec.md`;
629
- const onRemote = await existsOnRemote({
630
- getFileContent, token, owner, repo, pathRel: specRel,
631
- });
632
- p.log.warn(missingDocMessage({
633
- pathRel: specRel,
634
- onRemote,
653
+ p.log.warn(await probeMissingDoc({
654
+ token, owner, repo, root: repoRoot,
655
+ pathRel: specRel, doc: 'spec', issueNumber: feature.number,
635
656
  fallback: 'seguindo só com as Stories.',
636
657
  }));
637
658
  }
@@ -839,14 +860,13 @@ export async function implement({ issue: issueArg, featureDir: featureDirOpt, dr
839
860
  const specRel = feature?.title
840
861
  ? `docs/features/${slugify(feature.title)}/spec.md`
841
862
  : featureDir;
842
- const onRemote = feature?.title
843
- ? await existsOnRemote({ getFileContent, token, owner, repo, pathRel: specRel })
844
- : null;
845
- p.log.warn(missingDocMessage({
846
- pathRel: specRel,
847
- onRemote,
848
- fallback: 'seguindo só com as tasks.',
849
- }));
863
+ p.log.warn(feature?.title
864
+ ? await probeMissingDoc({
865
+ token, owner, repo, root: repoRoot,
866
+ pathRel: specRel, doc: 'spec', issueNumber: feature?.number,
867
+ fallback: 'seguindo só com as tasks.',
868
+ })
869
+ : missingDocMessage({ pathRel: specRel, onRemote: null, fallback: 'seguindo só com as tasks.' }));
850
870
  } else {
851
871
  p.log.warn('Não foi possível resolver a Feature; seguindo só com as tasks (use --feature-dir).');
852
872
  }
@@ -20,9 +20,9 @@ import path from 'node:path';
20
20
  import chalk from 'chalk';
21
21
 
22
22
  import { resolveToken } from '../api/auth.mjs';
23
- import { getIssue, getPR, getFileContent, listPullRequestReviews } from '../api/github-rest.mjs';
23
+ import { getIssue, getPR, listPullRequestReviews } from '../api/github-rest.mjs';
24
24
  import { loadProjectConfig } from '../lib/board.mjs';
25
- import { existsOnRemote } from '../lib/doc-availability.mjs';
25
+ import { loadArtifact } from '../lib/doc-source.mjs';
26
26
  import { featureDocPaths, bugDocPaths } from '../lib/doc-paths.mjs';
27
27
  import { isActionsRun, resolveFlowContext } from '../lib/flow-run.mjs';
28
28
  import { detectIssueType } from '../lib/issue-type.mjs';
@@ -114,19 +114,6 @@ function releaseLock(file) {
114
114
  } catch { /* já removido */ }
115
115
  }
116
116
 
117
- // Commits locais ainda não publicados: gerar o próximo documento por cima de um
118
- // anterior que ficou só no clone é execução parcial disfarçada de sucesso.
119
- function unpushedCommits(root) {
120
- try {
121
- const out = execSync('git rev-list --count @{u}..HEAD', {
122
- cwd: root || process.cwd(), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
123
- }).trim();
124
- return Number.parseInt(out, 10) || 0;
125
- } catch {
126
- return 0; // sem upstream configurado: não dá para afirmar nada
127
- }
128
- }
129
-
130
117
  function assertNotInActions() {
131
118
  if (!isActionsRun()) return;
132
119
  throw new Error(
@@ -189,18 +176,42 @@ export function docsToProbe(decision, type) {
189
176
  return stepDocs(decision.action, type);
190
177
  }
191
178
 
192
- async function probeRemote({ alvos, docs, docPaths, token, owner, repo }) {
193
- if (alvos.length === 0) return docs;
179
+ /**
180
+ * Resolve o estado remoto dos documentos que faltam no clone.
181
+ *
182
+ * Duas camadas, não uma: além da branch base, o documento pode estar num Pull
183
+ * Request ainda não mergeado — é onde ele NASCE agora. Sem essa segunda camada,
184
+ * o `run` concluiria "não existe" e regeraria a spec a cada execução até alguém
185
+ * mergear, pagando a IA de novo toda vez e descartando as edições do revisor.
186
+ *
187
+ * `doc` do resolvedor usa 'decomposition' com o mesmo nome do fluxo, então o
188
+ * mapeamento é direto.
189
+ */
190
+ async function probeRemote({ alvos, docs, docPaths, token, owner, repo, root, issueNumber }) {
191
+ if (alvos.length === 0) return { docs, docPrs: {} };
194
192
 
195
193
  const atualizado = { ...docs };
194
+ const docPrs = {};
196
195
  for (const doc of alvos) {
197
- const onRemote = await existsOnRemote({
198
- getFileContent, token, owner, repo, pathRel: docPaths[doc],
199
- });
200
- if (onRemote === true) atualizado[doc] = 'remote';
201
- else if (onRemote === null) atualizado[doc] = 'unknown';
196
+ const achado = await loadArtifact({
197
+ token, owner, repo, root, pathRel: docPaths[doc], doc, issueNumber,
198
+ }).catch(() => null);
199
+
200
+ if (!achado) {
201
+ atualizado[doc] = 'unknown';
202
+ continue;
203
+ }
204
+ // 'local' aqui só aconteceria se o arquivo tivesse aparecido no meio da
205
+ // execução; manter o estado que a sonda local já apurou é mais previsível.
206
+ if (achado.state === 'local') continue;
207
+ atualizado[doc] = achado.state;
208
+ // Guarda PR e branch: o bloqueio de `branch-only` cita a branch, porque não
209
+ // há PR nenhum a citar.
210
+ if (achado.state === 'pending-pr' || achado.state === 'branch-only') {
211
+ docPrs[doc] = { pr: achado.pr || null, branch: achado.ref || null };
212
+ }
202
213
  }
203
- return atualizado;
214
+ return { docs: atualizado, docPrs };
204
215
  }
205
216
 
206
217
  // ---------------------------------------------------------------------------
@@ -432,8 +443,10 @@ export async function run(issueArg, options = {}) {
432
443
  if (remoteCheck) {
433
444
  const alvos = docsToProbe(decision, type).filter(doc => docs[doc] === 'missing');
434
445
  if (alvos.length > 0) {
435
- const docsRemotos = await probeRemote({ alvos, docs, docPaths, token, owner, repo });
436
- decision = nextStep({ ...entrada, docs: docsRemotos });
446
+ const sondado = await probeRemote({
447
+ alvos, docs, docPaths, token, owner, repo, root, issueNumber,
448
+ });
449
+ decision = nextStep({ ...entrada, docs: sondado.docs, docPrs: sondado.docPrs });
437
450
  }
438
451
  }
439
452
  ultimaDecisao = decision;
@@ -470,11 +483,19 @@ export async function run(issueArg, options = {}) {
470
483
  break;
471
484
  }
472
485
 
473
- const pendentes = unpushedCommits(root);
474
- if (pendentes > 0) {
475
- console.warn(chalk.yellow(
476
- `\n⚠️ ${pendentes} commit(s) ainda não publicado(s). O próximo passo leria um documento ` +
477
- 'que existe no seu clone publique antes de continuar.'
486
+ // O passo publicou um documento em Pull Request? Então o próximo depende do
487
+ // merge, e encadear agora só produziria um bloqueio `pr-pending` uma volta
488
+ // depois — com uma mensagem menos direta e uma consulta à API a mais.
489
+ //
490
+ // Substitui o antigo guard de commits não publicados, que ficou
491
+ // permanentemente em zero: a publicação deixou de passar por git local.
492
+ const publicado = resultado?.pr || resultado?.published?.pr;
493
+ if (publicado?.number) {
494
+ console.log(chalk.yellow(
495
+ `\n⏸️ O documento foi publicado no PR #${publicado.number}` +
496
+ `${publicado.url ? ` (${publicado.url})` : ''}.\n` +
497
+ ' O próximo passo do fluxo lê esse documento da branch base — ' +
498
+ 'revise e faça o merge para continuar.'
478
499
  ));
479
500
  break;
480
501
  }