@spec-wave/cli 0.27.0 → 0.29.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/api/github-graphql.mjs +37 -0
  3. package/src/api/github-rest.mjs +48 -0
  4. package/src/cli.mjs +51 -2
  5. package/src/commands/audit.mjs +280 -0
  6. package/src/commands/doctor.mjs +40 -16
  7. package/src/commands/implement.mjs +19 -2
  8. package/src/commands/install-skill.mjs +18 -8
  9. package/src/commands/merge.mjs +292 -0
  10. package/src/commands/move.mjs +26 -11
  11. package/src/commands/order.mjs +42 -0
  12. package/src/commands/preflight.mjs +322 -0
  13. package/src/commands/run.mjs +4 -3
  14. package/src/commands/update.mjs +143 -12
  15. package/src/lib/board.mjs +18 -2
  16. package/src/lib/critique.mjs +64 -8
  17. package/src/lib/pr-branch.mjs +96 -7
  18. package/src/lib/pr-step.mjs +12 -7
  19. package/src/lib/spec-audit.mjs +372 -0
  20. package/src/lib/tech-context.mjs +20 -14
  21. package/src/plugin/.claude-plugin/plugin.json +1 -1
  22. package/src/plugin/README.md +5 -0
  23. package/src/plugin/skills/audit/SKILL.md +34 -0
  24. package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
  25. package/src/plugin/skills/merge/SKILL.md +34 -0
  26. package/src/plugin/skills/order/SKILL.md +1 -0
  27. package/src/plugin/skills/plan/model-prompt.md +1 -0
  28. package/src/plugin/skills/plan/reference/tech-context.md +6 -0
  29. package/src/plugin/skills/preparar-feature/SKILL.md +247 -0
  30. package/src/plugin/skills/preparar-feature/reference/critica.md +88 -0
  31. package/src/plugin/skills/preparar-specs/SKILL.md +191 -0
  32. package/src/plugin/skills/preparar-specs/reference/armadilhas.md +209 -0
  33. package/src/plugin/skills/preparar-specs/reference/revisao.md +110 -0
  34. package/src/plugin/skills/update/SKILL.md +10 -4
  35. package/src/plugin/skills/workflow/SKILL.md +6 -1
  36. package/src/templates/config/tech_context.yml +13 -0
  37. package/src/templates/skill/SKILL.md +36 -7
  38. package/src/templates/workflows/qa.yml +9 -1
@@ -44,12 +44,16 @@ 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
48
  };
48
49
 
49
50
  // Prompt por tipo de auditoria. 'plan' audita o plan.md contra a spec;
50
- // 'stories' audita a decomposição proposta contra spec + plan.
51
+ // 'stories' audita a decomposição proposta contra spec + plan; 'conjunto'
52
+ // audita TODAS as specs de uma milestone entre si — é a única cujo objeto é a
53
+ // relação entre documentos, não um documento.
51
54
  const KIND_PROMPT = {
52
55
  plan: 'plan/critique', stories: 'decompose/critique', bug: 'bug/critique', spec: 'spec/critique',
56
+ conjunto: 'audit/critique',
53
57
  };
54
58
 
55
59
  // A decomposição virou arquivo revisável (decomposition.md): um finding só é
@@ -62,6 +66,13 @@ const ANCHOR_RULE = `Cada finding DEVE citar, no campo "anchor", o trecho audita
62
66
  - "geral" quando o problema for da decomposição como um todo (ex.: requisito da spec que nenhuma Story cobre).
63
67
  Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "### Task N.M — ..." do documento.`;
64
68
 
69
+ // No conjunto o achado vive ENTRE documentos: sem dizer quais, o leitor relê a
70
+ // milestone inteira procurando o par. O campo é validado como array de números
71
+ // de issue — é o equivalente da âncora "Story N" para este contexto.
72
+ const FEATURES_RULE = `Cada finding DEVE listar, no campo "features", os números das issues das Features envolvidas
73
+ (ex.: [412, 415] para uma contradição entre as duas; [412] quando o problema é de uma spec só,
74
+ visto à luz das outras). Use EXATAMENTE os números que aparecem nos títulos "## spec.md — #N ..." fornecidos.`;
75
+
65
76
  /**
66
77
  * Monta o system prompt da crítica: corpo editável + contrato de máquina.
67
78
  *
@@ -79,7 +90,8 @@ Use EXATAMENTE os números que aparecem nos títulos "## Story N — ..." e "###
79
90
  */
