@spec-wave/cli 0.29.0 → 0.32.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 (44) hide show
  1. package/package.json +5 -3
  2. package/protocol/qa-result.v1.json +62 -0
  3. package/protocol/qa-trail-report.v1.json +113 -0
  4. package/src/api/github-graphql.mjs +6 -1
  5. package/src/api/github-rest.mjs +21 -0
  6. package/src/cli.mjs +114 -9
  7. package/src/commands/decompose.mjs +29 -3
  8. package/src/commands/doctor.mjs +183 -3
  9. package/src/commands/generate-qa-plan.mjs +421 -0
  10. package/src/commands/implement.mjs +56 -44
  11. package/src/commands/merge.mjs +43 -14
  12. package/src/commands/order.mjs +350 -96
  13. package/src/commands/qa-lead.mjs +748 -0
  14. package/src/commands/qa-run.mjs +892 -0
  15. package/src/commands/run.mjs +5 -1
  16. package/src/config.mjs +32 -1
  17. package/src/lib/artifact-pr.mjs +2 -0
  18. package/src/lib/artifact-publish.mjs +5 -2
  19. package/src/lib/board.mjs +14 -0
  20. package/src/lib/critique.mjs +38 -9
  21. package/src/lib/decomposition-doc.mjs +5 -1
  22. package/src/lib/dependency-map.mjs +300 -0
  23. package/src/lib/doc-paths.mjs +9 -2
  24. package/src/lib/git-retry.mjs +82 -0
  25. package/src/lib/net-cache.mjs +142 -0
  26. package/src/lib/next-step.mjs +15 -3
  27. package/src/lib/qa-exec.mjs +335 -0
  28. package/src/lib/qa-lead-backend.mjs +213 -0
  29. package/src/lib/qa-lead.mjs +627 -0
  30. package/src/lib/qa-plan-doc.mjs +340 -0
  31. package/src/lib/qa-report.mjs +396 -0
  32. package/src/lib/skill-compose.mjs +234 -0
  33. package/src/lib/story-graph.mjs +256 -0
  34. package/src/plugin/.claude-plugin/plugin.json +1 -1
  35. package/src/plugin/skills/merge/SKILL.md +1 -0
  36. package/src/plugin/skills/order/SKILL.md +21 -5
  37. package/src/plugin/skills/qa/SKILL.md +107 -0
  38. package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
  39. package/src/plugin/skills/qa/model-prompt.md +68 -0
  40. package/src/plugin/skills/qa-executor/SKILL.md +76 -0
  41. package/src/plugin/skills/qa-lead/SKILL.md +89 -0
  42. package/src/templates/skill/SKILL.md +981 -279
  43. package/src/templates/skill/core.md +584 -0
  44. package/src/templates/workflows/generate-qa-plan.yml +64 -0
