@spec-wave/cli 0.14.0 → 0.16.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.
- package/README.md +1 -0
- package/bin/spec-wave.mjs +44 -2
- package/package.json +8 -2
- package/src/agent/anthropic-agent.mjs +337 -0
- package/src/agent/errors.mjs +33 -0
- package/src/agent/index.mjs +108 -0
- package/src/agent/openrouter-agent.mjs +378 -0
- package/src/agent/run-types.mjs +59 -0
- package/src/agent/telemetry.mjs +54 -0
- package/src/agent/tools.mjs +452 -0
- package/src/agent/tracing.mjs +106 -0
- package/src/api/github-rest.mjs +206 -2
- package/src/commands/bug.mjs +8 -0
- package/src/commands/code-review.mjs +45 -4
- package/src/commands/decompose.mjs +11 -49
- package/src/commands/dev-agent.mjs +3 -3
- package/src/commands/doctor.mjs +77 -6
- package/src/commands/generate-bug.mjs +195 -0
- package/src/commands/generate-plan.mjs +6 -20
- package/src/commands/generate-spec.mjs +6 -22
- package/src/commands/implement.mjs +105 -2
- package/src/commands/init.mjs +3 -3
- package/src/commands/install-skill.mjs +72 -16
- package/src/commands/issue.mjs +9 -7
- package/src/commands/move.mjs +11 -1
- package/src/commands/qa.mjs +23 -2
- package/src/commands/refresh.mjs +145 -5
- package/src/commands/triage.mjs +174 -0
- package/src/commands/update.mjs +352 -62
- package/src/commands/validate.mjs +82 -10
- package/src/config.mjs +159 -1
- package/src/lib/bug-context.mjs +160 -0
- package/src/lib/bug-doc.mjs +51 -0
- package/src/lib/bug-triage.mjs +81 -0
- package/src/lib/claude.mjs +71 -254
- package/src/lib/critique.mjs +43 -30
- package/src/lib/implement-board.mjs +12 -1
- package/src/lib/plugin-skills.mjs +122 -0
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/prompt-loader.mjs +257 -0
- package/src/lib/skill-file.mjs +35 -0
- package/src/plugin/.claude-plugin/plugin.json +20 -0
- package/src/plugin/README.md +73 -0
- package/src/plugin/skills/bug/SKILL.md +60 -0
- package/src/plugin/skills/bug/model-prompt.critique.md +48 -0
- package/src/plugin/skills/bug/model-prompt.md +74 -0
- package/src/plugin/skills/decompose/SKILL.md +111 -0
- package/src/plugin/skills/decompose/model-prompt.critique.md +46 -0
- package/src/plugin/skills/decompose/model-prompt.feature.md +69 -0
- package/src/plugin/skills/decompose/model-prompt.rfc.md +52 -0
- package/src/plugin/skills/doctor/SKILL.md +51 -0
- package/src/plugin/skills/fix-pr/SKILL.md +130 -0
- package/src/plugin/skills/implement/SKILL.md +102 -0
- package/src/plugin/skills/info/SKILL.md +40 -0
- package/src/plugin/skills/issue/SKILL.md +63 -0
- package/src/plugin/skills/move/SKILL.md +52 -0
- package/src/plugin/skills/order/SKILL.md +36 -0
- package/src/plugin/skills/plan/SKILL.md +53 -0
- package/src/plugin/skills/plan/model-prompt.critique.md +44 -0
- package/src/plugin/skills/plan/model-prompt.md +59 -0
- package/src/plugin/skills/plan/reference/tech-context.md +56 -0
- package/src/plugin/skills/ready/SKILL.md +44 -0
- package/src/plugin/skills/rfc/SKILL.md +47 -0
- package/src/plugin/skills/setup/SKILL.md +67 -0
- package/src/plugin/skills/spec/SKILL.md +37 -0
- package/src/plugin/skills/spec/model-prompt.md +61 -0
- package/src/plugin/skills/story/SKILL.md +49 -0
- package/src/plugin/skills/task/SKILL.md +41 -0
- package/src/plugin/skills/triage/SKILL.md +52 -0
- package/src/plugin/skills/uninstall/SKILL.md +43 -0
- package/src/plugin/skills/update/SKILL.md +51 -0
- package/src/plugin/skills/workflow/SKILL.md +154 -0
- package/src/templates/skill/SKILL.md +69 -7
- package/src/templates/workflows/generate-bug.yml +36 -0
- package/src/templates/workflows/validate.yml +2 -1
- package/src/ui/wizard.mjs +5 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Enumeração e renderização das skills do PLUGIN — uma skill por comando.
|
|
2
|
+
//
|
|
3
|
+
// Há duas famílias de skill neste pacote, e elas não se misturam:
|
|
4
|
+
//
|
|
5
|
+
// • `src/plugin/skills/<nome>/model-prompt*.md` — textos que a CLI ENVIA a um
|
|
6
|
+
// modelo (ver `prompt-loader.mjs`). Moram DENTRO da skill do comando a que
|
|
7
|
+
// pertencem, mas não são instalados em agente nenhum: dizem o oposto do
|
|
8
|
+
// `SKILL.md` vizinho (um gera a spec.md, o outro proíbe gerá-la à mão).
|
|
9
|
+
// • `src/plugin/skills/<nome>/SKILL.md` — as skills que o USUÁRIO instala no
|
|
10
|
+
// agente de código dele. É o que este módulo enumera.
|
|
11
|
+
//
|
|
12
|
+
// O plugin existe porque o `templates/skill/SKILL.md` monolítico (~800 linhas)
|
|
13
|
+
// entra inteiro no contexto para qualquer pergunta, mesmo trivial. Quebrado em
|
|
14
|
+
// uma skill por comando, o agente carrega só a instrução do comando invocado —
|
|
15
|
+
// e ganha um `/spec-wave:<comando>` por ação em vez de um sub-comando textual.
|
|
16
|
+
//
|
|
17
|
+
// O MESMO diretório serve os dois destinos, porque o formato SKILL.md é comum:
|
|
18
|
+
// • Claude Code → instalado como plugin (`.claude-plugin/plugin.json`), com as
|
|
19
|
+
// skills namespaced em `/spec-wave:<pasta>`;
|
|
20
|
+
// • Codex → copiado para `.agents/skills/<nome>/`, onde não há namespace — daí
|
|
21
|
+
// o `name:` do frontmatter ser `spec-wave-<comando>` e não só `<comando>`.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/** Raiz do plugin empacotado (publicado via `"files": ["src"]`). */
|
|
30
|
+
export const PLUGIN_DIR = path.join(__dir, '..', 'plugin');
|
|
31
|
+
|
|
32
|
+
/** Diretório das skills dentro do plugin. */
|
|
33
|
+
export const PLUGIN_SKILLS_DIR = path.join(PLUGIN_DIR, 'skills');
|
|
34
|
+
|
|
35
|
+
// Arquivos que moram na skill mas NÃO são instalados no agente: os prompts que a
|
|
36
|
+
// CLI envia a um modelo. Instalá-los entregaria ao agente de código a instrução
|
|
37
|
+
// contrária à do SKILL.md ao lado — "gere a spec.md" vs. "nunca gere à mão".
|
|
38
|
+
const INSTALL_EXCLUDE_RE = /(^|\/)model-prompt(\.[\w-]+)?\.md$/;
|
|
39
|
+
|
|
40
|
+
/** True se o arquivo da skill deve ir para o agente (função PURA). */
|
|
41
|
+
export function isInstallableSkillFile(relPath) {
|
|
42
|
+
return !INSTALL_EXCLUDE_RE.test(relPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Lista os arquivos de um diretório recursivamente, em caminhos relativos a ele
|
|
46
|
+
// e em ordem estável — a ordem entra na comparação de "está atualizado?", então
|
|
47
|
+
// precisa ser determinística entre plataformas.
|
|
48
|
+
function walk(dir, prefix = '') {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
51
|
+
const abs = path.join(dir, entry);
|
|
52
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
53
|
+
if (statSync(abs).isDirectory()) out.push(...walk(abs, rel));
|
|
54
|
+
else out.push(rel);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Enumera as skills do plugin (função de I/O, mas sem efeitos colaterais).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} [skillsDir]
|
|
63
|
+
* @returns {Array<{ name: string, dir: string, files: string[] }>}
|
|
64
|
+
* `files` são caminhos relativos ao diretório da skill, incluindo o SKILL.md
|
|
65
|
+
* e quaisquer arquivos de apoio (ex.: `reference/tech-context.md`).
|
|
66
|
+
*/
|
|
67
|
+
export function listPluginSkills(skillsDir = PLUGIN_SKILLS_DIR) {
|
|
68
|
+
if (!existsSync(skillsDir)) return [];
|
|
69
|
+
return readdirSync(skillsDir)
|
|
70
|
+
.sort()
|
|
71
|
+
.map((name) => ({ name, dir: path.join(skillsDir, name) }))
|
|
72
|
+
.filter((s) => statSync(s.dir).isDirectory() && existsSync(path.join(s.dir, 'SKILL.md')))
|
|
73
|
+
.map((s) => ({ ...s, files: walk(s.dir).filter(isInstallableSkillFile) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Conteúdo desejado de um arquivo de skill no destino (função PURA).
|
|
78
|
+
*
|
|
79
|
+
* Só o `SKILL.md` recebe o banner de versão — arquivos de apoio são copiados
|
|
80
|
+
* verbatim. O banner entra depois do frontmatter, no topo do corpo, que é onde
|
|
81
|
+
* o agente o lê ao carregar a skill.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} relPath caminho relativo dentro da skill
|
|
84
|
+
* @param {string} raw conteúdo original
|
|
85
|
+
* @param {string} version versão da CLI
|
|
86
|
+
* @param {(v: string) => string} banner
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function renderPluginSkillFile(relPath, raw, version, banner) {
|
|
90
|
+
if (relPath !== 'SKILL.md') return raw;
|
|
91
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
92
|
+
if (!match) return `${banner(version)}\n\n${raw.trim()}\n`;
|
|
93
|
+
return `---\n${match[1]}\n---\n\n${banner(version)}\n\n${match[2].trim()}\n`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Plano de gravação de todas as skills do plugin num diretório de destino.
|
|
98
|
+
*
|
|
99
|
+
* Devolve pares `{ path, content }` já renderizados, sem tocar no disco — quem
|
|
100
|
+
* chama decide gravar, comparar (para detectar desatualização) ou só listar
|
|
101
|
+
* (`--dry-run`).
|
|
102
|
+
*
|
|
103
|
+
* @param {string} destDir ex.: `<repo>/.agents/skills`
|
|
104
|
+
* @param {string} version
|
|
105
|
+
* @param {(v: string) => string} banner
|
|
106
|
+
* @param {string} [skillsDir]
|
|
107
|
+
* @returns {Array<{ path: string, content: string, skill: string }>}
|
|
108
|
+
*/
|
|
109
|
+
export function planPluginSkillFiles(destDir, version, banner, skillsDir = PLUGIN_SKILLS_DIR) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const skill of listPluginSkills(skillsDir)) {
|
|
112
|
+
for (const rel of skill.files) {
|
|
113
|
+
const raw = readFileSync(path.join(skill.dir, rel), 'utf-8');
|
|
114
|
+
out.push({
|
|
115
|
+
skill: skill.name,
|
|
116
|
+
path: path.join(destDir, skill.name, ...rel.split('/')),
|
|
117
|
+
content: renderPluginSkillFile(rel, raw, version, banner),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -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,257 @@
|
|
|
1
|
+
// Carregador dos prompts de IA — a fonte dos textos que a CLI ENVIA a um modelo.
|
|
2
|
+
//
|
|
3
|
+
// Antes, cada ação carregava seu prompt como template string no próprio comando
|
|
4
|
+
// (`generate-spec.mjs`, `generate-plan.mjs`, `decompose.mjs`, `critique.mjs`),
|
|
5
|
+
// amarrando três coisas que mudam em ritmos diferentes: o texto do prompt, o
|
|
6
|
+
// contrato de saída e o encanamento do GitHub.
|
|
7
|
+
//
|
|
8
|
+
// Hoje os prompts moram DENTRO da skill do comando a que pertencem, como
|
|
9
|
+
// arquivo de apoio ao lado do `SKILL.md`:
|
|
10
|
+
//
|
|
11
|
+
// src/plugin/skills/spec/SKILL.md ← o agente LÊ (dirige a CLI)
|
|
12
|
+
// src/plugin/skills/spec/model-prompt.md ← a CLI ENVIA a um modelo
|
|
13
|
+
// src/plugin/skills/plan/model-prompt.critique.md ← variante `plan/critique`
|
|
14
|
+
//
|
|
15
|
+
// Um diretório por comando, com as duas pontas do handoff lado a lado. Os dois
|
|
16
|
+
// arquivos dizem coisas OPOSTAS de propósito — o `SKILL.md` de `spec` manda
|
|
17
|
+
// nunca gerar a spec.md à mão, e o `model-prompt.md` é justamente quem a gera —
|
|
18
|
+
// e é por isso que continuam sendo arquivos separados em vez de um só.
|
|
19
|
+
//
|
|
20
|
+
// Arquivo de apoio não entra sozinho no contexto de um agente: o Claude Code e o
|
|
21
|
+
// Codex carregam o `SKILL.md`, e só leem o resto se a instrução mandar. Então o
|
|
22
|
+
// `model-prompt.md` fica inerte para quem instala o plugin, e o `install-skill`
|
|
23
|
+
// nem o copia (ver `plugin-skills.mjs`).
|
|
24
|
+
//
|
|
25
|
+
// Precedência: um `.spec-wave/prompts/<id>.md` versionado no repositório do
|
|
26
|
+
// usuário vence o prompt empacotado. É assim que um time ajusta o tom de uma
|
|
27
|
+
// spec, ou aperta o critério da crítica, sem fork da CLI.
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { parseSkill } from './skill-file.mjs';
|
|
33
|
+
|
|
34
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
|
|
36
|
+
/** Skills empacotadas — os prompts moram dentro delas. */
|
|
37
|
+
export const BUNDLED_SKILLS_DIR = path.join(__dir, '..', 'plugin', 'skills');
|
|
38
|
+
|
|
39
|
+
/** Override por projeto, relativo à raiz do repositório do usuário. */
|
|
40
|
+
export const PROJECT_PROMPTS_DIR = path.join('.spec-wave', 'prompts');
|
|
41
|
+
|
|
42
|
+
/** Prefixo dos arquivos de prompt dentro de uma skill. */
|
|
43
|
+
export const MODEL_PROMPT_PREFIX = 'model-prompt';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve o id de um prompt no arquivo dentro da skill (função PURA).
|
|
47
|
+
*
|
|
48
|
+
* `spec` → skills/spec/model-prompt.md
|
|
49
|
+
* `plan/critique` → skills/plan/model-prompt.critique.md
|
|
50
|
+
* `decompose/feature`→ skills/decompose/model-prompt.feature.md
|
|
51
|
+
*
|
|
52
|
+
* @param {string} id
|
|
53
|
+
* @returns {{ skill: string, file: string }}
|
|
54
|
+
*/
|
|
55
|
+
export function resolvePromptId(id) {
|
|
56
|
+
const [skill, variant] = String(id).split('/');
|
|
57
|
+
if (!skill) throw new Error(`Id de prompt inválido: "${id}".`);
|
|
58
|
+
const file = variant
|
|
59
|
+
? `${MODEL_PROMPT_PREFIX}.${variant}.md`
|
|
60
|
+
: `${MODEL_PROMPT_PREFIX}.md`;
|
|
61
|
+
return { skill, file };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Somente leitura por padrão. Um prompt que precise escrever declara `tools`
|
|
65
|
+
// explicitamente E recebe `allowedWritePaths` de quem o executa — o motor nega
|
|
66
|
+
// qualquer Write fora dessa lista. Deixar Write no default transformaria um
|
|
67
|
+
// prompt mal calibrado em escrita arbitrária no repo.
|
|
68
|
+
export const DEFAULT_PROMPT_TOOLS = ['Read', 'Glob', 'Grep'];
|
|
69
|
+
|
|
70
|
+
// Teto de turnos. Alto o bastante para explorar um repositório de verdade
|
|
71
|
+
// (Glob → Grep → vários Read) antes de produzir o documento, e baixo o
|
|
72
|
+
// bastante para um loop degenerado morrer em vez de queimar orçamento.
|
|
73
|
+
export const DEFAULT_PROMPT_MAX_TURNS = 30;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Caminhos candidatos de um prompt, em ordem de precedência (função PURA).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} name nome do prompt (ex.: 'decompose-feature')
|
|
79
|
+
* @param {string} [cwd] raiz do repositório do usuário
|
|
80
|
+
* @returns {Array<{ source: 'project'|'bundled', path: string }>}
|
|
81
|
+
*/
|
|
82
|
+
export function promptCandidates(name, cwd = process.cwd()) {
|
|
83
|
+
const { skill, file } = resolvePromptId(name);
|
|
84
|
+
return [
|
|
85
|
+
// Override achatado: `.spec-wave/prompts/plan.critique.md`. Um caminho só,
|
|
86
|
+
// sem recriar a árvore de skills num repositório que não tem plugin nenhum.
|
|
87
|
+
{ source: 'project', path: path.join(cwd, PROJECT_PROMPTS_DIR, `${String(name).replace('/', '.')}.md`) },
|
|
88
|
+
{ source: 'bundled', path: path.join(BUNDLED_SKILLS_DIR, skill, file) },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Aceita inteiro positivo vindo de YAML (que pode entregar string ou lixo);
|
|
93
|
+
// qualquer outra coisa cai no default em vez de virar NaN lá na frente.
|
|
94
|
+
function positiveInt(value) {
|
|
95
|
+
const n = Number(value);
|
|
96
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Normaliza `tools`: aceita lista YAML ou string separada por vírgula.
|
|
100
|
+
function normalizeTools(value) {
|
|
101
|
+
const raw = Array.isArray(value)
|
|
102
|
+
? value
|
|
103
|
+
: typeof value === 'string'
|
|
104
|
+
? value.split(',')
|
|
105
|
+
: null;
|
|
106
|
+
if (!raw) return undefined;
|
|
107
|
+
const tools = raw.map(t => String(t).trim()).filter(Boolean);
|
|
108
|
+
return tools.length > 0 ? tools : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Valida e normaliza um prompt já lido do disco (função PURA — testável sem fs).
|
|
113
|
+
*
|
|
114
|
+
* Um corpo vazio é erro, não default: um prompt sem instrução produziria uma
|
|
115
|
+
* chamada de modelo sem propósito, e o custo só apareceria depois.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} params
|
|
118
|
+
* @param {string} params.name
|
|
119
|
+
* @param {object} [params.meta] frontmatter já parseado
|
|
120
|
+
* @param {string} [params.body] corpo markdown (a instrução)
|
|
121
|
+
* @param {'project'|'bundled'} [params.source]
|
|
122
|
+
* @param {string} [params.path]
|
|
123
|
+
* @returns {{ name: string, action: string|null, prompt: string, tools: string[],
|
|
124
|
+
* maxTurns: number, json: { shape: string, retries: number }|null,
|
|
125
|
+
* source: string, path: string, meta: object }}
|
|
126
|
+
*/
|
|
127
|
+
export function normalizePrompt({ name, meta = {}, body = '', source = 'bundled', path: filePath = '' } = {}) {
|
|
128
|
+
const prompt = (body || '').trim();
|
|
129
|
+
if (!prompt) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Prompt "${name}" está sem corpo (${filePath || 'origem desconhecida'}). ` +
|
|
132
|
+
'O corpo do model-prompt é a instrução enviada ao modelo — sem ele não há o que executar.'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `json.shape` é o contrato de saída mostrado ao modelo verbatim; quando
|
|
137
|
+
// presente, quem executa exige JSON parseável e re-pede em caso de falha.
|
|
138
|
+
let json = null;
|
|
139
|
+
if (meta.json && typeof meta.json === 'object') {
|
|
140
|
+
const shape = typeof meta.json.shape === 'string' ? meta.json.shape.trim() : '';
|
|
141
|
+
if (!shape) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Prompt "${name}" declara \`json\` sem \`shape\` (${filePath}). ` +
|
|
144
|
+
'Informe o formato esperado ou remova o bloco `json`.'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
json = { shape, retries: positiveInt(meta.json.retries) ?? 1 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
action: typeof meta.action === 'string' ? meta.action : null,
|
|
153
|
+
prompt,
|
|
154
|
+
tools: normalizeTools(meta.tools) ?? [...DEFAULT_PROMPT_TOOLS],
|
|
155
|
+
maxTurns: positiveInt(meta.maxTurns) ?? DEFAULT_PROMPT_MAX_TURNS,
|
|
156
|
+
json,
|
|
157
|
+
source,
|
|
158
|
+
path: filePath,
|
|
159
|
+
meta,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Encontra e carrega um prompt, respeitando o override do projeto.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} name
|
|
167
|
+
* @param {object} [opts]
|
|
168
|
+
* @param {string} [opts.cwd]
|
|
169
|
+
* @returns {ReturnType<typeof normalizePrompt>}
|
|
170
|
+
*/
|
|
171
|
+
export function loadPrompt(name, { cwd = process.cwd() } = {}) {
|
|
172
|
+
const candidates = promptCandidates(name, cwd);
|
|
173
|
+
const found = candidates.find(c => existsSync(c.path));
|
|
174
|
+
if (!found) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Prompt "${name}" não encontrado. Procurei em:\n` +
|
|
177
|
+
candidates.map(c => ` - ${c.path} (${c.source})`).join('\n')
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const { meta, body } = parseSkill(readFileSync(found.path, 'utf-8'));
|
|
181
|
+
return normalizePrompt({ name, meta, body, source: found.source, path: found.path });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Delimitadores da orientação que só faz sentido com ferramentas de leitura.
|
|
185
|
+
const REQUIRES_TOOLS_RE = /<!--\s*requires-tools\s*-->[\s\S]*?<!--\s*\/requires-tools\s*-->\n?/g;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Texto de sistema para um runtime SEM ferramentas (função PURA).
|
|
189
|
+
*
|
|
190
|
+
* `generateDocument`/`generateStructured` fazem UMA chamada de completions: não
|
|
191
|
+
* há loop de tool use, então o modelo não tem `Read`/`Glob`/`Grep` por mais que
|
|
192
|
+
* o frontmatter os declare. Um prompt que afirme possuir ferramentas nesse
|
|
193
|
+
* runtime mente para o modelo — e o resultado típico é uma seção inventada
|
|
194
|
+
* "conforme verifiquei no repositório".
|
|
195
|
+
*
|
|
196
|
+
* Por isso o corpo marca a orientação dependente de ferramentas entre
|
|
197
|
+
* `<!-- requires-tools -->` e `<!-- /requires-tools -->`, e este recorte a
|
|
198
|
+
* remove. O mesmo arquivo serve os dois runtimes sem manter duas versões.
|
|
199
|
+
*
|
|
200
|
+
* @param {ReturnType<typeof normalizePrompt>} prompt
|
|
201
|
+
* @param {string} [appendInstruction] regra que a CLI precisa garantir; vem por
|
|
202
|
+
* ÚLTIMO de propósito — um prompt sobrescrito pelo projeto não pode anulá-la
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
export function toolFreeSystemPrompt(prompt, appendInstruction = '') {
|
|
206
|
+
const stripped = prompt.prompt
|
|
207
|
+
.replace(REQUIRES_TOOLS_RE, '')
|
|
208
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
209
|
+
.trim();
|
|
210
|
+
return [stripped, (appendInstruction || '').trim()].filter(Boolean).join('\n\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Converte um prompt em `AgentRunOptions` do `@spec-wave/agent` (função PURA).
|
|
215
|
+
*
|
|
216
|
+
* O corpo vira `systemPromptAppend` — no backend anthropic ele é anexado ao
|
|
217
|
+
* preset `claude_code`, preservando a competência de uso de tools do harness em
|
|
218
|
+
* vez de substituí-la. Aqui o bloco `requires-tools` é PRESERVADO: esse runtime
|
|
219
|
+
* de fato entrega as ferramentas.
|
|
220
|
+
*
|
|
221
|
+
* @param {ReturnType<typeof normalizePrompt>} prompt
|
|
222
|
+
* @param {object} run
|
|
223
|
+
* @param {string} run.model
|
|
224
|
+
* @param {string} run.sessionId
|
|
225
|
+
* @param {string} run.userId
|
|
226
|
+
* @param {boolean} [run.verbose]
|
|
227
|
+
* @param {string[]} [run.allowedWritePaths] quando presente, Write só é
|
|
228
|
+
* permitido nestes caminhos (e `Write` entra na tool surface)
|
|
229
|
+
* @param {string} [run.appendInstruction] instrução controlada pela CLI,
|
|
230
|
+
* anexada DEPOIS do corpo do prompt (ex.: o caminho exato do arquivo de saída)
|
|
231
|
+
* @param {string[]} [run.extraTags] tags extras da trace
|
|
232
|
+
* @returns {object} AgentRunOptions
|
|
233
|
+
*/
|
|
234
|
+
export function buildPromptRunOptions(prompt, run) {
|
|
235
|
+
const tools = run.allowedWritePaths?.length && !prompt.tools.includes('Write')
|
|
236
|
+
? [...prompt.tools, 'Write']
|
|
237
|
+
: [...prompt.tools];
|
|
238
|
+
|
|
239
|
+
const systemPromptAppend = [prompt.prompt, run.appendInstruction]
|
|
240
|
+
.map(part => (part || '').trim())
|
|
241
|
+
.filter(Boolean)
|
|
242
|
+
.join('\n\n');
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
model: run.model,
|
|
246
|
+
sessionId: run.sessionId,
|
|
247
|
+
userId: run.userId,
|
|
248
|
+
...(run.verbose === undefined ? {} : { verbose: run.verbose }),
|
|
249
|
+
tools,
|
|
250
|
+
systemPromptAppend,
|
|
251
|
+
maxTurns: prompt.maxTurns,
|
|
252
|
+
...(run.allowedWritePaths?.length
|
|
253
|
+
? { allowedWritePaths: [...run.allowedWritePaths] }
|
|
254
|
+
: {}),
|
|
255
|
+
extraTags: ['spec-wave', prompt.name, ...(run.extraTags ?? [])],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Formato de arquivo das skills: frontmatter YAML + corpo markdown.
|
|
2
|
+
//
|
|
3
|
+
// Duas famílias de skill usam o MESMO formato e por isso o mesmo parser:
|
|
4
|
+
// • a skill de orquestração (`src/templates/skill/SKILL.md`), instalada nos
|
|
5
|
+
// agentes de código pelo `install-skill`;
|
|
6
|
+
// • os prompts de IA (`src/plugin/skills/<nome>/model-prompt*.md`), que a CLI
|
|
7
|
+
// envia a um modelo — ver `prompt-loader.mjs`.
|
|
8
|
+
//
|
|
9
|
+
// Vivia dentro de `commands/install-skill.mjs`; foi movido para cá quando o
|
|
10
|
+
// loader passou a precisar dele, para não haver duas implementações do mesmo
|
|
11
|
+
// recorte de frontmatter.
|
|
12
|
+
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Separa o frontmatter YAML do corpo do markdown (função PURA — testável).
|
|
17
|
+
*
|
|
18
|
+
* Tolerante por contrato: arquivo sem frontmatter devolve `meta` vazio e o
|
|
19
|
+
* texto inteiro como corpo; YAML inválido devolve `meta` vazio em vez de
|
|
20
|
+
* lançar. Quem precisa de um campo obrigatório valida depois.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} raw conteúdo do arquivo
|
|
23
|
+
* @returns {{ meta: object, frontmatter: string, body: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function parseSkill(raw) {
|
|
26
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
27
|
+
if (!match) return { meta: {}, frontmatter: '', body: raw.trim() };
|
|
28
|
+
let meta = {};
|
|
29
|
+
try {
|
|
30
|
+
meta = yaml.load(match[1]) || {};
|
|
31
|
+
} catch {
|
|
32
|
+
meta = {};
|
|
33
|
+
}
|
|
34
|
+
return { meta, frontmatter: match[1], body: match[2].trim() };
|
|
35
|
+
}
|