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,401 @@
1
+ // wizz-feature.js — CLI do ciclo de vida da feature ativa.
2
+ //
3
+ // Fonte de verdade: wizz-method/tools/hooks/. Instalada em `_wizz/hooks/` de
4
+ // cada projeto (installer) e em `~/.claude/hooks/` (sync:global). Não é hook
5
+ // registrado: é o passo VERIFICÁVEL que router, maestro e agentes de área
6
+ // rodam, em vez de lembrar de editar o config à mão (foi o que falhou no
7
+ // Spider: a regra "derive do pedido" dependia de memória do agente).
8
+ //
9
+ // resolver feature ativa e de onde veio (branch > config > nada)
10
+ // definir <slug|texto> grava active_feature e cria as subpastas (sem perguntar)
11
+ // artefato <tipo> cria <tipo>-<slug>.md com frontmatter no lugar certo
12
+ // decidir "<texto>" registra D-NN na spec da feature, na hora da decisão
13
+ // status <status> rascunho|aprovado|implementado|no-ar na spec
14
+ // verificar checklist de fechamento (exit 1 se houver pendência)
15
+ // encerrar status final na spec, limpa/troca active_feature
16
+ // (recusa com pendência do checklist, salvo --forcar)
17
+ // listar features com pasta e o status de cada spec
18
+ //
19
+ // Opções: --json (saída para máquina), --cwd <dir>, --feature <slug>.
20
+ // Fail-open: fora de projeto Wizz, avisa e sai com 0 sem escrever nada.
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+ const lib = require('./wizz-feature-lib.js');
25
+
26
+ const ALIASES = {
27
+ resolve: 'resolver',
28
+ set: 'definir',
29
+ artifact: 'artefato',
30
+ decide: 'decidir',
31
+ check: 'verificar',
32
+ close: 'encerrar',
33
+ list: 'listar',
34
+ };
35
+
36
+ function parseArgs(argv) {
37
+ const opts = { _: [] };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a === '--json') opts.json = true;
41
+ else if (a.startsWith('--')) {
42
+ const key = a.slice(2);
43
+ const next = argv[i + 1];
44
+ if (next !== undefined && !next.startsWith('--')) {
45
+ opts[key] = next;
46
+ i++;
47
+ } else opts[key] = true;
48
+ } else opts._.push(a);
49
+ }
50
+ return opts;
51
+ }
52
+
53
+ function rel(root, p) {
54
+ return path.relative(root, p) || '.';
55
+ }
56
+
57
+ /** Pasta de cada tipo de artefato, para uma feature. */
58
+ function artifactPath(root, type, slug, dirs = lib.artifactDirs(root)) {
59
+ const where = lib.ARTIFACT_TYPES[type];
60
+ if (where === 'knowledge') return path.join(dirs.knowledge, `${type}-${slug}.md`);
61
+ return path.join(dirs[where], slug, `${type}-${slug}.md`);
62
+ }
63
+
64
+ function frontmatter(slug, type, status = 'rascunho') {
65
+ return ['---', `feature: ${slug}`, `tipo: ${type}`, `status: ${status}`, `atualizado_em: ${lib.today()}`, 'decisoes: []', '---', ''].join(
66
+ '\n',
67
+ );
68
+ }
69
+
70
+ const TEMPLATES = {
71
+ spec: (slug, title) =>
72
+ `${frontmatter(slug, 'spec')}
73
+ # Spec · ${title}
74
+
75
+ ## Objetivo
76
+
77
+ ## Escopo
78
+
79
+ ## Critérios de aceite
80
+
81
+ - [ ]
82
+
83
+ ## Decisões
84
+
85
+ ## Implementação
86
+
87
+ <!-- Ao implementar: arquivos tocados, desvios da spec e testes que provam. -->
88
+ `,
89
+ plano: (slug, title) => `${frontmatter(slug, 'plano')}\n# Plano · ${title}\n\n## Passos\n\n## Riscos\n`,
90
+ auditoria: (slug, title) => `${frontmatter(slug, 'auditoria')}\n# Auditoria · ${title}\n\n## Achados\n\n## Prioridades\n`,
91
+ story: (slug, title) => `${frontmatter(slug, 'story')}\n# Story · ${title}\n\n## Contexto\n\n## Critérios de aceite\n\n- [ ]\n`,
92
+ review: (slug, title) => `${frontmatter(slug, 'review')}\n# Review · ${title}\n\n## Achados\n\n## Veredito\n`,
93
+ roteiro: (slug, title) =>
94
+ `${frontmatter(slug, 'roteiro')}
95
+ # Roteiro de aplicação · ${title}
96
+
97
+ ## Pré-requisitos
98
+
99
+ ## Passos
100
+
101
+ ## Verificação
102
+
103
+ ## Rollback
104
+ `,
105
+ };
106
+
107
+ function titleOf(slug, explicit) {
108
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
109
+ return slug.replaceAll('-', ' ').replace(/^./, (c) => c.toUpperCase());
110
+ }
111
+
112
+ /** Cria o artefato se não existir. Devolve {path, created}. */
113
+ function ensureArtifact(root, type, slug, title) {
114
+ const file = artifactPath(root, type, slug);
115
+ if (fs.existsSync(file)) return { path: file, created: false };
116
+ fs.mkdirSync(path.dirname(file), { recursive: true });
117
+ fs.writeFileSync(file, TEMPLATES[type](slug, titleOf(slug, title)));
118
+ return { path: file, created: true };
119
+ }
120
+
121
+ function touch(text) {
122
+ return lib.setFrontmatterField(text, 'atualizado_em', lib.today());
123
+ }
124
+
125
+ // ── comandos ────────────────────────────────────────────────────────────
126
+
127
+ function cmdResolver(state) {
128
+ return {
129
+ ok: true,
130
+ data: { feature: state.slug, origem: state.source || 'nenhuma', branch: state.git.branch, config: state.fromConfig },
131
+ line: state.slug
132
+ ? `Feature ativa: ${state.slug} (${state.source === 'branch' ? 'do branch git' : 'do config'}).`
133
+ : 'Nenhuma feature ativa: o primeiro pedido nomeado define uma (`definir <slug>`).',
134
+ };
135
+ }
136
+
137
+ function cmdDefinir(state, opts) {
138
+ const raw = opts._.slice(1).join(' ');
139
+ const slug = lib.toSlug(raw);
140
+ if (!slug) return { ok: false, code: 2, line: 'Uso: definir <slug ou nome da feature>. Nada foi gravado.' };
141
+ const { root } = state;
142
+ const previous = state.fromConfig;
143
+ lib.writeConfigValue(root, 'active_feature', slug);
144
+ const dirs = lib.artifactDirs(root);
145
+ for (const d of [dirs.planning, dirs.implementation]) fs.mkdirSync(path.join(d, slug), { recursive: true });
146
+
147
+ const parts = [
148
+ `Feature: ${slug}. Artefatos em ${rel(root, path.join(dirs.planning, slug))}/ e ${rel(root, path.join(dirs.implementation, slug))}/.`,
149
+ ];
150
+ if (previous && previous !== slug) parts.push(`Troca: ${previous} → ${slug} (${previous} segue aberta até ser encerrada).`);
151
+ if (state.fromBranch && state.fromBranch !== slug)
152
+ parts.push(
153
+ `Atenção: o branch ${state.git.branch} aponta para ${state.fromBranch} e ganha do config. Troque de branch se ${slug} é outro trabalho.`,
154
+ );
155
+ const proposeBranch = state.git.isRepo && state.onTrunk && state.featureBranches === 'auto';
156
+ if (proposeBranch) parts.push(`Proposta (feature_branches: auto): git switch -c feat/${slug}`);
157
+ return {
158
+ ok: true,
159
+ data: { feature: slug, anterior: previous, branch_proposto: proposeBranch ? `feat/${slug}` : '' },
160
+ line: parts.join(' '),
161
+ };
162
+ }
163
+
164
+ function requireFeature(state, opts) {
165
+ const slug = lib.toSlug(typeof opts.feature === 'string' ? opts.feature : '') || state.slug;
166
+ return slug;
167
+ }
168
+
169
+ function cmdArtefato(state, opts) {
170
+ const type = (opts._[1] || '').toLowerCase();
171
+ if (!lib.ARTIFACT_TYPES[type]) return { ok: false, code: 2, line: `Tipo inválido. Use: ${Object.keys(lib.ARTIFACT_TYPES).join(', ')}.` };
172
+ const slug = requireFeature(state, opts);
173
+ if (!slug) return { ok: false, code: 2, line: 'Sem feature ativa: rode `definir <slug>` antes de criar artefato.' };
174
+ const { path: file, created } = ensureArtifact(state.root, type, slug, opts.titulo);
175
+ return {
176
+ ok: true,
177
+ data: { caminho: rel(state.root, file), criado: created },
178
+ line: `${created ? 'Criado' : 'Já existe'}: ${rel(state.root, file)}`,
179
+ };
180
+ }
181
+
182
+ function cmdDecidir(state, opts) {
183
+ const texto = opts._.slice(1).join(' ').trim();
184
+ if (!texto) return { ok: false, code: 2, line: 'Uso: decidir "<decisão do dono>".' };
185
+ const slug = requireFeature(state, opts);
186
+ if (!slug) return { ok: false, code: 2, line: 'Sem feature ativa: rode `definir <slug>` antes de registrar decisão.' };
187
+ const { path: file } = ensureArtifact(state.root, 'spec', slug);
188
+ let text = fs.readFileSync(file, 'utf8');
189
+ const ids = [...text.matchAll(/\bD-(\d+)\b/g)].map((m) => Number(m[1]));
190
+ const id = `D-${String((ids.length > 0 ? Math.max(...ids) : 0) + 1).padStart(2, '0')}`;
191
+ const entry = `- **${id}** (${lib.today()}): ${texto}`;
192
+
193
+ const lines = text.split('\n');
194
+ const head = lines.findIndex((l) => /^##\s+Decis(õ|o)es\s*$/i.test(l.trim()));
195
+ if (head === -1) {
196
+ text = `${text.replace(/\n*$/, '\n')}\n## Decisões\n\n${entry}\n`;
197
+ } else {
198
+ let end = head + 1;
199
+ while (end < lines.length && !/^#{1,2}\s/.test(lines[end])) end++;
200
+ while (end > head + 1 && lines[end - 1].trim() === '') end--;
201
+ const block = end === head + 1 ? ['', entry] : [entry];
202
+ text = [...lines.slice(0, end), ...block, ...lines.slice(end)].join('\n');
203
+ }
204
+ const current = lib.parseFrontmatter(text).data.decisoes || '[]';
205
+ const list = current
206
+ .replaceAll(/[[\]]/g, '')
207
+ .split(',')
208
+ .map((s) => s.trim())
209
+ .filter(Boolean);
210
+ text = touch(lib.setFrontmatterField(text, 'decisoes', `[${[...list, id].join(', ')}]`));
211
+ fs.writeFileSync(file, text);
212
+ return { ok: true, data: { id, caminho: rel(state.root, file) }, line: `${id} registrada em ${rel(state.root, file)}.` };
213
+ }
214
+
215
+ function setSpecStatus(state, slug, status) {
216
+ const { path: file } = ensureArtifact(state.root, 'spec', slug);
217
+ const text = fs.readFileSync(file, 'utf8');
218
+ fs.writeFileSync(file, touch(lib.setFrontmatterField(text, 'status', status)));
219
+ return file;
220
+ }
221
+
222
+ function cmdStatus(state, opts) {
223
+ const status = (opts._[1] || '').toLowerCase();
224
+ if (!lib.STATUS.includes(status)) return { ok: false, code: 2, line: `Status inválido. Use: ${lib.STATUS.join('|')}.` };
225
+ const slug = requireFeature(state, opts);
226
+ if (!slug) return { ok: false, code: 2, line: 'Sem feature ativa: rode `definir <slug>` primeiro.' };
227
+ const file = setSpecStatus(state, slug, status);
228
+ return { ok: true, data: { status, caminho: rel(state.root, file) }, line: `Status de ${slug}: ${status}.` };
229
+ }
230
+
231
+ /** Artefatos tipados soltos na raiz das pastas de artefato (fora de qualquer feature). */
232
+ function looseArtifacts(root, dirs) {
233
+ const out = [];
234
+ for (const d of [dirs.planning, dirs.implementation]) {
235
+ let entries = [];
236
+ try {
237
+ entries = fs.readdirSync(d, { withFileTypes: true });
238
+ } catch {
239
+ continue;
240
+ }
241
+ for (const e of entries) if (e.isFile() && lib.artifactTypeOf(e.name)) out.push(rel(root, path.join(d, e.name)));
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /**
247
+ * Checklist de fechamento. Itens mecânicos são checados; os de julgamento
248
+ * (Cérebro, memória) voltam como "confirmar" para o agente responder.
249
+ */
250
+ function checklist(state, slug, { statusOverride } = {}) {
251
+ const { root } = state;
252
+ const dirs = lib.artifactDirs(root);
253
+ const items = [];
254
+ const add = (ok, item, detalhe = '') => items.push({ ok, item, detalhe });
255
+
256
+ add(Boolean(slug), 'feature resolvida', slug || 'nenhuma feature ativa');
257
+ const soltos = looseArtifacts(root, dirs);
258
+ add(soltos.length === 0, 'artefatos no lugar', soltos.length > 0 ? `soltos: ${soltos.join(', ')}` : '');
259
+
260
+ if (slug) {
261
+ const spec = artifactPath(root, 'spec', slug, dirs);
262
+ const exists = fs.existsSync(spec);
263
+ add(exists, 'spec da feature existe', rel(root, spec));
264
+ if (exists) {
265
+ const text = fs.readFileSync(spec, 'utf8');
266
+ const problems = lib.frontmatterProblems(text, slug);
267
+ add(problems.length === 0, 'frontmatter da spec válido', problems.join('; '));
268
+ add(
269
+ Boolean(
270
+ lib
271
+ .sectionContent(text, 'Crit[ée]rios de aceite')
272
+ ?.replaceAll(/^- \[ \]\s*$/gm, '')
273
+ .trim(),
274
+ ),
275
+ 'spec com critérios de aceite',
276
+ );
277
+ const status = statusOverride || lib.parseFrontmatter(text).data.status;
278
+ if (status === 'implementado' || status === 'no-ar')
279
+ add(Boolean(lib.sectionContent(text, 'Implementa[çc][ãa]o')), 'seção Implementação preenchida');
280
+ if (status === 'no-ar') {
281
+ const roteiro = artifactPath(root, 'roteiro', slug, dirs);
282
+ const ok = fs.existsSync(roteiro) && lib.sectionContent(fs.readFileSync(roteiro, 'utf8'), 'Rollback');
283
+ add(Boolean(ok), 'roteiro de aplicação com rollback', rel(root, roteiro));
284
+ }
285
+ }
286
+ }
287
+ const pendentes = items.filter((i) => !i.ok);
288
+ const confirmar = [
289
+ 'Cérebro: bloco Estado do projeto atualizado + 1 linha na tabela Sessões',
290
+ 'Auto-memória: só armadilha reproduzível ou preferência do dono (estado e decisão vão no Cérebro)',
291
+ ];
292
+ return { items, pendentes, confirmar };
293
+ }
294
+
295
+ function cmdVerificar(state, opts) {
296
+ const slug = requireFeature(state, opts);
297
+ const { items, pendentes, confirmar } = checklist(state, slug);
298
+ const lines = items.map((i) => `${i.ok ? '✓' : '✗'} ${i.item}${i.detalhe ? ` (${i.detalhe})` : ''}`);
299
+ lines.push(...confirmar.map((c) => `? confirmar: ${c}`));
300
+ return {
301
+ ok: pendentes.length === 0,
302
+ code: pendentes.length === 0 ? 0 : 1,
303
+ data: { feature: slug, itens: items, confirmar },
304
+ line: lines.join('\n'),
305
+ };
306
+ }
307
+
308
+ function cmdEncerrar(state, opts) {
309
+ const slug = requireFeature(state, opts);
310
+ if (!slug) return { ok: false, code: 2, line: 'Nenhuma feature ativa para encerrar.' };
311
+ const status = typeof opts.status === 'string' ? opts.status.toLowerCase() : 'no-ar';
312
+ if (!lib.STATUS.includes(status)) return { ok: false, code: 2, line: `Status inválido. Use: ${lib.STATUS.join('|')}.` };
313
+ // Não fecha com pendência: a checagem roda ANTES de gravar, já com o status final.
314
+ const pend = checklist(state, slug, { statusOverride: status }).pendentes;
315
+ if (pend.length > 0 && !opts.forcar) {
316
+ return {
317
+ ok: false,
318
+ code: 1,
319
+ data: { feature: slug, status, pendencias: pend.map((p) => p.item) },
320
+ line: `Não encerrei ${slug}: ${pend.map((p) => p.item + (p.detalhe ? ` (${p.detalhe})` : '')).join('; ')}. Resolva e rode de novo (ou --forcar se o dono aceitar a pendência).`,
321
+ };
322
+ }
323
+ const file = setSpecStatus(state, slug, status);
324
+ const next = lib.toSlug(typeof opts.proxima === 'string' ? opts.proxima : '');
325
+ if (state.fromConfig === slug || !state.fromConfig) lib.writeConfigValue(state.root, 'active_feature', next);
326
+ if (next) {
327
+ const dirs = lib.artifactDirs(state.root);
328
+ for (const d of [dirs.planning, dirs.implementation]) fs.mkdirSync(path.join(d, next), { recursive: true });
329
+ }
330
+ const parts = [`Feature ${slug} encerrada (${status}) em ${lib.today()}. Spec: ${rel(state.root, file)}.`];
331
+ parts.push(next ? `Feature ativa agora: ${next}.` : 'Nenhuma feature ativa agora.');
332
+ if (state.fromBranch === slug) parts.push(`O branch ${state.git.branch} ainda aponta para ${slug}: volte ao tronco ou troque de branch.`);
333
+ parts.push(`Registre no Cérebro: Estado do projeto + linha na tabela Sessões (${slug}: ${status} em ${lib.today()}).`);
334
+ if (pend.length > 0) parts.push(`Pendências: ${pend.map((p) => p.item).join('; ')}.`);
335
+ return { ok: true, data: { feature: slug, status, proxima: next, pendencias: pend.map((p) => p.item) }, line: parts.join(' ') };
336
+ }
337
+
338
+ function cmdListar(state) {
339
+ const dirs = lib.artifactDirs(state.root);
340
+ let names = [];
341
+ try {
342
+ names = fs
343
+ .readdirSync(dirs.planning, { withFileTypes: true })
344
+ .filter((e) => e.isDirectory())
345
+ .map((e) => e.name);
346
+ } catch {
347
+ // sem pasta de planejamento: nenhuma feature
348
+ }
349
+ const features = names.map((slug) => {
350
+ const spec = artifactPath(state.root, 'spec', slug, dirs);
351
+ const status = fs.existsSync(spec) ? lib.parseFrontmatter(fs.readFileSync(spec, 'utf8')).data.status || '?' : 'sem spec';
352
+ return { feature: slug, status, ativa: slug === state.slug };
353
+ });
354
+ const line =
355
+ features.length === 0
356
+ ? 'Nenhuma feature com pasta ainda.'
357
+ : features.map((f) => `${f.ativa ? '*' : ' '} ${f.feature} (${f.status})`).join('\n');
358
+ return { ok: true, data: { features }, line };
359
+ }
360
+
361
+ const COMMANDS = {
362
+ resolver: cmdResolver,
363
+ definir: cmdDefinir,
364
+ artefato: cmdArtefato,
365
+ decidir: cmdDecidir,
366
+ status: cmdStatus,
367
+ verificar: cmdVerificar,
368
+ encerrar: cmdEncerrar,
369
+ listar: cmdListar,
370
+ };
371
+
372
+ /**
373
+ * Roda um comando e devolve o resultado (sem escrever no stdout): é o que os
374
+ * testes e o eval chamam.
375
+ * @param {string[]} argv
376
+ * @returns {{ok: boolean, code?: number, data?: object, line: string}}
377
+ */
378
+ function run(argv) {
379
+ const opts = parseArgs(argv);
380
+ const name = ALIASES[opts._[0]] || opts._[0];
381
+ const command = COMMANDS[name];
382
+ if (!command) return { ok: false, code: 2, line: `Comandos: ${Object.keys(COMMANDS).join(', ')}.` };
383
+ const cwd = typeof opts.cwd === 'string' ? path.resolve(opts.cwd) : process.cwd();
384
+ const state = lib.resolveFeature(cwd);
385
+ if (!state.isWizz) return { ok: true, code: 0, data: { wizz: false }, line: 'Não é projeto Wizz (sem _wizz/): nada a fazer.' };
386
+ try {
387
+ return command(state, opts);
388
+ } catch (error) {
389
+ return { ok: false, code: 1, line: `Falhou sem alterar o resto do projeto: ${error.message}` };
390
+ }
391
+ }
392
+
393
+ if (require.main === module) {
394
+ const argv = process.argv.slice(2);
395
+ const result = run(argv);
396
+ const json = argv.includes('--json');
397
+ process.stdout.write(json ? `${JSON.stringify({ ok: result.ok, ...result.data, mensagem: result.line })}\n` : `${result.line}\n`);
398
+ process.exit(result.code ?? (result.ok ? 0 : 1));
399
+ }
400
+
401
+ module.exports = { run, checklist, artifactPath, TEMPLATES };
@@ -0,0 +1,152 @@
1
+ // wizz-layout-lib.js — o que pode ficar solto na raiz e para onde vai o resto.
2
+ //
3
+ // Fonte de verdade: wizz-method/tools/hooks/. Usado pelo hook
4
+ // wizz-artifact-guard (sugere destino ao criar arquivo solto) e pela CLI
5
+ // wizz-arrumar (propõe layout, monta o manifesto da arrumação).
6
+ //
7
+ // O layout mora em `_wizz/bmm/config.yaml`, bloco `layout:` (tipo → pasta).
8
+ // Tipos canônicos: codigo, scripts, dados, docs, midia, arquivo. Um tipo com
9
+ // pasta "." significa "esse tipo vive na raiz" (ex.: app Next.js na raiz).
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ const LAYOUT_TYPES = {
15
+ codigo: 'código do produto (app, lib, componentes)',
16
+ scripts: 'scripts e utilitários soltos (.sh, .py, .mjs)',
17
+ dados: 'dados de entrada e saída (.csv, .json, planilhas, bancos locais)',
18
+ docs: 'documentação e anotações (.md, .pdf, .docx)',
19
+ midia: 'imagens, vídeos e áudios',
20
+ arquivo: 'o que saiu de uso mas não pode ser apagado (zip, backup, versões antigas)',
21
+ };
22
+
23
+ const DEFAULT_LAYOUT = { scripts: 'scripts', dados: 'dados', docs: 'docs', arquivo: 'arquivo' };
24
+
25
+ // Arquivos que pertencem à raiz por convenção de ferramenta. Nunca viram aviso.
26
+ const ROOT_ALLOWED = [
27
+ /^\./, // dotfiles (.gitignore, .env.example, .nvmrc...)
28
+ /^(readme|license|licence|changelog|contributing|security|code_of_conduct|notice|third-party-notices|authors)(\..+)?$/i,
29
+ /^(claude|agents|gemini|copilot-instructions)\.md$/i,
30
+ /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|bun\.lockb?|bunfig\.toml)$/i,
31
+ /^(ts|js)config(\..+)?\.json$/i,
32
+ /^[\w.-]+\.config\.(js|cjs|mjs|ts|cts|mts|json|yaml|yml|toml)$/i,
33
+ /^(next-env\.d\.ts|middleware\.(js|ts)|proxy\.(js|ts)|instrumentation(-client)?\.(js|ts))$/i,
34
+ /^(vercel\.json|vercel\.ts|netlify\.toml|fly\.toml|render\.yaml|railway\.json|app\.json|wrangler\.(toml|jsonc?))$/i,
35
+ /^(dockerfile.*|docker-compose.*\.ya?ml|compose\.ya?ml|makefile|procfile|justfile|taskfile\.ya?ml)$/i,
36
+ /^(requirements.*\.txt|pyproject\.toml|setup\.(py|cfg)|pipfile(\.lock)?|poetry\.lock|uv\.lock|\.python-version|manage\.py)$/i,
37
+ /^(go\.(mod|sum|work)|cargo\.(toml|lock)|gemfile(\.lock)?|rakefile|deno\.jsonc?|deno\.lock|composer\.(json|lock))$/i,
38
+ /^(biome\.jsonc?|turbo\.json|nx\.json|lerna\.json|renovate\.json|components\.json|skills-lock\.json|tsconfig\.tsbuildinfo)$/i,
39
+ ];
40
+
41
+ const EXT_TYPE = {
42
+ scripts: ['.sh', '.bash', '.zsh', '.ps1', '.py', '.mjs', '.cjs', '.js', '.ts', '.rb', '.pl', '.php', '.ipynb'],
43
+ dados: ['.csv', '.tsv', '.json', '.jsonl', '.ndjson', '.xlsx', '.xls', '.ods', '.parquet', '.sqlite', '.sqlite3', '.db', '.sql', '.xml'],
44
+ docs: ['.md', '.mdx', '.pdf', '.docx', '.doc', '.txt', '.rtf', '.odt', '.pptx', '.key', '.html', '.htm'],
45
+ midia: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.heic', '.mp4', '.mov', '.webm', '.mp3', '.wav', '.m4a'],
46
+ arquivo: ['.zip', '.tar', '.gz', '.tgz', '.7z', '.rar', '.bak', '.old', '.orig', '.log', '.tmp'],
47
+ };
48
+
49
+ // Tipo de reserva quando o layout não tem o tipo natural do arquivo.
50
+ const FALLBACK = { midia: 'docs', scripts: 'codigo', dados: 'arquivo', docs: 'arquivo' };
51
+
52
+ /** Tipo natural de um arquivo pela extensão, ou ''. */
53
+ function typeOfFile(fileName) {
54
+ const ext = path.extname(fileName).toLowerCase();
55
+ for (const [type, exts] of Object.entries(EXT_TYPE)) if (exts.includes(ext)) return type;
56
+ return '';
57
+ }
58
+
59
+ function normalizeDir(dir) {
60
+ const d = String(dir || '')
61
+ .trim()
62
+ .replace(/^\.\//, '')
63
+ .replace(/\/+$/, '');
64
+ return d === '' ? '.' : d;
65
+ }
66
+
67
+ /** Arquivos extras liberados na raiz via `layout.raiz_extra: "a.txt, b.md"`. */
68
+ function extraAllowed(layout) {
69
+ return String(layout.raiz_extra || '')
70
+ .split(',')
71
+ .map((s) => s.trim())
72
+ .filter(Boolean);
73
+ }
74
+
75
+ /**
76
+ * Pode ficar na raiz? Convenção de ferramenta, lista extra, ou o tipo do
77
+ * arquivo vive na raiz por decisão do layout (pasta ".").
78
+ * @param {string} fileName
79
+ * @param {Record<string,string>} layout
80
+ */
81
+ function isAllowedAtRoot(fileName, layout = {}) {
82
+ if (ROOT_ALLOWED.some((re) => re.test(fileName))) return true;
83
+ if (extraAllowed(layout).includes(fileName)) return true;
84
+ const type = typeOfFile(fileName);
85
+ if (type && normalizeDir(layout[type]) === '.' && layout[type] !== undefined) return true;
86
+ // Código na raiz é decisão do layout (codigo: "."), não acidente.
87
+ return type === 'scripts' && layout.codigo !== undefined && normalizeDir(layout.codigo) === '.';
88
+ }
89
+
90
+ /**
91
+ * Pasta de destino sugerida para um arquivo solto na raiz.
92
+ * @returns {{type: string, dir: string}|null} null quando não há sugestão
93
+ */
94
+ function suggestDestination(fileName, layout = {}) {
95
+ const natural = typeOfFile(fileName);
96
+ if (!natural) return null;
97
+ const candidates = [natural, FALLBACK[natural]].filter(Boolean);
98
+ for (const type of candidates) {
99
+ if (layout[type] !== undefined && normalizeDir(layout[type]) !== '.') return { type, dir: normalizeDir(layout[type]) };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Propõe um layout olhando o que já existe: reaproveita pastas com o nome de
106
+ * um tipo (ou sinônimo) e só sugere criar as que os arquivos soltos pedem.
107
+ * @param {string} root
108
+ * @returns {{layout: Record<string,string>, loose: string[]}}
109
+ */
110
+ function proposeLayout(root) {
111
+ const SYNONYMS = {
112
+ codigo: ['src', 'app', 'apps', 'packages', 'lib'],
113
+ scripts: ['scripts', 'bin', 'tools'],
114
+ dados: ['dados', 'data', 'datasets'],
115
+ docs: ['docs', 'doc', 'documentacao'],
116
+ midia: ['midia', 'media', 'assets'],
117
+ arquivo: ['arquivo', 'archive', 'old', 'backups'],
118
+ };
119
+ let entries = [];
120
+ try {
121
+ entries = fs.readdirSync(root, { withFileTypes: true });
122
+ } catch {
123
+ return { layout: { ...DEFAULT_LAYOUT }, loose: [] };
124
+ }
125
+ const dirs = new Set(entries.filter((e) => e.isDirectory()).map((e) => e.name));
126
+ const loose = entries.filter((e) => e.isFile() && !isAllowedAtRoot(e.name, {})).map((e) => e.name);
127
+ const layout = {};
128
+ for (const [type, names] of Object.entries(SYNONYMS)) {
129
+ const found = names.find((n) => dirs.has(n));
130
+ if (found) layout[type] = found;
131
+ }
132
+ // Projeto JS com app na raiz (package.json + app/ ou src/): código vive na raiz.
133
+ if (entries.some((e) => e.name === 'package.json') && layout.codigo) layout.codigo = '.';
134
+ for (const name of loose) {
135
+ const type = typeOfFile(name);
136
+ if (type && !layout[type]) layout[type] = DEFAULT_LAYOUT[type] || type;
137
+ }
138
+ for (const [type, dir] of Object.entries(DEFAULT_LAYOUT)) if (!layout[type]) layout[type] = dir;
139
+ return { layout, loose };
140
+ }
141
+
142
+ module.exports = {
143
+ LAYOUT_TYPES,
144
+ DEFAULT_LAYOUT,
145
+ ROOT_ALLOWED,
146
+ EXT_TYPE,
147
+ typeOfFile,
148
+ normalizeDir,
149
+ isAllowedAtRoot,
150
+ suggestDestination,
151
+ proposeLayout,
152
+ };
@@ -138,6 +138,25 @@ function buildContext(isWizzProject) {
138
138
  );
139
139
  }
140
140
 
141
+ // Linha de feature, por prompt, em projeto Wizz (2026-10-02, caso Spider).
142
+ // O SessionStart avisa 1x, mas é aqui que o pedido nomeado chega: sem este
143
+ // lembrete o despacho saía sem feature e o artefato caía na raiz. Fail-open:
144
+ // lib ausente ou erro = linha vazia, o roteamento segue igual.
145
+ function featureLine(wizzRoot) {
146
+ if (!wizzRoot) return '';
147
+ try {
148
+ const lib = require('./wizz-feature-lib.js');
149
+ const state = lib.resolveFeature(wizzRoot);
150
+ const cmd = 'node _wizz/hooks/wizz-feature.js';
151
+ if (!state.slug) {
152
+ return ` Feature: nenhuma ativa. Pedido nomeado → ANTES de despachar \`${cmd} definir <slug do pedido>\` (sem perguntar); handoff leva \`feature:\`.`;
153
+ }
154
+ return ` Feature: ${state.slug}. Outro assunto → \`${cmd} definir <novo>\`; continuação mantém; entrega fechada → \`${cmd} encerrar\`. Handoff leva \`feature: ${state.slug}\`.`;
155
+ } catch {
156
+ return '';
157
+ }
158
+ }
159
+
141
160
  function isTraceEnabled() {
142
161
  return process.env.WIZZ_TRACE === '1';
143
162
  }
@@ -216,7 +235,7 @@ function runHook() {
216
235
  const cwd = data.cwd || process.cwd();
217
236
  const wizzRoot = findWizzRoot(cwd);
218
237
  const isWizzProject = Boolean(wizzRoot);
219
- const context = buildContext(isWizzProject);
238
+ const context = buildContext(isWizzProject) + featureLine(wizzRoot);
220
239
 
221
240
  const warnings = checkFallbackWarnings(isWizzProject, wizzRoot);
222
241
  if (warnings.length > 0) emitFallbackWarnings(warnings);
@@ -249,6 +268,7 @@ if (require.main === module) {
249
268
  module.exports = {
250
269
  isTrivial,
251
270
  buildContext,
271
+ featureLine,
252
272
  findWizzRoot,
253
273
  isTraceEnabled,
254
274
  getTraceFile,
@@ -21,7 +21,7 @@
21
21
  // — os testes rodam sem rtk/jq/python instalados e sem rede.
22
22
 
23
23
  const path = require('node:path');
24
- const { HOOK_DEFS, isOurHookCommand } = require('./hooks-settings');
24
+ const { HOOK_DEFS, SUPPORT_FILES, isOurHookCommand } = require('./hooks-settings');
25
25
  const { classifyPython, PYTHON_ONLY_SKILLS } = require('./python-check');
26
26
 
27
27
  // Piso de versão do rtk que o hook rtk-rewrite.sh exige para `rtk rewrite`
@@ -169,6 +169,10 @@ function checkHooks({ wizzDir, settingsFile, globalSettingsFile, fileExists, rea
169
169
  }
170
170
  }
171
171
 
172
+ for (const id of SUPPORT_FILES) {
173
+ if (!fileExists(path.join(hooksDir, id))) missingFiles.push(id);
174
+ }
175
+
172
176
  if (missingFiles.length > 0) {
173
177
  return {
174
178
  check: 'hooks',
@@ -30,8 +30,17 @@ const HOOK_DEFS = [
30
30
  // `rtkOnPath` é true — ver buildHookEntries abaixo.
31
31
  { id: 'rtk-rewrite.sh', event: 'PreToolUse', matcher: 'Bash', kind: 'shell', requires: 'rtk' },
32
32
  { id: 'wizz-decision-trace.js', event: 'Stop', kind: 'node' },
33
+ // Avisa artefato fora da pasta da feature e arquivo solto na raiz (2026-10-02).
34
+ // Nunca bloqueia: PostToolUse só devolve contexto para o agente corrigir.
35
+ { id: 'wizz-artifact-guard.js', event: 'PostToolUse', matcher: 'Write', kind: 'node' },
33
36
  ];
34
37
 
38
+ // Arquivos de apoio copiados junto com os hooks, mas NUNCA registrados em
39
+ // settings.json: bibliotecas que os hooks importam e as CLIs que os agentes
40
+ // rodam (`node _wizz/hooks/wizz-feature.js definir <slug>`). Sem eles a
41
+ // organização automática cala em silêncio (fail-open), então o doctor cobra.
42
+ const SUPPORT_FILES = ['wizz-feature-lib.js', 'wizz-layout-lib.js', 'wizz-feature.js', 'wizz-arrumar.js'];
43
+
35
44
  const DEFAULT_TIMEOUT = 5;
36
45
 
37
46
  function commandFor(def) {
@@ -169,6 +178,7 @@ function mergeHooksIntoSettings(existingSettings, hookEntries) {
169
178
  }
170
179
 
171
180
  module.exports = {
181
+ SUPPORT_FILES,
172
182
  HOOKS_DIR,
173
183
  HOOK_DEFS,
174
184
  buildHookEntries,