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.
@@ -11,8 +11,11 @@
11
11
  // 2. Feature ativa: resolve o `active_feature` SOZINHO, sem o usuário
12
12
  // precisar invocar `wizz-set-feature` e responder duas perguntas. Ordem:
13
13
  // branch git (mais fresco que o config) > `_wizz/bmm/config.yaml` > nada.
14
- // Custo zero quando não há `_wizz/` nem branch de feature: a linha
15
- // simplesmente não é emitida.
14
+ // Custo zero fora de projeto Wizz sem branch de feature: a linha
15
+ // simplesmente não é emitida. DENTRO de projeto Wizz sem feature sai um
16
+ // lembrete curto ("o primeiro pedido nomeado define uma", 2026-10-02):
17
+ // sem ele, projeto sem git ou na main ficava a vida toda sem feature
18
+ // (caso Spider). Mesmo lembrete para layout de pastas ausente.
16
19
  //
17
20
  // 3. Teto da auto-memória: o teto de 40 memórias e 8 KB de índice está
18
21
  // escrito no CLAUDE.md global desde 2026-08-28, mas nada disparava a
@@ -39,163 +42,85 @@ const RULES =
39
42
  'concise communication by default, expand only on request; ' +
40
43
  'reply to the user in the language chosen at install (communication_language).';
41
44
 
