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
|
@@ -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
|
+
};
|