@@ -137,7 +137,7 @@ function localDocStates(issue, type, root) {
137
137
  const paths = featureDocPaths(root, issue, type);
138
138
  const docs = {};
139
139
  const docPaths = {};
140
- for (const nome of ['spec', 'plan', 'decomposition']) {
140
+ for (const nome of ['spec', 'plan', 'decomposition', 'qa-plan']) {
141
141
  docs[nome] = existsSync(paths[nome].abs) ? 'local' : 'missing';
142
142
  docPaths[nome] = paths[nome].rel;
143
143
  }
@@ -305,6 +305,10 @@ async function dispatch(action, { issueNumber }) {
305
305
  const { generateBug } = await import('./generate-bug.mjs');
306
306
  return await generateBug({ issueNumber });
307
307
  }
308
+ case 'generate-qa-plan': {
309
+ const { generateQaPlan } = await import('./generate-qa-plan.mjs');
310
+ return await generateQaPlan({ issueNumber });
311
+ }
308
312
  default:
309
313
  throw new Error(`Passo sem despacho: ${action}`);
310
314
  }
package/src/config.mjs CHANGED
@@ -54,7 +54,7 @@ export const DEFAULT_PROVIDER = 'anthropic';
54
54
  // Ações de IA que podem ter modelo próprio no .spec-wave.json (bloco
55
55
  // `ai.models`, ex.: { "critique": "claude-opus-4-1" }). Resolvidas em runtime
56
56
  // por resolveAiConfig() em src/lib/claude.mjs.
57
- export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug'];
57
+ export const AI_ACTIONS = ['spec', 'plan', 'decompose', 'critique', 'bug', 'qa'];
58
58
 
59
59
  // Override de modelo POR EXECUÇÃO: a label `spec-wave:model:<apelido>` na issue
60
60
  // aponta para uma entrada de `ai.modelAliases` do .spec-wave.json. Serve para
@@ -346,6 +346,32 @@ export const LABEL_DEV_AGENT = 'spec-wave:dev-agent';
346
346
  export const LABEL_BUG = 'spec-wave:bug';
347
347
  export const LABEL_BUG_APPROVED = 'spec-wave:bug-approved';
348
348
 
349
+ // Fluxo de QA (rfc/spec-qa-skill.md). O gatilho gera (ou re-critica) o
350
+ // qa-plan.md; as duas de estado são gravadas pelas automações, nunca à mão.
351
+ //
352
+ // ⚠️ `spec-wave:qa-ready` NÃO é `spec-wave:ready`: a primeira é ESTADO do plano
353
+ // de QA (passou na crítica, espera revisão humana — o portão do D-QA4); a
354
+ // segunda é o GATILHO da validação de spec/plan. Nenhuma checagem pode usar
355
+ // prefixo (`startsWith('spec-wave:ready')`) para distinguir as duas.
356
+ export const LABEL_QA = 'spec-wave:qa';
357
+ export const LABEL_QA_READY = 'spec-wave:qa-ready';
358
+ export const LABEL_QA_APPROVED = 'spec-wave:qa-approved';
359
+
360
+ // Motivos de bloqueio de um cenário de QA (rfc/spec-qa-lead.md, D-QAL6).
361
+ // Enum FECHADO: o executor escolhe um destes no `blockedReason` do arquivo de
362
+ // resultados; `outro` exige evidência em texto livre não vazia. O agregado por
363
+ // motivo (`blockedByReason` do relatório de trilha) é o que diz se o problema
364
+ // é ambiente, massa de dados ou dependência — por isso não aceita texto livre.
365
+ export const QA_BLOCKED_REASONS = [
366
+ 'ambiente',
367
+ 'setup-falhou',
368
+ 'massa-de-dados',
369
+ 'dependencia-nao-entregue',
370
+ 'bloqueado-por-bug',
371
+ 'credencial',
372
+ 'outro',
373
+ ];
374
+
349
375
  // Desfecho da triagem do PM (RFC-004 §4.1). São mutuamente exclusivas: um bug
350
376
  // triado foi aceito, rejeitado ou marcado como duplicata.
351
377
  export const LABEL_TRIAGED = 'spec-wave:triaged';
@@ -432,6 +458,9 @@ export const TRIGGER_LABELS = [
432
458
  { name: LABEL_DEV_AGENT, color: '5319E7', description: 'Enfileira a issue para o dev-agent autônomo' },
433
459
  { name: LABEL_BUG, color: 'BFD4F2', description: 'Gerar bug.md via GitHub Action' },
434
460
  { name: LABEL_BUG_APPROVED, color: '0E8A16', description: 'bug.md validado (reprodução, causa raiz e teste de regressão)' },
461
+ { name: LABEL_QA, color: 'BFD4F2', description: 'Gerar/re-criticar o plano de QA (qa-plan.md) via GitHub Action' },
462
+ { name: LABEL_QA_READY, color: '0E8A16', description: 'Plano de QA aprovado pela crítica — revise-o e rode `spec-wave qa <n>`' },
463
+ { name: LABEL_QA_APPROVED, color: '0E8A16', description: 'Execução do QA passou em todos os cenários' },
435
464
  { name: LABEL_TRIAGED, color: '0E8A16', description: 'Bug triado pelo PM (severidade, origem e pai definidos)' },
436
465
  { name: LABEL_DUPLICATE, color: 'EDEDED', description: 'Duplicata de outra issue (o corpo aponta qual)' },
437
466
  { name: LABEL_WONT_FIX, color: 'EDEDED', description: 'Rejeitado na triagem — não será corrigido' },
@@ -515,6 +544,7 @@ export const WORKFLOW_FILES = [
515
544
  'generate-spec.yml',
516
545
  'validate.yml',
517
546
  'decompose.yml',
547
+ 'generate-qa-plan.yml',
518
548
  'code-review.yml',
519
549
  'qa.yml',
520
550
  ];
@@ -535,6 +565,7 @@ export const ARTIFACT_WORKFLOW_FILES = [
535
565
  'generate-plan.yml',
536
566
  'generate-bug.yml',
537
567
  'decompose.yml',
568
+ 'generate-qa-plan.yml',
538
569
  ];
539
570
 
540
571
  export const ISSUE_TEMPLATE_FILES = [
@@ -34,6 +34,7 @@ export const ARTIFACT_DOCS = {
34
34
  bug: { suffix: 'bug', file: 'bug.md' },
35
35
  decomposition: { suffix: 'decompose', file: 'decomposition.md' },
36
36
  'decomposition-apply': { suffix: 'decompose-apply', file: 'decomposition.md' },
37
+ 'qa-plan': { suffix: 'qa-plan', file: 'qa-plan.md' },
37
38
  };
38
39
 
39
40
  /** Nomes aceitos em `doc`, na ordem do fluxo. */
@@ -102,6 +103,7 @@ const DOC_LABEL = {
102
103
  bug: 'documento de bug',
103
104
  decomposition: 'rascunho de decomposição',
104
105
  'decomposition-apply': 'registro das issues criadas',
106
+ 'qa-plan': 'plano de QA',
105
107
  };
106
108
 
107
109
  /**
@@ -85,7 +85,7 @@ export function isStaleArtifactBranch(status, openPr) {
85
85
  */
86
86
  export async function publishArtifact({
87
87
  token, owner, repo, doc, issueNumber, issueTitle = '', issueUrl = '',
88
- pathRel, content, base = null, nextLabel = null, deps = {},
88
+ pathRel, content, base = null, nextLabel = null, extraFiles = [], deps = {},
89
89
  }) {
90
90
  const api = {
91
91
  getRepoDefaultBranch, compareBranches, findOpenPR, commitFilesToBranch,
@@ -123,7 +123,10 @@ export async function publishArtifact({
123
123
  commit = await api.commitFilesToBranch(token, owner, repo, {
124
124
  branch,
125
125
  base: alvo,
126
- files: [{ path: pathRel, content }],
126
+ // `extraFiles`: derivados que viajam no MESMO commit do documento (ex.: o
127
+ // dependency-map.json do apply) — dois commits/PRs para um só evento
128
+ // dariam dois estados intermediários para o mesmo fato.
129
+ files: [{ path: pathRel, content }, ...extraFiles],
127
130
  message: artifactCommitMessage({ doc, issueNumber, pathRel }),
128
131
  });
129
132
  } catch (err) {
package/src/lib/board.mjs CHANGED
@@ -4,6 +4,16 @@
4
4
  import { addProjectItem, setItemSingleSelect, getSingleSelectField, getItemSingleSelectValue } from '../api/github-graphql.mjs';
5
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
+ import { invalidateCache } from './net-cache.mjs';
8
+
9
+ // Toda ESCRITA de board passa por advanceToStage/setItemStage/setItemStatus —
10
+ // invalidar aqui cobre todos os comandos de uma vez (move, qa, code-review,
11
+ // merge, implement…): o snapshot cacheado (`board-items`, lib/net-cache.mjs)
12
+ // não pode sobreviver a uma Etapa que acabou de mudar. Best-effort por
13
+ // construção: o invalidate nunca lança.
14
+ function dropBoardCache() {
15
+ invalidateCache(loadConfig().root, 'board-items');
16
+ }
7
17
 
8
18
  /**
9
19
  * Carrega o bloco `project` do .spec-wave.json, procurando-o a partir de `cwd`
@@ -229,6 +239,7 @@ export async function advanceToStage(
229
239
  );
230
240
  }
231
241
  await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
242
+ dropBoardCache();
232
243
  }
233
244
  if (statusField?.id && targetStatus) {
234
245
  const optionId = statusField.options?.[targetStatus];
@@ -239,6 +250,7 @@ export async function advanceToStage(
239
250
  );
240
251
  }
241
252
  await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
253
+ dropBoardCache();
242
254
  }
243
255
  return true;
244
256
  }
@@ -288,6 +300,7 @@ export async function setItemStage(
288
300
  const statusOption = statusField.options?.[targetStatus];
289
301
  if (statusOption) await setItemSingleSelect(token, project.id, itemId, statusField.id, statusOption);
290
302
  }
303
+ dropBoardCache();
291
304
  return { from };
292
305
  }
293
306
 
@@ -308,5 +321,6 @@ export async function setItemStatus(token, project, statusField, nodeId, status)
308
321
  if (!optionId) return false;
309
322
  const itemId = await addProjectItem(token, project.id, nodeId);
310
323
  await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
324
+ dropBoardCache();
311
325
  return true;
312
326
  }
@@ -44,7 +44,7 @@ export const CRITIQUE_TOOL_NAME = 'registrar_findings';
44
44
  // Rótulo do artefato auditado, por contexto — usado no cabeçalho do comentário.
45
45
  const KIND_LABEL = {
46
46
  plan: 'plan.md', stories: 'decomposition.md', bug: 'bug.md', spec: 'spec.md',
47
- conjunto: 'specs da milestone (conjunto)',
47
+ conjunto: 'specs da milestone (conjunto)', qa: 'qa-plan.md',
48
48
  };
49
49
 
50
50
  // Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
@@ -53,7 +53,7 @@ const KIND_LABEL = {
53
53
  // relação entre documentos, não um documento.
54
54
  const KIND_PROMPT = {
55
55
  plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
56
- conjunto: 'audit/critique',
56
+ conjunto: 'audit/critique', qa: 'qa/critique',
57
57
  };
58
58
 
59
59
  // A decomposição virou arquivo revisável (decomposition.md): um finding só é
@@ -66,6 +66,13 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
66
66
  - "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
67
67
  Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
68
68
 
69
+ // O qa-plan.md tem a mesma necessidade da decomposição: o finding precisa dizer
70
+ // QUAL cenário está errado, e o título "## Cenário N" é a âncora estável.
71
+ const QA_ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho auditado:
72
+ - "Cenário N" para um problema no Cenário N (ex.: "Cenário 3");
73
+ - "geral" quando o problema for do plano como um todo (ex.: critério de aceite da spec que nenhum cenário cobre).
74
+ Use EXATAMENTE os números que aparecem nos títulos "## Cenário N — Story #X" do documento.`;
75
+
69
76
  // No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
70
77
  // milestone inteira procurando o par. O campo é validado como array de números
71
78
  // de issue — é o equivalente da âncora "Story N" para este contexto.
@@ -91,6 +98,7 @@ visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "
91
98
  function buildSystemPrompt(kind, cwd) {
92
99
  const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
93
100
  const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
101
+ : kind === 'qa' ? `\n\n${QA_ANCHOR_RULE}`
94
102
  : kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
95
103
 
96
104
  const contract = `## Contrato de saída
@@ -146,6 +154,13 @@ function critiqueJsonSchema(kind) {
146
154
  };
147
155
  required.push('anchor');
148
156
  }
157
+ if (kind === 'qa') {
158
+ properties.anchor = {
159
+ type: 'string',
160
+ description: 'Âncora do trecho auditado: "Cenário N" ou "geral".',
161
+ };
162
+ required.push('anchor');
163
+ }
149
164
  if (kind === 'conjunto') {
150
165
  properties.features = {
151
166
  type: 'array',
@@ -195,14 +210,14 @@ function describe(value) {
195
210
  return typeof value === 'object' ? 'um objeto' : `${typeof value} (${JSON.stringify(value).slice(0, 60)})`;
196
211
  }
197
212
 
198
- const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
213
+ const ANCHOR_RE = /^(story|task|cen[aá]rio)[ \t]*(\d+(?:\.\d+)?)$/i;
199
214
 
200
215
  /**
201
216
  * Normaliza a âncora de um finding (função PURA).
202
217
  *
203
- * Aceita "Story 3", "story3", "TASK 3.2". Qualquer outra coisa inclusive
204
- * "geral" — vira ausência de âncora: melhor não citar do que citar errado e
205
- * mandar o humano para o trecho errado do documento.
218
+ * Aceita "Story 3", "story3", "TASK 3.2", "Cenário 2" (com ou sem acento).
219
+ * Qualquer outra coisa — inclusive "geral" — vira ausência de âncora: melhor
220
+ * não citar do que citar errado e mandar o humano para o trecho errado.
206
221
  *
207
222
  * @param {*} value valor cru do campo `anchor`
208
223
  * @returns {string} âncora normalizada, ou '' quando não há
@@ -210,7 +225,9 @@ const ANCHOR_RE = /^(story|task)[ \t]*(\d+(?:\.\d+)?)$/i;
210
225
  export function normalizeAnchor(value) {
211
226
  const m = ANCHOR_RE.exec(String(value ?? '').trim());
212
227
  if (!m) return '';
213
- return `${m[1].toLowerCase() === 'task' ? 'Task' : 'Story'} ${m[2]}`;
228
+ const kind = m[1].toLowerCase();
229
+ const nome = kind === 'task' ? 'Task' : kind === 'story' ? 'Story' : 'Cenário';
230
+ return `${nome} ${m[2]}`;
214
231
  }
215
232
 
216
233
  /**
@@ -758,6 +775,11 @@ const KIND_TRAILER = {
758
775
  'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
759
776
  '`bug.md` e reaplique `spec-wave:bug`.'
760
777
  : '_Findings menores não bloqueiam a triagem._'),
778
+ qa: (graves) => (graves
779
+ ? '⛔ Há findings **graves**: a label `spec-wave:critique-failed` impede o `qa run` ' +
780
+ 'até ser removida. Corrija o `qa-plan.md` (as âncoras acima apontam para ele), ' +
781
+ 'remova a label e reaplique `spec-wave:qa` para uma nova crítica.'
782
+ : '_Findings menores não bloqueiam a execução do QA._'),
761
783
  // O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
762
784
  // decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
763
785
  conjunto: (graves) => (graves
@@ -897,7 +919,7 @@ export function renderCritiqueMarkdown({
897
919
  * @returns {Promise<{grave, findings, markdown, attempt, model}>}
898
920
  */
899
921
  export async function runCritique({
900
- kind, spec, specs, plan, techContextYaml, decomposition, bugDoc, bugReport,
922
+ kind, spec, specs, plan, techContextYaml, decomposition, qaPlan, bugDoc, bugReport,
901
923
  attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
902
924
  model, labels = [], usage, cwd, decisions = null, standalone = false,
903
925
  } = {}) {
@@ -917,6 +939,13 @@ export async function runCritique({
917
939
  `## Decomposição proposta (decomposition.md)\n\n\`\`\`\`markdown\n${decomposition}\n\`\`\`\``
918
940
  );
919
941
  }
942
+ // Mesma cerca de quatro crases da decomposição, pelo mesmo motivo: o plano
943
+ // pode conter blocos de código de três.
944
+ if (qaPlan) {
945
+ sections.push(
946
+ `## Plano de QA auditado (qa-plan.md)\n\n\`\`\`\`markdown\n${qaPlan}\n\`\`\`\``
947
+ );
948
+ }
920
949
  // O relato é a fonte contra a qual o bug.md é auditado: a causa raiz proposta
921
950
  // tem que explicar OS SINTOMAS RELATADOS, não sintomas plausíveis quaisquer.
922
951
  if (bugReport) sections.push(`## Relato original (issue e comentários)\n\n${bugReport}`);
@@ -953,7 +982,7 @@ export async function runCritique({
953
982
  // sustenta não deve entrar no contador de tentativas nem bloquear o Action.
954
983
  const findings = downgradeUnsupportedFindings(
955
984
  report.value.findings,
956
- [spec, ...(specs || []).map(s => s.content), plan, decomposition, bugDoc, techContextYaml]
985
+ [spec, ...(specs || []).map(s => s.content), plan, decomposition, qaPlan, bugDoc, techContextYaml]
957
986
  .filter(Boolean),
958
987
  );
959
988
  const grave = findings.some(f => f.severity === 'grave');
@@ -69,8 +69,12 @@ function invalid(reason) {
69
69
  * Regras do CommonMark que importam aqui: a cerca tem 3+ caracteres, o
70
70
  * fechamento usa o mesmo caractere e comprimento >= o da abertura, e uma cerca
71
71
  * de crase não aceita crase na info string.
72
+ *
73
+ * Exportado porque o qa-plan.md (lib/qa-plan-doc.mjs) espelha as MESMAS regras
74
+ * de parsing deste documento — duplicar o rastreador seria duas cópias para
75
+ * divergir.
72
76
  */
73
- function fenceScanner() {
77
+ export function fenceScanner() {
74
78
  let open = null;
75
79
  return {
76
80
  // true quando a linha pertence a um bloco de código (abertura, conteúdo ou
@@ -0,0 +1,300 @@
1
+ // Dependency map pré-computado de uma Feature (módulo PURO — sem I/O).
2
+ //
3
+ // Montar a ordem das Stories consultava a API por Story (`listBlockedBy` + o
4
+ // par addProjectItem/getItemSingleSelectValue) em QUATRO comandos diferentes —
5
+ // e estourava rate limit. O grafo, porém, já nasce pronto: o `decompose
6
+ // --apply` conhece todas as arestas quando cria as issues. Este módulo define o
7
+ // artefato COMMITADO `docs/features/<slug>/dependency-map.json` (escrito pelo
8
+ // apply, atualizado pelo `order --sync`) e as decisões puras de consumo:
9
+ // validade do artefato, união de fontes de aresta, frescor de cache e o JSON
10
+ // que o `order --json` entrega a agentes.
11
+ //
12
+ // O que o artefato NÃO carrega, de propósito: Etapa e estado open/closed — são
13
+ // do board, mudam a cada `move`, e commitá-los seria gravar mentira com hora
14
+ // marcada. Quem precisa deles paga UMA chamada paginada (`listProjectItems`),
15
+ // cacheada com TTL (ver lib/net-cache.mjs e lib/story-graph.mjs).
16
+
17
+ import { orderStories } from './dependencies.mjs';
18
+
19
+ /** Nome do artefato dentro do diretório da feature. */
20
+ export const DEPENDENCY_MAP_FILE = 'dependency-map.json';
21
+
22
+ // Versão do formato. Maior que a conhecida → parse recusa e o chamador cai no
23
+ // fallback (decomposition.md/body) — nunca interpretar errado em silêncio.
24
+ export const DEPENDENCY_MAP_VERSION = 1;
25
+
26
+ /**
27
+ * Monta o objeto do artefato (função PURA).
28
+ *
29
+ * `order`/`cycle` são conveniência derivada de `dependsOn` (a mesma
30
+ * `orderStories` do comando) — consumidores podem recalcular, mas um agente
31
+ * lendo o JSON não deveria precisar reimplementar Kahn.
32
+ *
33
+ * @param {object} params
34
+ * @param {number} params.featureNumber
35
+ * @param {Array<{number:number, title?:string, dependsOn?:number[], tasks?:number[]}>} params.stories
36
+ * @param {'apply'|'sync'} [params.source]
37
+ * @param {string} [params.generatedAt] ISO — injetável nos testes
38
+ * @returns {object} o dependency-map.json (v1)
39
+ */
40
+ export function buildDependencyMap({ featureNumber, stories = [], source = 'apply', generatedAt } = {}) {
41
+ const clean = stories
42
+ .filter(s => Number.isInteger(s?.number) && s.number > 0)
43
+ .map(s => ({
44
+ number: s.number,
45
+ title: String(s.title ?? ''),
46
+ dependsOn: [...new Set((s.dependsOn || [])
47
+ .filter(d => Number.isInteger(d) && d > 0 && d !== s.number))]
48
+ .sort((a, b) => a - b),
49
+ tasks: (s.tasks || []).filter(t => Number.isInteger(t) && t > 0),
50
+ }));
51
+ const { order, cycle } = orderStories(clean);
52
+ return {
53
+ version: DEPENDENCY_MAP_VERSION,
54
+ feature: featureNumber,
55
+ generatedAt: generatedAt || new Date().toISOString(),
56
+ source,
57
+ stories: clean,
58
+ order,
59
+ cycle,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Valida um dependency-map.json lido do disco (função PURA).
65
+ *
66
+ * Inválido nunca é erro do comando — é `{ ok: false, reason }` e o chamador
67
+ * cai no fallback (decomposition.md ∪ body).
68
+ *
69
+ * @param {*} value objeto já parseado do JSON
70
+ * @returns {{ ok: boolean, reason: string|null, map: object|null }}
71
+ */
72
+ export function parseDependencyMap(value) {
73
+ const bad = (reason) => ({ ok: false, reason, map: null });
74
+ if (!value || typeof value !== 'object') return bad('não é um objeto JSON');
75
+ if (value.version !== DEPENDENCY_MAP_VERSION) {
76
+ return bad(`version ${JSON.stringify(value.version)} (esta CLI entende v${DEPENDENCY_MAP_VERSION})`);
77
+ }
78
+ if (!Number.isInteger(value.feature) || value.feature <= 0) return bad('campo "feature" ausente/inválido');
79
+ if (!Array.isArray(value.stories)) return bad('campo "stories" ausente');
80
+ for (const [i, s] of value.stories.entries()) {
81
+ if (!Number.isInteger(s?.number) || s.number <= 0) return bad(`stories[${i}].number inválido`);
82
+ if (s.dependsOn != null && !Array.isArray(s.dependsOn)) return bad(`stories[${i}].dependsOn inválido`);
83
+ }
84
+ return { ok: true, reason: null, map: value };
85
+ }
86
+
87
+ /**
88
+ * Converte um decomposition.md APLICADO em Stories com arestas por número de
89
+ * issue (função PURA) — o fallback quando o dependency-map.json não existe.
90
+ *
91
+ * `ok: false` quando o doc não serve de fonte: proposta ainda não aplicada
92
+ * (números não existem), kind=tasks (RFC não tem Stories) ou apply parcial
93
+ * (Story sem `issue` — grafo não confiável).
94
+ *
95
+ * @param {object} doc saída de parseDecompositionDoc
96
+ * @returns {{ ok: boolean, reason: string|null,
97
+ * stories: Array<{number:number, title:string, dependsOn:number[], tasks:number[]}> }}
98
+ */
99
+ export function storiesFromAppliedDoc(doc) {
100
+ const bad = (reason) => ({ ok: false, reason, stories: [] });
101
+ if (!doc || typeof doc !== 'object') return bad('documento ilegível');
102
+ if (!doc.appliedAt) return bad('decomposition.md ainda é proposta (sem applied=) — os números não existem');
103
+ if (doc.kind !== 'stories') return bad(`kind=${doc.kind || '?'} não tem Stories`);
104
+ const stories = doc.stories || [];
105
+ const semIssue = stories.filter(s => !Number.isInteger(s?.issue) || s.issue <= 0);
106
+ if (semIssue.length > 0) {
107
+ return bad(`${semIssue.length} Story(ies) sem "**Issue:** #N" — apply parcial, grafo não confiável`);
108
+ }
109
+ return {
110
+ ok: true,
111
+ reason: null,
112
+ stories: stories.map((s, i) => ({
113
+ number: s.issue,
114
+ title: s.title || '',
115
+ dependsOn: [...new Set([
116
+ // irmãs por índice 0-based → número da issue da irmã
117
+ ...(s.dependsOn || []).map(idx => stories[idx]?.issue).filter(n => Number.isInteger(n)),
118
+ ...(s.dependsOnIssues || []),
119
+ ])].filter(n => n !== s.issue).sort((a, b) => a - b),
120
+ tasks: (s.tasks || []).map(t => t.issue).filter(n => Number.isInteger(n) && n > 0),
121
+ index: i,
122
+ })),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * União de arestas por Story, vindas de fontes diferentes (função PURA).
128
+ *
129
+ * Cada fonte é um array `[{number, dependsOn}]`; a união deduplica, remove
130
+ * self-loop e ordena — duas escritas do mesmo conteúdo dão o mesmo resultado.
131
+ *
132
+ * @param {...Array<{number:number, dependsOn?:number[]}>} sources
133
+ * @returns {Map<number, number[]>} number da Story → dependências
134
+ */
135
+ export function mergeDependencyEdges(...sources) {
136
+ const out = new Map();
137
+ for (const source of sources) {
138
+ for (const s of source || []) {
139
+ if (!Number.isInteger(s?.number)) continue;
140
+ if (!out.has(s.number)) out.set(s.number, new Set());
141
+ for (const d of s.dependsOn || []) {
142
+ if (Number.isInteger(d) && d > 0 && d !== s.number) out.get(s.number).add(d);
143
+ }
144
+ }
145
+ }
146
+ return new Map([...out.entries()].map(([n, deps]) => [n, [...deps].sort((a, b) => a - b)]));
147
+ }
148
+
149
+ // ── frescor de cache ─────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * A entrada de cache ainda vale? (função PURA)
153
+ *
154
+ * `ttlSec <= 0` desliga o cache (nunca fresco). `fetchedAt` no futuro conta
155
+ * como fresco — relógio torto não pode transformar cache válido em refetch em
156
+ * loop.
157
+ *
158
+ * @param {{fetchedAt?: string}|null} entry
159
+ * @param {number} ttlSec
160
+ * @param {number} [nowMs]
161
+ */
162
+ export function isFresh(entry, ttlSec, nowMs = Date.now()) {
163
+ if (!entry?.fetchedAt || !Number.isFinite(ttlSec) || ttlSec <= 0) return false;
164
+ const fetched = Date.parse(entry.fetchedAt);
165
+ if (!Number.isFinite(fetched)) return false;
166
+ return nowMs - fetched < ttlSec * 1000;
167
+ }
168
+
169
+ /**
170
+ * Aviso de staleness para a saída (função PURA).
171
+ *
172
+ * Dados com menos de 60s não geram aviso — poluir toda saída fresca ensinaria
173
+ * a ignorar a linha. `null` também para timestamp ilegível.
174
+ *
175
+ * @param {string} fetchedAt ISO
176
+ * @param {number} [nowMs]
177
+ * @returns {string|null}
178
+ */
179
+ export function stalenessNotice(fetchedAt, nowMs = Date.now()) {
180
+ const fetched = Date.parse(fetchedAt || '');
181
+ if (!Number.isFinite(fetched)) return null;
182
+ const ageSec = Math.floor((nowMs - fetched) / 1000);
183
+ if (ageSec < 60) return null;
184
+ const idade = ageSec < 3600
185
+ ? `${Math.floor(ageSec / 60)} min`
186
+ : `${Math.floor(ageSec / 3600)}h${String(Math.floor((ageSec % 3600) / 60)).padStart(2, '0')}`;
187
+ return `dados do board de ${idade} atrás — use --refresh para reconsultar`;
188
+ }
189
+
190
+ // ── filtros e saída ──────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Filtra itens do board por milestone (função PURA) — `order --milestone`.
194
+ *
195
+ * `ref` aceita número ("3"/"#3") ou título (case-insensitive, comparação
196
+ * exata). Exige o campo `milestone` no shape de listProjectItems.
197
+ *
198
+ * @param {Array<{milestone?: {number:number, title:string}|null}>} items
199
+ * @param {string|number} ref
200
+ * @returns {Array} os itens da milestone
201
+ */
202
+ export function filterItemsByMilestone(items = [], ref) {
203
+ const text = String(ref ?? '').trim();
204
+ if (/^#?\d+$/.test(text)) {
205
+ const number = parseInt(text.replace('#', ''), 10);
206
+ return items.filter(i => i?.milestone?.number === number);
207
+ }
208
+ const alvo = text.toLowerCase();
209
+ return items.filter(i => String(i?.milestone?.title || '').toLowerCase() === alvo);
210
+ }
211
+
212
+ /**
213
+ * A saída de `order --json` (função PURA) — contrato para agentes.
214
+ *
215
+ * Shape estável de propósito: é o que o dev-agent e outros consumidores
216
+ * programáticos leem no lugar de parsear o texto com ANSI.
217
+ *
218
+ * @param {object} params
219
+ * @param {number[]} params.sorted ordem topológica
220
+ * @param {Map<number, {title?:string, dependsOn?:number[]}>} params.byNumber
221
+ * @param {Map<number, object>} [params.featureOf] Story → Feature dona
222
+ * @param {Map<number, string|null>} [params.stageOf]
223
+ * @param {Map<number, number[]>} [params.external]
224
+ * @param {number[]} [params.cycle]
225
+ * @param {{edges?:string, stages?:string|null, fetchedAt?:string|null,
226
+ * generatedAt?:string, warnings?:string[]}} [params.meta]
227
+ * @returns {object}
228
+ */
229
+ export function renderOrderJson({
230
+ sorted = [], byNumber = new Map(), featureOf = new Map(), stageOf = new Map(),
231
+ external = new Map(), cycle = [], meta = {},
232
+ } = {}) {
233
+ return {
234
+ version: 1,
235
+ generatedAt: meta.generatedAt || new Date().toISOString(),
236
+ source: {
237
+ edges: meta.edges || 'remote',
238
+ stages: meta.stages ?? null,
239
+ fetchedAt: meta.fetchedAt ?? null,
240
+ },
241
+ order: sorted.map((n, i) => {
242
+ const s = byNumber.get(n) || {};
243
+ const feature = featureOf.get(n) || null;
244
+ return {
245
+ position: i + 1,
246
+ number: n,
247
+ title: s.title || '',
248
+ feature: feature ? { number: feature.number, title: feature.title || '' } : null,
249
+ stage: stageOf.get(n) ?? null,
250
+ dependsOn: (s.dependsOn || []).slice().sort((a, b) => a - b),
251
+ };
252
+ }),
253
+ external: [...external.entries()].map(([number, dependsOn]) => ({ number, dependsOn })),
254
+ cycle: cycle.slice(),
255
+ warnings: meta.warnings || [],
256
+ };
257
+ }
258
+
259
+ // ── sync API → doc (`order --sync`) ──────────────────────────────────────────
260
+
261
+ /**
262
+ * Reescreve as dependências de um decomposition.md APLICADO a partir do estado
263
+ * VIVO das issues (função PURA) — a metade de decisão do `order --sync`.
264
+ *
265
+ * `liveDeps` é a união body ∪ blocked_by por Story (a mesma que o `order`
266
+ * remoto usa). Cada Story do doc passa a declarar exatamente essas arestas:
267
+ * irmã ANTERIOR vira `Story N` (a forma legível, que a gramática exige "só
268
+ * para trás"); irmã posterior ou issue de fora vira `#N`. Dependência que
269
+ * sumiu do GitHub sai do doc — sync é espelho, não união.
270
+ *
271
+ * @param {object} doc saída de parseDecompositionDoc (aplicado, kind=stories)
272
+ * @param {Map<number, number[]>} liveDeps número da Story → deps vivas
273
+ * @returns {{ doc: object, changed: boolean,
274
+ * changes: Array<{story:number, before:number[], after:number[]}> }}
275
+ */
276
+ export function syncDocDependencies(doc, liveDeps = new Map()) {
277
+ const indexOfIssue = new Map((doc.stories || []).map((s, i) => [s.issue, i]));
278
+ const changes = [];
279
+ const stories = (doc.stories || []).map((story, i) => {
280
+ if (!liveDeps.has(story.issue)) return story; // sem leitura viva → não toca
281
+ const before = [...new Set([
282
+ ...(story.dependsOn || []).map(idx => doc.stories[idx]?.issue).filter(Number.isInteger),
283
+ ...(story.dependsOnIssues || []),
284
+ ])].sort((a, b) => a - b);
285
+ const after = [...new Set(liveDeps.get(story.issue) || [])]
286
+ .filter(n => Number.isInteger(n) && n > 0 && n !== story.issue)
287
+ .sort((a, b) => a - b);
288
+ if (before.join(',') === after.join(',')) return story;
289
+ changes.push({ story: story.issue, before, after });
290
+ const siblings = [];
291
+ const issues = [];
292
+ for (const dep of after) {
293
+ const idx = indexOfIssue.get(dep);
294
+ if (idx !== undefined && idx < i) siblings.push(idx);
295
+ else issues.push(dep);
296
+ }
297
+ return { ...story, dependsOn: siblings, dependsOnIssues: issues };
298
+ });
299
+ return { doc: { ...doc, stories }, changed: changes.length > 0, changes };
300
+ }
@@ -30,12 +30,12 @@ export function resolveDocDir(root, issue, type) {
30
30
  }
31
31
 
32
32
  /**
33
- * Caminhos dos três documentos de uma Feature/RFC (função PURA).
33
+ * Caminhos dos documentos de uma Feature/RFC (função PURA).
34
34
  *
35
35
  * @param {string|null} root
36
36
  * @param {{title?: string}} issue
37
37
  * @param {string|null} [type]
38
- * @returns {{slug, dirRel, dirAbs, spec, plan, decomposition}} cada documento com {rel, abs}
38
+ * @returns {{slug, dirRel, dirAbs, spec, plan, decomposition, 'qa-plan'}} cada documento com {rel, abs}
39
39
  */
40
40
  export function featureDocPaths(root, issue, type) {
41
41
  const { slug, rel, dir } = resolveDocDir(root, issue, type);
@@ -47,5 +47,12 @@ export function featureDocPaths(root, issue, type) {
47
47
  spec: doc('spec.md'),
48
48
  plan: doc('plan.md'),
49
49
  decomposition: doc('decomposition.md'),
50
+ // A chave leva o hífen do nome do documento (ARTIFACT_DOCS) de propósito:
51
+ // é o mesmo id que o resto do fluxo usa para este artefato.
52
+ 'qa-plan': doc('qa-plan.md'),
53
+ // Grafo de dependências pré-computado (lib/dependency-map.mjs) — escrito
54
+ // pelo decompose --apply, atualizado pelo `order --sync`, lido por
55
+ // order/implement/merge/qa-lead no lugar de N chamadas de API.
56
+ 'dependency-map': doc('dependency-map.json'),
50
57
  };
51
58
  }