@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.
package/dist/cli.js ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { KuyperRefusal } from './errors.js';
6
+ import { requireProject } from './project.js';
7
+ import { generate } from './generate.js';
8
+ import { validate } from './validate.js';
9
+ import { init } from './init.js';
10
+ import { integrate } from './integrate.js';
11
+ import { publish } from './publish.js';
12
+ import { update, continueUpdate } from './update.js';
13
+ import { runCapabilityCommand } from './capabilityCommands.js';
14
+ import { runPreCommitHook, runPrePushHook } from './hookBehavior.js';
15
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(moduleDir, '..', 'package.json'), 'utf8'));
17
+ /** As oito interfaces públicas (PRD §1). Nada além disso. */
18
+ export const COMMANDS = [
19
+ 'init',
20
+ 'generate',
21
+ 'validate',
22
+ 'integrate',
23
+ 'publish',
24
+ 'update',
25
+ 'rule',
26
+ 'skill',
27
+ ];
28
+ function isCommand(value) {
29
+ return COMMANDS.includes(value);
30
+ }
31
+ function printCommandList() {
32
+ console.log('kuyper — copiloto de desenvolvimento\n');
33
+ console.log('Comandos:');
34
+ for (const cmd of COMMANDS)
35
+ console.log(` kuyper ${cmd}`);
36
+ }
37
+ /**
38
+ * Todos os comandos, menos `init`, herdam a checagem comum (R1) — mesmo
39
+ * antes de ter lógica própria (prova do B1). No B10, as oito interfaces
40
+ * públicas passam a ter lógica real.
41
+ */
42
+ const HANDLERS = {
43
+ init: async () => init(),
44
+ generate: async (rest) => {
45
+ await requireProject();
46
+ await generate({ force: rest.includes('--force') });
47
+ return 0;
48
+ },
49
+ validate: async () => {
50
+ await requireProject();
51
+ const report = await validate();
52
+ return report.code;
53
+ },
54
+ integrate: async () => {
55
+ await requireProject();
56
+ const report = await integrate();
57
+ return report.code;
58
+ },
59
+ publish: async () => {
60
+ await requireProject();
61
+ const report = await publish();
62
+ return report.code;
63
+ },
64
+ update: async () => {
65
+ await requireProject();
66
+ return update();
67
+ },
68
+ rule: async (rest) => {
69
+ await requireProject();
70
+ return runCapabilityCommand('rule', rest, { readStdin: () => readStdin(process.stdin) });
71
+ },
72
+ skill: async (rest) => {
73
+ await requireProject();
74
+ return runCapabilityCommand('skill', rest, { readStdin: () => readStdin(process.stdin) });
75
+ },
76
+ };
77
+ async function readStdin(stream) {
78
+ const chunks = [];
79
+ for await (const chunk of stream) {
80
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
81
+ }
82
+ return Buffer.concat(chunks).toString('utf8');
83
+ }
84
+ /**
85
+ * `__hook` e `__update-continue` são maquinaria declarada, não a nona
86
+ * interface: fora de `COMMANDS`, da ajuda e da lista de comandos. `__hook`
87
+ * (PRD §3.8) é o ponto de entrada que o script de hook gerado chama;
88
+ * `__update-continue` (PRD §3.6, passos 5–9) é o que `update()` (Fase 1,
89
+ * `update.ts`) invoca relançando o binário recém-instalado — nunca algo
90
+ * que um humano ou uma LLM digita.
91
+ */
92
+ async function runHook(rest) {
93
+ const [name] = rest;
94
+ if (name === 'pre-commit')
95
+ return runPreCommitHook(process.cwd());
96
+ if (name === 'pre-push')
97
+ return runPrePushHook(process.cwd(), await readStdin(process.stdin));
98
+ console.error(`✗ __hook desconhecido: ${String(name)}`);
99
+ return 1;
100
+ }
101
+ export async function main(argv) {
102
+ const [maybeCmd, ...rest] = argv;
103
+ if (maybeCmd === '--version' || maybeCmd === '-v') {
104
+ console.log(pkg.version);
105
+ return 0;
106
+ }
107
+ if (maybeCmd === undefined) {
108
+ printCommandList();
109
+ return 0;
110
+ }
111
+ try {
112
+ if (maybeCmd === '__hook') {
113
+ return await runHook(rest);
114
+ }
115
+ if (maybeCmd === '__update-continue') {
116
+ return await continueUpdate({ projectRoot: process.cwd() });
117
+ }
118
+ if (!isCommand(maybeCmd)) {
119
+ console.error(`✗ Comando desconhecido: ${maybeCmd}\n`);
120
+ printCommandList();
121
+ return 1;
122
+ }
123
+ return await HANDLERS[maybeCmd](rest);
124
+ }
125
+ catch (err) {
126
+ if (err instanceof KuyperRefusal) {
127
+ console.error(err.format());
128
+ return 1;
129
+ }
130
+ throw err;
131
+ }
132
+ }
133
+ /**
134
+ * Comparar `process.argv[1]` direto com `fileURLToPath(import.meta.url)`
135
+ * quebra em qualquer invocação com um symlink no meio do caminho — que é
136
+ * exatamente como o binário é invocado de verdade: `node_modules/.bin/kuyper`
137
+ * (o que `pnpm add -D` cria) É um symlink, e macOS já resolve `/tmp` para
138
+ * `/private/tmp` por conta própria. Sem `realpathSync` dos dois lados,
139
+ * `isMain` dá falso sempre que a instalação real acontece, e o binário
140
+ * inteiro não faz nada — silenciosamente, saindo com 0. Achado testando o
141
+ * hook materializado de verdade via `node_modules/.bin/`, não em uso comum
142
+ * (B6) — nenhum teste anterior invocava por aí.
143
+ */
144
+ function isMainModule() {
145
+ const invoked = process.argv[1];
146
+ if (invoked === undefined)
147
+ return false;
148
+ try {
149
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ const isMain = isMainModule();
156
+ if (isMain) {
157
+ main(process.argv.slice(2))
158
+ .then((code) => {
159
+ process.exitCode = code;
160
+ })
161
+ .catch((err) => {
162
+ console.error(err instanceof Error ? err.message : String(err));
163
+ process.exitCode = 1;
164
+ });
165
+ }
package/dist/config.js ADDED
@@ -0,0 +1,159 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parse as parseYaml } from 'yaml';
3
+ /**
4
+ * Schema inválido recusa (R25): campo obrigatório ausente, gate duplicado,
5
+ * estágio desconhecido, comando vazio, ou campo que o produto já declarou
6
+ * morto (`mainBranch`, `workBranch`, `remote` — PRD §4.1).
7
+ */
8
+ export class ConfigSchemaError extends Error {
9
+ issues;
10
+ constructor(issues) {
11
+ super(`.kuyper/config.yaml inválido:\n${issues.map((i) => ` - ${i}`).join('\n')}`);
12
+ this.issues = issues;
13
+ this.name = 'ConfigSchemaError';
14
+ }
15
+ }
16
+ const KNOWN_PROVIDERS = new Set(['claude', 'codex']);
17
+ const KNOWN_STAGES = new Set(['pre-commit', 'publish']);
18
+ const TOP_LEVEL_KEYS = new Set(['schemaVersion', 'providers', 'gates']);
19
+ const PROVIDERS_KEYS = new Set(['enabled']);
20
+ const GATE_KEYS = new Set(['id', 'command', 'stages']);
21
+ function isPlainObject(value) {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ }
24
+ /**
25
+ * Puro: recebe o texto do YAML, devolve a config validada ou lança
26
+ * `ConfigSchemaError` com todos os achados — não só o primeiro.
27
+ */
28
+ export function parseConfig(text) {
29
+ const issues = [];
30
+ let data;
31
+ try {
32
+ data = parseYaml(text);
33
+ }
34
+ catch (err) {
35
+ throw new ConfigSchemaError([`YAML malformado: ${err.message}`]);
36
+ }
37
+ if (!isPlainObject(data)) {
38
+ throw new ConfigSchemaError(['o arquivo não é um mapa YAML']);
39
+ }
40
+ for (const key of Object.keys(data)) {
41
+ if (!TOP_LEVEL_KEYS.has(key))
42
+ issues.push(`campo desconhecido: ${key}`);
43
+ }
44
+ if (data['schemaVersion'] !== 1) {
45
+ issues.push(`schemaVersion deve ser 1 (encontrado: ${JSON.stringify(data['schemaVersion'])})`);
46
+ }
47
+ const enabled = [];
48
+ const providers = data['providers'];
49
+ if (!isPlainObject(providers)) {
50
+ issues.push('providers ausente ou inválido');
51
+ }
52
+ else {
53
+ for (const key of Object.keys(providers)) {
54
+ if (!PROVIDERS_KEYS.has(key))
55
+ issues.push(`providers: campo desconhecido: ${key}`);
56
+ }
57
+ const enabledRaw = providers['enabled'];
58
+ if (!Array.isArray(enabledRaw)) {
59
+ issues.push('providers.enabled ausente ou não é uma lista');
60
+ }
61
+ else {
62
+ for (const p of enabledRaw) {
63
+ if (typeof p === 'string' && KNOWN_PROVIDERS.has(p)) {
64
+ enabled.push(p);
65
+ }
66
+ else {
67
+ issues.push(`provider desconhecido: ${JSON.stringify(p)}`);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ const gates = [];
73
+ const gatesRaw = data['gates'];
74
+ const seenIds = new Set();
75
+ if (!Array.isArray(gatesRaw)) {
76
+ issues.push('gates ausente ou não é uma lista');
77
+ }
78
+ else {
79
+ gatesRaw.forEach((gateRaw, i) => {
80
+ if (!isPlainObject(gateRaw)) {
81
+ issues.push(`gates[${i}]: não é um mapa`);
82
+ return;
83
+ }
84
+ for (const key of Object.keys(gateRaw)) {
85
+ if (!GATE_KEYS.has(key))
86
+ issues.push(`gates[${i}]: campo desconhecido: ${key}`);
87
+ }
88
+ const id = gateRaw['id'];
89
+ const command = gateRaw['command'];
90
+ const stagesRaw = gateRaw['stages'];
91
+ let idOk = false;
92
+ if (typeof id !== 'string' || id.length === 0) {
93
+ issues.push(`gates[${i}]: id ausente ou vazio`);
94
+ }
95
+ else if (seenIds.has(id)) {
96
+ issues.push(`gate duplicado: ${id}`);
97
+ }
98
+ else {
99
+ seenIds.add(id);
100
+ idOk = true;
101
+ }
102
+ const commandOk = typeof command === 'string' && command.trim().length > 0;
103
+ if (!commandOk)
104
+ issues.push(`gates[${i}]: comando vazio`);
105
+ const stages = [];
106
+ if (!Array.isArray(stagesRaw)) {
107
+ issues.push(`gates[${i}]: stages ausente ou não é uma lista`);
108
+ }
109
+ else {
110
+ for (const s of stagesRaw) {
111
+ if (typeof s === 'string' && KNOWN_STAGES.has(s)) {
112
+ stages.push(s);
113
+ }
114
+ else {
115
+ issues.push(`gates[${i}]: estágio desconhecido: ${JSON.stringify(s)}`);
116
+ }
117
+ }
118
+ }
119
+ if (idOk && commandOk) {
120
+ gates.push({ id: id, command: command, stages });
121
+ }
122
+ });
123
+ }
124
+ if (issues.length > 0) {
125
+ throw new ConfigSchemaError(issues);
126
+ }
127
+ return { schemaVersion: 1, providers: { enabled }, gates };
128
+ }
129
+ export async function readConfig(configPath) {
130
+ const text = await readFile(configPath, 'utf8');
131
+ return parseConfig(text);
132
+ }
133
+ /**
134
+ * "Um gate que não pode ser executado não é gate vermelho: é configuração
135
+ * quebrada, e o Harness acusa o config.yaml, não o código" (PRD §4.1).
136
+ *
137
+ * Só confere gates cujo comando é `pnpm <script>` — é a convenção que o
138
+ * corpus inteiro usa, e a checagem aponta o config.yaml, não tenta validar
139
+ * comando arbitrário. Devolve os gates cujo script não existe no
140
+ * `package.json` do projeto.
141
+ */
142
+ export function findGatesWithMissingScript(gates, packageJsonScripts) {
143
+ const missing = [];
144
+ for (const gate of gates) {
145
+ const match = /^pnpm\s+([\w:-]+)/.exec(gate.command);
146
+ if (!match)
147
+ continue;
148
+ const scriptName = match[1];
149
+ if (!(scriptName in packageJsonScripts)) {
150
+ missing.push(gate);
151
+ }
152
+ }
153
+ return missing;
154
+ }
155
+ export async function readPackageScripts(packageJsonPath) {
156
+ const text = await readFile(packageJsonPath, 'utf8');
157
+ const data = JSON.parse(text);
158
+ return data.scripts ?? {};
159
+ }
@@ -0,0 +1,76 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isTempPath } from './atomicWrite.js';
4
+ import { computeChecksum } from './lock.js';
5
+ /**
6
+ * Chaves relativas (com `/`) → checksum, de todo arquivo regular sob
7
+ * `rootDir`. Leitores ignoram `.kuyper-tmp` e não-regulares (symlink etc.) —
8
+ * "o mesmo conjunto de arquivos regulares" da SPEC protocolo-core-lock §4.
9
+ */
10
+ export async function checksumTree(rootDir) {
11
+ const result = new Map();
12
+ await walk(rootDir, '');
13
+ return result;
14
+ async function walk(absDir, relPrefix) {
15
+ let entries;
16
+ try {
17
+ entries = await readdir(absDir, { withFileTypes: true });
18
+ }
19
+ catch (err) {
20
+ if (err.code === 'ENOENT')
21
+ return;
22
+ throw err;
23
+ }
24
+ for (const entry of entries) {
25
+ if (isTempPath(entry.name))
26
+ continue;
27
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
28
+ const absPath = join(absDir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ await walk(absPath, relPath);
31
+ }
32
+ else if (entry.isFile()) {
33
+ const content = await readFile(absPath);
34
+ result.set(relPath, computeChecksum(content));
35
+ }
36
+ // symlinks e outros tipos: fora de "arquivo regular", ignorados.
37
+ }
38
+ }
39
+ }
40
+ export function mapsEqual(a, b) {
41
+ if (a.size !== b.size)
42
+ return false;
43
+ for (const [k, v] of a) {
44
+ if (b.get(k) !== v)
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ /**
50
+ * `.kuyper/core/**` do lock anterior, com o prefixo removido — o candidato
51
+ * `L` da SPEC protocolo-core-lock §4.
52
+ */
53
+ export function coreEntriesFromLock(lockEntries) {
54
+ const prefix = '.kuyper/core/';
55
+ const out = new Map();
56
+ for (const [key, value] of Object.entries(lockEntries)) {
57
+ if (key.startsWith(prefix))
58
+ out.set(key.slice(prefix.length), value);
59
+ }
60
+ return out;
61
+ }
62
+ /**
63
+ * A classificação agregada (SPEC protocolo-core-lock §4). A unidade de
64
+ * autorização é a árvore inteira, nunca um arquivo — não existe aceitação
65
+ * independente por caminho.
66
+ */
67
+ export function classifyCore(L, P, D) {
68
+ const unknown = [...D.keys()].filter((k) => !L.has(k) && !P.has(k));
69
+ if (unknown.length > 0)
70
+ return { state: 'unknown-path', paths: unknown.sort() };
71
+ if (mapsEqual(D, L))
72
+ return { state: 'anterior-intact' };
73
+ if (mapsEqual(D, P))
74
+ return { state: 'transition-complete' };
75
+ return { state: 'mismatch' };
76
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ const LINE_WIDTH = 68;
2
+ function withCodeTag(headline, code) {
3
+ const prefix = `✗ ${headline}`;
4
+ const tag = `(${code})`;
5
+ const gap = LINE_WIDTH - prefix.length - tag.length;
6
+ return gap > 1 ? `${prefix}${' '.repeat(gap)}${tag}` : `${prefix} ${tag}`;
7
+ }
8
+ function indentBlock(lines, indent) {
9
+ return lines.map((line) => (line === '' ? '' : `${indent}${line}`));
10
+ }
11
+ export function formatRefusal(opts) {
12
+ const out = [];
13
+ out.push(opts.code ? withCodeTag(opts.headline, opts.code) : `✗ ${opts.headline}`);
14
+ if (opts.details && opts.details.length > 0) {
15
+ out.push('');
16
+ out.push(...indentBlock(opts.details, ' '));
17
+ }
18
+ if (opts.route && opts.route.length > 0) {
19
+ out.push('');
20
+ out.push(...indentBlock(opts.route, ' '));
21
+ }
22
+ return out.join('\n');
23
+ }
24
+ /**
25
+ * Uma recusa do produto. `format()` produz o texto exatamente na forma do
26
+ * PRD §3 — quem lança isto no dispatcher só precisa imprimir e sair com 1.
27
+ */
28
+ export class KuyperRefusal extends Error {
29
+ opts;
30
+ constructor(opts) {
31
+ super(opts.headline);
32
+ this.opts = opts;
33
+ this.name = 'KuyperRefusal';
34
+ }
35
+ format() {
36
+ return formatRefusal(this.opts);
37
+ }
38
+ }
@@ -0,0 +1,109 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { addPaths, dirtyPaths, hashFiles, unstagedAndUntrackedPaths } from './gitPlumbing.js';
4
+ const execAsync = promisify(exec);
5
+ export function gatesForStage(gates, stage) {
6
+ return gates.filter((g) => g.stages.includes(stage));
7
+ }
8
+ /**
9
+ * Roda o comando do gate via shell (`sh -c`) — o mesmo tanto faz `pnpm
10
+ * typecheck` quanto algo com pipe ou aspas. Um gate que não pode ser
11
+ * executado (comando inexistente) e um gate que roda e falha caem no mesmo
12
+ * `ok: false` — a distinção entre "configuração quebrada" e "código
13
+ * quebrado" não é deste módulo (PRD §4.1); ele só relata.
14
+ */
15
+ export async function runGateCommand(command, cwd) {
16
+ try {
17
+ const { stdout, stderr } = await execAsync(command, { cwd, maxBuffer: 16 * 1024 * 1024 });
18
+ return { ok: true, output: `${stdout}${stderr}` };
19
+ }
20
+ catch (err) {
21
+ const e = err;
22
+ const output = `${e.stdout ?? ''}${e.stderr ?? ''}`;
23
+ return { ok: false, output: output.length > 0 ? output : e.message };
24
+ }
25
+ }
26
+ /**
27
+ * O laço do `pre-commit` (PRD §4.1, ADR gates-que-escrevem). Até
28
+ * `maxRounds` voltas, cada volta rodando **todos** os gates do estágio em
29
+ * ordem — se o lint conserta um arquivo, o typecheck da mesma volta já
30
+ * rodou sobre a versão de antes, então uma volta que mudou algo sempre
31
+ * repete a lista inteira, nunca só o gate seguinte.
32
+ *
33
+ * A fotografia (diff + não rastreados) é tirada **por gate**, não por
34
+ * volta — é o que permite o relatório final dizer qual gate corrigiu o
35
+ * quê (§3.8: *"lint corrigiu 2 arquivos"*), sem mudar a regra de quando
36
+ * repetir a volta.
37
+ *
38
+ * Um arquivo que já estava sujo antes de um gate rodar e teve o
39
+ * **conteúdo** mudado por ele (não só a categoria do `status` — daí o
40
+ * hash) nunca é readicionado: é recusa, a limitação do staging parcial
41
+ * declarada no §3.8.
42
+ */
43
+ export async function runConverging(gates, cwd, options = {}) {
44
+ const maxRounds = options.maxRounds ?? 3;
45
+ const fixedByGate = new Map();
46
+ let lastTouched = [];
47
+ for (let round = 0; round < maxRounds; round++) {
48
+ let touchedThisRound = false;
49
+ for (const gate of gates) {
50
+ const before = await unstagedAndUntrackedPaths(cwd);
51
+ const beforeHashes = await hashFiles(cwd, before);
52
+ const result = await runGateCommand(gate.command, cwd);
53
+ if (!result.ok) {
54
+ return { kind: 'gate-failed', gate: gate.id, output: result.output };
55
+ }
56
+ const after = await unstagedAndUntrackedPaths(cwd);
57
+ const touchedNew = [...after].filter((p) => !before.has(p)).sort();
58
+ const stillDirty = [...before].filter((p) => after.has(p));
59
+ const afterHashesForStillDirty = await hashFiles(cwd, stillDirty);
60
+ const touchedPreexisting = stillDirty.filter((p) => afterHashesForStillDirty.get(p) !== beforeHashes.get(p)).sort();
61
+ if (touchedPreexisting.length > 0) {
62
+ return { kind: 'edited-unstaged', gate: gate.id, paths: touchedPreexisting };
63
+ }
64
+ if (touchedNew.length === 0)
65
+ continue;
66
+ touchedThisRound = true;
67
+ lastTouched = touchedNew;
68
+ await addPaths(cwd, touchedNew);
69
+ const existing = fixedByGate.get(gate.id) ?? new Set();
70
+ for (const p of touchedNew)
71
+ existing.add(p);
72
+ fixedByGate.set(gate.id, existing);
73
+ }
74
+ if (!touchedThisRound) {
75
+ const fixed = [...fixedByGate.entries()].map(([id, paths]) => ({ gate: id, paths: [...paths].sort() }));
76
+ return { kind: 'ok', fixed };
77
+ }
78
+ }
79
+ return { kind: 'no-converge', paths: lastTouched };
80
+ }
81
+ /**
82
+ * O modo `integrate`/`publish`/`pre-push` (PRD §4.1, ADR gates-que-escrevem,
83
+ * decisão 4): sem conserto. Quem chama garante a árvore limpa **antes** de
84
+ * entrar aqui — a R14 nos comandos, a precondição do §3.8 no `pre-push`;
85
+ * este runner não reconfere isso, só detecta se algum gate sujou o que
86
+ * devia continuar limpo. `git status --porcelain` basta (não precisa de
87
+ * `write-tree`): a árvore começou limpa, então qualquer entrada nova já é
88
+ * a resposta — diferente do `pre-commit`, onde ela é suja por natureza.
89
+ *
90
+ * A duração por gate (`results`) é o que o `integrate` (§3.4) e o `publish`
91
+ * (§3.5) imprimem no relatório de sucesso — medida aqui porque é aqui que
92
+ * cada gate roda, não recalculada por quem chama.
93
+ */
94
+ export async function runExact(gates, cwd) {
95
+ const results = [];
96
+ for (const gate of gates) {
97
+ const start = Date.now();
98
+ const result = await runGateCommand(gate.command, cwd);
99
+ if (!result.ok) {
100
+ return { kind: 'gate-failed', gate: gate.id, output: result.output };
101
+ }
102
+ results.push({ gate: gate.id, durationMs: Date.now() - start });
103
+ }
104
+ const paths = await dirtyPaths(cwd);
105
+ if (paths.length > 0) {
106
+ return { kind: 'wrote', paths };
107
+ }
108
+ return { kind: 'ok', results };
109
+ }