@kuyper/harness 0.1.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.
@@ -0,0 +1,212 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ import { readConfig } from './config.js';
5
+ import { KuyperRefusal } from './errors.js';
6
+ import { gatesForStage, runConverging, runExact } from './gateRunner.js';
7
+ import { currentBranch, headSha, statusPorcelain } from './gitPlumbing.js';
8
+ import { validate } from './validate.js';
9
+ const execFileAsync = promisify(execFile);
10
+ async function devBranchExists(cwd) {
11
+ try {
12
+ await execFileAsync('git', ['show-ref', '--verify', '--quiet', 'refs/heads/dev'], { cwd });
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function switchToDevRoute(cwd) {
20
+ return (await devBranchExists(cwd)) ? 'git switch dev' : 'git switch -c dev';
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // pre-commit (PRD §3.8, §4.1)
24
+ // ---------------------------------------------------------------------------
25
+ function refuseR22(route) {
26
+ throw new KuyperRefusal({
27
+ code: 'R22',
28
+ headline: 'Commit direto na main não é permitido.',
29
+ details: ['A main recebe merges, não commits diretos.'],
30
+ route: [route],
31
+ });
32
+ }
33
+ function refuseGateFailed(gate, output) {
34
+ throw new KuyperRefusal({
35
+ code: 'R19',
36
+ headline: `O gate ${gate} falhou.`,
37
+ details: output.split('\n').filter((l) => l.length > 0),
38
+ route: ['Corrija o que o gate acusou e commite de novo.'],
39
+ });
40
+ }
41
+ function refuseNoConverge(paths) {
42
+ throw new KuyperRefusal({
43
+ code: 'R20',
44
+ headline: 'Um gate não converge — continua alterando arquivos depois de três voltas.',
45
+ details: ['Isto é configuração quebrada, não código quebrado.', '', ...paths],
46
+ route: ['Corrija o gate (ele não pode reescrever a cada execução) e commite de novo.'],
47
+ });
48
+ }
49
+ function refuseEditedUnstaged(gate, paths) {
50
+ throw new KuyperRefusal({
51
+ code: 'R20',
52
+ headline: paths.length === 1 ? `${gate} alterou um arquivo que já tinha edição não staged.` : `${gate} alterou arquivos que já tinham edição não staged.`,
53
+ details: [
54
+ 'Não foi readicionado — isso incluiria conteúdo que você deixou de fora',
55
+ 'do commit de propósito (staging parcial).',
56
+ '',
57
+ ...paths,
58
+ ],
59
+ route: ['Revise os arquivos acima, decida o que fica no commit, e rode git add você mesmo antes de commitar de novo.'],
60
+ });
61
+ }
62
+ function formatFixedReport(fixed) {
63
+ const lines = ['✓ gates concluídos.'];
64
+ for (const f of fixed) {
65
+ const n = f.paths.length;
66
+ lines.push(` ${f.gate} corrigiu ${n} arquivo${n === 1 ? '' : 's'}, incluíd${n === 1 ? 'o' : 'os'} no commit.`);
67
+ }
68
+ return lines.join('\n');
69
+ }
70
+ /**
71
+ * O comportamento do `pre-commit` (PRD §3.8): recusa commit direto na
72
+ * `main`, sem rodar gate nenhum — ali não existe pergunta de qualidade a
73
+ * fazer. Senão, roda o laço de convergência dos gates do estágio
74
+ * `pre-commit` e traduz o resultado. Termina **antes** de o Git criar o
75
+ * commit — nunca diz "commit criado".
76
+ */
77
+ export async function runPreCommitHook(cwd) {
78
+ const branch = await currentBranch(cwd);
79
+ if (branch === 'main') {
80
+ refuseR22(await switchToDevRoute(cwd));
81
+ }
82
+ const config = await readConfig(join(cwd, '.kuyper', 'config.yaml'));
83
+ const outcome = await runConverging(gatesForStage(config.gates, 'pre-commit'), cwd);
84
+ if (outcome.kind === 'gate-failed')
85
+ refuseGateFailed(outcome.gate, outcome.output);
86
+ if (outcome.kind === 'no-converge')
87
+ refuseNoConverge(outcome.paths);
88
+ if (outcome.kind === 'edited-unstaged')
89
+ refuseEditedUnstaged(outcome.gate, outcome.paths);
90
+ console.log(formatFixedReport(outcome.fixed));
91
+ return 0;
92
+ }
93
+ const ZERO_SHA = '0'.repeat(40);
94
+ /** O `stdin` do Git: uma linha por atualização, `local_ref local_sha remote_ref remote_sha`. */
95
+ function parseRefUpdates(stdin) {
96
+ const updates = [];
97
+ for (const raw of stdin.split('\n')) {
98
+ const line = raw.trim();
99
+ if (line.length === 0)
100
+ continue;
101
+ const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/u);
102
+ if (localRef === undefined || localSha === undefined || remoteRef === undefined || remoteSha === undefined)
103
+ continue;
104
+ updates.push({ localRef, localSha, remoteRef, remoteSha });
105
+ }
106
+ return updates;
107
+ }
108
+ function refuseDirtyTreeForPush(route) {
109
+ throw new KuyperRefusal({
110
+ headline: 'A árvore de trabalho está suja — o push para a main foi recusado.',
111
+ details: ['O que seria testado não é necessariamente o que está sendo enviado.'],
112
+ route: [route],
113
+ });
114
+ }
115
+ function refuseWrongBranchForPush(route) {
116
+ throw new KuyperRefusal({
117
+ headline: 'A branch atual não é a main, mas o push tem a main como destino.',
118
+ route: [route],
119
+ });
120
+ }
121
+ function refuseShaMismatchForPush() {
122
+ throw new KuyperRefusal({
123
+ headline: 'O commit sendo enviado não é o HEAD local.',
124
+ details: ['O candidato testado precisa ser exatamente o candidato enviado.'],
125
+ route: ['git switch main', 'Confira que o HEAD é o commit que você quer publicar, e empurre de novo.'],
126
+ });
127
+ }
128
+ function refuseValidateDiverged(report) {
129
+ const details = report.code === 2 ? report.findings : [];
130
+ throw new KuyperRefusal({
131
+ code: 'R23',
132
+ headline: report.code === 1 ? 'kuyper validate ficou inconclusivo — o push para a main foi recusado.' : 'kuyper validate está divergente — o push para a main foi recusado.',
133
+ details,
134
+ route: ['pnpm exec kuyper generate', 'Revise, commite na dev, integre, e publique de novo.'],
135
+ });
136
+ }
137
+ function refuseGateFailedPush(gate, output) {
138
+ throw new KuyperRefusal({
139
+ code: 'R19',
140
+ headline: `O gate ${gate} falhou no publish.`,
141
+ details: output.split('\n').filter((l) => l.length > 0),
142
+ route: ['Corrija o que o gate acusou, leve para a dev, commite, integre, e publique de novo.'],
143
+ });
144
+ }
145
+ /**
146
+ * R23 — tanto para "um gate escreveu durante os gates" quanto para "a
147
+ * árvore ou o HEAD mudaram depois dos gates" (checagem final, §3.8). A
148
+ * mensagem nunca sugere `push --no-verify`: ele enviaria exatamente a
149
+ * versão sem a correção (§3.8).
150
+ */
151
+ function refuseWroteDuringPush(paths) {
152
+ throw new KuyperRefusal({
153
+ code: 'R23',
154
+ headline: 'Um gate alterou arquivos durante a execução — o push para a main foi recusado.',
155
+ details: ['O que foi testado não é o que seria enviado. A correção está na sua árvore.', '', ...paths],
156
+ route: [
157
+ 'Leve a correção para a dev, commite lá, e integre:',
158
+ ' git stash && git switch dev && git stash pop',
159
+ ' git add -- <arquivos acima> && git commit',
160
+ ' pnpm exec kuyper integrate && pnpm exec kuyper publish',
161
+ ],
162
+ });
163
+ }
164
+ function refuseHeadMovedDuringPush() {
165
+ throw new KuyperRefusal({
166
+ code: 'R23',
167
+ headline: 'O HEAD mudou durante a execução dos gates — o push para a main foi recusado.',
168
+ details: ['O candidato testado não é mais o candidato enviado.'],
169
+ route: ['git switch main', 'Confira o estado do repositório antes de empurrar de novo.'],
170
+ });
171
+ }
172
+ /**
173
+ * O comportamento do `pre-push` (PRD §3.8). Só age nas atualizações
174
+ * destinadas a `refs/heads/main` — push de outra branch não dispara nada
175
+ * daqui. Três preconições antes de qualquer gate (árvore limpa, branch
176
+ * atual `main`, SHA enviado igual ao `HEAD`), depois `validate()`, depois
177
+ * os gates do estágio `publish` em modo exato, depois a checagem final
178
+ * (árvore continua limpa, `HEAD` não se moveu).
179
+ */
180
+ export async function runPrePushHook(cwd, stdin) {
181
+ const updates = parseRefUpdates(stdin);
182
+ const mainUpdate = updates.find((u) => u.remoteRef === 'refs/heads/main' && u.localSha !== ZERO_SHA);
183
+ if (mainUpdate === undefined)
184
+ return 0;
185
+ const status = await statusPorcelain(cwd);
186
+ if (status.trim().length > 0) {
187
+ refuseDirtyTreeForPush(await switchToDevRoute(cwd));
188
+ }
189
+ const branch = await currentBranch(cwd);
190
+ if (branch !== 'main') {
191
+ refuseWrongBranchForPush('git switch main');
192
+ }
193
+ const headBefore = await headSha(cwd);
194
+ if (headBefore !== mainUpdate.localSha) {
195
+ refuseShaMismatchForPush();
196
+ }
197
+ const validateReport = await validate({ projectRoot: cwd, silent: true });
198
+ if (validateReport.code !== 0) {
199
+ refuseValidateDiverged(validateReport);
200
+ }
201
+ const config = await readConfig(join(cwd, '.kuyper', 'config.yaml'));
202
+ const outcome = await runExact(gatesForStage(config.gates, 'publish'), cwd);
203
+ if (outcome.kind === 'gate-failed')
204
+ refuseGateFailedPush(outcome.gate, outcome.output);
205
+ if (outcome.kind === 'wrote')
206
+ refuseWroteDuringPush(outcome.paths);
207
+ const headAfter = await headSha(cwd);
208
+ if (headAfter !== headBefore) {
209
+ refuseHeadMovedDuringPush();
210
+ }
211
+ return 0;
212
+ }
package/dist/hooks.js ADDED
@@ -0,0 +1,49 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ export const HOOK_NAMES = ['pre-commit', 'pre-push'];
5
+ export const HOOKS_PATH = '.kuyper/hooks';
6
+ export const HOOK_MODE = 0o755;
7
+ /**
8
+ * O conteúdo dos dois hooks (PRD §3.8) — idêntico, salvo o estágio na última
9
+ * linha. Resolve o binário em `node_modules/.bin/`, nunca por `pnpm exec` nem
10
+ * pelo `PATH` (mesma regra do relançamento no `update`).
11
+ */
12
+ export function hookScript(name) {
13
+ return `#!/bin/sh
14
+ # gerado por @kuyper/harness — não editar
15
+ set -e
16
+ root=$(git rev-parse --show-toplevel)
17
+ bin="$root/node_modules/.bin/kuyper"
18
+ if [ ! -x "$bin" ]; then
19
+ echo "✗ O Harness não está instalado neste clone." >&2
20
+ echo " pnpm install" >&2
21
+ exit 1
22
+ fi
23
+ exec "$bin" __hook ${name} "$@"
24
+ `;
25
+ }
26
+ /** `undefined` quando `core.hooksPath` não está configurado. */
27
+ export async function getCoreHooksPath(cwd) {
28
+ try {
29
+ const { stdout } = await execFileAsync('git', ['config', '--get', 'core.hooksPath'], { cwd });
30
+ const value = stdout.trim();
31
+ return value.length > 0 ? value : undefined;
32
+ }
33
+ catch (err) {
34
+ // git config --get sai com 1 quando a chave não está definida — não é erro.
35
+ if (err.code === 1)
36
+ return undefined;
37
+ throw err;
38
+ }
39
+ }
40
+ export async function setCoreHooksPath(cwd, value) {
41
+ await execFileAsync('git', ['config', 'core.hooksPath', value], { cwd });
42
+ }
43
+ export function classifyHooksPath(current) {
44
+ if (current === undefined)
45
+ return 'absent';
46
+ // git normaliza separador e pode devolver com ou sem barra final.
47
+ const normalized = current.replace(/\/+$/u, '');
48
+ return normalized === HOOKS_PATH ? 'correct' : 'other';
49
+ }
package/dist/init.js ADDED
@@ -0,0 +1,265 @@
1
+ import { access, readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { readPackageScripts, findGatesWithMissingScript } from './config.js';
5
+ import { KuyperRefusal } from './errors.js';
6
+ import { getCoreHooksPath, classifyHooksPath } from './hooks.js';
7
+ import { packageCorePath } from './paths.js';
8
+ import { writeFileAtomic } from './atomicWrite.js';
9
+ import { generate, loadCapabilities } from './generate.js';
10
+ import { isGitRepo, currentBranch } from './gitPlumbing.js';
11
+ /** A lista fixa de gates que o Harness exige — não é escolha do usuário (PRD §4.1). */
12
+ const DEFAULT_GATES = [
13
+ { id: 'typecheck', command: 'pnpm typecheck', stages: ['pre-commit', 'publish'] },
14
+ { id: 'lint', command: 'pnpm lint', stages: ['pre-commit', 'publish'] },
15
+ { id: 'test', command: 'pnpm test', stages: ['publish'] },
16
+ { id: 'build', command: 'pnpm build', stages: ['publish'] },
17
+ ];
18
+ const GATE_SUGGESTIONS = {
19
+ typecheck: { install: 'pnpm add -D typescript', scriptLine: '"typecheck": "tsc --noEmit"' },
20
+ lint: { install: 'pnpm add -D eslint', scriptLine: '"lint": "eslint ."' },
21
+ test: { install: 'pnpm add -D vitest (sugestão — qualquer test runner serve)', scriptLine: '"test": "vitest run"' },
22
+ build: { scriptLine: '"build": "<o comando que produz sua saída publicável>"' },
23
+ };
24
+ const PREPARE_SCRIPT = 'pnpm exec kuyper generate';
25
+ const FIXED_PROVIDER_ORDER = ['claude', 'codex'];
26
+ const SOBRE_O_PROJETO_TITLE = 'Sobre este projeto';
27
+ function configYaml(providers) {
28
+ const gateBlocks = DEFAULT_GATES.map((g) => ` - id: ${g.id}\n command: ${g.command}\n stages: [${g.stages.join(', ')}]`).join('\n');
29
+ return `schemaVersion: 1\nproviders:\n enabled: [${providers.join(', ')}]\ngates:\n${gateBlocks}\n`;
30
+ }
31
+ function stateMd(today) {
32
+ return `Atualizado por: init — ${today}\n\n## Onde parei\n## O que descobri que não estava óbvio\n## Próximo passo concreto\n## Cuidado\n`;
33
+ }
34
+ function sobreOProjetoBody(name, description) {
35
+ const trimmed = description.trim();
36
+ return trimmed.length > 0 ? `\`${name}\` — ${trimmed}.\n` : `\`${name}\`.\n`;
37
+ }
38
+ function todayIso() {
39
+ const d = new Date();
40
+ const pad = (n) => String(n).padStart(2, '0');
41
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
42
+ }
43
+ async function pathExists(p) {
44
+ try {
45
+ await access(p);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ function refuseNoTty() {
53
+ throw new KuyperRefusal({
54
+ headline: 'O init precisa de um terminal interativo.',
55
+ details: [
56
+ 'Ele faz três perguntas e espera a sua aprovação antes de criar a base.',
57
+ 'Sem terminal não há como respondê-las, e o Harness não inventa as respostas por você.',
58
+ ],
59
+ route: ['Rode num terminal interativo — é o único comando do Harness que exige um.'],
60
+ });
61
+ }
62
+ function refuseR2() {
63
+ throw new KuyperRefusal({ code: 'R2', headline: 'Isto não é um repositório Git.', route: ['git init'] });
64
+ }
65
+ function refuseR3() {
66
+ throw new KuyperRefusal({ code: 'R3', headline: 'Não existe package.json.', route: ['pnpm init'] });
67
+ }
68
+ function refuseR4() {
69
+ throw new KuyperRefusal({
70
+ code: 'R4',
71
+ headline: 'Já existe .kuyper/ neste projeto.',
72
+ details: ['O init não volta.'],
73
+ route: ['Para regenerar as saídas:', ' pnpm exec kuyper generate'],
74
+ });
75
+ }
76
+ function refuseR5() {
77
+ throw new KuyperRefusal({
78
+ code: 'R5',
79
+ headline: 'Nenhum provider escolhido.',
80
+ details: ['Sem provider não há saída a materializar.'],
81
+ route: ['Rode de novo e escolha ao menos um: claude, codex.'],
82
+ });
83
+ }
84
+ function refuseR6(missing) {
85
+ const details = [];
86
+ for (const gate of missing) {
87
+ const suggestion = GATE_SUGGESTIONS[gate.id];
88
+ if (suggestion?.install)
89
+ details.push(`${gate.id} ${suggestion.install}`);
90
+ else
91
+ details.push(gate.id);
92
+ details.push(`${' '.repeat(gate.id.length)} package.json → ${suggestion?.scriptLine ?? gate.command}`);
93
+ details.push('');
94
+ }
95
+ details.pop();
96
+ throw new KuyperRefusal({
97
+ headline: missing.length === 1
98
+ ? 'Falta o script de um gate que o Harness usa.'
99
+ : `Faltam scripts para ${missing.length} gates que o Harness usa.`,
100
+ details,
101
+ route: ['Instale, declare os scripts, e rode pnpm exec kuyper init de novo.'],
102
+ });
103
+ }
104
+ function refuseR24(current) {
105
+ throw new KuyperRefusal({
106
+ code: 'R24',
107
+ headline: 'O core.hooksPath já aponta para outro lugar.',
108
+ details: [`atual: ${current}`, '', 'Outro gerenciador de hooks controla este repositório. O Harness não', 'assume esse controle por conta própria — a decisão é sua.'],
109
+ route: ['Para entregar os hooks ao Harness:', ' git config --unset core.hooksPath', ' pnpm exec kuyper init'],
110
+ });
111
+ }
112
+ function parseProviders(raw) {
113
+ const trimmed = raw.trim();
114
+ if (trimmed.length === 0)
115
+ return [...FIXED_PROVIDER_ORDER];
116
+ const tokens = new Set(trimmed.toLowerCase().split(/[\s,]+/u).filter((t) => t.length > 0));
117
+ return FIXED_PROVIDER_ORDER.filter((p) => tokens.has(p));
118
+ }
119
+ function parseYesNo(raw, defaultYes) {
120
+ const trimmed = raw.trim().toLowerCase();
121
+ if (trimmed.length === 0)
122
+ return defaultYes;
123
+ return trimmed[0] !== 'n';
124
+ }
125
+ async function copyCoreTree(fromDir, toDir) {
126
+ let entries;
127
+ try {
128
+ entries = await readdir(fromDir, { withFileTypes: true });
129
+ }
130
+ catch (err) {
131
+ if (err.code === 'ENOENT')
132
+ return;
133
+ throw err;
134
+ }
135
+ for (const entry of entries) {
136
+ const from = join(fromDir, entry.name);
137
+ const to = join(toDir, entry.name);
138
+ if (entry.isDirectory()) {
139
+ await copyCoreTree(from, to);
140
+ }
141
+ else if (entry.isFile()) {
142
+ const content = await readFile(from);
143
+ await writeFileAtomic(to, content);
144
+ }
145
+ }
146
+ }
147
+ async function readPackageJson(path) {
148
+ const text = await readFile(path, 'utf8');
149
+ return JSON.parse(text);
150
+ }
151
+ /**
152
+ * Do repositório cru à base pronta para revisão (PRD §3.1). Único comando
153
+ * que exige terminal interativo — três perguntas, uma confirmação, depois
154
+ * cria `.kuyper/` inteiro e chama `generate`. Não commita, não empurra.
155
+ */
156
+ export async function init(options = {}) {
157
+ const projectRoot = options.projectRoot ?? process.cwd();
158
+ // Preflight primeiro, TTY depois — de propósito. Nenhuma das checagens
159
+ // abaixo precisa de interação, e rodar assim faz um init sem terminal
160
+ // (ex.: dentro de um script de CI, num repositório que nem é Git ainda)
161
+ // ver o motivo de verdade em vez de só "precisa de terminal", que não
162
+ // ajudaria a consertar o problema real. Cada uma sua própria recusa,
163
+ // nada é criado antes daqui.
164
+ if (!(await isGitRepo(projectRoot)))
165
+ refuseR2();
166
+ const packageJsonPath = join(projectRoot, 'package.json');
167
+ if (!(await pathExists(packageJsonPath)))
168
+ refuseR3();
169
+ if (await pathExists(join(projectRoot, '.kuyper')))
170
+ refuseR4();
171
+ const scripts = await readPackageScripts(packageJsonPath);
172
+ const missingGates = findGatesWithMissingScript([...DEFAULT_GATES], scripts);
173
+ if (missingGates.length > 0)
174
+ refuseR6(missingGates);
175
+ const hooksPathCurrent = await getCoreHooksPath(projectRoot);
176
+ const hooksPathState = classifyHooksPath(hooksPathCurrent);
177
+ if (hooksPathState === 'other')
178
+ refuseR24(hooksPathCurrent);
179
+ const interactive = options.input !== undefined || options.output !== undefined;
180
+ if (!interactive && process.stdin.isTTY !== true) {
181
+ refuseNoTty();
182
+ }
183
+ const input = options.input ?? process.stdin;
184
+ const output = options.output ?? process.stdout;
185
+ const print = (line) => {
186
+ output.write(`${line}\n`);
187
+ };
188
+ const pkg = await readPackageJson(packageJsonPath);
189
+ const rl = createInterface({ input: input, output });
190
+ let name;
191
+ let description;
192
+ let providers;
193
+ try {
194
+ const defaultName = pkg.name ?? '';
195
+ const nameAnswer = (await rl.question(`Nome do projeto [${defaultName}]: `)).trim();
196
+ name = nameAnswer.length > 0 ? nameAnswer : defaultName;
197
+ const defaultDescription = pkg.description ?? '';
198
+ const descriptionAnswer = (await rl.question(`Descrição [${defaultDescription || '(nenhuma)'}]: `)).trim();
199
+ description = descriptionAnswer.length > 0 ? descriptionAnswer : defaultDescription;
200
+ const providersAnswer = await rl.question('Providers a habilitar — claude, codex [ambos]: ');
201
+ providers = parseProviders(providersAnswer);
202
+ if (providers.length === 0)
203
+ refuseR5();
204
+ print('');
205
+ print('Vou criar:');
206
+ print('');
207
+ print(` nome: ${name}`);
208
+ print(` descrição: ${description.length > 0 ? description : '(nenhuma)'}`);
209
+ print(` providers: ${providers.join(', ')}`);
210
+ print('');
211
+ print(' .kuyper/config.yaml');
212
+ print(' .kuyper/project/rules/sobre-o-projeto.md');
213
+ print(' .kuyper/STATE.md');
214
+ print(' .kuyper/core/ (6 rules, 4 skills)');
215
+ print(' arquivo(s) de instrução, skills e hooks dos providers escolhidos');
216
+ print('');
217
+ const confirmAnswer = await rl.question('Confirma? [S/n] ');
218
+ if (!parseYesNo(confirmAnswer, true)) {
219
+ print('Cancelado. Nada foi criado.');
220
+ return 0;
221
+ }
222
+ }
223
+ finally {
224
+ rl.close();
225
+ }
226
+ const kuyperDir = join(projectRoot, '.kuyper');
227
+ await writeFileAtomic(join(kuyperDir, 'config.yaml'), configYaml(providers));
228
+ const projectRuleContent = `---\ntitle: ${SOBRE_O_PROJETO_TITLE}\n---\n\n${sobreOProjetoBody(name, description)}`;
229
+ await writeFileAtomic(join(kuyperDir, 'project', 'rules', 'sobre-o-projeto.md'), projectRuleContent);
230
+ await writeFileAtomic(join(kuyperDir, 'STATE.md'), stateMd(todayIso()));
231
+ await copyCoreTree(options.packageCoreDir ?? packageCorePath(), join(kuyperDir, 'core'));
232
+ await generate(options.packageCoreDir === undefined ? { projectRoot } : { projectRoot, packageCoreDir: options.packageCoreDir });
233
+ const pkgWithPrepare = { ...pkg, scripts: { ...pkg.scripts, prepare: PREPARE_SCRIPT } };
234
+ await writeFileAtomic(packageJsonPath, `${JSON.stringify(pkgWithPrepare, null, 2)}\n`);
235
+ const claudeEnabled = providers.includes('claude');
236
+ const codexEnabled = providers.includes('codex');
237
+ const outputs = [
238
+ claudeEnabled ? 'CLAUDE.md' : undefined,
239
+ 'AGENTS.md',
240
+ claudeEnabled ? '.claude/skills/' : undefined,
241
+ codexEnabled ? '.agents/skills/' : undefined,
242
+ ].filter((v) => v !== undefined);
243
+ // Contado de verdade, não cravado — o número de rules/skills do core não é
244
+ // uma constante do produto, é o que o pacote em execução realmente trouxe.
245
+ const { set } = await loadCapabilities(projectRoot);
246
+ print('');
247
+ print('✓ Base criada em .kuyper/');
248
+ print('');
249
+ print(` core: ${set.coreRules.length} rules, ${set.coreSkills.length} skills`);
250
+ print(` projeto: ${set.projectRules.length} rule${set.projectRules.length === 1 ? '' : 's'}`);
251
+ print(` saídas: ${outputs.join(', ')}`);
252
+ print(' hooks: pre-commit, pre-push (ativos)');
253
+ print('');
254
+ print(' Revise e commite:');
255
+ print(' git add -A && git commit');
256
+ const branch = await currentBranch(projectRoot);
257
+ if (branch === 'main') {
258
+ print('');
259
+ print('⚠ você está na main; crie a dev antes de commitar, senão o pre-commit');
260
+ print(' que acabou de ser instalado vai recusar:');
261
+ print('');
262
+ print(' git switch -c dev');
263
+ }
264
+ return 0;
265
+ }