@spec-wave/cli 0.13.0 → 0.15.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/README.md +39 -6
  2. package/bin/spec-wave.mjs +17 -1
  3. package/package.json +1 -1
  4. package/src/api/github-rest.mjs +198 -2
  5. package/src/commands/code-review.mjs +5 -8
  6. package/src/commands/decompose.mjs +412 -130
  7. package/src/commands/dev-agent.mjs +3 -2
  8. package/src/commands/doctor.mjs +239 -9
  9. package/src/commands/generate-plan.mjs +105 -30
  10. package/src/commands/generate-spec.mjs +17 -5
  11. package/src/commands/implement.mjs +39 -15
  12. package/src/commands/info.mjs +4 -3
  13. package/src/commands/issue.mjs +4 -4
  14. package/src/commands/move.mjs +162 -0
  15. package/src/commands/order.mjs +1 -12
  16. package/src/commands/qa.mjs +5 -8
  17. package/src/commands/refresh.mjs +4 -3
  18. package/src/commands/story.mjs +1 -12
  19. package/src/commands/task.mjs +1 -11
  20. package/src/commands/update.mjs +372 -71
  21. package/src/commands/validate.mjs +37 -22
  22. package/src/config.mjs +40 -1
  23. package/src/lib/board.mjs +88 -26
  24. package/src/lib/claude.mjs +315 -70
  25. package/src/lib/critique.mjs +391 -91
  26. package/src/lib/decomposition-doc.mjs +451 -0
  27. package/src/lib/implement-board.mjs +14 -1
  28. package/src/lib/pr-branch.mjs +267 -0
  29. package/src/lib/project-root.mjs +93 -0
  30. package/src/lib/templates.mjs +53 -0
  31. package/src/setup/files.mjs +3 -10
  32. package/src/templates/skill/SKILL.md +158 -30
  33. package/src/templates/workflows/code-review.yml +1 -1
  34. package/src/templates/workflows/decompose.yml +20 -6
  35. package/src/templates/workflows/generate-plan.yml +1 -1
  36. package/src/templates/workflows/generate-spec.yml +1 -1
  37. package/src/templates/workflows/qa.yml +1 -1
  38. package/src/templates/workflows/validate.yml +1 -1
