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.
- package/README.md +12 -1
- package/package.json +5 -2
- package/src/bmm-skills/1-analysis/wizz-agent-analyst/customize.toml +1 -1
- package/src/bmm-skills/1-analysis/wizz-set-feature/SKILL.md +34 -12
- package/src/bmm-skills/2-plan-workflows/wizz-agent-pm/customize.toml +1 -1
- package/src/bmm-skills/2-plan-workflows/wizz-agent-ux-designer/customize.toml +1 -1
- package/src/bmm-skills/3-solutioning/wizz-agent-architect/customize.toml +1 -1
- package/src/bmm-skills/4-implementation/wizz-agent-dev/customize.toml +1 -1
- package/src/bmm-skills/module.yaml +10 -0
- package/src/core-skills/_shared/handoff-protocol.md +10 -0
- package/src/modules/wizz/_shared/encerramento.md +4 -0
- package/src/modules/wizz/_shared/organizacao.md +58 -0
- package/src/modules/wizz/agents/wizz-maestro/SKILL.md +9 -1
- package/src/modules/wizz/agents/wizz-maestro/customize.toml +8 -2
- package/src/skills-lib/wizz-inicio-de-projeto/SKILL.md +11 -0
- package/src/skills-lib/wizz-router/SKILL.md +3 -1
- package/tools/hooks/README.md +14 -2
- package/tools/hooks/session-rules.js +65 -140
- package/tools/hooks/wizz-arrumar.js +418 -0
- package/tools/hooks/wizz-artifact-guard.js +160 -0
- package/tools/hooks/wizz-feature-lib.js +414 -0
- package/tools/hooks/wizz-feature.js +401 -0
- package/tools/hooks/wizz-layout-lib.js +152 -0
- package/tools/hooks/wizz-router-enforce.js +21 -1
- package/tools/installer/core/doctor.js +5 -1
- package/tools/installer/core/hooks-settings.js +10 -0
|
@@ -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
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
122
|
-
* o
|
|
123
|
-
*
|
|
124
|
-
* @
|
|
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
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
165
|
-
*
|
|
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
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 };
|