wizz-method 2.15.0 → 2.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.
@@ -0,0 +1,160 @@
1
+ // wizz-artifact-guard.js — PostToolUse hook (matcher: Write)
2
+ //
3
+ // Fonte de verdade: wizz-method/tools/hooks/. Instalar via `npm run sync:global`
4
+ // ou pelo installer (registra em `.claude/settings.json` do projeto).
5
+ //
6
+ // Dois avisos, nunca bloqueio (o arquivo já foi escrito; o aviso entra como
7
+ // contexto para o agente corrigir na hora):
8
+ //
9
+ // 1. Artefato fora do lugar: escrito na pasta de artefatos mas fora da
10
+ // subpasta da feature ativa, artefato de feature sem feature ativa, ou
11
+ // artefato tipado (`spec-x.md`...) com frontmatter faltando/errado.
12
+ // 2. Arquivo solto na raiz: fora do `layout:` do config. Sugere o destino e
13
+ // o comando que move atualizando as referências.
14
+ //
15
+ // Fail-open: sem `_wizz/`, sem config, payload estranho ou qualquer erro =
16
+ // silêncio e exit 0. Caso comum (arquivo dentro de uma pasta qualquer) custa
17
+ // zero token.
18
+
19
+ const path = require('node:path');
20
+
21
+ let lib;
22
+ let layoutLib;
23
+ try {
24
+ lib = require('./wizz-feature-lib.js');
25
+ layoutLib = require('./wizz-layout-lib.js');
26
+ } catch {
27
+ lib = null;
28
+ }
29
+
30
+ const HOOK_CMD = '_wizz/hooks';
31
+
32
+ function isInside(child, parent) {
33
+ const r = path.relative(parent, child);
34
+ return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
35
+ }
36
+
37
+ /**
38
+ * Aviso para um arquivo dentro das pastas de artefato, ou ''.
39
+ * @param {string} file - caminho absoluto
40
+ * @param {string} content - conteúdo escrito (pode ser '')
41
+ * @param {ReturnType<typeof lib.resolveFeature>} state
42
+ */
43
+ function artifactWarning(file, content, state) {
44
+ const dirs = lib.artifactDirs(state.root, state.config);
45
+ const base = [dirs.planning, dirs.implementation].find((d) => isInside(file, d));
46
+ if (!base) return '';
47
+
48
+ const parts = path.relative(base, file).split(path.sep);
49
+ const name = parts.at(-1);
50
+ const type = lib.artifactTypeOf(name);
51
+ const relFile = path.relative(state.root, file);
52
+ const slug = state.slug;
53
+ const msgs = [];
54
+
55
+ if (parts.length === 1) {
56
+ if (lib.PROJECT_WIDE.test(name)) return '';
57
+ if (slug) {
58
+ msgs.push(
59
+ `ARTEFATO FORA DA FEATURE: ${relFile} está na raiz das pastas de artefato, mas a feature ativa é "${slug}". ` +
60
+ `Mova para ${path.relative(state.root, path.join(base, slug, name))} agora (raiz é só para artefato de projeto inteiro: arquitetura, contexto).`,
61
+ );
62
+ } else if (type) {
63
+ msgs.push(
64
+ `ARTEFATO SEM FEATURE: ${relFile} é de feature, mas nenhuma está ativa. ` +
65
+ `Grave a feature a partir do pedido (node ${HOOK_CMD}/wizz-feature.js definir <slug>) e mova o arquivo para a subpasta dela.`,
66
+ );
67
+ }
68
+ } else if (slug && parts[0] !== slug) {
69
+ msgs.push(
70
+ `ARTEFATO EM OUTRA FEATURE: ${relFile} está em "${parts[0]}", mas a ativa é "${slug}". ` +
71
+ `Se o trabalho é de "${slug}", mova; se o assunto mudou, troque a feature (wizz-feature.js definir ${parts[0]}).`,
72
+ );
73
+ }
74
+
75
+ if (type) {
76
+ const problems = lib.frontmatterProblems(content, parts.length > 1 ? parts[0] : slug);
77
+ if (problems.length > 0) msgs.push(`FRONTMATTER de ${relFile}: ${problems.join('; ')}.`);
78
+ }
79
+ return msgs.join(' ');
80
+ }
81
+
82
+ /**
83
+ * Aviso para arquivo solto na raiz do projeto, ou ''.
84
+ * @param {string} file
85
+ * @param {ReturnType<typeof lib.resolveFeature>} state
86
+ */
87
+ function rootWarning(file, state) {
88
+ if (path.dirname(file) !== state.root) return '';
89
+ const name = path.basename(file);
90
+ const layout = state.config.layout;
91
+ if (layoutLib.isAllowedAtRoot(name, layout)) return '';
92
+
93
+ if (!state.hasLayout) {
94
+ return (
95
+ `ARQUIVO SOLTO NA RAIZ: ${name}. O projeto ainda não tem layout de pastas. ` +
96
+ `Proponha um em UMA linha (node ${HOOK_CMD}/wizz-arrumar.js layout) e grave com \`layout --gravar\`; depois mova o arquivo.`
97
+ );
98
+ }
99
+ const dest = layoutLib.suggestDestination(name, layout);
100
+ if (!dest) {
101
+ return `ARQUIVO SOLTO NA RAIZ: ${name} fica fora do layout. Mova para a pasta do tipo dele, ou libere em \`layout.raiz_extra\` se pertence à raiz.`;
102
+ }
103
+ const target = `${dest.dir}/${name}`;
104
+ return (
105
+ `ARQUIVO SOLTO NA RAIZ: ${name} fica fora do layout (${dest.type} → ${dest.dir}/). ` +
106
+ `Mova agora, atualizando as referências: node ${HOOK_CMD}/wizz-arrumar.js mover ${name} ${target}`
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Decide o aviso a partir do payload do PostToolUse. Pura o bastante para teste.
112
+ * @param {{tool_name?: string, tool_input?: {file_path?: string, content?: string}, cwd?: string}} payload
113
+ * @returns {string} '' quando não há o que dizer
114
+ */
115
+ function buildWarning(payload) {
116
+ if (!lib || !payload || typeof payload !== 'object') return '';
117
+ if (payload.tool_name && payload.tool_name !== 'Write') return '';
118
+ const input = payload.tool_input || {};
119
+ if (typeof input.file_path !== 'string' || !input.file_path) return '';
120
+
121
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
122
+ const file = path.resolve(cwd, input.file_path);
123
+ // O cwd da sessão manda; o diretório do arquivo é a reserva (sessão aberta fora do projeto).
124
+ let state = lib.resolveFeature(cwd);
125
+ if (!state.isWizz || !isInside(file, state.root)) state = lib.resolveFeature(path.dirname(file));
126
+ if (!state.isWizz || !state.config.exists) return '';
127
+ if (!isInside(file, state.root)) return '';
128
+ // Nada dentro de _wizz/ (engine instalada) ou de pastas ocultas é artefato.
129
+ const first = path.relative(state.root, file).split(path.sep)[0];
130
+ if (first === '_wizz' || first.startsWith('.')) return '';
131
+
132
+ return artifactWarning(file, typeof input.content === 'string' ? input.content : '', state) || rootWarning(file, state);
133
+ }
134
+
135
+ if (require.main === module) {
136
+ let raw = '';
137
+ const timer = setTimeout(() => finish(), 3000);
138
+ process.stdin.setEncoding('utf8');
139
+ process.stdin.on('data', (chunk) => (raw += chunk));
140
+ process.stdin.on('end', () => {
141
+ clearTimeout(timer);
142
+ finish();
143
+ });
144
+
145
+ function finish() {
146
+ try {
147
+ const warning = buildWarning(JSON.parse(raw || '{}'));
148
+ if (warning) {
149
+ process.stdout.write(
150
+ JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: `📁 Wizz: ${warning}` } }),
151
+ );
152
+ }
153
+ } catch {
154
+ // aviso nunca derruba a ferramenta
155
+ }
156
+ process.exit(0);
157
+ }
158
+ }
159
+
160
+ module.exports = { buildWarning, artifactWarning, rootWarning };
@@ -0,0 +1,414 @@
1
+ // wizz-feature-lib.js — funções puras de feature, artefato e layout.
2
+ //
3
+ // Fonte de verdade: wizz-method/tools/hooks/. Não é hook registrado: é a
4
+ // biblioteca que os hooks (session-rules, wizz-router-enforce,
5
+ // wizz-artifact-guard) e as CLIs (wizz-feature, wizz-arrumar) dividem. Mora
6
+ // AQUI porque tudo em tools/hooks/ viaja junto: o installer copia a pasta
7
+ // inteira para `_wizz/hooks/` e o `sync:global` para `~/.claude/hooks/`.
8
+ //
9
+ // Sem dependência de pacote de propósito (roda fora do node_modules do repo):
10
+ // o YAML do config é lido com parser de chave única, que é tudo o que precisa.
11
+ // Toda leitura é fail-open: config ausente ou ilegível vira "sem valor".
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ // Branches que NUNCA viram feature: são linha principal, não trabalho nomeado.
17
+ const TRUNK = new Set(['main', 'master', 'develop', 'dev', 'staging', 'stage', 'prod', 'production', 'trunk', 'default', 'release']);
18
+
19
+ // Branch de release/versão não é feature: nomeia um corte do produto, não um trabalho.
20
+ const RELEASE_PREFIX = /^(release|rc|v)[/-]/i;
21
+
22
+ // Prefixos de convenção que descrevem o TIPO do trabalho, não a feature.
23
+ const TYPE_PREFIX = /^(feat|feature|fix|bugfix|hotfix|chore|refactor|docs|test|perf|ci|build|style)[/-]/i;
24
+
25
+ // Tipos de artefato com nome padronizado `<tipo>-<slug>.md` e a pasta de cada um.
26
+ // `roteiro` (aplicação + rollback) vai para o conhecimento do projeto, não para
27
+ // os artefatos: é o que alguém reabre no dia do deploy, meses depois.
28
+ const ARTIFACT_TYPES = {
29
+ spec: 'planning',
30
+ plano: 'planning',
31
+ auditoria: 'planning',
32
+ story: 'implementation',
33
+ review: 'implementation',
34
+ roteiro: 'knowledge',
35
+ };
36
+
37
+ const STATUS = ['rascunho', 'aprovado', 'implementado', 'no-ar'];
38
+
39
+ // Artefato de projeto inteiro: fica na raiz das pastas de artefato, nunca numa feature.
40
+ const PROJECT_WIDE = /^(architecture|arquitetura|project-context|contexto|sprint-status|readme|index)([-._]|$)/i;
41
+
42
+ const CONFIG_REL = path.join('_wizz', 'bmm', 'config.yaml');
43
+
44
+ /**
45
+ * Normaliza texto livre para slug kebab-case: o mesmo formato que a skill
46
+ * `wizz-set-feature` grava, para branch e config nunca divergirem.
47
+ * @param {string} raw
48
+ * @returns {string} slug, ou '' se não sobrar nada utilizável
49
+ */
50
+ function toSlug(raw) {
51
+ if (typeof raw !== 'string') return '';
52
+ return raw
53
+ .normalize('NFD')
54
+ .replaceAll(/[̀-ͯ]/g, '')
55
+ .toLowerCase()
56
+ .replaceAll(/[^a-z0-9]+/g, '-')
57
+ .replaceAll(/^-+|-+$/g, '')
58
+ .slice(0, 60)
59
+ .replace(/-+$/, '');
60
+ }
61
+
62
+ /**
63
+ * Data local em YYYY-MM-DD. `toISOString()` daria o dia em UTC, que em UTC-3
64
+ * vira o dia seguinte depois das 21h. `WIZZ_TODAY` fixa a data nos testes.
65
+ * @returns {string}
66
+ */
67
+ function today() {
68
+ if (process.env.WIZZ_TODAY && /^\d{4}-\d{2}-\d{2}$/.test(process.env.WIZZ_TODAY)) return process.env.WIZZ_TODAY;
69
+ const d = new Date();
70
+ const pad = (n) => String(n).padStart(2, '0');
71
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
72
+ }
73
+
74
+ /**
75
+ * Sobe a partir de `start` procurando `name` (até 6 níveis).
76
+ * @param {string} start
77
+ * @param {string} name
78
+ * @returns {string} diretório que contém `name`, ou ''
79
+ */
80
+ function findUp(start, name) {
81
+ let dir = start;
82
+ for (let i = 0; i < 6; i++) {
83
+ try {
84
+ if (fs.existsSync(path.join(dir, name))) return dir;
85
+ } catch {
86
+ // segue subindo
87
+ }
88
+ const parent = path.dirname(dir);
89
+ if (parent === dir) break;
90
+ dir = parent;
91
+ }
92
+ return '';
93
+ }
94
+
95
+ /** Raiz do projeto Wizz (a pasta que contém `_wizz/`), ou ''. */
96
+ function findWizzRoot(cwd) {
97
+ return findUp(cwd, '_wizz');
98
+ }
99
+
100
+ /**
101
+ * Lê o estado git sem subprocesso: `.git/HEAD` já traz o ref.
102
+ * @param {string} cwd
103
+ * @returns {{isRepo: boolean, branch: string}} branch '' em detached HEAD
104
+ */
105
+ function readGitState(cwd) {
106
+ const repoDir = findUp(cwd, '.git');
107
+ if (!repoDir) return { isRepo: false, branch: '' };
108
+ try {
109
+ const gitPath = path.join(repoDir, '.git');
110
+ let headFile = path.join(gitPath, 'HEAD');
111
+ if (fs.statSync(gitPath).isFile()) {
112
+ // worktree/submódulo: `.git` é um arquivo `gitdir: <caminho>`
113
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
114
+ if (!m) return { isRepo: true, branch: '' };
115
+ headFile = path.join(path.resolve(repoDir, m[1].trim()), 'HEAD');
116
+ }
117
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(fs.readFileSync(headFile, 'utf8').trim());
118
+ return { isRepo: true, branch: ref ? ref[1] : '' };
119
+ } catch {
120
+ return { isRepo: true, branch: '' };
121
+ }
122
+ }
123
+
124
+ /** Só o nome do branch (compatível com a API antiga do session-rules). */
125
+ function readGitBranch(cwd) {
126
+ return readGitState(cwd).branch;
127
+ }
128
+
129
+ /** Branch de tronco ou de release: linha principal, não trabalho nomeado. */
130
+ function isTrunkBranch(branch) {
131
+ return Boolean(branch) && (TRUNK.has(branch.toLowerCase()) || RELEASE_PREFIX.test(branch));
132
+ }
133
+
134
+ /**
135
+ * Deriva o slug de feature do nome do branch. Tronco e branch sem nome útil
136
+ * devolvem '' (nenhuma feature), nunca um slug inventado.
137
+ * @param {string} branch
138
+ * @returns {string}
139
+ */
140
+ function featureFromBranch(branch) {
141
+ if (!branch || isTrunkBranch(branch)) return '';
142
+ const slug = toSlug(branch.replace(TYPE_PREFIX, ''));
143
+ if (!slug || TRUNK.has(slug)) return '';
144
+ // Branch que é só um número de issue não descreve feature nenhuma.
145
+ if (/^\d+$/.test(slug)) return '';
146
+ return slug;
147
+ }
148
+
149
+ function unquote(raw) {
150
+ return String(raw)
151
+ .replace(/\s+#.*$/, '')
152
+ .trim()
153
+ .replaceAll(/^["']|["']$/g, '');
154
+ }
155
+
156
+ /**
157
+ * Lê o config do bmm num objeto plano + o bloco `layout:`. Fail-open.
158
+ * @param {string} root - raiz do projeto Wizz
159
+ * @returns {{exists: boolean, text: string, values: Record<string,string>, layout: Record<string,string>}}
160
+ */
161
+ function readConfig(root) {
162
+ const empty = { exists: false, text: '', values: {}, layout: {} };
163
+ if (!root) return empty;
164
+ let text;
165
+ try {
166
+ text = fs.readFileSync(path.join(root, CONFIG_REL), 'utf8');
167
+ } catch {
168
+ return empty;
169
+ }
170
+ const values = {};
171
+ const layout = {};
172
+ let inLayout = false;
173
+ for (const line of text.split(/\r?\n/)) {
174
+ const top = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
175
+ if (top) {
176
+ inLayout = top[1] === 'layout' && unquote(top[2]) === '';
177
+ if (!inLayout) values[top[1]] = unquote(top[2]);
178
+ continue;
179
+ }
180
+ const nested = /^\s+([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
181
+ if (inLayout && nested) layout[nested[1]] = unquote(nested[2]);
182
+ else if (line.trim() && !line.trim().startsWith('#') && !/^\s/.test(line)) inLayout = false;
183
+ }
184
+ return { exists: true, text, values, layout };
185
+ }
186
+
187
+ /** Lê só o `active_feature` (API antiga do session-rules). */
188
+ function readConfigFeature(root) {
189
+ const configPath = path.join(root || '', CONFIG_REL);
190
+ return { slug: toSlug(readConfig(root).values.active_feature || ''), configPath };
191
+ }
192
+
193
+ /**
194
+ * Resolve um caminho do config (`{project-root}/x`, relativo ou absoluto).
195
+ * @param {string|undefined} value
196
+ * @param {string} root
197
+ * @param {string} fallback - relativo à raiz
198
+ */
199
+ function resolveConfigPath(value, root, fallback) {
200
+ const raw = value && value.trim() ? value.trim() : fallback;
201
+ const replaced = raw.replace('{project-root}', root);
202
+ return path.isAbsolute(replaced) ? path.normalize(replaced) : path.join(root, replaced);
203
+ }
204
+
205
+ /**
206
+ * Pastas de artefato do projeto, já resolvidas.
207
+ * @param {string} root
208
+ * @param {{values: Record<string,string>}} [config]
209
+ */
210
+ function artifactDirs(root, config = readConfig(root)) {
211
+ const output = resolveConfigPath(config.values.output_folder, root, '_wizz-output');
212
+ return {
213
+ planning: resolveConfigPath(config.values.planning_artifacts, root, path.join(path.relative(root, output), 'planning-artifacts')),
214
+ implementation: resolveConfigPath(
215
+ config.values.implementation_artifacts,
216
+ root,
217
+ path.join(path.relative(root, output), 'implementation-artifacts'),
218
+ ),
219
+ knowledge: resolveConfigPath(config.values.project_knowledge, root, 'docs'),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Estado completo da feature: de onde veio, branch, config e opção de branch.
225
+ * Ordem: branch git > `active_feature` do config > nada.
226
+ * @param {string} cwd
227
+ */
228
+ function resolveFeature(cwd) {
229
+ const root = findWizzRoot(cwd);
230
+ const config = readConfig(root);
231
+ const git = readGitState(root || cwd);
232
+ const fromBranch = featureFromBranch(git.branch);
233
+ const fromConfig = toSlug(config.values.active_feature || '');
234
+ const slug = fromBranch || fromConfig;
235
+ let source = '';
236
+ if (fromBranch) source = 'branch';
237
+ else if (fromConfig) source = 'config';
238
+ return {
239
+ root,
240
+ isWizz: Boolean(root),
241
+ slug,
242
+ source,
243
+ fromBranch,
244
+ fromConfig,
245
+ git,
246
+ onTrunk: git.isRepo && (isTrunkBranch(git.branch) || !git.branch),
247
+ featureBranches: (config.values.feature_branches || 'off').toLowerCase() === 'auto' ? 'auto' : 'off',
248
+ hasLayout: Object.keys(config.layout).length > 0,
249
+ config,
250
+ };
251
+ }
252
+
253
+ function yamlScalar(value) {
254
+ return `"${String(value)
255
+ .replaceAll('\\', '\\\\')
256
+ .replaceAll('"', String.raw`\"`)}"`;
257
+ }
258
+
259
+ /**
260
+ * Grava uma chave escalar de topo no config, preservando o resto do arquivo.
261
+ * Escrita atômica (tmp + rename): config pela metade derrubaria todo agente.
262
+ * @returns {boolean} true se gravou
263
+ */
264
+ function writeConfigValue(root, key, value) {
265
+ const file = path.join(root, CONFIG_REL);
266
+ let text = '';
267
+ try {
268
+ text = fs.readFileSync(file, 'utf8');
269
+ } catch {
270
+ fs.mkdirSync(path.dirname(file), { recursive: true });
271
+ }
272
+ const line = `${key}: ${yamlScalar(value)}`;
273
+ const re = new RegExp(String.raw`^${key}:.*$`, 'm');
274
+ let next;
275
+ if (re.test(text)) next = text.replace(re, line);
276
+ else if (text && !text.endsWith('\n')) next = `${text}\n${line}\n`;
277
+ else next = `${text}${line}\n`;
278
+ atomicWrite(file, next);
279
+ return true;
280
+ }
281
+
282
+ /**
283
+ * Grava (substitui) o bloco `layout:` do config.
284
+ * @param {string} root
285
+ * @param {Record<string,string>} layout
286
+ */
287
+ function writeLayout(root, layout) {
288
+ const file = path.join(root, CONFIG_REL);
289
+ let text = '';
290
+ try {
291
+ text = fs.readFileSync(file, 'utf8');
292
+ } catch {
293
+ fs.mkdirSync(path.dirname(file), { recursive: true });
294
+ }
295
+ const block = ['layout:', ...Object.entries(layout).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`)].join('\n');
296
+ const lines = text.split('\n');
297
+ const start = lines.findIndex((l) => /^layout:\s*$/.test(l));
298
+ if (start === -1) {
299
+ atomicWrite(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${block}\n`);
300
+ return;
301
+ }
302
+ let end = start + 1;
303
+ while (end < lines.length && (/^\s+\S/.test(lines[end]) || lines[end].trim() === '')) end++;
304
+ // Linha em branco final pertence ao que vem depois, não ao bloco.
305
+ while (end > start + 1 && lines[end - 1].trim() === '') end--;
306
+ atomicWrite(file, [...lines.slice(0, start), block, ...lines.slice(end)].join('\n'));
307
+ }
308
+
309
+ function atomicWrite(file, content) {
310
+ const tmp = `${file}.${process.pid}.tmp`;
311
+ fs.writeFileSync(tmp, content);
312
+ fs.renameSync(tmp, file);
313
+ }
314
+
315
+ // ── Frontmatter ─────────────────────────────────────────────────────────
316
+
317
+ /**
318
+ * Lê o frontmatter YAML simples (chave: valor) do topo de um markdown.
319
+ * @param {string} text
320
+ * @returns {{has: boolean, data: Record<string,string>}}
321
+ */
322
+ function parseFrontmatter(text) {
323
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(String(text || ''));
324
+ if (!m) return { has: false, data: {} };
325
+ const data = {};
326
+ for (const line of m[1].split(/\r?\n/)) {
327
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
328
+ if (kv) data[kv[1]] = unquote(kv[2]);
329
+ }
330
+ return { has: true, data };
331
+ }
332
+
333
+ /**
334
+ * Troca (ou adiciona) uma chave do frontmatter. Sem frontmatter, cria um.
335
+ * @param {string} text
336
+ * @param {string} key
337
+ * @param {string} rawValue - já formatado (ex.: `no-ar`, `[D-01, D-02]`)
338
+ */
339
+ function setFrontmatterField(text, key, rawValue) {
340
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
341
+ if (!m) return `---\n${key}: ${rawValue}\n---\n\n${text}`;
342
+ const body = m[1];
343
+ const re = new RegExp(String.raw`^${key}:.*$`, 'm');
344
+ const nextBody = re.test(body) ? body.replace(re, `${key}: ${rawValue}`) : `${body}\n${key}: ${rawValue}`;
345
+ return `---\n${nextBody}\n---${text.slice(m[0].length)}`;
346
+ }
347
+
348
+ /**
349
+ * Valida o frontmatter de um artefato tipado. Devolve a lista de problemas
350
+ * (vazia quando está tudo certo).
351
+ * @param {string} text
352
+ * @param {string} [expectedFeature]
353
+ * @returns {string[]}
354
+ */
355
+ function frontmatterProblems(text, expectedFeature) {
356
+ const { has, data } = parseFrontmatter(text);
357
+ if (!has) return ['sem frontmatter (feature, status, atualizado_em, decisoes)'];
358
+ const problems = [];
359
+ if (!data.feature) problems.push('falta `feature:`');
360
+ else if (expectedFeature && toSlug(data.feature) !== expectedFeature)
361
+ problems.push(`\`feature: ${data.feature}\` diverge da pasta (${expectedFeature})`);
362
+ if (!STATUS.includes(data.status)) problems.push(`\`status:\` precisa ser ${STATUS.join('|')}`);
363
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(data.atualizado_em || '')) problems.push('`atualizado_em:` precisa ser YYYY-MM-DD');
364
+ if (!('decisoes' in data)) problems.push('falta `decisoes:` (use [] quando não houver)');
365
+ return problems;
366
+ }
367
+
368
+ /** Tipo do artefato pelo nome (`spec-x.md` → spec), ou ''. */
369
+ function artifactTypeOf(fileName) {
370
+ const m = /^([a-z]+)-[a-z0-9][a-z0-9-]*\.md$/.exec(fileName);
371
+ return m && ARTIFACT_TYPES[m[1]] ? m[1] : '';
372
+ }
373
+
374
+ /** Conteúdo de uma seção `## Título` (sem comentários HTML), ou null se ausente. */
375
+ function sectionContent(text, title) {
376
+ const lines = String(text).split('\n');
377
+ const start = lines.findIndex((l) => new RegExp(String.raw`^##\s+${title}\s*$`, 'i').test(l.trim()));
378
+ if (start === -1) return null;
379
+ const out = [];
380
+ for (let i = start + 1; i < lines.length && !/^#{1,2}\s/.test(lines[i]); i++) out.push(lines[i]);
381
+ return out
382
+ .join('\n')
383
+ .replaceAll(/<!--[\s\S]*?-->/g, '')
384
+ .trim();
385
+ }
386
+
387
+ module.exports = {
388
+ TRUNK,
389
+ ARTIFACT_TYPES,
390
+ STATUS,
391
+ PROJECT_WIDE,
392
+ CONFIG_REL,
393
+ toSlug,
394
+ today,
395
+ findUp,
396
+ findWizzRoot,
397
+ readGitState,
398
+ readGitBranch,
399
+ isTrunkBranch,
400
+ featureFromBranch,
401
+ readConfig,
402
+ readConfigFeature,
403
+ resolveConfigPath,
404
+ artifactDirs,
405
+ resolveFeature,
406
+ writeConfigValue,
407
+ writeLayout,
408
+ atomicWrite,
409
+ parseFrontmatter,
410
+ setFrontmatterField,
411
+ frontmatterProblems,
412
+ artifactTypeOf,
413
+ sectionContent,
414
+ };