@@ -0,0 +1,267 @@
1
+ // Regras PURAS do modo `--branch` do update (arquivos do repo via Pull Request).
2
+ //
3
+ // Até aqui o `update` era a única exceção ao fluxo de PR: commitava os arquivos
4
+ // do repo direto na branch default, um commit por arquivo. Em repositório com
5
+ // proteção de branch isso falha no meio da execução e deixa parte aplicada; e
6
+ // mesmo sem proteção, contorna a revisão que todo o resto do fluxo tem.
7
+ //
8
+ // Tudo aqui é puro de propósito. A suíte não tem nenhuma infraestrutura de mock
9
+ // de HTTP, então a única forma de testar o modo PR é manter as DECISÕES (nome da
10
+ // branch, o que entra no PR, o que o corpo do PR conta a quem revisa) separadas
11
+ // das chamadas de rede, que ficam em api/github-rest.mjs.
12
+
13
+ import { CLI_VERSION } from './templates.mjs';
14
+ import { CONFIG_FILE } from '../config.mjs';
15
+
16
+ /**
17
+ * Nome automático da branch quando `--branch` vem sem valor.
18
+ *
19
+ * Deliberadamente SEM timestamp: rodar o update duas vezes para a MESMA versão
20
+ * da CLI tem que reaproveitar o mesmo PR, não espalhar uma branch nova por
21
+ * tentativa. O bump de versão é o que separa uma atualização da próxima, e ele
22
+ * já está no nome.
23
+ *
24
+ * @param {string} [version]
25
+ * @returns {string}
26
+ */
27
+ export function autoBranchName(version = CLI_VERSION) {
28
+ return `spec-wave/update-v${version}`;
29
+ }
30
+
31
+ // Proibições de `git check-ref-format` para refs/heads/<nome>: caractere
32
+ // de controle e espaço (\u0000-\u0020), DEL (\u007f) e os
33
+ // metacaracteres de revisão. Escapes explícitos de propósito: caractere de
34
+ // controle literal no fonte é invisível e não sobrevive a copiar/colar.
35
+ const REF_FORBIDDEN = /[\u0000-\u0020\u007f~^:?*[\\]/;
36
+
37
+ const MAX_BRANCH_LENGTH = 200;
38
+
39
+ /**
40
+ * Resolve e VALIDA o nome da branch (função PURA).
41
+ *
42
+ * Validar aqui, antes de qualquer chamada de rede, evita o pior desperdício
43
+ * possível: gastar meia dúzia de requisições de comparação e só descobrir no
44
+ * `createRef` que o nome tinha um espaço — com a mensagem crua da API, longe da
45
+ * causa.
46
+ *
47
+ * Mesmo contrato de `resolveStageName` em lib/board.mjs: `{valor, error}`.
48
+ *
49
+ * @param {string|boolean|undefined|null} value valor de `options.branch` — o
50
+ * commander entrega `true` para `--branch` sem valor
51
+ * @param {object} [opts]
52
+ * @param {string} [opts.version] versão usada no nome automático
53
+ * @returns {{ branch: string|null, error: string|null }}
54
+ */
55
+ export function resolveBranchName(value, { version = CLI_VERSION } = {}) {
56
+ if (value === true || value === undefined || value === null || String(value).trim() === '') {
57
+ return { branch: autoBranchName(version), error: null };
58
+ }
59
+
60
+ let branch = String(value).trim();
61
+ // Erro comum: colar o ref completo em vez do nome. Corrigir é mais útil que recusar.
62
+ if (branch.startsWith('refs/heads/')) branch = branch.slice('refs/heads/'.length);
63
+
64
+ const bad = (why) => ({ branch: null, error: `Nome de branch inválido ("${branch}"): ${why}.` });
65
+
66
+ if (!branch) return bad('nome vazio');
67
+ if (branch.length > MAX_BRANCH_LENGTH) {
68
+ return bad(`nome longo demais (máximo ${MAX_BRANCH_LENGTH} caracteres)`);
69
+ }
70
+ if (REF_FORBIDDEN.test(branch)) {
71
+ return bad('não pode conter espaço, caractere de controle ou ~ ^ : ? * [ \\');
72
+ }
73
+ if (branch.includes('..')) return bad('não pode conter ".."');
74
+ if (branch.includes('@{')) return bad('não pode conter "@{"');
75
+ if (branch === '@') return bad('não pode ser apenas "@"');
76
+ if (branch.startsWith('/') || branch.endsWith('/')) return bad('não pode começar nem terminar com "/"');
77
+ if (branch.includes('//')) return bad('não pode conter "//"');
78
+ if (branch.startsWith('-')) return bad('não pode começar com "-"');
79
+ if (branch.endsWith('.')) return bad('não pode terminar com "."');
80
+ if (branch.toUpperCase() === 'HEAD') return bad('"HEAD" é reservado');
81
+ for (const part of branch.split('/')) {
82
+ if (part.startsWith('.')) return bad('nenhum componente pode começar com "."');
83
+ if (part.endsWith('.lock')) return bad('nenhum componente pode terminar com ".lock"');
84
+ }
85
+ return { branch, error: null };
86
+ }
87
+
88
+ /**
89
+ * Título do PR — e assunto do commit único (função PURA).
90
+ *
91
+ * A mesma frase nos dois lugares de propósito: no modo PR existe um commit só, e
92
+ * ver textos diferentes para a mesma mudança na lista de commits e no título do
93
+ * PR só gera dúvida.
94
+ */
95
+ export function composePrTitle({ version = CLI_VERSION } = {}) {
96
+ return `chore(spec-wave): atualiza arquivos do repo para v${version}`;
97
+ }
98
+
99
+ /**
100
+ * Mensagem do commit ÚNICO (função PURA).
101
+ *
102
+ * O corpo lista arquivo + motivo porque este é o único commit da branch: ele
103
+ * precisa se explicar sozinho num `git log` feito seis meses depois, sem o PR
104
+ * aberto ao lado.
105
+ *
106
+ * @param {object} [a]
107
+ * @param {string} [a.version]
108
+ * @param {Array<{path: string, reason: string}>} [a.files]
109
+ * @returns {string}
110
+ */
111
+ export function buildCommitMessage({ version = CLI_VERSION, files = [] } = {}) {
112
+ const subject = composePrTitle({ version });
113
+ if (!files.length) return `${subject}\n`;
114
+ return `${subject}\n\n${files.map(f => `- ${f.path} (${f.reason})`).join('\n')}\n`;
115
+ }
116
+
117
+ /**
118
+ * Corpo do PR (função PURA).
119
+ *
120
+ * Quem revisa precisa saber DUAS coisas que o diff não mostra:
121
+ * • as labels já foram aplicadas direto na base — label é metadado do
122
+ * repositório, não existe forma de versioná-la num PR, então ela mudou ANTES
123
+ * de alguém revisar isto;
124
+ * • a skill dos agentes foi atualizada em máquina local, fora do repositório.
125
+ * Sem esses dois blocos o PR parece ser a totalidade do que o update fez — e não é.
126
+ *
127
+ * @param {object} [a]
128
+ * @param {Array<{path: string, reason: string}>} [a.files] arquivos que ENTRARAM no commit
129
+ * @param {{included: boolean, reason: string}|null} [a.config] decisão sobre o .spec-wave.json
130
+ * @param {{created: string[], updated: string[], removed: string[]}|null} [a.labels]
131
+ * labels EFETIVAMENTE aplicadas (não o diff detectado — o corpo não pode
132
+ * prometer o que falhou)
133
+ * @param {string[]} [a.skill] nomes dos agentes cuja skill foi atualizada localmente
134
+ * @returns {string} markdown
135
+ */
136
+ export function composePrBody({
137
+ version = CLI_VERSION, base = '?', branch = '?',
138
+ files = [], config = null, labels = null, skill = [],
139
+ } = {}) {
140
+ const l = [];
141
+ l.push(`Atualização gerada por \`spec-wave update\` (CLI v${version}).`);
142
+ l.push('');
143
+ l.push(`Base: \`${base}\` · Branch: \`${branch}\``);
144
+ l.push('');
145
+ l.push('## Arquivos do repositório');
146
+ l.push('');
147
+ if (files.length) for (const f of files) l.push(`- \`${f.path}\` — ${f.reason}`);
148
+ else l.push('_Nenhum._');
149
+
150
+ if (config) {
151
+ l.push('');
152
+ l.push(`## ${CONFIG_FILE}`);
153
+ l.push('');
154
+ l.push(config.included
155
+ ? `Incluído neste PR — ${config.reason}.`
156
+ : `**Fora** deste PR — ${config.reason}.`);
157
+ }
158
+
159
+ const labelTotal = labels
160
+ ? labels.created.length + labels.updated.length + labels.removed.length
161
+ : 0;
162
+ if (labelTotal) {
163
+ l.push('');
164
+ l.push(`## Labels — JÁ aplicadas em \`${base}\`, fora deste PR`);
165
+ l.push('');
166
+ l.push(
167
+ 'Label é metadado do repositório e não pode ser versionada: estas mudanças já ' +
168
+ 'valem, com ou sem o merge deste PR.'
169
+ );
170
+ l.push('');
171
+ if (labels.created.length) l.push(`- criadas: ${labels.created.map(n => `\`${n}\``).join(', ')}`);
172
+ if (labels.updated.length) l.push(`- atualizadas: ${labels.updated.map(n => `\`${n}\``).join(', ')}`);
173
+ if (labels.removed.length) l.push(`- removidas (descontinuadas): ${labels.removed.map(n => `\`${n}\``).join(', ')}`);
174
+ }
175
+
176
+ if (skill.length) {
177
+ l.push('');
178
+ l.push('## Skill dos agentes — fora deste PR');
179
+ l.push('');
180
+ l.push(
181
+ `Atualizada localmente em: ${skill.map(n => `\`${n}\``).join(', ')}. ` +
182
+ 'Não faz parte do repositório.'
183
+ );
184
+ }
185
+
186
+ l.push('');
187
+ l.push('---');
188
+ l.push(`Depois do merge, \`npx @spec-wave/cli@${version} update --dry-run\` deve reportar tudo em dia.`);
189
+ return `${l.join('\n')}\n`;
190
+ }
191
+
192
+ /**
193
+ * O .spec-wave.json entra no PR? (função PURA)
194
+ *
195
+ * Política herdada do init (ver o comentário em commands/init.mjs): o config é
196
+ * gravado LOCAL de propósito. Quem não o commitou escolheu mantê-lo fora do
197
+ * versionamento, e passar a versioná-lo é decisão de projeto — não pode ser
198
+ * efeito colateral de um `update`. Logo: só entra se JÁ estiver versionado na
199
+ * base. `--config-in-pr` / `--no-config-in-pr` forçam os dois lados.
200
+ *
201
+ * Cobre também o caso em que o config NÃO está desatualizado pela versão mas o
202
+ * arquivo local difere do que está na base (alguém regenerou e esqueceu de
203
+ * commitar): entra igual, porque é exatamente para isso que o PR serve.
204
+ *
205
+ * @param {object} [a]
206
+ * @param {string|null|undefined} [a.remote] conteúdo do config na base (null/undefined = não versionado)
207
+ * @param {string|null} [a.desired] conteúdo que deveria estar lá
208
+ * @param {boolean} [a.willRegenerate] o conteúdo ainda vai ser regenerado (no
209
+ * resumo e no dry-run ele não existe); o regenerado SEMPRE difere do
210
+ * remoto, porque `refreshedAt` muda a cada execução
211
+ * @param {boolean|undefined} [a.force] undefined | true (--config-in-pr) | false (--no-config-in-pr)
212
+ * @returns {{ included: boolean, reason: string }}
213
+ */
214
+ export function decideConfigInPr({ remote, desired, willRegenerate = false, force } = {}) {
215
+ const versioned = remote !== null && remote !== undefined;
216
+ if (force === false) return { included: false, reason: '--no-config-in-pr' };
217
+ if (!desired && !willRegenerate) {
218
+ return { included: false, reason: 'sem conteúdo local para enviar' };
219
+ }
220
+ if (force === true) {
221
+ return versioned
222
+ ? { included: true, reason: '--config-in-pr' }
223
+ : { included: true, reason: `--config-in-pr (passa a versionar o ${CONFIG_FILE})` };
224
+ }
225
+ if (!versioned) {
226
+ return {
227
+ included: false,
228
+ reason: 'não está versionado na base, segue apenas local ' +
229
+ '(use --config-in-pr para versioná-lo)',
230
+ };
231
+ }
232
+ if (!willRegenerate && remote === desired) {
233
+ return { included: false, reason: 'já idêntico na base' };
234
+ }
235
+ return { included: true, reason: 'versionado na base e divergente do local' };
236
+ }
237
+
238
+ /**
239
+ * Traduz o erro cru da Git Data API para uma dica acionável (função PURA).
240
+ *
241
+ * Sem isto o usuário vê "Not Found" num POST e conclui que o repositório não
242
+ * existe, quando o problema é um token sem escrita em Contents — e "Resource not
243
+ * accessible" não diz QUAL permissão falta.
244
+ *
245
+ * @param {{status?: number, message?: string}} err
246
+ * @returns {string}
247
+ */
248
+ export function explainGitWriteError(err) {
249
+ const msg = err?.message || 'erro desconhecido';
250
+ switch (err?.status) {
251
+ case 401:
252
+ return `${msg} — token inválido ou expirado.`;
253
+ case 403:
254
+ return `${msg} — o token não tem permissão de escrita. Precisa de "Contents: write" e ` +
255
+ '"Pull requests: write" (fine-grained) ou do escopo `repo` (classic).';
256
+ case 404:
257
+ return `${msg} — repositório ou branch inexistente, OU token sem acesso a este ` +
258
+ 'repositório (o GitHub responde 404 em vez de 403 para não revelar repos privados).';
259
+ case 409:
260
+ return `${msg} — repositório vazio (sem nenhum commit): rode \`spec-wave init\` antes.`;
261
+ case 422:
262
+ return `${msg} — o GitHub recusou a operação. Causas comuns: regra de proteção/ruleset ` +
263
+ 'na branch (commits da API não são assinados) ou branch já em dia.';
264
+ default:
265
+ return msg;
266
+ }
267
+ }
@@ -0,0 +1,93 @@
1
+ // Descoberta da raiz do projeto spec-wave (o diretório com .spec-wave.json).
2
+ //
3
+ // Antes, cada comando fazia `path.join(process.cwd(), CONFIG_FILE)` — mais de
4
+ // dez cópias da mesma linha, e nenhuma subia na árvore. O efeito prático era que
5
+ // qualquer comando rodado de dentro de `apps/web` falhava com "repositório não
6
+ // inicializado", mesmo com o config uma pasta acima. Aqui o config é procurado
7
+ // subindo até a raiz do filesystem, como o git faz com o .git.
8
+ //
9
+ // Achar o config é metade do problema: os documentos gerados vivem em
10
+ // `docs/features/<slug>/`, relativo à RAIZ, não ao cwd. Por isso `loadConfig`
11
+ // devolve `root` e existe `resolveFromRoot` — sem eles, rodar de um
12
+ // subdiretório acharia o config e erraria todos os caminhos de documento.
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { CONFIG_FILE } from '../config.mjs';
17
+
18
+ /**
19
+ * Procura o .spec-wave.json em `cwd` e nos diretórios acima (função quase pura:
20
+ * só toca o filesystem via existsSync).
21
+ *
22
+ * @param {string} [cwd=process.cwd()] diretório onde começar a busca
23
+ * @returns {string|null} caminho absoluto do arquivo, ou null se não houver
24
+ */
25
+ export function findConfigPath(cwd = process.cwd()) {
26
+ let dir = path.resolve(cwd);
27
+ // path.dirname('/') === '/': a igualdade é o que encerra o laço na raiz.
28
+ for (;;) {
29
+ const candidate = path.join(dir, CONFIG_FILE);
30
+ if (existsSync(candidate)) return candidate;
31
+ const parent = path.dirname(dir);
32
+ if (parent === dir) return null;
33
+ dir = parent;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Localiza e lê o .spec-wave.json.
39
+ *
40
+ * Nunca lança: devolve `error` legível para o chamador compor a mensagem que já
41
+ * exibia ("… — board não atualizado.", "Rode spec-wave init primeiro.").
42
+ *
43
+ * @param {string} [cwd=process.cwd()]
44
+ * @returns {{ config: object|null, root: string|null, configPath: string|null, error: string|null }}
45
+ * root = diretório que contém o config (âncora dos caminhos de documento)
46
+ */
47
+ export function loadConfig(cwd = process.cwd()) {
48
+ const configPath = findConfigPath(cwd);
49
+ if (!configPath) {
50
+ return { config: null, root: null, configPath: null, error: `${CONFIG_FILE} não encontrado` };
51
+ }
52
+ const root = path.dirname(configPath);
53
+ try {
54
+ return { config: JSON.parse(readFileSync(configPath, 'utf-8')), root, configPath, error: null };
55
+ } catch (err) {
56
+ return { config: null, root, configPath, error: `${CONFIG_FILE} corrompido (${err.message})` };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolve um caminho relativo do projeto (ex.: `docs/features/<slug>`) contra a
62
+ * raiz descoberta, com fallback para o cwd quando não há config — assim os
63
+ * comandos que rodam sem `.spec-wave.json` (ou em teste) seguem funcionando.
64
+ *
65
+ * @param {string|null} root raiz devolvida por loadConfig
66
+ * @param {...string} parts segmentos relativos
67
+ * @returns {string} caminho absoluto
68
+ */
69
+ export function resolveFromRoot(root, ...parts) {
70
+ return path.resolve(root || process.cwd(), ...parts);
71
+ }
72
+
73
+ /**
74
+ * Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos rodados em
75
+ * Action) com fallback no .spec-wave.json — comandos locais rodam sem essa env.
76
+ *
77
+ * Era o mesmo bloco copiado em story/task/order/qa/code-review/validate.
78
+ *
79
+ * @param {string} [cwd=process.cwd()]
80
+ * @returns {{ owner: string|undefined, repo: string|undefined,
81
+ * root: string|null, config: object|null, error: string|null }}
82
+ */
83
+ export function resolveRepoContext(cwd = process.cwd()) {
84
+ const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
85
+ const { config, root, error } = loadConfig(cwd);
86
+ return {
87
+ owner: envOwner || config?.owner,
88
+ repo: envRepo || config?.repo,
89
+ root,
90
+ config,
91
+ error,
92
+ };
93
+ }
@@ -0,0 +1,53 @@
1
+ // Leitura dos templates empacotados, com resolução de placeholders.
2
+ //
3
+ // Os workflows chamavam `npx @spec-wave/cli@latest`, o que significava que uma
4
+ // release da CLI mudava o comportamento de pipelines já em andamento — foi o que
5
+ // aconteceu quando a 0.13.0 removeu o `--force` sem que nada no repositório
6
+ // mudasse. Agora o template traz `{{CLI_VERSION}}` e o `init`/`update` gravam a
7
+ // versão fixa, tornando o bump um diff explícito e revisável.
8
+ //
9
+ // A substituição PRECISA passar por aqui nos dois lados: o `init`/`setupFiles`
10
+ // que escreve, e o `update` que compara byte a byte com o remoto. Se só um lado
11
+ // resolvesse o placeholder, todo `update` veria os seis workflows como
12
+ // "desatualizados" para sempre.
13
+
14
+ import { readFileSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import path from 'node:path';
17
+
18
+ const __dir = path.dirname(fileURLToPath(import.meta.url));
19
+ export const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
20
+
21
+ const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
22
+ export const CLI_VERSION = pkg.version;
23
+
24
+ const PLACEHOLDERS = { CLI_VERSION };
25
+
26
+ /**
27
+ * Resolve os placeholders `{{NOME}}` de um template (função PURA).
28
+ *
29
+ * Placeholder desconhecido é deixado intacto em vez de virar string vazia: um
30
+ * `{{TYPO}}` visível no arquivo gerado é muito mais fácil de diagnosticar do que
31
+ * um trecho que simplesmente desapareceu.
32
+ *
33
+ * @param {string} content conteúdo cru do template
34
+ * @param {object} [values] sobrescreve/estende os placeholders padrão
35
+ * @returns {string}
36
+ */
37
+ export function renderTemplate(content, values = {}) {
38
+ const table = { ...PLACEHOLDERS, ...values };
39
+ return String(content ?? '').replace(
40
+ /\{\{(\w+)\}\}/g,
41
+ (match, key) => (key in table ? String(table[key]) : match)
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Lê um template empacotado com os placeholders já resolvidos.
47
+ *
48
+ * @param {...string} parts caminho relativo dentro de src/templates
49
+ * @returns {string}
50
+ */
51
+ export function readTemplate(...parts) {
52
+ return renderTemplate(readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8'));
53
+ }
@@ -1,15 +1,8 @@
1
- import { readFileSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- import path from 'node:path';
4
1
  import { upsertFile, getFileContent, isRepoInitialized } from '../api/github-rest.mjs';
5
2
  import { WORKFLOW_FILES } from '../config.mjs';
6
-
7
- const __dir = path.dirname(fileURLToPath(import.meta.url));
8
- const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
9
-
10
- function readTemplate(...parts) {
11
- return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
12
- }
3
+ // readTemplate resolve {{CLI_VERSION}} — é o que fixa a versão da CLI nos
4
+ // workflows gravados no repo-alvo (ver src/lib/templates.mjs).
5
+ import { readTemplate } from '../lib/templates.mjs';
13
6
 
14
7
  export async function setupFiles(token, owner, repo, spinner) {
15
8
  spinner.message('Verificando repositório...');