karajan-code 4.17.0 → 4.19.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 +10 -0
- package/package.json +4 -2
- package/scripts/dual-publish.mjs +67 -0
- package/scripts/verify-pack.mjs +8 -5
- package/src/agents/claude-agent.js +7 -2
- package/src/checks/guarantee-level.js +38 -0
- package/src/cli/register-meta.js +33 -2
- package/src/cli/register-roles-skills.js +17 -1
- package/src/commands/agents.js +4 -2
- package/src/commands/doctor.js +16 -0
- package/src/commands/policy.js +103 -9
- package/src/commands/review-gate.js +111 -4
- package/src/environment/briefs.js +3 -0
- package/src/environment/playbook.js +3 -1
- package/src/harden/sentinel-hooks.js +92 -2
- package/src/harden/workflow-engine.js +35 -3
- package/src/harden/workflow-templates.js +28 -0
- package/src/mutate/diff-scope.js +6 -2
- package/src/policy/add.js +111 -0
- package/src/policy/decisions.js +42 -0
- package/src/policy/engine.js +44 -238
- package/src/policy/exceptions.js +60 -0
- package/src/review/mutation-pregate.js +48 -0
- package/src/review/policy-gate.js +26 -0
- package/src/start/sweep.js +46 -4
- package/src/utils/update-check.js +25 -6
- package/src/policy/glob.js +0 -44
package/README.md
CHANGED
|
@@ -75,6 +75,16 @@ Karajan **governs** any agent with git gates — the false green is structurally
|
|
|
75
75
|
|
|
76
76
|
Synchronous blocking hooks exist today only in Claude Code. That makes the supported setup explicit: **to guarantee a harness that controls the LLM, use Claude Code as the host** — Claude writes, Codex reviews (a review subprocess needs no hooks), and a third CLI arbitrates when available. On any other host Karajan still governs at the full git-gate level and tells you which level is active — it never pretends a supervision it cannot enforce.
|
|
77
77
|
|
|
78
|
+
With a declared `.karajan/policy.yml`, the policy layer enforces at three tiers, and which tier applies depends on the host:
|
|
79
|
+
|
|
80
|
+
| Tier | Where | Requires | What it guarantees |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| **A — tool time** | Sentinel PreToolUse → `kj policy eval --strict` | Claude Code as host (synchronous hooks) | The rule fires BEFORE the damage; the acting agent's role travels in `KJ_POLICY_ROLE` |
|
|
83
|
+
| **B — commit time** | `kj review --staged` / pre-commit → deny + evidentiary exceptions | Any host (the gate lives in git) | The violating diff never enters, no matter who wrote it or what host ran it |
|
|
84
|
+
| **C — merge time** | `kj-policy.yml` CI workflow → `kj policy check --range --strict` | GitHub Actions (seeded by `kj harden`) | Covers a tampered local hook: the PR diff is re-checked against the same policy, merge-blocking |
|
|
85
|
+
|
|
86
|
+
Tier B is the guarantee floor — hosts without hooks lose A, never B; C re-verifies both. Security-class rules and consumer defaults are non-exemptable at every tier: no escape, no arbitration, no grant.
|
|
87
|
+
|
|
78
88
|
## Headless mode
|
|
79
89
|
|
|
80
90
|
The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. Agents and CI pass `--non-interactive` (or `KJ_NON_INTERACTIVE=1`): safe gates auto-answer, FAIL findings stop the run with a real exit code. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.19.0",
|
|
4
4
|
"description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"workspaces": [
|
|
38
38
|
"packages/ai-trash",
|
|
39
39
|
"packages/core",
|
|
40
|
-
"packages/hu-board"
|
|
40
|
+
"packages/hu-board",
|
|
41
|
+
"packages/governance"
|
|
41
42
|
],
|
|
42
43
|
"imports": {
|
|
43
44
|
"#utils/*": "./src/utils/*",
|
|
@@ -100,6 +101,7 @@
|
|
|
100
101
|
},
|
|
101
102
|
"dependencies": {
|
|
102
103
|
"@babel/parser": "^7.29.7",
|
|
104
|
+
"@karajan-family/governance": "^0.1.0",
|
|
103
105
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
104
106
|
"better-sqlite3": "^12.10.0",
|
|
105
107
|
"chokidar": "^5.0.0",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dual-publish (MIG-A, KJC-TSK-0751, épica KJC-PCS-0077, ADR 0004) — el
|
|
4
|
+
* MISMO contenido sale bajo dos nombres npm durante la migración al scope:
|
|
5
|
+
* karajan-code (legacy, publish normal) y @karajan-family/code (scoped).
|
|
6
|
+
*
|
|
7
|
+
* Uso:
|
|
8
|
+
* node scripts/dual-publish.mjs --pack-only # genera+verifica el tarball scoped
|
|
9
|
+
* node scripts/dual-publish.mjs --publish --otp=XXX # publica el scoped (tras publicar el legacy)
|
|
10
|
+
*
|
|
11
|
+
* El swap del name es EN SITIO con restauración en finally: un crash a mitad
|
|
12
|
+
* no deja el manifest renombrado. La verificación es el MISMO verify-pack
|
|
13
|
+
* (name-agnóstico desde la parte 1): empaqueta, instala aislado y corre kj.
|
|
14
|
+
* El PRIMER publish del scoped lo hace el usuario (cuenta karajan-family,
|
|
15
|
+
* security key); los siguientes van con la sesión normal.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { join, dirname, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
export const LEGACY_NAME = "karajan-code";
|
|
23
|
+
export const SCOPED_NAME = "@karajan-family/code";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ejecuta `fn` con el manifest renombrado al scoped (+ publishConfig public,
|
|
27
|
+
* obligatorio para scoped) y lo RESTAURA byte a byte pase lo que pase.
|
|
28
|
+
*/
|
|
29
|
+
export async function withScopedName(repoRoot, fn) {
|
|
30
|
+
const pkgPath = join(repoRoot, "package.json");
|
|
31
|
+
const original = readFileSync(pkgPath, "utf8");
|
|
32
|
+
const pkg = JSON.parse(original);
|
|
33
|
+
if (pkg.name !== LEGACY_NAME) {
|
|
34
|
+
throw new Error(`dual-publish: el manifest dice "${pkg.name}" y solo se renombra desde ${LEGACY_NAME} — jamás a ciegas`);
|
|
35
|
+
}
|
|
36
|
+
// publishConfig se MERGEA, no se pisa (catch de codex): un registry/tag
|
|
37
|
+
// preexistente debe sobrevivir al publish scoped.
|
|
38
|
+
writeFileSync(pkgPath, `${JSON.stringify({ ...pkg, name: SCOPED_NAME, publishConfig: { ...pkg.publishConfig, access: "public" } }, null, 2)}\n`, "utf8");
|
|
39
|
+
try {
|
|
40
|
+
return await fn();
|
|
41
|
+
} finally {
|
|
42
|
+
writeFileSync(pkgPath, original, "utf8");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// argv[1] normalizado (catch de codex): node lo absolutiza hoy, pero el
|
|
47
|
+
// guard no debe depender de ese detalle de implementación.
|
|
48
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
49
|
+
if (isMain) {
|
|
50
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
const publish = args.includes("--publish");
|
|
53
|
+
const otp = args.find((a) => a.startsWith("--otp="))?.slice(6);
|
|
54
|
+
await withScopedName(repoRoot, async () => {
|
|
55
|
+
console.log(`dual-publish: manifest → ${SCOPED_NAME} (temporal)`);
|
|
56
|
+
// La E2E real: empaqueta el scoped, instálalo aislado y corre kj.
|
|
57
|
+
execFileSync("node", [join(repoRoot, "scripts", "verify-pack.mjs")], { cwd: repoRoot, stdio: "inherit" });
|
|
58
|
+
if (publish) {
|
|
59
|
+
const pubArgs = ["publish", "--ignore-scripts", ...(otp ? [`--otp=${otp}`] : [])];
|
|
60
|
+
console.log(`dual-publish: npm ${pubArgs.join(" ").replace(/--otp=\S+/, "--otp=***")}`);
|
|
61
|
+
execFileSync("npm", pubArgs, { cwd: repoRoot, stdio: "inherit" });
|
|
62
|
+
} else {
|
|
63
|
+
console.log("dual-publish: --pack-only — verificado y SIN publicar (usa --publish en la release)");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
console.log(`dual-publish: manifest restaurado a ${LEGACY_NAME} ✓`);
|
|
67
|
+
}
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -27,6 +27,9 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
28
|
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
29
29
|
const expectedVersion = pkg.version;
|
|
30
|
+
// MIG-A (KJC-TSK-0751): el nombre sale del manifest — el MISMO verify-pack
|
|
31
|
+
// valida el tarball unscoped y el @karajan-family/* del dual-publish.
|
|
32
|
+
const pkgName = pkg.name;
|
|
30
33
|
|
|
31
34
|
// Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
|
|
32
35
|
// runs otherwise) and force a non-interactive, quiet npm.
|
|
@@ -54,13 +57,13 @@ let gTmp = null;
|
|
|
54
57
|
let pnpmTmp = null;
|
|
55
58
|
let qsTmp = null;
|
|
56
59
|
try {
|
|
57
|
-
console.log(`verify-pack: packing
|
|
60
|
+
console.log(`verify-pack: packing ${pkgName}@${expectedVersion}…`);
|
|
58
61
|
// --json gives us the exact filename without parsing human output.
|
|
59
62
|
const packOut = run("npm", ["pack", "--json", "--silent"], { cwd: repoRoot });
|
|
60
63
|
const filename = JSON.parse(packOut)[0]?.filename;
|
|
61
64
|
if (!filename) fail("npm pack did not report a filename", packOut);
|
|
62
|
-
// npm
|
|
63
|
-
// the file lands in cwd under
|
|
65
|
+
// npm --json reports the exact filename (scoped names normalized to
|
|
66
|
+
// scope-name-x.y.z.tgz); the file lands in cwd under that name.
|
|
64
67
|
tgzPath = path.join(repoRoot, filename);
|
|
65
68
|
if (!fs.existsSync(tgzPath)) fail(`packed tarball not found at ${tgzPath}`);
|
|
66
69
|
|
|
@@ -93,7 +96,7 @@ try {
|
|
|
93
96
|
const { scanPaths, loadPrivacyList } = await import(
|
|
94
97
|
path.join(repoRoot, "src", "privacy", "scan.js")
|
|
95
98
|
);
|
|
96
|
-
const shippedDir = path.join(tmpDir, "node_modules", "
|
|
99
|
+
const shippedDir = path.join(tmpDir, "node_modules", ...pkgName.split("/"));
|
|
97
100
|
const pFindings = scanPaths([shippedDir], { list: loadPrivacyList() });
|
|
98
101
|
const pBlocks = pFindings.filter((f) => f.severity === "block");
|
|
99
102
|
if (pBlocks.length > 0) {
|
|
@@ -265,7 +268,7 @@ try {
|
|
|
265
268
|
);
|
|
266
269
|
}
|
|
267
270
|
|
|
268
|
-
console.log(`\n✓ verify-pack:
|
|
271
|
+
console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
|
|
269
272
|
} finally {
|
|
270
273
|
if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
|
|
271
274
|
if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -365,6 +365,11 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
365
365
|
async _runTaskExec(task, model, _role) {
|
|
366
366
|
const args = buildPromptArgs(task);
|
|
367
367
|
if (model) args.push("--model", model);
|
|
368
|
+
// PL-C (KJC-TSK-0735): el rol del subproceso viaja en KJ_POLICY_ROLE —
|
|
369
|
+
// si su harness corre hooks (claude -p), el tier A evalúa la policy con
|
|
370
|
+
// el rol que ACTÚA. El rol del ORQUESTADOR es autoritativo: task.env no
|
|
371
|
+
// puede spoofearlo (catch de codex: sería un bypass de reglas por rol).
|
|
372
|
+
const roleEnv = task.role ? { ...task.env, KJ_POLICY_ROLE: task.role } : task.env;
|
|
368
373
|
|
|
369
374
|
// Use stream-json when onOutput is provided to get real-time feedback
|
|
370
375
|
if (task.onOutput) {
|
|
@@ -374,7 +379,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
374
379
|
onOutput: streamFilter,
|
|
375
380
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
376
381
|
timeout: task.timeoutMs,
|
|
377
|
-
env:
|
|
382
|
+
env: roleEnv,
|
|
378
383
|
cwd: task.cwd
|
|
379
384
|
}));
|
|
380
385
|
const raw = pickOutput(res);
|
|
@@ -385,7 +390,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
385
390
|
|
|
386
391
|
// Without streaming, use json output to get structured response via stderr
|
|
387
392
|
args.push("--output-format", "json");
|
|
388
|
-
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env:
|
|
393
|
+
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: roleEnv, cwd: task.cwd }));
|
|
389
394
|
const raw = pickOutput(res);
|
|
390
395
|
const output = extractTextFromStreamJson(raw);
|
|
391
396
|
const usage = extractUsageFromStreamJson(raw);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarantee level en runtime (KJC-TSK-0756, hallazgo de campo de Pedro):
|
|
3
|
+
* la doctrina "kj jamás finge una supervisión que no puede aplicar" vivía
|
|
4
|
+
* solo en el README — ahora kj doctor la DECLARA para el host actual, con
|
|
5
|
+
* el porqué. El acoplamiento a Claude es UNA capa (tier A: hooks síncronos
|
|
6
|
+
* bloqueantes, que hoy solo ese harness expone) y es decisión de ADR
|
|
7
|
+
* (v4.13), no accidente; el tier B (git gates) es el suelo agnóstico.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { detectHostAgent } from "../utils/agent-detect.js";
|
|
12
|
+
|
|
13
|
+
/** Pure: computes the three tiers from observable facts. */
|
|
14
|
+
export function guaranteeLevel({ host, harnessWired, policyDeclared, policyWorkflow }) {
|
|
15
|
+
const tierA = host === "claude" && harnessWired
|
|
16
|
+
? { active: true, reason: "Claude Code expone hooks síncronos bloqueantes y el harness del Sentinel está instalado (kj harden)" }
|
|
17
|
+
: host === "claude"
|
|
18
|
+
? { active: false, reason: "host Claude sin harness instalado — corre kj harden para activar la supervisión en el turno" }
|
|
19
|
+
: { active: false, reason: `el host ${host ?? "desconocido"} no expone hooks síncronos bloqueantes — no se finge la supervisión; el tier B sigue siendo el suelo de garantía` };
|
|
20
|
+
const tierB = { active: true, reason: "los gates viven en el repositorio, no en el harness: cualquier host, el diff violador no entra" };
|
|
21
|
+
const tierC = policyDeclared && policyWorkflow
|
|
22
|
+
? { active: true, reason: "kj-policy.yml re-verifica el PR contra la policy declarada (merge-blocking)" }
|
|
23
|
+
: policyDeclared
|
|
24
|
+
? { active: false, reason: "hay policy declarada pero sin workflow de CI — corre kj harden para sembrar kj-policy.yml" }
|
|
25
|
+
: { active: false, reason: "sin .karajan/policy.yml declarada — el tier C solo existe donde hay policy que verificar" };
|
|
26
|
+
return { host: host ?? null, tierA, tierB, tierC };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Facts from disk + env; injectable for tests. */
|
|
30
|
+
export function collectGuaranteeFacts({ projectDir = process.cwd(), deps = {} } = {}) {
|
|
31
|
+
const { detectHost = detectHostAgent, exists = existsSync } = deps;
|
|
32
|
+
return guaranteeLevel({
|
|
33
|
+
host: detectHost(),
|
|
34
|
+
harnessWired: exists(join(projectDir, ".karajan", "harness", "pretooluse-sentinel.mjs")),
|
|
35
|
+
policyDeclared: exists(join(projectDir, ".karajan", "policy.yml")),
|
|
36
|
+
policyWorkflow: exists(join(projectDir, ".github", "workflows", "kj-policy.yml")),
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -27,6 +27,7 @@ import { huCommand } from "../commands/hu.js";
|
|
|
27
27
|
import { worktreeCommand } from "../commands/worktree.js";
|
|
28
28
|
import { addAdr, listAdrs } from "../environment/adr.js";
|
|
29
29
|
import { formatAdvancedIndex } from "../commands/advanced.js";
|
|
30
|
+
import { policyAddCommand } from "../policy/add.js";
|
|
30
31
|
import { withConfig } from "./_shared.js";
|
|
31
32
|
import { existsSync } from "node:fs";
|
|
32
33
|
import { join } from "node:path";
|
|
@@ -285,7 +286,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
285
286
|
});
|
|
286
287
|
|
|
287
288
|
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
288
|
-
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado) —
|
|
289
|
+
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
|
|
289
290
|
policyCmd.command("eval")
|
|
290
291
|
.description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
|
|
291
292
|
.option("--role <role>", "Rol del agente", "coder")
|
|
@@ -298,9 +299,39 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
298
299
|
process.exitCode = await policyCommand({ action: "eval", config, flags });
|
|
299
300
|
});
|
|
300
301
|
});
|
|
302
|
+
policyCmd.command("grant")
|
|
303
|
+
.description("Concede una excepción PERMANENTE con caducidad a una regla NO-security: quién (identidad), regla exacta, justificación y hasta cuándo — GOV-B")
|
|
304
|
+
.option("--rule <rule_id>", "Regla exacta (p.ej. roles.coder.write.deny)")
|
|
305
|
+
.option("--until <iso>", "Caducidad ISO-8601 (p.ej. 2026-09-01T00:00:00Z)")
|
|
306
|
+
.option("--reason <text>", "Justificación escrita en el momento")
|
|
307
|
+
.action(async (flags) => {
|
|
308
|
+
await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
|
|
309
|
+
const { policyCommand } = await import("../commands/policy.js");
|
|
310
|
+
process.exitCode = await policyCommand({ action: "grant", config, flags });
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
policyCmd.command("add")
|
|
314
|
+
.description("Traduce una regla hablada al vocabulario cerrado de policy.yml — propone el diff y SOLO escribe con --yes (PL-C)")
|
|
315
|
+
.argument("<text...>", "La regla en lenguaje natural")
|
|
316
|
+
.option("--yes", "Aplicar el diff propuesto")
|
|
317
|
+
.action(async (textParts, flags) => {
|
|
318
|
+
await withConfig(pkgVersion, "policy-add", flags, async ({ config, logger }) => {
|
|
319
|
+
process.exitCode = await policyAddCommand({ text: textParts.join(" "), config, flags, logger });
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
policyCmd.command("anchor")
|
|
323
|
+
.description("Verifica la cadena del decision log y sella su head-hash en .karajan/policy-anchor.json (trackeado) — anclaje temporal en la historia de git, GOV-C2")
|
|
324
|
+
.action(async (flags) => {
|
|
325
|
+
await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
|
|
326
|
+
const { policyCommand } = await import("../commands/policy.js");
|
|
327
|
+
process.exitCode = await policyCommand({ action: "anchor", config, flags });
|
|
328
|
+
});
|
|
329
|
+
});
|
|
301
330
|
policyCmd.command("check")
|
|
302
|
-
.description("Comprueba el diff
|
|
331
|
+
.description("Comprueba el diff (staged, o base...head con --range) contra la policy — warn por defecto; --strict devuelve exit 2 si hay violación enforcement=deny (tier C, merge-blocking)")
|
|
303
332
|
.option("--role <role>", "Rol del agente", "coder")
|
|
333
|
+
.option("--range <ref>", "Evalúa un rango git (p.ej. origin/main...HEAD) en vez del staged (CI)")
|
|
334
|
+
.option("--strict", "Exit 2 si hay violación con enforcement=deny")
|
|
304
335
|
.option("--json", "Machine-readable")
|
|
305
336
|
.action(async (flags) => {
|
|
306
337
|
await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { rolesCommand } from "../commands/roles.js";
|
|
2
|
-
import { agentsCommand } from "../commands/agents.js";
|
|
2
|
+
import { agentsCommand, ASSIGNABLE_ROLES, VALID_PROVIDERS } from "../commands/agents.js";
|
|
3
3
|
import { withConfig } from "./_shared.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -30,6 +30,22 @@ export function registerRolesSkills(program, { pkgVersion }) {
|
|
|
30
30
|
.command("agents [subcommand] [role] [provider]")
|
|
31
31
|
.description("List or change AI agent assignments per role (e.g. kj agents set coder gemini)")
|
|
32
32
|
.option("--global", "Persist change to kj.config.yml (default for CLI)")
|
|
33
|
+
// KJC-TSK-0755: el --help enumera valores REALES (de las constantes, no
|
|
34
|
+
// duplicados a mano) — la firma genérica obligaba a preguntar.
|
|
35
|
+
.addHelpText("after", [
|
|
36
|
+
"",
|
|
37
|
+
"Subcommands:",
|
|
38
|
+
" list (default) Show the provider assigned to each role",
|
|
39
|
+
" set Change one: kj agents set <role> <provider>",
|
|
40
|
+
"",
|
|
41
|
+
`Roles: ${ASSIGNABLE_ROLES.join(", ")}`,
|
|
42
|
+
`Providers: ${VALID_PROVIDERS.join(", ")} (kj doctor shows which are installed)`,
|
|
43
|
+
"",
|
|
44
|
+
"Examples:",
|
|
45
|
+
" kj agents # current assignments",
|
|
46
|
+
" kj agents set reviewer codex",
|
|
47
|
+
" kj agents set solomon agy --global",
|
|
48
|
+
].join("\n"))
|
|
33
49
|
.action(async (subcommand, role, provider, flags) => {
|
|
34
50
|
await withConfig(pkgVersion, "agents", {}, async ({ config }) => {
|
|
35
51
|
await agentsCommand({ config, subcommand: subcommand || "list", role, provider, global: flags.global });
|
package/src/commands/agents.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { loadConfig, writeConfig, getConfigPath, getProjectConfigPath, loadProjectConfig, resolveRole } from "../config.js";
|
|
2
2
|
import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Exportados para que el --help se autoalimente de las constantes reales
|
|
5
|
+
// (KJC-TSK-0755, hallazgo de campo: la firma genérica obligaba a preguntar).
|
|
6
|
+
export const ASSIGNABLE_ROLES = [
|
|
5
7
|
"coder", "reviewer", "planner", "refactorer", "triage",
|
|
6
8
|
"researcher", "tester", "security", "solomon"
|
|
7
9
|
];
|
|
8
10
|
|
|
9
|
-
const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
|
|
11
|
+
export const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
|
|
10
12
|
|
|
11
13
|
export function listAgents(config, sessionOverrides = {}, projectConfig = null) {
|
|
12
14
|
return ASSIGNABLE_ROLES.map((role) => {
|
package/src/commands/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import readline from "node:readline";
|
|
15
15
|
import { runChecks as runCheckPipeline, toLegacyShape } from "../checks/runner.js";
|
|
16
|
+
import { collectGuaranteeFacts } from "../checks/guarantee-level.js";
|
|
16
17
|
import { STATUS } from "../checks/types.js";
|
|
17
18
|
import { getSystemChecks } from "../checks/system.js";
|
|
18
19
|
import { getConfigFileChecks } from "../checks/config-files.js";
|
|
@@ -140,16 +141,31 @@ export async function runChecks({ config }) {
|
|
|
140
141
|
*/
|
|
141
142
|
export async function doctorCommand({ config, checkOnly = false, yes = false, json = false, verbose = false, projectOnly = false }) {
|
|
142
143
|
const report = await runDoctor({ config, checkOnly, yes, projectOnly });
|
|
144
|
+
// KJC-TSK-0756 (campo, Pedro): el guarantee level del host actual se
|
|
145
|
+
// DECLARA en runtime — la doctrina no vive solo en el README.
|
|
146
|
+
report.guarantee = collectGuaranteeFacts({ projectDir: config?.projectDir || process.cwd() });
|
|
143
147
|
|
|
144
148
|
if (json) {
|
|
145
149
|
console.log(JSON.stringify(report, null, 2));
|
|
146
150
|
} else {
|
|
147
151
|
printHuman(report, { verbose });
|
|
152
|
+
printGuaranteeLevel(report.guarantee);
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
return hasBlockingFailure(report) ? 1 : 0;
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
// KJC-TSK-0756: la seccion de guarantee level — consola es el medio del
|
|
159
|
+
// doctor (excepcion no-console de commands).
|
|
160
|
+
function printGuaranteeLevel(g) {
|
|
161
|
+
const mark = (t) => (t.active ? "ACTIVE " : "OFF ");
|
|
162
|
+
console.log();
|
|
163
|
+
console.log(`Guarantee level (host: ${g.host ?? "desconocido"}) — kj jamas finge una supervision que no puede aplicar:`);
|
|
164
|
+
console.log(` ${mark(g.tierA)} A tool-time (Sentinel en el turno): ${g.tierA.reason}`);
|
|
165
|
+
console.log(` ${mark(g.tierB)} B commit-time (git gates, el SUELO): ${g.tierB.reason}`);
|
|
166
|
+
console.log(` ${mark(g.tierC)} C merge-time (re-check en CI): ${g.tierC.reason}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
153
169
|
function hasBlockingFailure(report) {
|
|
154
170
|
return report.checks.some((c) => c.status === STATUS.FAIL || c.status === STATUS.TIMEOUT);
|
|
155
171
|
}
|
package/src/commands/policy.js
CHANGED
|
@@ -8,20 +8,28 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { execFile } from "node:child_process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
11
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
+
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
|
+
import { verifyDecisionChain } from "@karajan-family/governance";
|
|
12
17
|
|
|
13
18
|
const execFileAsync = promisify(execFile);
|
|
14
19
|
|
|
15
|
-
async function stagedFacts(projectDir, gitFn) {
|
|
20
|
+
async function stagedFacts(projectDir, gitFn, range = null) {
|
|
16
21
|
const run =
|
|
17
22
|
gitFn ||
|
|
18
23
|
(async (args) => (await execFileAsync("git", args, { cwd: projectDir, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
|
19
|
-
|
|
24
|
+
// PL-C (KJC-TSK-0735): en CI no hay staged — con --range se evalúa
|
|
25
|
+
// base...head, el MISMO motor sobre el diff del PR (tier C del ADR 0001).
|
|
26
|
+
const base = range ? ["diff", range] : ["diff", "--cached"];
|
|
27
|
+
const files = (await run([...base, "--name-only"]))
|
|
20
28
|
.split("\n")
|
|
21
29
|
.map((s) => s.trim())
|
|
22
30
|
.filter(Boolean);
|
|
23
31
|
let net = 0;
|
|
24
|
-
for (const line of (await run([
|
|
32
|
+
for (const line of (await run([...base, "--numstat"])).split("\n")) {
|
|
25
33
|
const [a, r] = line.trim().split(/\s+/);
|
|
26
34
|
if (a && a !== "-") net += Number(a) || 0;
|
|
27
35
|
if (r && r !== "-") net -= Number(r) || 0;
|
|
@@ -38,6 +46,83 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
38
46
|
return 1;
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
// GOV-C2 (KJC-TSK-0749): anclaje temporal sin blockchain — verificar la
|
|
50
|
+
// cadena ENTERA y sellar su head-hash en un fichero TRACKEADO por git:
|
|
51
|
+
// reescribir el log de ayer exige reescribir el repo de ayer. Un anchor
|
|
52
|
+
// previo con más entradas que el log actual = truncado ⇒ no se re-sella.
|
|
53
|
+
if (action === "anchor") {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8");
|
|
57
|
+
} catch {
|
|
58
|
+
logger.info?.("policy anchor: sin decisiones que anclar — el log nace con el primer deny/excepción/commit-allow");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
62
|
+
// Fichero vacío o solo whitespace = sin decisiones (catch de codex:
|
|
63
|
+
// lines.at(-1) undefined reventaba el hash en vez de salir limpio).
|
|
64
|
+
if (lines.length === 0) {
|
|
65
|
+
logger.info?.("policy anchor: sin decisiones que anclar — el log nace con el primer deny/excepción/commit-allow");
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const chain = verifyDecisionChain(lines);
|
|
69
|
+
if (!chain.ok) {
|
|
70
|
+
logger.error?.(`✗ policy anchor: cadena rota en la entrada ${chain.at} (${chain.reason}) — el log ha sido manipulado; NO se sella`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
const anchorFile = join(projectDir, ".karajan", "policy-anchor.json");
|
|
74
|
+
try {
|
|
75
|
+
const prev = JSON.parse(readFileSync(anchorFile, "utf8"));
|
|
76
|
+
if (Number.isFinite(prev.length) && prev.length > chain.length) {
|
|
77
|
+
logger.error?.(`✗ policy anchor: el log retrocede (anchor previo=${prev.length} entradas, actual=${chain.length}) — truncado tras el último sello; NO se re-sella`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
} catch { /* sin anchor previo (o ilegible): primer sello */ }
|
|
81
|
+
const head = createHash("sha256").update(lines.at(-1), "utf8").digest("hex");
|
|
82
|
+
writeFileSync(anchorFile, `${JSON.stringify({ head, length: chain.length, ts: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
83
|
+
logger.info?.(`✓ policy anchor: cadena íntegra (${chain.length} decisiones) — head ${head.slice(0, 12)} sellado en .karajan/policy-anchor.json; committéalo para anclarlo en la historia de git`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// GOV-B (KJC-TSK-0746): conceder una excepción PERMANENTE con el modelo
|
|
88
|
+
// probatorio completo — quién (identidad), regla exacta, justificación en
|
|
89
|
+
// el momento y caducidad obligatoria. Los defaults.* del consumidor son
|
|
90
|
+
// inexcepcionables: no se conceden ni desde aquí.
|
|
91
|
+
if (action === "grant") {
|
|
92
|
+
const { rule, until, reason } = flags;
|
|
93
|
+
if (!rule || !until || !reason?.trim()) {
|
|
94
|
+
logger.error?.("policy grant: --rule, --until (ISO) y --reason son obligatorios — una excepción sin quién/por qué/hasta cuándo no es una excepción, es un agujero");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
// Inexcepcionable = inexcepcionable TAMBIÉN al conceder (catch de codex):
|
|
98
|
+
// defaults.*, cualquier cap con class security, y una policy inválida
|
|
99
|
+
// (sin policy legible no se puede probar que la regla NO es security).
|
|
100
|
+
const m = /^roles\.([^.]+)\.(write|shell)/.exec(rule);
|
|
101
|
+
if (rule.startsWith("defaults.") || errors.length > 0 || (m && policy.roles?.[m[1]]?.[m[2]]?.class === "security")) {
|
|
102
|
+
logger.error?.(`policy grant: "${rule}" es inexcepcionable (default del proyecto o clase security) o la policy no es verificable — no se concede, ni con razón`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
// GOV-E (KJC-TSK-0750, idea del lector): la renovación es SEÑAL — una
|
|
107
|
+
// regla re-concedida N veces es la política real pidiendo que la
|
|
108
|
+
// cambien por su cauce. Se cuenta contra TODAS las permanentes previas
|
|
109
|
+
// (vencidas incluidas: precisamente esas son la sedimentación).
|
|
110
|
+
const previous = loadStandingExceptions(projectDir).standing.filter((e) => e.rule_id === rule).length;
|
|
111
|
+
const rec = recordPolicyException({
|
|
112
|
+
projectDir,
|
|
113
|
+
entry: { rule_id: rule, justification: reason.trim(), scopeKind: "permanente", expiresAt: until },
|
|
114
|
+
});
|
|
115
|
+
logger.info?.(`✓ excepción permanente registrada: [${rec.rule_id}] hasta ${rec.expiresAt} — concedida por ${rec.who?.git ?? "?"} (${rec.who?.grade ?? "?"})`);
|
|
116
|
+
if (previous >= 1) {
|
|
117
|
+
logger.warn?.(`⚠ ${previous + 1}ª concesión sobre esta regla — una excepción que se renueva ya no es una excepción: considera cambiar la política por su cauce (PR a .karajan/policy.yml)`);
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
logger.error?.(`policy grant: ${err.message}`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
41
126
|
if (action === "eval") {
|
|
42
127
|
let input;
|
|
43
128
|
try {
|
|
@@ -51,20 +136,29 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
51
136
|
return verdict.decision === "deny" && flags.strict ? 2 : 0;
|
|
52
137
|
}
|
|
53
138
|
|
|
54
|
-
// check —
|
|
55
|
-
|
|
139
|
+
// check — staged por defecto, base...head con --range (CI). Warn salvo
|
|
140
|
+
// --strict (PL-C): con --strict, una violación enforcement=deny devuelve
|
|
141
|
+
// exit 2 nombrando la regla — el contrato merge-blocking del tier C.
|
|
142
|
+
const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
|
|
56
143
|
const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
|
|
144
|
+
const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
|
|
57
145
|
if (flags.json) {
|
|
58
|
-
logger.info?.(JSON.stringify({ mode: "warn", violations }));
|
|
59
|
-
return 0;
|
|
146
|
+
logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
|
|
147
|
+
return hard.length > 0 ? 2 : 0;
|
|
60
148
|
}
|
|
61
149
|
if (violations.length === 0) {
|
|
62
150
|
logger.info?.("policy check: limpio");
|
|
63
151
|
return 0;
|
|
64
152
|
}
|
|
65
153
|
for (const v of violations) {
|
|
66
|
-
|
|
154
|
+
const where = v.file ? ` (${v.file})` : "";
|
|
155
|
+
const mark = flags.strict && v.enforcement === "deny" ? "✗" : "⚠";
|
|
156
|
+
logger.warn?.(`${mark} policy [${v.rule_id}] ${v.reason}${where}`);
|
|
157
|
+
}
|
|
158
|
+
if (hard.length > 0) {
|
|
159
|
+
logger.error?.(`policy check: ${hard.length} violación(es) con enforcement=deny — el merge no procede (--strict)`);
|
|
160
|
+
return 2;
|
|
67
161
|
}
|
|
68
|
-
logger.warn?.(`policy check: ${violations.length} aviso(s) — modo warn
|
|
162
|
+
logger.warn?.(`policy check: ${violations.length} aviso(s) — check es modo warn, no bloquea; los deny los aplican kj review --staged y el pre-commit (PL-B)`);
|
|
69
163
|
return 0;
|
|
70
164
|
}
|