42
- // Branches que NUNCA viram feature: são linha principal, não trabalho nomeado.
43
- const TRUNK = new Set(['main', 'master', 'develop', 'dev', 'staging', 'stage', 'prod', 'production', 'trunk', 'default', 'release']);
44
-
45
- // Branch de release/versão não é feature: nomeia um corte do produto, não um trabalho.
46
- const RELEASE_PREFIX = /^(release|rc|v)[/-]/i;
47
-
48
- // Prefixos de convenção que descrevem o TIPO do trabalho, não a feature.
49
- const TYPE_PREFIX = /^(feat|feature|fix|bugfix|hotfix|chore|refactor|docs|test|perf|ci|build|style)[/-]/i;
50
-
51
- /**
52
- * Normaliza um texto livre para o mesmo slug kebab-case que a skill
53
- * `wizz-set-feature` grava, para branch e config nunca divergirem por formato.
54
- * @param {string} raw
55
- * @returns {string} slug kebab-case, ou '' se não sobrar nada utilizável
56
- */
57
- function toSlug(raw) {
58
- if (typeof raw !== 'string') return '';
59
- return raw
60
- .normalize('NFD')
61
- .replaceAll(/[\u0300-\u036F]/g, '')
62
- .toLowerCase()
63
- .replaceAll(/[^a-z0-9]+/g, '-')
64
- .replaceAll(/^-+|-+$/g, '')
65
- .slice(0, 60);
45
+ // Feature, git e config moram em wizz-feature-lib.js (mesma pasta: o installer
46
+ // e o sync:global copiam tools/hooks/ inteiro). Se a lib faltar numa cópia
47
+ // manual parcial, a regra de comunicação continua saindo e a feature cala.
48
+ let lib = null;
49
+ try {
50
+ lib = require('./wizz-feature-lib.js');
51
+ } catch {
52
+ lib = null;
66
53
  }
67
54
 
68
- /**
69
- * Lê o branch atual sem subprocesso: `.git/HEAD` já traz o ref, e um read de
70
- * arquivo é mais barato e mais seguro que um `git` no PATH (que pode nem existir).
71
- * Sobe até 6 níveis para achar o `.git` de um subdiretório do repo.
72
- * @param {string} cwd
73
- * @returns {string} nome do branch, ou '' (detached HEAD, sem repo, erro)
74
- */
75
- function readGitBranch(cwd) {
76
- let dir = cwd;
77
- for (let i = 0; i < 6; i++) {
78
- const gitPath = path.join(dir, '.git');
79
- try {
80
- const stat = fs.statSync(gitPath);
81
- let headFile = path.join(gitPath, 'HEAD');
82
- if (stat.isFile()) {
83
- // worktree/submódulo: `.git` é um arquivo `gitdir: <caminho>`
84
- const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
85
- if (!m) return '';
86
- const resolved = path.resolve(dir, m[1].trim());
87
- headFile = path.join(resolved, 'HEAD');
88
- }
89
- const head = fs.readFileSync(headFile, 'utf8').trim();
90
- const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
91
- return ref ? ref[1] : ''; // detached HEAD => sem branch => sem feature
92
- } catch {
93
- // segue subindo
94
- }
95
- const parent = path.dirname(dir);
96
- if (parent === dir) break;
97
- dir = parent;
98
- }
99
- return '';
100
- }
55
+ const HOOK_CMD = 'node _wizz/hooks/wizz-feature.js';
101
56
 
102
- /**
103
- * Deriva um slug de feature do nome do branch. Branch de tronco e branch sem
104
- * nome útil devolvem '' (nenhuma feature), nunca um slug inventado.
105
- * @param {string} branch
106
- * @returns {string}
107
- */
108
- function featureFromBranch(branch) {
109
- if (!branch) return '';
110
- if (TRUNK.has(branch.toLowerCase())) return '';
111
- if (RELEASE_PREFIX.test(branch)) return '';
112
- const stripped = branch.replace(TYPE_PREFIX, '');
113
- const slug = toSlug(stripped);
114
- if (!slug || TRUNK.has(slug)) return '';
115
- // Branch que é só um número de issue não descreve feature nenhuma.
116
- if (/^\d+$/.test(slug)) return '';
117
- return slug;
118
- }
57
+ const toSlug = (raw) => (lib ? lib.toSlug(raw) : '');
58
+ const readGitBranch = (cwd) => (lib ? lib.readGitBranch(cwd) : '');
59
+ const featureFromBranch = (branch) => (lib ? lib.featureFromBranch(branch) : '');
60
+ const readConfigFeature = (root) => (lib ? lib.readConfigFeature(root) : { slug: '', configPath: '' });
61
+ const findWizzRoot = (cwd) => (lib ? lib.findWizzRoot(cwd) : '');
119
62
 
120
63
  /**
121
- * Lê `active_feature` do config do módulo bmm. Parser de 1 chave de propósito:
122
- * o hook não pode depender de uma lib de YAML (roda fora do node_modules do repo).
123
- * @param {string} cwd
124
- * @returns {{slug: string, configPath: string}} slug '' quando ausente/vazio
64
+ * Lembretes de projeto Wizz que valem mesmo sem feature: nenhuma feature
65
+ * ativa (o primeiro pedido nomeado define uma), layout de pastas ausente e
66
+ * proposta de branch quando `feature_branches: auto`.
67
+ * @param {ReturnType<typeof lib.resolveFeature>} state
68
+ * @returns {string[]}
125
69
  */
126
- function readConfigFeature(cwd) {
127
- const configPath = path.join(cwd, '_wizz', 'bmm', 'config.yaml');
128
- try {
129
- const text = fs.readFileSync(configPath, 'utf8');
130
- const m = /^active_feature:\s*(.*)$/m.exec(text);
131
- if (!m) return { slug: '', configPath };
132
- const value = m[1]
133
- .replace(/#.*$/, '')
134
- .trim()
135
- .replaceAll(/^["']|["']$/g, '');
136
- return { slug: toSlug(value), configPath };
137
- } catch {
138
- return { slug: '', configPath };
70
+ function wizzReminders(state) {
71
+ const out = [];
72
+ if (!state.slug) {
73
+ out.push(
74
+ `NENHUMA FEATURE ATIVA: o primeiro pedido nomeado (feature, lote, tela, bug com escopo) define uma. ` +
75
+ `Antes de despachar, derive o slug do pedido e grave: ${HOOK_CMD} definir <slug>. Sem perguntar; uma linha de confirmação.`,
76
+ );
77
+ } else if (state.featureBranches === 'auto' && state.git.isRepo && state.onTrunk) {
78
+ out.push(
79
+ `feature_branches: auto e você está em ${state.git.branch || 'HEAD solto'}: proponha em UMA linha \`git switch -c feat/${state.slug}\`.`,
80
+ );
139
81
  }
140
- }
141
-
142
- /**
143
- * Acha a raiz do projeto Wizz (a pasta que contém `_wizz/`) subindo do cwd.
144
- * O agente costuma rodar de uma subpasta, então checar só o cwd erraria.
145
- * @param {string} cwd
146
- * @returns {string} caminho da raiz, ou '' se não for projeto Wizz
147
- */
148
- function findWizzRoot(cwd) {
149
- let dir = cwd;
150
- for (let i = 0; i < 6; i++) {
151
- try {
152
- if (fs.existsSync(path.join(dir, '_wizz'))) return dir;
153
- } catch {
154
- // ignora e segue subindo
155
- }
156
- const parent = path.dirname(dir);
157
- if (parent === dir) break;
158
- dir = parent;
82
+ if (!state.hasLayout) {
83
+ out.push(
84
+ 'PROJETO SEM LAYOUT DE PASTAS: no primeiro pedido, proponha a estrutura por tipo em UMA linha ' +
85
+ '(node _wizz/hooks/wizz-arrumar.js layout) e grave com `layout --gravar`. Arquivo novo nunca nasce solto na raiz.',
86
+ );
159
87
  }
160
- return '';
88
+ return out;
161
89
  }
162
90
 
163
91
  /**
164
- * Monta a linha de contexto da feature. Devolve '' quando não há feature
165
- * nenhuma. Silêncio custa zero token e é o caso comum fora de projeto Wizz.
92
+ * Monta o contexto de feature. Fora de projeto Wizz e sem branch de feature
93
+ * devolve '' (silêncio custa zero token). Em projeto Wizz sem feature, sai o
94
+ * lembrete curto: é o que faz a organização acontecer sem o dono pedir.
166
95
  * @param {string} cwd
167
96
  * @returns {string}
168
97
  */
169
98
  function buildFeatureContext(cwd) {
170
- const wizzRoot = findWizzRoot(cwd);
171
- const isWizzProject = Boolean(wizzRoot);
172
- const fromConfig = isWizzProject ? readConfigFeature(wizzRoot) : { slug: '', configPath: '' };
173
- const fromBranch = featureFromBranch(readGitBranch(cwd));
174
-
175
- // O branch ganha do config: é o que descreve o trabalho de AGORA. O config
176
- // pode ter ficado numa feature antiga que ninguém lembrou de trocar.
177
- const active = fromBranch || fromConfig.slug;
178
- if (!active) return '';
99
+ if (!lib) return '';
100
+ const state = lib.resolveFeature(cwd);
101
+ const fromBranch = state.isWizz ? state.fromBranch : featureFromBranch(readGitBranch(cwd));
102
+ const active = state.isWizz ? state.slug : fromBranch;
103
+ const parts = [];
179
104
 
180
- const parts = [
181
- `FEATURE ATIVA: ${active}` + (fromBranch ? ' (derivada do branch git).' : ' (do config).'),
182
- 'Use como contexto sem perguntar nada:',
183
- isWizzProject
184
- ? `artefatos de planejamento e implementação vão para as subpastas ${active}/;`
185
- : 'não é projeto Wizz, então não há subpasta de artefato;',
186
- `memória gravada nesta sessão leva tema/feature "${active}".`,
187
- ];
188
-
189
- if (fromBranch && isWizzProject && fromConfig.slug !== fromBranch) {
105
+ if (active) {
106
+ parts.push(
107
+ `FEATURE ATIVA: ${active}` + (fromBranch ? ' (derivada do branch git).' : ' (do config).'),
108
+ 'Use como contexto sem perguntar nada:',
109
+ state.isWizz
110
+ ? `artefatos de planejamento e implementação vão para as subpastas ${active}/ com nome <tipo>-<slug>.md (${HOOK_CMD} artefato <tipo>);`
111
+ : 'não é projeto Wizz, então não há subpasta de artefato;',
112
+ `memória gravada nesta sessão leva tema/feature "${active}".`,
113
+ );
114
+ if (fromBranch && state.isWizz && state.fromConfig !== fromBranch) {
115
+ parts.push(
116
+ `O config (_wizz/bmm/config.yaml) ainda diz "${state.fromConfig || 'vazio'}": grave "${active}" nele (${HOOK_CMD} definir ${active}) e avise em UMA linha.`,
117
+ );
118
+ }
190
119
  parts.push(
191
- `O config (_wizz/bmm/config.yaml) ainda diz "${fromConfig.slug || 'vazio'}": grave "${active}" nele na primeira vez que escrever um artefato, e avise em UMA linha.`,
120
+ 'NÃO peça confirmação de feature. Pedido de OUTRO assunto troca a feature (definir <novo>); continuação mantém. Pergunte só se houver ambiguidade real entre duas features abertas.',
192
121
  );
193
122
  }
194
-
195
- parts.push(
196
- 'NÃO peça confirmação de feature. Só invoque a skill wizz-set-feature se o usuário pedir explicitamente para trocar, ou se houver ambiguidade real entre duas features abertas.',
197
- );
198
-
123
+ if (state.isWizz) parts.push(...wizzReminders(state));
199
124
  return parts.join(' ');
200
125
  }
201
126
 
@@ -0,0 +1,418 @@
1
+ // wizz-arrumar.js — arrumação da raiz do projeto com manifesto e desfazer.
2
+ //
3
+ // Fonte de verdade: wizz-method/tools/hooks/. Instalada em `_wizz/hooks/`.
4
+ // É o padrão que deu certo no Spider (2026-10-02), agora repetível:
5
+ // manifesto .tsv revisável, script de desfazer, referências atualizadas em
6
+ // código/docs/memórias e testes rodando antes e depois. Diferença do Spider:
7
+ // o desfazer também restaura os arquivos cujas referências foram reescritas
8
+ // (lá o desfazer só revertia os moves e avisava que o resto ficava torto).
9
+ //
10
+ // layout propõe o layout de pastas (só mostra)
11
+ // layout --gravar [t=dir] grava `layout:` no config, cria pastas + README curto
12
+ // planejar monta o manifesto dos arquivos soltos (não move nada)
13
+ // aplicar <manifesto.tsv> move, atualiza referências, gera desfazer, roda testes
14
+ // mover <origem> <destino> atalho de um arquivo (manifesto de uma linha + aplicar)
15
+ // desfazer <pasta-da-arrumacao>
16
+ //
17
+ // Opções de aplicar/mover: --testes "<comando>", --refs <dir,dir> (ex.: pasta
18
+ // da auto-memória), --manter (não desfaz sozinho se os testes quebrarem).
19
+ // Cada execução fica em `<arquivo>/arrumacao/<data>-<hora>/`.
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const { spawnSync } = require('node:child_process');
24
+ const lib = require('./wizz-feature-lib.js');
25
+ const layoutLib = require('./wizz-layout-lib.js');
26
+
27
+ const SKIP_DIRS = new Set([
28
+ 'node_modules',
29
+ '.git',
30
+ '.next',
31
+ '.nuxt',
32
+ '.svelte-kit',
33
+ 'dist',
34
+ 'build',
35
+ 'out',
36
+ 'coverage',
37
+ '_wizz',
38
+ '.venv',
39
+ 'venv',
40
+ '__pycache__',
41
+ '.turbo',
42
+ '.vercel',
43
+ '.cache',
44
+ ]);
45
+ const TEXT_EXT = new Set([
46
+ '.md',
47
+ '.mdx',
48
+ '.txt',
49
+ '.js',
50
+ '.cjs',
51
+ '.mjs',
52
+ '.ts',
53
+ '.tsx',
54
+ '.jsx',
55
+ '.py',
56
+ '.sh',
57
+ '.bash',
58
+ '.zsh',
59
+ '.json',
60
+ '.yaml',
61
+ '.yml',
62
+ '.toml',
63
+ '.html',
64
+ '.css',
65
+ '.sql',
66
+ '.ini',
67
+ '.cfg',
68
+ '.env.example',
69
+ ]);
70
+ const MAX_TEXT_BYTES = 1024 * 1024;
71
+
72
+ function parseArgs(argv) {
73
+ const opts = { _: [] };
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const a = argv[i];
76
+ if (a.startsWith('--')) {
77
+ const key = a.slice(2);
78
+ const next = argv[i + 1];
79
+ if (next !== undefined && !next.startsWith('--')) {
80
+ opts[key] = next;
81
+ i++;
82
+ } else opts[key] = true;
83
+ } else opts._.push(a);
84
+ }
85
+ return opts;
86
+ }
87
+
88
+ function stamp() {
89
+ const d = new Date();
90
+ const pad = (n) => String(n).padStart(2, '0');
91
+ return `${lib.today()}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
92
+ }
93
+
94
+ function archiveDir(root, layout) {
95
+ const dir = layout.arquivo && layoutLib.normalizeDir(layout.arquivo) !== '.' ? layoutLib.normalizeDir(layout.arquivo) : 'arquivo';
96
+ return path.join(root, dir, 'arrumacao');
97
+ }
98
+
99
+ function isInside(child, parent) {
100
+ const r = path.relative(parent, child);
101
+ return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
102
+ }
103
+
104
+ // ── layout ──────────────────────────────────────────────────────────────
105
+
106
+ const README_BODY = (type, dir) =>
107
+ `# ${dir}\n\nPasta de ${layoutLib.LAYOUT_TYPES[type] || type}.\n\nDefinida em \`_wizz/bmm/config.yaml\` (bloco \`layout:\`). Arquivo novo deste tipo nasce aqui, nunca solto na raiz.\n`;
108
+
109
+ function cmdLayout(root, opts) {
110
+ const config = lib.readConfig(root);
111
+ const { layout: proposed, loose } = layoutLib.proposeLayout(root);
112
+ if (!opts.gravar) {
113
+ const current = Object.keys(config.layout).length > 0 ? config.layout : null;
114
+ const shown = current || proposed;
115
+ const pairs = Object.entries(shown)
116
+ .map(([t, d]) => `${t}=${d}`)
117
+ .join(' ');
118
+ return {
119
+ ok: true,
120
+ line: current
121
+ ? `Layout atual: ${pairs}.`
122
+ : `Proposta de layout: ${pairs}. ${loose.length} arquivo(s) solto(s) na raiz. Grave com: layout --gravar ${pairs}`,
123
+ };
124
+ }
125
+ const given = opts._.slice(1).filter((a) => a.includes('='));
126
+ const layout = given.length > 0 ? Object.fromEntries(given.map((a) => a.split('=').map((s) => s.trim()))) : proposed;
127
+ lib.writeLayout(root, layout);
128
+ const created = [];
129
+ for (const [type, dir] of Object.entries(layout)) {
130
+ if (type === 'raiz_extra' || layoutLib.normalizeDir(dir) === '.') continue;
131
+ const abs = path.join(root, layoutLib.normalizeDir(dir));
132
+ if (!isInside(abs, root)) continue;
133
+ fs.mkdirSync(abs, { recursive: true });
134
+ const readme = path.join(abs, 'README.md');
135
+ if (!fs.existsSync(readme)) {
136
+ fs.writeFileSync(readme, README_BODY(type, layoutLib.normalizeDir(dir)));
137
+ created.push(layoutLib.normalizeDir(dir));
138
+ }
139
+ }
140
+ return {
141
+ ok: true,
142
+ line: `Layout gravado em _wizz/bmm/config.yaml: ${Object.entries(layout)
143
+ .map(([t, d]) => `${t}=${d}`)
144
+ .join(' ')}.${created.length > 0 ? ` README curto criado em: ${created.join(', ')}.` : ''}`,
145
+ };
146
+ }
147
+
148
+ // ── manifesto ───────────────────────────────────────────────────────────
149
+
150
+ function readManifest(file) {
151
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean);
152
+ return lines
153
+ .slice(lines[0] && lines[0].startsWith('origem\t') ? 1 : 0)
154
+ .map((l) => l.split('\t'))
155
+ .filter((cols) => cols.length >= 2 && cols[0] && cols[1])
156
+ .map(([origem, destino]) => ({ origem, destino }));
157
+ }
158
+
159
+ function writeManifest(file, moves) {
160
+ fs.mkdirSync(path.dirname(file), { recursive: true });
161
+ fs.writeFileSync(file, ['origem\tdestino', ...moves.map((m) => `${m.origem}\t${m.destino}`)].join('\n') + '\n');
162
+ }
163
+
164
+ function planMoves(root, layout) {
165
+ const moves = [];
166
+ const semDestino = [];
167
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
168
+ if (!e.isFile() || layoutLib.isAllowedAtRoot(e.name, layout)) continue;
169
+ const dest = layoutLib.suggestDestination(e.name, layout);
170
+ if (dest) moves.push({ origem: e.name, destino: `${dest.dir}/${e.name}` });
171
+ else semDestino.push(e.name);
172
+ }
173
+ return { moves, semDestino };
174
+ }
175
+
176
+ function cmdPlanejar(root, opts) {
177
+ const config = lib.readConfig(root);
178
+ if (Object.keys(config.layout).length === 0) return { ok: false, code: 2, line: 'Sem layout no config: rode `layout --gravar` antes.' };
179
+ const { moves, semDestino } = planMoves(root, config.layout);
180
+ if (moves.length === 0)
181
+ return { ok: true, line: `Raiz já está arrumada.${semDestino.length > 0 ? ` Sem destino: ${semDestino.join(', ')}.` : ''}` };
182
+ const file =
183
+ typeof opts.saida === 'string' ? path.resolve(root, opts.saida) : path.join(archiveDir(root, config.layout), `plano-${stamp()}.tsv`);
184
+ writeManifest(file, moves);
185
+ return {
186
+ ok: true,
187
+ line:
188
+ `Manifesto com ${moves.length} move(s): ${path.relative(root, file)}. Revise e rode: aplicar ${path.relative(root, file)}` +
189
+ (semDestino.length > 0 ? `. Sem destino sugerido: ${semDestino.join(', ')}.` : '.'),
190
+ };
191
+ }
192
+
193
+ // ── referências ─────────────────────────────────────────────────────────
194
+
195
+ function* walkText(dir, skipAbs) {
196
+ let entries = [];
197
+ try {
198
+ entries = fs.readdirSync(dir, { withFileTypes: true });
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const e of entries) {
203
+ const abs = path.join(dir, e.name);
204
+ if (e.isDirectory()) {
205
+ if (SKIP_DIRS.has(e.name) || skipAbs.some((s) => abs === s || isInside(abs, s))) continue;
206
+ yield* walkText(abs, skipAbs);
207
+ } else if (e.isFile()) {
208
+ const ext = e.name.endsWith('.env.example') ? '.env.example' : path.extname(e.name).toLowerCase();
209
+ if (!TEXT_EXT.has(ext)) continue;
210
+ try {
211
+ if (fs.statSync(abs).size <= MAX_TEXT_BYTES) yield abs;
212
+ } catch {
213
+ // some no meio do caminho: ignora
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ function escapeRe(s) {
220
+ return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
221
+ }
222
+
223
+ /**
224
+ * Reescreve referências a `origem` (caminho relativo à raiz) para `destino`.
225
+ * Casa `origem` e `./origem` quando não colado a outro caminho; não toca um
226
+ * arquivo cuja pasta tem um irmão com o mesmo nome (a referência é local).
227
+ */
228
+ function rewriteRefs(text, moves, fileDir) {
229
+ let next = text;
230
+ for (const { origem, destino } of moves) {
231
+ if (fs.existsSync(path.join(fileDir, path.basename(origem))) && fileDir !== '') continue;
232
+ const re = new RegExp(String.raw`(?<![\w/.-])(\./)?${escapeRe(origem)}(?![\w-])`, 'g');
233
+ next = next.replace(re, (_m, dot) => `${dot || ''}${destino}`);
234
+ }
235
+ return next;
236
+ }
237
+
238
+ // ── aplicar / desfazer ──────────────────────────────────────────────────
239
+
240
+ function runTests(root, cmd) {
241
+ if (!cmd || cmd === true) return null;
242
+ const r = spawnSync(cmd, { cwd: root, shell: true, encoding: 'utf8', timeout: 10 * 60 * 1000 });
243
+ return { ok: r.status === 0, code: r.status, tail: `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').slice(-5).join('\n') };
244
+ }
245
+
246
+ function shQuote(s) {
247
+ return `'${String(s).replaceAll("'", String.raw`'\''`)}'`;
248
+ }
249
+
250
+ function writeUndoScript(runDir, root, moves, edits) {
251
+ const lines = [
252
+ '#!/usr/bin/env bash',
253
+ `# Desfaz a arrumação de ${path.basename(runDir)}: restaura os arquivos editados e devolve os moves.`,
254
+ 'set -euo pipefail',
255
+ `cd ${shQuote(root)}`,
256
+ ...edits.map((e) => `cp -p ${shQuote(path.join(runDir, e.backup))} ${shQuote(e.original)}`),
257
+ ...moves
258
+ .toReversed()
259
+ .map((m) => `[[ -e ${shQuote(m.destino)} && ! -e ${shQuote(m.origem)} ]] && mv ${shQuote(m.destino)} ${shQuote(m.origem)}`),
260
+ 'echo "Arrumação desfeita."',
261
+ '',
262
+ ];
263
+ const file = path.join(runDir, 'desfazer.sh');
264
+ fs.writeFileSync(file, lines.join('\n'));
265
+ fs.chmodSync(file, 0o755);
266
+ return file;
267
+ }
268
+
269
+ function undo(root, runDir) {
270
+ const moves = readManifest(path.join(runDir, 'manifesto.tsv'));
271
+ const editsFile = path.join(runDir, 'edicoes.tsv');
272
+ const edits = fs.existsSync(editsFile)
273
+ ? fs
274
+ .readFileSync(editsFile, 'utf8')
275
+ .split('\n')
276
+ .filter(Boolean)
277
+ .map((l) => {
278
+ const [original, backup] = l.split('\t');
279
+ return { original, backup };
280
+ })
281
+ : [];
282
+ for (const e of edits) fs.copyFileSync(path.join(runDir, e.backup), e.original);
283
+ for (const m of moves.toReversed()) {
284
+ const from = path.join(root, m.destino);
285
+ const to = path.join(root, m.origem);
286
+ if (fs.existsSync(from) && !fs.existsSync(to)) fs.renameSync(from, to);
287
+ }
288
+ return { moves: moves.length, edits: edits.length };
289
+ }
290
+
291
+ function validateMoves(root, moves) {
292
+ const problems = [];
293
+ for (const m of moves) {
294
+ const from = path.join(root, m.origem);
295
+ const to = path.join(root, m.destino);
296
+ if (!isInside(from, root) || !isInside(to, root)) problems.push(`${m.origem}: fora do projeto`);
297
+ else if (['_wizz', '.git'].includes(m.origem.split('/')[0])) problems.push(`${m.origem}: pasta protegida`);
298
+ else if (!fs.existsSync(from)) problems.push(`${m.origem}: não existe`);
299
+ else if (fs.existsSync(to)) problems.push(`${m.destino}: destino já existe`);
300
+ }
301
+ return problems;
302
+ }
303
+
304
+ function applyMoves(root, moves, opts) {
305
+ const config = lib.readConfig(root);
306
+ const problems = validateMoves(root, moves);
307
+ if (problems.length > 0) return { ok: false, code: 2, line: `Nada foi movido: ${problems.join('; ')}.` };
308
+
309
+ const runDir = path.join(archiveDir(root, config.layout), stamp());
310
+ fs.mkdirSync(runDir, { recursive: true });
311
+ writeManifest(path.join(runDir, 'manifesto.tsv'), moves);
312
+
313
+ const before = runTests(root, opts.testes);
314
+
315
+ // Referências: projeto inteiro + pastas extras (auto-memória, Cérebro).
316
+ const extra = typeof opts.refs === 'string' ? opts.refs.split(',').map((d) => path.resolve(root, d.trim())) : [];
317
+ const skip = [archiveDir(root, config.layout)];
318
+ const targets = new Set();
319
+ for (const base of [root, ...extra]) for (const f of walkText(base, skip)) targets.add(f);
320
+ const movedAbs = new Set(moves.map((m) => path.join(root, m.origem)));
321
+
322
+ // 1) Backup + reescrita ANTES de mover: o backup guarda o texto original.
323
+ const edits = [];
324
+ let n = 0;
325
+ for (const file of targets) {
326
+ const text = fs.readFileSync(file, 'utf8');
327
+ const dir = movedAbs.has(file) ? '' : path.dirname(file) === root ? '' : path.dirname(file);
328
+ const next = rewriteRefs(text, moves, dir);
329
+ if (next === text) continue;
330
+ const backup = path.join('antes', String(++n).padStart(4, '0') + '-' + path.basename(file));
331
+ fs.mkdirSync(path.join(runDir, 'antes'), { recursive: true });
332
+ fs.copyFileSync(file, path.join(runDir, backup));
333
+ // Arquivo que também muda de lugar: o desfazer restaura no caminho NOVO e
334
+ // só depois devolve o move; restaurar no antigo deixaria uma cópia órfã.
335
+ const moved = moves.find((m) => path.join(root, m.origem) === file);
336
+ edits.push({ original: moved ? path.join(root, moved.destino) : file, backup });
337
+ fs.writeFileSync(file, next);
338
+ }
339
+ fs.writeFileSync(
340
+ path.join(runDir, 'edicoes.tsv'),
341
+ edits.map((e) => `${e.original}\t${e.backup}`).join('\n') + (edits.length > 0 ? '\n' : ''),
342
+ );
343
+
344
+ // 2) Moves.
345
+ for (const m of moves) {
346
+ const to = path.join(root, m.destino);
347
+ fs.mkdirSync(path.dirname(to), { recursive: true });
348
+ fs.renameSync(path.join(root, m.origem), to);
349
+ }
350
+ const undoFile = writeUndoScript(runDir, root, moves, edits);
351
+ const after = runTests(root, opts.testes);
352
+ const relRun = path.relative(root, runDir);
353
+
354
+ if (before && after && before.ok && !after.ok && !opts.manter) {
355
+ undo(root, runDir);
356
+ return {
357
+ ok: false,
358
+ code: 1,
359
+ line: `Os testes passavam antes e quebraram depois: desfiz tudo. Últimas linhas: ${after.tail}. Registro em ${relRun}/.`,
360
+ };
361
+ }
362
+ const testes = before
363
+ ? ` Testes: antes ${before.ok ? 'ok' : `falhando (${before.code})`}, depois ${after.ok ? 'ok' : `falhando (${after.code})`}.`
364
+ : ' Sem comando de testes (--testes): confira à mão.';
365
+ return {
366
+ ok: !after || after.ok || !before?.ok,
367
+ code: 0,
368
+ line: `Movi ${moves.length} arquivo(s) e atualizei referências em ${edits.length}.${testes} Desfazer: ${path.relative(root, undoFile)}.`,
369
+ };
370
+ }
371
+
372
+ function cmdAplicar(root, opts) {
373
+ const file = opts._[1];
374
+ if (!file) return { ok: false, code: 2, line: 'Uso: aplicar <manifesto.tsv>.' };
375
+ const moves = readManifest(path.resolve(root, file));
376
+ if (moves.length === 0) return { ok: false, code: 2, line: 'Manifesto vazio: nada a fazer.' };
377
+ return applyMoves(root, moves, opts);
378
+ }
379
+
380
+ function cmdMover(root, opts) {
381
+ const [, origem, destino] = opts._;
382
+ if (!origem || !destino) return { ok: false, code: 2, line: 'Uso: mover <origem> <destino>.' };
383
+ const relOf = (p) => path.relative(root, path.resolve(root, p));
384
+ return applyMoves(root, [{ origem: relOf(origem), destino: relOf(destino) }], opts);
385
+ }
386
+
387
+ function cmdDesfazer(root, opts) {
388
+ const dir = opts._[1];
389
+ if (!dir) return { ok: false, code: 2, line: 'Uso: desfazer <pasta-da-arrumacao>.' };
390
+ const runDir = path.resolve(root, dir);
391
+ if (!fs.existsSync(path.join(runDir, 'manifesto.tsv'))) return { ok: false, code: 2, line: `Sem manifesto.tsv em ${dir}.` };
392
+ const r = undo(root, runDir);
393
+ return { ok: true, line: `Desfeito: ${r.moves} move(s) revertido(s), ${r.edits} arquivo(s) restaurado(s).` };
394
+ }
395
+
396
+ const COMMANDS = { layout: cmdLayout, planejar: cmdPlanejar, aplicar: cmdAplicar, mover: cmdMover, desfazer: cmdDesfazer };
397
+
398
+ function run(argv) {
399
+ const opts = parseArgs(argv);
400
+ const command = COMMANDS[opts._[0]];
401
+ if (!command) return { ok: false, code: 2, line: `Comandos: ${Object.keys(COMMANDS).join(', ')}.` };
402
+ const cwd = typeof opts.cwd === 'string' ? path.resolve(opts.cwd) : process.cwd();
403
+ const root = lib.findWizzRoot(cwd);
404
+ if (!root) return { ok: true, code: 0, line: 'Não é projeto Wizz (sem _wizz/): nada a fazer.' };
405
+ try {
406
+ return command(root, opts);
407
+ } catch (error) {
408
+ return { ok: false, code: 1, line: `Falhou: ${error.message}` };
409
+ }
410
+ }
411
+
412
+ if (require.main === module) {
413
+ const result = run(process.argv.slice(2));
414
+ process.stdout.write(`${result.line}\n`);
415
+ process.exit(result.code ?? (result.ok ? 0 : 1));
416
+ }
417
+
418
+ module.exports = { run, rewriteRefs, readManifest, planMoves, undo };