wizz-method 2.15.0 → 2.17.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/THIRD-PARTY-NOTICES.md +6 -0
- package/package.json +5 -2
- package/skills-registry.yaml +18 -6
- 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/_shared/video-pipeline.md +1 -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/src/skills-lib/wizz-router/references/routing-table-flat.md +1 -0
- package/src/skills-lib/wizz-video-styles/SKILL.md +66 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/CREDITS.md +54 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/LIBRARIES.md +31 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/kit.json +182 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/acoustic-lifestyle.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/cinematic-tense.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/classical-light.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/corporate-light.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/lofi-chill.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/meme-beat.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/piano-editorial.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/music/tech-pulse.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/boom-impact.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/click-mouse.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/ding-notify.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/glitch-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/ka-ching.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/pop-bubble.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/record-scratch.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/riser-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/swoosh-text.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/typing-short.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/whoosh-fast.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/audio/sfx/whoosh-soft.mp3 +0 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/EditorialReel.tsx +111 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/README.md +77 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/captions.tsx +56 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/example-words.json +285 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/primitives.tsx +241 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/scenes.tsx +113 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/theme.ts +68 -0
- package/src/skills-lib/wizz-video-styles/assets/remotion/editorial/timeline.ts +65 -0
- package/src/skills-lib/wizz-video-styles/data/recipes.json +341 -0
- package/src/skills-lib/wizz-video-styles/data/references.json +3397 -0
- package/src/skills-lib/wizz-video-styles/data/styles.json +726 -0
- package/src/skills-lib/wizz-video-styles/references/audio.md +54 -0
- package/src/skills-lib/wizz-video-styles/references/measured-patterns.md +38 -0
- package/src/skills-lib/wizz-video-styles/references/research-method.md +33 -0
- package/src/skills-lib/wizz-video-styles/references/style-picker.md +51 -0
- package/src/skills-lib/wizz-video-styles/references/styles/camera-no-setup.md +60 -0
- package/src/skills-lib/wizz-video-styles/references/styles/desafio-numero.md +60 -0
- package/src/skills-lib/wizz-video-styles/references/styles/esquete.md +57 -0
- package/src/skills-lib/wizz-video-styles/references/styles/experimento.md +56 -0
- package/src/skills-lib/wizz-video-styles/references/styles/explicador-animado.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/macro-asmr.md +61 -0
- package/src/skills-lib/wizz-video-styles/references/styles/meme-rapido.md +65 -0
- package/src/skills-lib/wizz-video-styles/references/styles/motion-editorial.md +69 -0
- package/src/skills-lib/wizz-video-styles/references/styles/podcast-clip.md +61 -0
- package/src/skills-lib/wizz-video-styles/references/styles/pov-vlog.md +57 -0
- package/src/skills-lib/wizz-video-styles/references/styles/quadro-branco.md +60 -0
- package/src/skills-lib/wizz-video-styles/references/styles/talking-head-cards.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/talking-head-legenda.md +62 -0
- package/src/skills-lib/wizz-video-styles/references/styles/tour.md +59 -0
- package/src/skills-lib/wizz-video-styles/scripts/analyze.sh +66 -0
- package/src/skills-lib/wizz-video-styles/scripts/build_catalog.py +128 -0
- 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,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
|
+
};
|