80
91
  function buildSystemPrompt(kind, cwd) {
81
92
  const prompt = loadPrompt(KIND_PROMPT[kind] || KIND_PROMPT.plan, ...(cwd ? [{ cwd }] : []));
82
- const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}` : '';
93
+ const anchor = kind === 'stories' ? `\n\n${ANCHOR_RULE}`
94
+ : kind === 'conjunto' ? `\n\n${FEATURES_RULE}` : '';
83
95
 
84
96
  const contract = `## Contrato de saída
85
97
 
@@ -134,6 +146,17 @@ function critiqueJsonSchema(kind) {
134
146
  };
135
147
  required.push('anchor');
136
148
  }
149
+ if (kind === 'conjunto') {
150
+ properties.features = {
151
+ type: 'array',
152
+ items: { type: 'integer' },
153
+ minItems: 1,
154
+ description:
155
+ 'Números das issues das Features envolvidas no finding — os "#N" dos títulos ' +
156
+ '"## spec.md — #N ..." fornecidos.',
157
+ };
158
+ required.push('features');
159
+ }
137
160
  return {
138
161
  type: 'object',
139
162
  properties: {
@@ -245,10 +268,23 @@ export function validateCritiquePayload(payload) {
245
268
  );
246
269
  }
247
270
  const extra = itemKeys.filter(
248
- k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote');
271
+ k => k !== 'severity' && k !== 'text' && k !== 'anchor' && k !== 'quote' && k !== 'features');
249
272
  if (extra.length > 0) {
250
273
  throw new CritiqueSchemaError(`${at} tem campo(s) não reconhecido(s): ${extra.join(', ')}.`);
251
274
  }
275
+ // `features` (kind 'conjunto'): quais issues o finding atravessa. Fora do
276
+ // formato é erro de schema — a localização é a metade do valor do achado.
277
+ let features = null;
278
+ if ('features' in item) {
279
+ if (!Array.isArray(item.features)
280
+ || item.features.length === 0
281
+ || !item.features.every(n => Number.isInteger(n) && n > 0)) {
282
+ throw new CritiqueSchemaError(
283
+ `${at}.features deveria ser um array não-vazio de números de issue, veio ${describe(item.features)}.`
284
+ );
285
+ }
286
+ features = [...new Set(item.features)];
287
+ }
252
288
  // Normaliza só caixa e espaço — isso não é ambiguidade semântica. "GRAVE"
253
289
  // passa; "gravíssimo", "critical" e "high" NÃO.
254
290
  const severity = typeof item.severity === 'string' ? item.severity.trim().toLowerCase() : null;
@@ -268,6 +304,7 @@ export function validateCritiquePayload(payload) {
268
304
  return {
269
305
  severity,
270
306
  ...(anchor ? { anchor } : {}),
307
+ ...(features ? { features } : {}),
271
308
  ...(quote ? { quote } : {}),
272
309
  text: item.text.trim(),
273
310
  };
@@ -721,6 +758,12 @@ const KIND_TRAILER = {
721
758
  'até ser removida. Um bug com causa raiz errada produz correção errada — corrija o ' +
722
759
  '`bug.md` e reaplique `spec-wave:bug`.'
723
760
  : '_Findings menores não bloqueiam a triagem._'),
761
+ // O conjunto roda fora do ciclo de tentativas e não aplica label: o achado é
762
+ // decisão de PO entre duas specs, e o destino dele é a issue das duas pontas.
763
+ conjunto: (graves) => (graves
764
+ ? '⛔ Há findings **graves** entre specs: comente nas issues DOS DOIS lados, apontando ' +
765
+ 'uma para a outra, e resolva antes de gerar os planos — cada spec sozinha parece certa.'
766
+ : '_Nenhuma contradição entre as specs que exija decisão antes dos planos._'),
724
767
  };
725
768
 
726
769
  /**
@@ -782,8 +825,12 @@ export function renderCritiqueMarkdown({
782
825
  return parts.join('\n\n');
783
826
  }
784
827
 
828
+ // Localização do finding: a âncora ("Story N") ou, no conjunto, as Features
829
+ // que ele atravessa ("#412 × #415").
830
+ const onde = f => f.anchor
831
+ || (f.features?.length ? f.features.map(n => `#${n}`).join(' × ') : '');
785
832
  const bullets = list => list
786
- .map(f => `- ${f.anchor ? `**${f.anchor}** — ` : ''}${sanitizeFindingText(f.text)}`)
833
+ .map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}`)
787
834
  .join('\n');
788
835
  if (graves.length > 0) parts.push(`### ❌ Graves\n\n${bullets(graves)}`);
789
836
  if (menores.length > 0) parts.push(`### ⚠️ Menores\n\n${bullets(menores)}`);
@@ -791,7 +838,7 @@ export function renderCritiqueMarkdown({
791
838
  parts.push(
792
839
  '### ↘️ Rebaixados (vieram como graves, não bloqueiam)\n\n' +
793
840
  rebaixados
794
- .map(f => `- ${f.anchor ? `**${f.anchor}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
841
+ .map(f => `- ${onde(f) ? `**${onde(f)}** — ` : ''}${sanitizeFindingText(f.text)}\n` +
795
842
  ` - _Rebaixado: ${sanitizeFindingText(f.downgradeReason || 'não se sustenta como bloqueio')}._`)
796
843
  .join('\n') +
797
844
  '\n\n_Continuam valendo como observação. Se algum for mesmo grave, corrija o documento ' +
@@ -811,6 +858,7 @@ export function renderCritiqueMarkdown({
811
858
  fingerprint: findingFingerprint({ kind, anchor: f.anchor, text: f.text }),
812
859
  severity: f.severity,
813
860
  anchor: f.anchor || null,
861
+ ...(f.features?.length ? { features: f.features } : {}),
814
862
  text: sanitizeFindingText(f.text),
815
863
  ...(f.downgraded ? { downgradedFrom: f.downgraded, downgradeReason: f.downgradeReason } : {}),
816
864
  })),
@@ -832,8 +880,10 @@ export function renderCritiqueMarkdown({
832
880
  * chamador decidir entre seguir com aviso e abortar.
833
881
  *
834
882
  * @param {object} params
835
- * @param {'plan'|'stories'|'bug'} params.kind o que está sendo auditado
883
+ * @param {'plan'|'stories'|'bug'|'spec'|'conjunto'} params.kind o que está sendo auditado
836
884
  * @param {string} [params.spec] conteúdo do spec.md
885
+ * @param {Array<{number:number, title:string, content:string}>} [params.specs]
886
+ * kind 'conjunto': TODAS as specs da milestone, uma seção por Feature
837
887
  * @param {string} [params.plan] conteúdo do plan.md
838
888
  * @param {string} [params.techContextYaml] tech_context serializado em YAML
839
889
  * @param {string} [params.decomposition] conteúdo do decomposition.md
@@ -847,12 +897,17 @@ export function renderCritiqueMarkdown({
847
897
  * @returns {Promise<{grave, findings, markdown, attempt, model}>}
848
898
  */
849
899
  export async function runCritique({
850
- kind, spec, plan, techContextYaml, decomposition, bugDoc, bugReport,
900
+ kind, spec, specs, plan, techContextYaml, decomposition, bugDoc, bugReport,
851
901
  attempt = 1, maxAttempts = DEFAULT_MAX_CRITIQUE_ATTEMPTS,
852
902
  model, labels = [], usage, cwd, decisions = null, standalone = false,
853
903
  } = {}) {
854
904
  const sections = [];
855
905
  if (spec) sections.push(`## spec.md\n\n${spec}`);
906
+ // Conjunto: o título de cada seção carrega o "#N" que o campo `features` dos
907
+ // findings cita de volta — é o contrato de localização deste kind.
908
+ for (const s of specs || []) {
909
+ sections.push(`## spec.md — #${s.number} ${s.title}\n\n${s.content}`);
910
+ }
856
911
  if (plan) sections.push(`## plan.md\n\n${plan}`);
857
912
  if (techContextYaml) sections.push(`## tech_context\n\n\`\`\`yaml\n${techContextYaml}\n\`\`\``);
858
913
  // Cerca de QUATRO crases: o decomposition.md contém cercas de três, e uma
@@ -898,7 +953,8 @@ export async function runCritique({
898
953
  // sustenta não deve entrar no contador de tentativas nem bloquear o Action.
899
954
  const findings = downgradeUnsupportedFindings(
900
955
  report.value.findings,
901
- [spec, plan, decomposition, bugDoc, techContextYaml].filter(Boolean),
956
+ [spec, ...(specs || []).map(s => s.content), plan, decomposition, bugDoc, techContextYaml]
957
+ .filter(Boolean),
902
958
  );
903
959
  const grave = findings.some(f => f.severity === 'grave');
904
960
  const rebaixados = findings.filter(f => f.downgraded).length;
@@ -130,7 +130,11 @@ export function buildCommitMessage({ version = CLI_VERSION, files = [] } = {}) {
130
130
  * @param {{created: string[], updated: string[], removed: string[]}|null} [a.labels]
131
131
  * labels EFETIVAMENTE aplicadas (não o diff detectado — o corpo não pode
132
132
  * prometer o que falhou)
133
- * @param {string[]} [a.skill] nomes dos agentes cuja skill foi atualizada localmente
133
+ * @param {string[]|{inPr?: string[], local?: string[]}} [a.skill] agentes cuja
134
+ * skill foi atualizada: `inPr` os que entraram no commit (a cópia da skill
135
+ * É versionada na base) e `local` os que ficaram só na máquina. Um array
136
+ * simples é lido como `local` — a forma antiga, de quando a skill nunca
137
+ * entrava no PR.
134
138
  * @returns {string} markdown
135
139
  */
136
140
  export function composePrBody({
@@ -173,14 +177,31 @@ export function composePrBody({
173
177
  if (labels.removed.length) l.push(`- removidas (descontinuadas): ${labels.removed.map(n => `\`${n}\``).join(', ')}`);
174
178
  }
175
179
 
176
- if (skill.length) {
180
+ // A skill não é mais declarada "fora do repositório" por premissa: quem chama
181
+ // consultou a base arquivo a arquivo (decideSkillInPr) e diz aqui o que
182
+ // realmente aconteceu. Afirmar "não faz parte do repositório" para um repo que
183
+ // versiona `.claude/skills/spec-wave/SKILL.md` mandava o revisor aprovar um PR
184
+ // incompleto — e deixava o repositório distribuindo a skill da versão anterior.
185
+ const skillInPr = Array.isArray(skill) ? [] : (skill?.inPr ?? []);
186
+ const skillLocal = Array.isArray(skill) ? skill : (skill?.local ?? []);
187
+ if (skillInPr.length || skillLocal.length) {
177
188
  l.push('');
178
- l.push('## Skill dos agentes — fora deste PR');
189
+ l.push('## Skill dos agentes');
179
190
  l.push('');
180
- l.push(
181
- `Atualizada localmente em: ${skill.map(n => `\`${n}\``).join(', ')}. ` +
182
- 'Não faz parte do repositório.'
183
- );
191
+ if (skillInPr.length) {
192
+ l.push(
193
+ `Incluída neste PR, nos arquivos listados acima: ${skillInPr.map(n => `\`${n}\``).join(', ')}. ` +
194
+ `Esta cópia da skill é conteúdo do repositório — o merge em \`${base}\` é o que a ` +
195
+ 'entrega a quem clonar.'
196
+ );
197
+ }
198
+ if (skillLocal.length) {
199
+ if (skillInPr.length) l.push('');
200
+ l.push(
201
+ `Atualizada só na máquina local: ${skillLocal.map(n => `\`${n}\``).join(', ')}. ` +
202
+ 'Esta cópia não é versionada no repositório e não faz parte deste PR.'
203
+ );
204
+ }
184
205
  }
185
206
 
186
207
  l.push('');
@@ -235,6 +256,74 @@ export function decideConfigInPr({ remote, desired, willRegenerate = false, forc
235
256
  return { included: true, reason: 'versionado na base e divergente do local' };
236
257
  }
237
258
 
259
+ /**
260
+ * Uma cópia da skill entra no PR? (função PURA)
261
+ *
262
+ * MESMA regra do .spec-wave.json, pelo mesmo motivo: onde o arquivo mora é
263
+ * decisão do projeto, e o update não pode mudá-la sozinho — nem passando a
264
+ * versionar o que ninguém commitou, nem deixando de atualizar o que o repositório
265
+ * já distribui. A diferença é que aqui não existe `willRegenerate`: o conteúdo
266
+ * desejado da skill é renderizado do pacote e já está pronto na hora de decidir.
267
+ *
268
+ * Vale por ARQUIVO, não por agente: o destino do Codex (`.agents/skills/`) são 20+
269
+ * arquivos, e nada garante que o repositório versione todos.
270
+ *
271
+ * @param {object} [a]
272
+ * @param {string|null|undefined} [a.remote] conteúdo do arquivo na base (null/undefined = não versionado)
273
+ * @param {string|null} [a.desired] conteúdo que a instalação local grava
274
+ * @param {boolean|undefined} [a.force] undefined | true (--skill-in-pr) | false (--no-skill-in-pr)
275
+ * @returns {{ included: boolean, reason: string }}
276
+ */
277
+ export function decideSkillInPr({ remote, desired, force } = {}) {
278
+ const versioned = remote !== null && remote !== undefined;
279
+ if (force === false) return { included: false, reason: '--no-skill-in-pr' };
280
+ if (!desired) return { included: false, reason: 'sem conteúdo local para enviar' };
281
+ if (force === true) {
282
+ return versioned
283
+ ? { included: true, reason: '--skill-in-pr' }
284
+ : { included: true, reason: '--skill-in-pr (passa a versionar a skill)' };
285
+ }
286
+ if (!versioned) {
287
+ return {
288
+ included: false,
289
+ reason: 'não está versionada na base, segue apenas local ' +
290
+ '(use --skill-in-pr para versioná-la)',
291
+ };
292
+ }
293
+ if (remote === desired) return { included: false, reason: 'já idêntica na base' };
294
+ return { included: true, reason: 'versionada na base e divergente do local' };
295
+ }
296
+
297
+ /**
298
+ * Dica para descartar a cópia local de um arquivo que também foi no PR (PURA).
299
+ *
300
+ * `git checkout -- <arquivo>` restaura a versão do ÍNDICE, isto é, a do HEAD da
301
+ * branch que está no checkout. Ele só descarta uma cópia redundante quando o
302
+ * clone está na base; rodado de uma branch de trabalho, faz o oposto do que a
303
+ * mensagem promete — reverte o arquivo para a versão antiga daquela branch. A
304
+ * dica, portanto, depende da branch corrente, que a CLI já sabe consultar.
305
+ *
306
+ * @param {object} [a]
307
+ * @param {string} [a.file] caminho do arquivo, relativo à raiz
308
+ * @param {string} [a.base] branch base do Pull Request
309
+ * @param {string|null} [a.current] branch do clone local (null = detached/fora de git)
310
+ * @returns {string} frase única, para o `outro` do comando
311
+ */
312
+ export function composeConfigCleanupHint({ file = CONFIG_FILE, base = 'main', current = null } = {}) {
313
+ const cabeca = `O ${file} local ficou igual ao do PR`;
314
+ if (current === base) {
315
+ return `${cabeca} — depois do merge, descarte a cópia local com ` +
316
+ `\`git checkout -- ${file}\` antes do \`git pull\`.`;
317
+ }
318
+ const onde = current
319
+ ? `o checkout está em "${current}", e \`git checkout -- ${file}\` ali restauraria a ` +
320
+ 'versão antiga dessa branch'
321
+ : `o checkout não está numa branch, e \`git checkout -- ${file}\` ali restauraria a ` +
322
+ 'versão antiga do HEAD atual';
323
+ return `${cabeca} — ${onde}. Depois do merge, descarte a cópia local na base: ` +
324
+ `\`git switch ${base} && git checkout -- ${file}\`.`;
325
+ }
326
+
238
327
  /**
239
328
  * Traduz o erro cru da Git Data API para uma dica acionável (função PURA).
240
329
  *
@@ -82,21 +82,26 @@ export function nextPrStep({
82
82
  return { steps: [], warnings, reason: 'PR fechado sem merge.', blocked: null };
83
83
  }
84
84
 
85
- if (merged) warnings.push('PR mergeado o board é atualizado como se o review tivesse acabado agora.');
86
- if (merged && !approved) warnings.push('Mergeado sem review aprovada: o `qa` não roda (o qa.yml também não rodaria).');
87
- if (changesRequestedAfterApproval) {
85
+ // Merge move até 🧪 QA mesmo sem aprovação formal: o autor não consegue
86
+ // aprovar o próprio PR (fluxo solo nunca teria QA por construção), e QA de
87
+ // verdade começa com o código integrado. O qa.yml tem o mesmo gatilho de
88
+ // merge — os dois modos continuam idênticos.
89
+ if (merged) warnings.push('PR já mergeado — merge move até 🧪 QA, com ou sem aprovação formal.');
90
+ if (changesRequestedAfterApproval && !merged) {
88
91
  warnings.push('Há aprovação E pedido de mudanças: o Actions moveria assim mesmo — confirme com `--yes`.');
89
92
  }
90
93
 
91
- let steps = approved ? ['code-review', 'qa'] : ['code-review'];
94
+ let steps = (approved || merged) ? ['code-review', 'qa'] : ['code-review'];
92
95
  if (only) steps = steps.filter(s => s === only);
93
96
 
94
97
  return {
95
98
  steps,
96
99
  warnings,
97
- reason: approved
98
- ? 'PR com review aprovada: board até 🧪 QA.'
99
- : 'PR sem review aprovada: board até 👀 Code Review.',
100
+ reason: merged
101
+ ? 'PR mergeado: board até 🧪 QA.'
102
+ : approved
103
+ ? 'PR com review aprovada: board até 🧪 QA.'
104
+ : 'PR sem review aprovada: board até 👀 Code Review.',
100
105
  blocked: null,
101
106
  };
102
107
  }