@daniellins/power-claude 0.16.3 → 0.16.4
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.
Potentially problematic release.
This version of @daniellins/power-claude might be problematic. Click here for more details.
- package/README.md +93 -29
- package/installer/motor-scripts.json +1 -1
- package/package.json +10 -2
- package/scripts/__tests__/enforce-git-push-authority.test.cjs +289 -2
- package/scripts/build-public-tree.cjs +372 -0
- package/scripts/derive-ready-transitions.cjs +40 -4
- package/scripts/validate-branding.js +19 -3
- package/scripts/validate-git-vetor.cjs +99 -0
- package/template/.claude/agents/pc-devops.md +2 -1
- package/template/.claude/agents/pc-tutor.md +78 -0
- package/template/.claude/commands/power/tutor.md +11 -0
- package/template/.claude/hooks/enforce-git-push-authority.cjs +385 -5
- package/template/.claude/hooks/irreversible-remotes.json +1 -0
- package/template/.claude/rules/orchestration.md +2 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* build-public-tree.cjs — monta a árvore do repositório PÚBLICO, REPO-ONLY.
|
|
5
|
+
*
|
|
6
|
+
* Um verbo único que: lê a fronteira decidida (Opção B) de UM manifesto, monta a
|
|
7
|
+
* árvore correspondente num diretório alvo, roda o gate de dado pessoal DENTRO
|
|
8
|
+
* da árvore montada — não no repositório privado — e se recusa a declarar
|
|
9
|
+
* sucesso quando qualquer etapa reprova. Story SF15.8; invariantes I1/I2 do
|
|
10
|
+
* ADR-037.
|
|
11
|
+
*
|
|
12
|
+
* ┌─ ESTE ARQUIVO NÃO VIAJA PARA INSTALAÇÃO ─────────────────────────────────┐
|
|
13
|
+
* │ Está no `exclude` de `installer/motor-scripts.json`, mesma classe de │
|
|
14
|
+
* │ `owner-layer.cjs` e `validate-git-vetor.cjs` (que ele importa). │
|
|
15
|
+
* └──────────────────────────────────────────────────────────────────────────┘
|
|
16
|
+
*
|
|
17
|
+
* CLI
|
|
18
|
+
* --out <dir> (obrigatório no modo montagem) diretório alvo
|
|
19
|
+
* --root <dir> (opcional) raiz-fonte; default = raiz deste repo
|
|
20
|
+
* --report <path> (opcional) grava o relatório JSON; default = stdout
|
|
21
|
+
* --verify-only <dir> (modo alternativo) re-audita uma árvore JÁ montada
|
|
22
|
+
* --allow-nonempty-out opt-in explícito para sobrescrever um `--out`
|
|
23
|
+
* existente e não-vazio (fail-closed por default)
|
|
24
|
+
*
|
|
25
|
+
* TODO — dono: `--expect-sha <sha>` (tripwire I3 do ADR-037) é ponto de extensão
|
|
26
|
+
* RESERVADO e NÃO implementado nesta story. I3 só passa a valer da 2ª publicação
|
|
27
|
+
* em diante (não há HEAD remoto para divergir no primeiro push) e pertence ao
|
|
28
|
+
* mecanismo de sincronização contínua (SF15.9+). Quem o implementar estende este
|
|
29
|
+
* verbo; não precisa redesenhá-lo.
|
|
30
|
+
*
|
|
31
|
+
* Node >= 18, sem dependências externas.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const { spawnSync } = require('node:child_process');
|
|
37
|
+
|
|
38
|
+
// I1 — manifesto ÚNICO: as entradas varridas pelo gate vêm por IMPORT, nunca
|
|
39
|
+
// redeclaradas aqui (lição SF13.9/SF13.10 — roster copiado 3×, uma errada).
|
|
40
|
+
const gitVetor = require('./validate-git-vetor.cjs');
|
|
41
|
+
|
|
42
|
+
// As 3 entradas que a Opção B COPIA mas que o gate de branding não VARRE por
|
|
43
|
+
// conteúdo — coisas diferentes, decisões diferentes, não fundidas aqui:
|
|
44
|
+
// • `scripts` — fora do escopo varrido por decisão da SF15.7 (Escopo OUT #1,
|
|
45
|
+
// auto-referência: o próprio validate-branding.js carrega os termos como
|
|
46
|
+
// literal). Fica fora do CONTEÚDO varrido, nunca fora do repositório público.
|
|
47
|
+
// • `LICENSE` — nunca esteve no escopo varrido, por desenho anterior a esta story.
|
|
48
|
+
// • `package.json` — decisão do dono sobre `m9` (2026-08-10): incluído para que
|
|
49
|
+
// o clone vire projeto funcional (bin/ e installer/ dependem dele em runtime).
|
|
50
|
+
const ADDITIONAL_MANIFEST_ENTRIES = ['scripts', 'LICENSE', 'package.json'];
|
|
51
|
+
const PUBLIC_MANIFEST = [...gitVetor.SCAN_TARGETS, ...ADDITIONAL_MANIFEST_ENTRIES];
|
|
52
|
+
|
|
53
|
+
// I2 — o gate é invocado por caminho RELATIVO, com `cwd` na árvore montada.
|
|
54
|
+
// `REPO_ROOT` do gate é `path.resolve(__dirname, '..')`, relativo ao PRÓPRIO
|
|
55
|
+
// arquivo: como o diretório de scripts está dentro do manifesto copiado, o gate
|
|
56
|
+
// reancora na árvore montada sozinho. Caminho absoluto do repositório aqui seria
|
|
57
|
+
// o mutante do falso verde (o gate varreria a fonte, não a montagem).
|
|
58
|
+
const GATE_SCRIPT = 'scripts/validate-git-vetor.cjs';
|
|
59
|
+
|
|
60
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
61
|
+
|
|
62
|
+
const COBERTURA_GATE =
|
|
63
|
+
`o gate composto varre ${gitVetor.SCAN_TARGETS.length} das ${PUBLIC_MANIFEST.length} entradas do manifesto; ` +
|
|
64
|
+
`${ADDITIONAL_MANIFEST_ENTRIES.join(', ')} não são varridas por conteúdo ` +
|
|
65
|
+
`(auto-referência — SF15.7 Escopo OUT #1). Um sucesso aqui significa "montada e auditada nesse limite", ` +
|
|
66
|
+
`nunca "sem dado pessoal em toda a árvore".`;
|
|
67
|
+
|
|
68
|
+
/* ------------------------------------------------------------------ *
|
|
69
|
+
* Níveis permitidos — DERIVADOS do manifesto em runtime, nunca escritos
|
|
70
|
+
* à mão (escrever os nomes aqui recriaria a 2ª lista que I1 existe para
|
|
71
|
+
* impedir, e reprovaria o AC-1 por cópia literal).
|
|
72
|
+
* ------------------------------------------------------------------ */
|
|
73
|
+
function manifestLevels(manifest) {
|
|
74
|
+
const top = new Set();
|
|
75
|
+
const nested = new Map();
|
|
76
|
+
for (const entry of manifest) {
|
|
77
|
+
const parts = entry.split('/');
|
|
78
|
+
top.add(parts[0]);
|
|
79
|
+
if (parts.length > 1) {
|
|
80
|
+
if (!nested.has(parts[0])) nested.set(parts[0], new Set());
|
|
81
|
+
nested.get(parts[0]).add(parts[1]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { top, nested };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function git(root, args) {
|
|
88
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
89
|
+
if (r.error) throw new Error(`git ${args[0]} falhou: ${r.error.message}`);
|
|
90
|
+
if (r.status !== 0) {
|
|
91
|
+
throw new Error(`git ${args[0]} saiu com ${r.status}: ${String(r.stderr || '').trim()}`);
|
|
92
|
+
}
|
|
93
|
+
return String(r.stdout || '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function gitZ(root, args) {
|
|
97
|
+
return git(root, args).split('\0').filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ------------------------------------------------------------------ *
|
|
101
|
+
* Checagem estrutural: PRESENÇA de todas as entradas do manifesto E
|
|
102
|
+
* ausência de qualquer coisa a mais, em todo nível onde o manifesto
|
|
103
|
+
* corta. Reusada pelo modo montagem e pelo --verify-only (M5/B1).
|
|
104
|
+
* ------------------------------------------------------------------ */
|
|
105
|
+
function auditStructure(dir) {
|
|
106
|
+
const problemas = [];
|
|
107
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
108
|
+
problemas.push(`diretório não encontrado: ${dir}`);
|
|
109
|
+
return problemas;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { top, nested } = manifestLevels(PUBLIC_MANIFEST);
|
|
113
|
+
|
|
114
|
+
for (const entry of PUBLIC_MANIFEST) {
|
|
115
|
+
if (!fs.existsSync(path.join(dir, ...entry.split('/')))) {
|
|
116
|
+
problemas.push(`entrada do manifesto ausente na árvore montada: ${entry}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const raiz = fs.readdirSync(dir).sort();
|
|
121
|
+
if (raiz.length === 0) problemas.push(`árvore vazia: nenhuma das ${PUBLIC_MANIFEST.length} entradas do manifesto está presente`);
|
|
122
|
+
for (const nome of raiz) {
|
|
123
|
+
if (!top.has(nome)) problemas.push(`entrada de 1º nível fora do manifesto: ${nome}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const [topSeg, permitidos] of nested) {
|
|
127
|
+
const sub = path.join(dir, topSeg);
|
|
128
|
+
if (!fs.existsSync(sub)) continue;
|
|
129
|
+
for (const nome of fs.readdirSync(sub).sort()) {
|
|
130
|
+
if (!permitidos.has(nome)) {
|
|
131
|
+
problemas.push(`entrada fora do manifesto sob ${topSeg}/: ${topSeg}/${nome}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return problemas;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ------------------------------------------------------------------ *
|
|
140
|
+
* Gate composto, com `cwd` na árvore auditada (I2). Código de saída
|
|
141
|
+
* fail-closed: `status === null` (filho morto por sinal) e `error`
|
|
142
|
+
* contam como FALHA — nunca 0 (B4).
|
|
143
|
+
* ------------------------------------------------------------------ */
|
|
144
|
+
function runGate(dir) {
|
|
145
|
+
const r = spawnSync(process.execPath, [GATE_SCRIPT], { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
146
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
147
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
148
|
+
const code = r.error ? 1 : (r.status === 0 ? 0 : (r.status || 1));
|
|
149
|
+
if (r.error) process.stderr.write(`gate não pôde ser executado: ${r.error.message}\n`);
|
|
150
|
+
if (r.status === null && !r.error) {
|
|
151
|
+
process.stderr.write(`gate encerrado por sinal (${r.signal}) — tratado como reprovação, nunca como sucesso.\n`);
|
|
152
|
+
}
|
|
153
|
+
return code;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function relatorioExclusoes(root) {
|
|
157
|
+
const { top, nested } = manifestLevels(PUBLIC_MANIFEST);
|
|
158
|
+
const rastreados = gitZ(root, ['ls-files', '-z']);
|
|
159
|
+
|
|
160
|
+
const excluidos = [];
|
|
161
|
+
|
|
162
|
+
const raizVistos = new Set();
|
|
163
|
+
for (const file of rastreados) {
|
|
164
|
+
const seg = file.split('/')[0];
|
|
165
|
+
if (top.has(seg) || raizVistos.has(seg)) continue;
|
|
166
|
+
raizVistos.add(seg);
|
|
167
|
+
excluidos.push({ nivel: 'raiz', path: seg, regra: 'fora das entradas de 1º nível do manifesto público' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const [topSeg, permitidos] of nested) {
|
|
171
|
+
const vistos = new Set();
|
|
172
|
+
for (const file of rastreados) {
|
|
173
|
+
const parts = file.split('/');
|
|
174
|
+
if (parts[0] !== topSeg || parts.length < 2) continue;
|
|
175
|
+
if (permitidos.has(parts[1]) || vistos.has(parts[1])) continue;
|
|
176
|
+
vistos.add(parts[1]);
|
|
177
|
+
excluidos.push({
|
|
178
|
+
nivel: `${topSeg}/`,
|
|
179
|
+
path: `${topSeg}/${parts[1]}`,
|
|
180
|
+
regra: `fora das entradas permitidas sob ${topSeg}/ pelo manifesto público`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
excluidos.sort((a, b) => (a.nivel + a.path).localeCompare(b.nivel + b.path));
|
|
186
|
+
return excluidos;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ------------------------------------------------------------------ *
|
|
190
|
+
* Montagem por ÍNDICE do git, nunca por disco (B2). Arquivo não-rastreado
|
|
191
|
+
* (inclusive ignorado) não aparece na enumeração e, por construção, nunca
|
|
192
|
+
* entra na árvore montada.
|
|
193
|
+
* ------------------------------------------------------------------ */
|
|
194
|
+
function copiarDoIndice(root, destino) {
|
|
195
|
+
const arquivos = gitZ(root, ['ls-files', '-z', '--', ...PUBLIC_MANIFEST]);
|
|
196
|
+
for (const rel of arquivos) {
|
|
197
|
+
const origem = path.join(root, rel);
|
|
198
|
+
const alvo = path.join(destino, rel);
|
|
199
|
+
fs.mkdirSync(path.dirname(alvo), { recursive: true });
|
|
200
|
+
fs.copyFileSync(origem, alvo);
|
|
201
|
+
}
|
|
202
|
+
return arquivos;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function checarSujeira(root) {
|
|
206
|
+
// `--untracked-files=no` é OBRIGATÓRIO: sem ele, arquivo NUNCA rastreado
|
|
207
|
+
// apareceria aqui e a montagem seria recusada por debris local. A regra é
|
|
208
|
+
// sobre COMMIT pendente, não sobre debris: arquivo rastreado com edição
|
|
209
|
+
// pendente recusa; arquivo não-rastreado é excluído em silêncio pelo índice.
|
|
210
|
+
const linhas = gitZ(root, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...PUBLIC_MANIFEST]);
|
|
211
|
+
return linhas.map((l) => l.trim()).filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function limpar(alvo) {
|
|
215
|
+
try {
|
|
216
|
+
fs.rmSync(alvo, { recursive: true, force: true });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
/* melhor esforço — a recusa já foi comunicada */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function estaDentro(pai, filho) {
|
|
223
|
+
const rel = path.relative(pai, filho);
|
|
224
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dirNaoVazio(dir) {
|
|
228
|
+
return fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length > 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseArgs(argv) {
|
|
232
|
+
const opts = { out: null, root: null, report: null, verifyOnly: null, allowNonEmptyOut: false };
|
|
233
|
+
for (let i = 0; i < argv.length; i++) {
|
|
234
|
+
const a = argv[i];
|
|
235
|
+
if (a === '--out') opts.out = argv[++i];
|
|
236
|
+
else if (a === '--root') opts.root = argv[++i];
|
|
237
|
+
else if (a === '--report') opts.report = argv[++i];
|
|
238
|
+
else if (a === '--verify-only') opts.verifyOnly = argv[++i];
|
|
239
|
+
else if (a === '--allow-nonempty-out') opts.allowNonEmptyOut = true;
|
|
240
|
+
else throw new Error(`argumento desconhecido: ${a}`);
|
|
241
|
+
}
|
|
242
|
+
return opts;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ------------------------------------------------------------------ *
|
|
246
|
+
* Modo --verify-only: re-audita uma árvore JÁ montada. Estrutura
|
|
247
|
+
* (presença ∧ contenção) primeiro, gate composto depois — auditoria de
|
|
248
|
+
* conteúdo real, não só estrutural (B1/M5).
|
|
249
|
+
* ------------------------------------------------------------------ */
|
|
250
|
+
function verifyOnly(dirArg) {
|
|
251
|
+
const dir = path.resolve(dirArg);
|
|
252
|
+
const problemas = auditStructure(dir);
|
|
253
|
+
if (problemas.length > 0) {
|
|
254
|
+
for (const p of problemas) process.stderr.write(`${p}\n`);
|
|
255
|
+
process.stderr.write(`\n${problemas.length} problema(s) estrutural(is) em ${dir}. Auditoria vermelha.\n`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
const code = runGate(dir);
|
|
259
|
+
if (code !== 0) {
|
|
260
|
+
process.stderr.write(`\nGate composto reprovou a árvore em ${dir}. Auditoria vermelha.\n`);
|
|
261
|
+
return code;
|
|
262
|
+
}
|
|
263
|
+
process.stdout.write(`Cobertura do gate: ${COBERTURA_GATE}\n`);
|
|
264
|
+
process.stdout.write(`Árvore auditada em ${dir}: contenção, presença e gate composto verdes.\n`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function montar(opts) {
|
|
269
|
+
// ── Validação de argumentos ANTES de criar qualquer `.partial` (M6) ──
|
|
270
|
+
if (!opts.out) throw new Error('--out <dir> é obrigatório (ou use --verify-only <dir>)');
|
|
271
|
+
const root = path.resolve(opts.root || REPO_ROOT);
|
|
272
|
+
const out = path.resolve(opts.out);
|
|
273
|
+
if (!fs.existsSync(root)) throw new Error(`--root não encontrado: ${root}`);
|
|
274
|
+
if (estaDentro(root, out)) {
|
|
275
|
+
process.stderr.write(`--out não pode ficar dentro de --root (auto-cópia): ${out} está sob ${root}\n`);
|
|
276
|
+
return 2;
|
|
277
|
+
}
|
|
278
|
+
if (dirNaoVazio(out) && !opts.allowNonEmptyOut) {
|
|
279
|
+
process.stderr.write(`--out existe e não está vazio: ${out}\n`);
|
|
280
|
+
process.stderr.write('Recusado sem apagar nada. Use --allow-nonempty-out para autorizar a sobrescrita explicitamente.\n');
|
|
281
|
+
return 2;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Sujeira sob o manifesto: fail-closed (B2) ──
|
|
285
|
+
const sujos = checarSujeira(root);
|
|
286
|
+
if (sujos.length > 0) {
|
|
287
|
+
process.stderr.write('modificação não-commitada sob o manifesto — o que se publica é um commit, não o estado da mesa de alguém:\n');
|
|
288
|
+
for (const s of sujos) process.stderr.write(` ${s}\n`);
|
|
289
|
+
return 3;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const partial = `${out}.partial`;
|
|
293
|
+
limpar(partial);
|
|
294
|
+
fs.mkdirSync(partial, { recursive: true });
|
|
295
|
+
|
|
296
|
+
let arquivos;
|
|
297
|
+
try {
|
|
298
|
+
arquivos = copiarDoIndice(root, partial);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
limpar(partial);
|
|
301
|
+
throw e;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Gate composto DENTRO da árvore montada (I2) ──
|
|
305
|
+
const gateCode = runGate(partial);
|
|
306
|
+
if (gateCode !== 0) {
|
|
307
|
+
limpar(partial);
|
|
308
|
+
process.stderr.write(`\nGate composto reprovou a árvore montada a partir de ${root}. Nada foi publicado.\n`);
|
|
309
|
+
return gateCode;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Contenção COM presença, sobre a mesma árvore (M5) ──
|
|
313
|
+
const problemas = auditStructure(partial);
|
|
314
|
+
if (problemas.length > 0) {
|
|
315
|
+
for (const p of problemas) process.stderr.write(`${p}\n`);
|
|
316
|
+
limpar(partial);
|
|
317
|
+
process.stderr.write(`\n${problemas.length} problema(s) estrutural(is) na árvore montada. Nada foi publicado.\n`);
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Só agora o rename: ele É o marcador de conclusão (M6/AC-20). ──
|
|
322
|
+
// `renameSync` sobre um alvo existente e não-vazio falha com ENOTEMPTY, então
|
|
323
|
+
// o alvo é removido antes — só se chegou aqui, ou seja, ou estava vazio, ou
|
|
324
|
+
// --allow-nonempty-out autorizou explicitamente.
|
|
325
|
+
if (fs.existsSync(out)) fs.rmSync(out, { recursive: true, force: true });
|
|
326
|
+
fs.renameSync(partial, out);
|
|
327
|
+
|
|
328
|
+
const relatorio = {
|
|
329
|
+
gerado_em: new Date().toISOString(),
|
|
330
|
+
root,
|
|
331
|
+
out,
|
|
332
|
+
arquivos_copiados: arquivos.length,
|
|
333
|
+
entradas_manifesto: PUBLIC_MANIFEST,
|
|
334
|
+
cobertura_gate: `Cobertura do gate: ${COBERTURA_GATE}`,
|
|
335
|
+
excluidos: relatorioExclusoes(root),
|
|
336
|
+
nao_rastreados_excluidos: gitZ(root, ['ls-files', '--others', '-z', '--', ...PUBLIC_MANIFEST]),
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const json = JSON.stringify(relatorio, null, 2);
|
|
340
|
+
if (opts.report) {
|
|
341
|
+
fs.mkdirSync(path.dirname(path.resolve(opts.report)), { recursive: true });
|
|
342
|
+
fs.writeFileSync(path.resolve(opts.report), `${json}\n`);
|
|
343
|
+
process.stdout.write(`Relatório em ${path.resolve(opts.report)}\n`);
|
|
344
|
+
} else {
|
|
345
|
+
process.stdout.write(`${json}\n`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
process.stdout.write(`Cobertura do gate: ${COBERTURA_GATE}\n`);
|
|
349
|
+
process.stdout.write(`Árvore pública pronta em ${out}.\n`);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function main() {
|
|
354
|
+
let code = 1;
|
|
355
|
+
try {
|
|
356
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
357
|
+
if (opts.verifyOnly) {
|
|
358
|
+
if (opts.out) throw new Error('--verify-only e --out são modos distintos; use um de cada vez');
|
|
359
|
+
code = verifyOnly(opts.verifyOnly);
|
|
360
|
+
} else {
|
|
361
|
+
code = montar(opts);
|
|
362
|
+
}
|
|
363
|
+
} catch (e) {
|
|
364
|
+
process.stderr.write(`${e && e.message ? e.message : e}\n`);
|
|
365
|
+
code = 1;
|
|
366
|
+
}
|
|
367
|
+
process.exit(typeof code === 'number' && Number.isInteger(code) ? code : 1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { PUBLIC_MANIFEST, ADDITIONAL_MANIFEST_ENTRIES, COBERTURA_GATE };
|
|
371
|
+
|
|
372
|
+
if (require.main === module) main();
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
// [--since <ref|data>] [--stories-dir <dir>]
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
48
|
|
|
49
|
+
const fs = require('fs');
|
|
49
50
|
const path = require('path');
|
|
50
51
|
const { execFileSync } = require('child_process');
|
|
51
52
|
|
|
@@ -89,6 +90,23 @@ function gitOrNull(repo, args) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// `F-4` do gate `SF13.8`: "binário `git` ausente do PATH" e "o path não existe /
|
|
94
|
+
// não é repo git" são causas DISTINTAS, e a mensagem antiga tratava as duas como
|
|
95
|
+
// a mesma coisa — acertava por acidente, via o parêntese "(ou o path não existe)".
|
|
96
|
+
// `ENOENT` no spawn é o único sinal que separa as duas: qualquer outro erro
|
|
97
|
+
// significa que o binário existe e falhou por outro motivo.
|
|
98
|
+
function gitBinarioAusente() {
|
|
99
|
+
try {
|
|
100
|
+
execFileSync('git', ['--version'], {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
103
|
+
});
|
|
104
|
+
return false;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return Boolean(err) && err.code === 'ENOENT';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
// --- CLI ---------------------------------------------------------------
|
|
93
111
|
|
|
94
112
|
function parseArgv(argv) {
|
|
@@ -186,7 +204,15 @@ function main(argv) {
|
|
|
186
204
|
const repo = path.resolve(opts.repoPath);
|
|
187
205
|
const topo = gitOrNull(repo, ['rev-parse', '--show-toplevel']);
|
|
188
206
|
if (topo === null) {
|
|
189
|
-
|
|
207
|
+
// Três causas distintas, nomeadas separadamente (`F-4` do gate `SF13.8`).
|
|
208
|
+
if (gitBinarioAusente()) {
|
|
209
|
+
const pathEnv = process.env.PATH ? process.env.PATH.slice(0, 200) : '(vazia)';
|
|
210
|
+
diagnostico(`binário \`git\` ausente do PATH (PATH=${pathEnv}) — o alvo "${repo}" não foi sequer consultado; sem git não há histórico a derivar.`);
|
|
211
|
+
} else if (!fs.existsSync(repo)) {
|
|
212
|
+
diagnostico(`o path "${repo}" não existe — nada a derivar.`);
|
|
213
|
+
} else {
|
|
214
|
+
diagnostico(`"${repo}" existe e o git está disponível, mas não é um repositório git acessível — nada a derivar.`);
|
|
215
|
+
}
|
|
190
216
|
return;
|
|
191
217
|
}
|
|
192
218
|
const repoRoot = topo.trim();
|
|
@@ -310,9 +336,19 @@ function main(argv) {
|
|
|
310
336
|
}
|
|
311
337
|
}
|
|
312
338
|
|
|
313
|
-
// D4: `exit 0` SEMPRE.
|
|
314
|
-
// try/catch
|
|
315
|
-
//
|
|
339
|
+
// D4: `exit 0` SEMPRE. Esta é a rede EXTERNA, e ela existe para o IMPREVISTO.
|
|
340
|
+
// Há, sim, 2 try/catch internos, e eles tratam classes ESPERADAS e DISTINTAS
|
|
341
|
+
// desta, cada uma com diagnóstico declarado: `gitOrNull()` (git indisponível ou
|
|
342
|
+
// comando que falha ⇒ `null`, tratado no ponto de uso com causa nomeada) e o
|
|
343
|
+
// replay de `evaluateReadyOracles` no laço (avaliação que lança ⇒ veredito
|
|
344
|
+
// `ERRO` visível, jamais contado como `silencio`). Nenhum deles cobre a classe
|
|
345
|
+
// desta rede, então ela não é redundante — mas também não é exercitada por
|
|
346
|
+
// teste: o `F-2` do gate `SF13.8` registrou 5 tentativas de alcançá-la, todas
|
|
347
|
+
// capturadas antes, com diagnóstico. Fica por desenho (mesma disciplina de
|
|
348
|
+
// `runWithFailSafe`): a garantia é `exit 0` mesmo no que ninguém previu.
|
|
349
|
+
// Leitura superada, não repetir: a formulação anterior deste comentário NEGAVA
|
|
350
|
+
// a existência dos 2 try/catch internos acima — o desenho estava certo, a prosa
|
|
351
|
+
// é que afirmava mais do que o código faz.
|
|
316
352
|
if (require.main === module) {
|
|
317
353
|
try {
|
|
318
354
|
main(process.argv.slice(2));
|
|
@@ -44,14 +44,24 @@ function collectFiles(target) {
|
|
|
44
44
|
return out.sort();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* scanFile(filePath, terms) — varre um arquivo por termos proibidos.
|
|
49
|
+
*
|
|
50
|
+
* O 2º parâmetro é opcional e defaulta para `FORBIDDEN_TERMS` deste módulo:
|
|
51
|
+
* `main()` daqui continua se comportando exatamente como antes. Ele existe
|
|
52
|
+
* para que um gate REPO-ONLY (`scripts/validate-git-vetor.cjs`, SF15.7) possa
|
|
53
|
+
* REUSAR esta varredura com uma lista composta, em vez de fazer uma 2ª cópia
|
|
54
|
+
* da lógica. Sem o parâmetro, `scanFile` fecharia sobre a lista de 5 deste
|
|
55
|
+
* módulo e o composto varreria com a lista errada — bug silencioso.
|
|
56
|
+
*/
|
|
57
|
+
function scanFile(filePath, terms = FORBIDDEN_TERMS) {
|
|
48
58
|
const buf = fs.readFileSync(filePath);
|
|
49
59
|
if (isProbablyBinary(buf)) return [];
|
|
50
60
|
const violations = [];
|
|
51
61
|
const lines = buf.toString('utf8').split(/\r?\n/);
|
|
52
62
|
lines.forEach((line, idx) => {
|
|
53
63
|
const lower = line.toLowerCase();
|
|
54
|
-
for (const term of
|
|
64
|
+
for (const term of terms) {
|
|
55
65
|
if (lower.includes(term)) {
|
|
56
66
|
violations.push({ line: idx + 1, term, text: line.trim() });
|
|
57
67
|
}
|
|
@@ -79,6 +89,12 @@ function main() {
|
|
|
79
89
|
process.exit(0);
|
|
80
90
|
}
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
// Exports ADITIVOS (SF15.7): `FORBIDDEN_TERMS`/`SCAN_TARGETS` são o contrato
|
|
93
|
+
// que `scripts/validate-no-clone.cjs` importa e que VIAJA para toda instalação
|
|
94
|
+
// (`installer/motor-scripts.json`) — inalterados. `collectFiles`,
|
|
95
|
+
// `isProbablyBinary` e `scanFile` passam a ser exportados para reuso local do
|
|
96
|
+
// repositório; este arquivo NÃO ganha nenhum `require` novo (um require para
|
|
97
|
+
// módulo repo-only aqui crasharia toda instalação — medido, SF15.7 `B1`).
|
|
98
|
+
module.exports = { FORBIDDEN_TERMS, SCAN_TARGETS, collectFiles, isProbablyBinary, scanFile };
|
|
83
99
|
|
|
84
100
|
if (require.main === module) main();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* validate-git-vetor.cjs — guard-rail do VETOR GIT (commits/PRs), REPO-ONLY.
|
|
5
|
+
*
|
|
6
|
+
* O vetor `npm pack` já tem guarda (`scripts/owner-layer.cjs` + `npm-scope`);
|
|
7
|
+
* o vetor `git` — o que um repositório PÚBLICO expõe a cada commit — não tinha.
|
|
8
|
+
* Esta é a ampliação medida na `SF15.7`: o gate de branding
|
|
9
|
+
* (`scripts/validate-branding.js`) já PROIBIA os nomes que vazaram na `SF15.6`,
|
|
10
|
+
* mas não OLHAVA para `docs/adr/` nem `docs/research/`, que era onde eles
|
|
11
|
+
* estavam. "Não era ausência de regra: era vão de superfície."
|
|
12
|
+
*
|
|
13
|
+
* ┌─ ESTE ARQUIVO NÃO VIAJA PARA INSTALAÇÃO — e é por isso que ele existe ────┐
|
|
14
|
+
* │ Está no `exclude` de `installer/motor-scripts.json`, mesma classe de │
|
|
15
|
+
* │ `owner-layer.cjs` e `derive-ready-transitions.cjs`. A dependência corre │
|
|
16
|
+
* │ REPO-ONLY → DISTRIBUÍDO (este arquivo importa `validate-branding.js`), │
|
|
17
|
+
* │ nunca o contrário: um `require('./owner-layer.cjs')` DENTRO de │
|
|
18
|
+
* │ `validate-branding.js` crasharia com `MODULE_NOT_FOUND` toda instalação │
|
|
19
|
+
* │ de terceiro que roda `validate-no-clone.cjs` (medido, `SF15.7` `B1`). │
|
|
20
|
+
* │ Por isso as 2 listas são COMPOSTAS aqui por import, e o contrato │
|
|
21
|
+
* │ distribuído (`FORBIDDEN_TERMS`/`SCAN_TARGETS` de `validate-branding.js`) │
|
|
22
|
+
* │ fica intocado — 5 termos, 5 alvos, exatamente como antes. │
|
|
23
|
+
* └──────────────────────────────────────────────────────────────────────────┘
|
|
24
|
+
*
|
|
25
|
+
* ┌─ FRONTEIRA HONESTA — o que este gate NÃO detecta, por desenho ───────────┐
|
|
26
|
+
* │ O gate reprova SE E SOMENTE SE um dos literais enumerados aparecer, em │
|
|
27
|
+
* │ forma literal contígua, no conteúdo de um arquivo regular sob um dos │
|
|
28
|
+
* │ alvos enumerados. Nome de arquivo, symlink, forma não-literal │
|
|
29
|
+
* │ (espaçamento, ênfase, quebra de linha, `~`, URL-encode) e diretório fora │
|
|
30
|
+
* │ de `SCAN_TARGETS` NÃO são cobertos, por desenho. Classe nova — termo, │
|
|
31
|
+
* │ alvo ou forma — entra por CURADORIA HUMANA, com dono declarado; o │
|
|
32
|
+
* │ mecanismo não promete detectar o desconhecido. │
|
|
33
|
+
* │ │
|
|
34
|
+
* │ DONO DAS LISTAS: `EXTRA_TARGETS`/`EXTRA_TERMS` (as 2 listas que a │
|
|
35
|
+
* │ `SF15.7` acrescenta) são mantidas por quem editar ESTE arquivo. │
|
|
36
|
+
* │ `SCAN_TARGETS`/`FORBIDDEN_TERMS` de origem continuam com o dono de sempre │
|
|
37
|
+
* │ (`scripts/validate-branding.js`, que hoje não tem cabeçalho de manutenção │
|
|
38
|
+
* │ formal — acrescentar um está fora do escopo da `SF15.7`). │
|
|
39
|
+
* └──────────────────────────────────────────────────────────────────────────┘
|
|
40
|
+
*
|
|
41
|
+
* Node >= 18, sem dependências externas. Exit 1 listando arquivo:linha de cada
|
|
42
|
+
* ocorrência; exit 0 = vetor git limpo.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const path = require('node:path');
|
|
46
|
+
|
|
47
|
+
const branding = require('./validate-branding.js');
|
|
48
|
+
const { patternByRole } = require('./owner-layer.cjs');
|
|
49
|
+
|
|
50
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
51
|
+
|
|
52
|
+
// Alvos que o gate distribuído não varre e que o repositório público expõe.
|
|
53
|
+
const EXTRA_TARGETS = ['docs/adr', 'docs/research'];
|
|
54
|
+
|
|
55
|
+
// Termos que NÃO entram no contrato distribuído:
|
|
56
|
+
// • `venturus` / `executive circle` — identidade de terceiro medida na `SF15.6`
|
|
57
|
+
// (`F-8`), ainda ausente do denylist de clone;
|
|
58
|
+
// • slug e path de máquina do DONO do framework — IMPORTADOS de
|
|
59
|
+
// `owner-layer.cjs` por papel (`patternByRole`), nunca copiados como literal:
|
|
60
|
+
// uma 3ª cópia derivaria em silêncio (lição `SF13.9`/`SF13.10`). Não entram
|
|
61
|
+
// em `FORBIDDEN_TERMS` distribuído por erro de categoria: o path do autor do
|
|
62
|
+
// framework não é "identidade de clone" (`SF15.7` `B1`, nota semântica).
|
|
63
|
+
// • `patternByRole('nome-civil')` NÃO entra — varrer por nome do dono foi
|
|
64
|
+
// aposentado como proxy por decisão dele (`owner-layer.cjs`, cabeçalho da
|
|
65
|
+
// seção 2; `ADR-027` Emenda 2).
|
|
66
|
+
const EXTRA_TERMS = [
|
|
67
|
+
'venturus',
|
|
68
|
+
'executive circle',
|
|
69
|
+
patternByRole('slug').toLowerCase(),
|
|
70
|
+
patternByRole('path-maquina').toLowerCase(),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const SCAN_TARGETS = [...branding.SCAN_TARGETS, ...EXTRA_TARGETS];
|
|
74
|
+
const FORBIDDEN_TERMS = [...branding.FORBIDDEN_TERMS, ...EXTRA_TERMS];
|
|
75
|
+
|
|
76
|
+
function main() {
|
|
77
|
+
let total = 0;
|
|
78
|
+
for (const target of SCAN_TARGETS) {
|
|
79
|
+
// `collectFiles` e `scanFile` vêm de `validate-branding.js` — nenhuma 2ª
|
|
80
|
+
// implementação da varredura, só uma 2ª LISTA.
|
|
81
|
+
for (const filePath of branding.collectFiles(target)) {
|
|
82
|
+
const rel = path.relative(REPO_ROOT, filePath);
|
|
83
|
+
for (const v of branding.scanFile(filePath, FORBIDDEN_TERMS)) {
|
|
84
|
+
total++;
|
|
85
|
+
console.error(`${rel}:${v.line}: termo proibido "${v.term}" — ${v.text.slice(0, 100)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (total > 0) {
|
|
90
|
+
console.error(`\n${total} ocorrência(s) no vetor git (branding + identidade de terceiro + padrões do dono). Gate vermelho.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log('Vetor git limpo.');
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { SCAN_TARGETS, FORBIDDEN_TERMS, EXTRA_TARGETS, EXTRA_TERMS };
|
|
98
|
+
|
|
99
|
+
if (require.main === module) main();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pc-devops
|
|
3
3
|
description: "Papel DevOps do ciclo POWER CLAUDE — o ÚNICO autorizado a publicar (git push, PR, merge, release). Use para: gate pre-push completo, push fast-forward, criar PR com o porquê, merge-back de branches de wave, release com tag semver e changelog."
|
|
4
4
|
model: sonnet
|
|
5
|
-
budget_tokens:
|
|
5
|
+
budget_tokens: 1500
|
|
6
6
|
tools: Read, Grep, Glob, Write, Edit, Bash
|
|
7
7
|
permissionMode: acceptEdits
|
|
8
8
|
---
|
|
@@ -27,6 +27,7 @@ Você é o papel **DevOps** do ciclo POWER CLAUDE — o único autorizado a publ
|
|
|
27
27
|
|
|
28
28
|
- **Superfície (5 arquivos), resposta POR ARQUIVO escrita na story da release, nunca um "sim" global** (`DF-SF13.5-5`): `README.md`, `template/pods/README.md`, `BACKLOG.md`, `CHANGELOG.md` e este arquivo (`pc-devops.md`) — os 2 últimos entraram porque a classe já ocorreu neles (`ADR-036`). Para cada um: "revisado; linha `<path>:<n>` reconciliada" ou "revisado; nada a mudar porque `<razão>`".
|
|
29
29
|
- **Todo achado carregado é pré-condição, não nota** (`DF-SF13.5-6`): antes da tag, abrir o `docs/qa/gates/release-*.yaml` mais recente e responder na story, uma a uma e na mesma forma acima, as entradas com `dono: pc-devops` de desfecho não fechado. Sem momento obrigatório o achado reaparece: o `F-1` da `0.16.1` voltou idêntico na `0.16.2`.
|
|
30
|
+
- **Espelho público (Vitrine, `ADR-037`)**, depois do `npm publish`: siga `docs/mecanismos/espelho-publico-vitrine.md` — monta com `build-public-tree.cjs`, sincroniza o remoto de NOME DISTINTIVO (nunca `origin`) e empurra por `git push` explícito, jamais dentro de script: só assim o hook vê o comando e pede `ask` (`I4`). Cadência hoje: release.
|
|
30
31
|
|
|
31
32
|
## Publicação no npm — 3 lições operacionais (`0.16.0`, 2026-08-04)
|
|
32
33
|
|