karajan-code 4.12.0 → 4.14.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.
@@ -12,8 +12,6 @@
12
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
- const SCRIPT_REL = join(".karajan", "harness", "pretooluse.mjs");
16
-
17
15
  const SCRIPT_BODY = `#!/usr/bin/env node
18
16
  // kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
19
17
  // tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
@@ -43,38 +41,67 @@ process.stdin.on("end", () => {
43
41
  });
44
42
  `;
45
43
 
46
- const hookEntry = (matcher) => ({ matcher, hooks: [{ type: "command", command: `node ${SCRIPT_REL}` }] });
47
-
48
- /** Write the script and merge the PreToolUse entries into .claude/settings.json. */
49
- export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
50
- const scriptAbs = join(projectDir, SCRIPT_REL);
51
- mkdirSync(join(projectDir, ".karajan", "harness"), { recursive: true });
52
- writeFileSync(scriptAbs, SCRIPT_BODY, { mode: 0o755 });
44
+ /** Write a managed hook script under .karajan/harness/ and return its path. */
45
+ export function writeHarnessScript(projectDir, name, body) {
46
+ const dir = join(projectDir, ".karajan", "harness");
47
+ mkdirSync(dir, { recursive: true });
48
+ const abs = join(dir, name);
49
+ writeFileSync(abs, body, { mode: 0o755 });
50
+ return abs;
51
+ }
53
52
 
53
+ /**
54
+ * Merge hook entries into .claude/settings.json. Preserve, never clobber:
55
+ * invalid JSON or a hook event with an unexpected (non-array) shape is the
56
+ * user's business — the file is left alone and the caller wires manually.
57
+ * entries: [{ event, matcher?, script }] where script is a .karajan/harness
58
+ * basename; an entry is present when that script already appears under the
59
+ * same event (and matcher, when given).
60
+ */
61
+ export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
54
62
  const settingsPath = join(projectDir, ".claude", "settings.json");
55
63
  let settings = {};
56
64
  if (existsSync(settingsPath)) {
57
65
  try {
58
66
  settings = JSON.parse(readFileSync(settingsPath, "utf8"));
59
67
  } catch {
60
- logger.warn?.(`kj harden: ${settingsPath} is not valid JSON — leaving it untouched (tool gate script written, wire it manually)`);
61
- return { script: scriptAbs, wired: false };
68
+ logger.warn?.(`kj harden: ${settingsPath} is not valid JSON — leaving it untouched (hook scripts written, wire them manually)`);
69
+ return { wired: false };
62
70
  }
63
71
  }
64
72
  settings.hooks = settings.hooks || {};
65
- // Preserve, never clobber: an existing PreToolUse with an unexpected shape
66
- // is the user's business — leave the file alone (same as invalid JSON).
67
- if ("PreToolUse" in settings.hooks && !Array.isArray(settings.hooks.PreToolUse)) {
68
- logger.warn?.(`kj harden: ${settingsPath} has a non-array hooks.PreToolUse — leaving it untouched (tool gate script written, wire it manually)`);
69
- return { script: scriptAbs, wired: false };
73
+ for (const { event } of entries) {
74
+ if (event in settings.hooks && !Array.isArray(settings.hooks[event])) {
75
+ logger.warn?.(`kj harden: ${settingsPath} has a non-array hooks.${event} — leaving it untouched (hook scripts written, wire them manually)`);
76
+ return { wired: false };
77
+ }
70
78
  }
71
- const pre = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
72
- for (const matcher of ["Write", "Bash"]) {
73
- const present = pre.some((e) => e?.matcher === matcher && JSON.stringify(e).includes("pretooluse.mjs"));
74
- if (!present) pre.push(hookEntry(matcher));
79
+ for (const { event, matcher, script } of entries) {
80
+ const list = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
81
+ const present = list.some(
82
+ (e) => JSON.stringify(e).includes(script) && (matcher === undefined || e?.matcher === matcher),
83
+ );
84
+ if (!present) {
85
+ const cmd = { type: "command", command: `node ${join(".karajan", "harness", script)}` };
86
+ list.push(matcher === undefined ? { hooks: [cmd] } : { matcher, hooks: [cmd] });
87
+ }
88
+ settings.hooks[event] = list;
75
89
  }
76
- settings.hooks.PreToolUse = pre;
77
90
  mkdirSync(join(projectDir, ".claude"), { recursive: true });
78
91
  writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
79
- return { script: scriptAbs, wired: true };
92
+ return { wired: true };
93
+ }
94
+
95
+ /** Write the script and merge the PreToolUse entries into .claude/settings.json. */
96
+ export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
97
+ const scriptAbs = writeHarnessScript(projectDir, "pretooluse.mjs", SCRIPT_BODY);
98
+ const { wired } = mergeClaudeHooks({
99
+ projectDir,
100
+ logger,
101
+ entries: [
102
+ { event: "PreToolUse", matcher: "Write", script: "pretooluse.mjs" },
103
+ { event: "PreToolUse", matcher: "Bash", script: "pretooluse.mjs" },
104
+ ],
105
+ });
106
+ return { script: scriptAbs, wired };
80
107
  }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * sentinel-hooks — SEN-A (KJC-TSK-0713, epic KJC-PCS-0071 Karajan Sentinel).
3
+ * v3 had authority without intelligence; v4 intelligence without authority.
4
+ * The Sentinel separates them: a deterministic PROGRAM (zero LLM) supervises
5
+ * the agent through the harness's synchronous hooks — PostToolUse records
6
+ * method facts per session, Stop BLOCKS ending the turn while violations are
7
+ * open. Claude Code only: it is the one harness with synchronous blocking
8
+ * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
13
+ import { join } from "node:path";
14
+ import { CARD_REF_RE } from "../review/card-first.js";
15
+ import { mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
16
+
17
+ /** The harness lives at the PROJECT root — resolve it even from a subdir. */
18
+ export function resolveSentinelRoot(dir = process.cwd()) {
19
+ try {
20
+ return (
21
+ execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir
22
+ );
23
+ } catch {
24
+ return dir;
25
+ }
26
+ }
27
+
28
+ const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
29
+ // Single source for every sentinel script: state, branch, classification,
30
+ // violations, and escape recording.
31
+ import { readFileSync, writeFileSync } from "node:fs";
32
+ import { execSync } from "node:child_process";
33
+ import { dirname, join } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ export const STATE = join(here, "sentinel-state.json");
37
+ export const ROOT = join(here, "..", "..");
38
+ export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$/;
39
+ export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp)$/;
40
+ export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
41
+ export const BASE_BRANCHES = new Set(["main", "master"]);
42
+ export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
43
+ export const save = (state) => writeFileSync(STATE, JSON.stringify(state, null, 2));
44
+ export const session = (state, sid) => {
45
+ state.sessions ||= {};
46
+ return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
47
+ };
48
+ export const branchOf = () => {
49
+ try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
50
+ catch { return null; }
51
+ };
52
+ export const violations = (s, branch) => {
53
+ const v = [];
54
+ if (!s || !(s.edited_sources || []).length) return v;
55
+ if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
56
+ else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
57
+ if (!(s.edited_tests || []).length) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
58
+ return v;
59
+ };
60
+ export const recordEscape = (sid, escape, tool) => {
61
+ const state = load();
62
+ const s = session(state, sid);
63
+ s.at = Date.now();
64
+ if (!s.escapes.includes(escape)) s.escapes.push(escape);
65
+ (state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
66
+ save(state);
67
+ };
68
+ `;
69
+
70
+ const POST_BODY = `#!/usr/bin/env node
71
+ // kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
72
+ // Records deterministic method facts per session; never blocks, never fails
73
+ // a tool call (PostToolUse, always exit 0).
74
+ import { relative } from "node:path";
75
+ import { CODE, TESTS, ROOT, load, save, session } from "./sentinel-lib.mjs";
76
+ const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII"];
77
+ let raw = "";
78
+ process.stdin.on("data", (d) => { raw += d; });
79
+ process.stdin.on("end", () => {
80
+ try {
81
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
82
+ const file = input.file_path || input.notebook_path;
83
+ if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
84
+ const state = load();
85
+ const s = session(state, sid);
86
+ s.at = Date.now();
87
+ const rel = relative(ROOT, file).replaceAll("\\\\", "/");
88
+ const bucket = TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
89
+ if (bucket && !bucket.includes(rel)) bucket.push(rel);
90
+ for (const e of ESCAPES)
91
+ if (process.env[e] === "1" && !s.escapes.includes(e)) {
92
+ s.escapes.push(e);
93
+ (state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
94
+ }
95
+ const ids = Object.keys(state.sessions);
96
+ if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
97
+ save(state);
98
+ } catch { /* fail open — the sentinel never breaks a tool call */ }
99
+ process.exit(0);
100
+ });
101
+ `;
102
+
103
+ const STOP_BODY = `#!/usr/bin/env node
104
+ // kj sentinel stop gate (KJC-TSK-0713) — managed by \`kj harden\`. Exit 2
105
+ // BLOCKS ending the turn while method violations are open (stderr lists each
106
+ // one with its remediation). Fails OPEN on corrupt state, git errors, or
107
+ // after 3 unresolved blocks — a sentinel bug never bricks the session — and
108
+ // the fail-open is recorded in the state. \`--status\` prints, never blocks.
109
+ import { spawnSync } from "node:child_process";
110
+ import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
111
+ if (process.argv.includes("--status")) {
112
+ const st = load();
113
+ const branch = branchOf();
114
+ const sessions = Object.entries(st.sessions || {});
115
+ if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
116
+ for (const [sid, s] of sessions) {
117
+ console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : ""));
118
+ for (const x of violations(s, branch)) console.log(" ROJO: " + x);
119
+ }
120
+ process.exit(0);
121
+ }
122
+ let raw = "";
123
+ process.stdin.on("data", (d) => { raw += d; });
124
+ process.stdin.on("end", () => {
125
+ try {
126
+ if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
127
+ const { session_id: sid = "default" } = JSON.parse(raw);
128
+ const state = load();
129
+ // Tamper check runs regardless of session state: shell indirection leaves
130
+ // no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
131
+ // root of trust is the INSTALLED kj package (outside this project tree) —
132
+ // nothing under .karajan can vouch for itself. No kj on PATH → fail open.
133
+ const ver = spawnSync("kj", ["sentinel", "verify", "--json"], { cwd: ROOT, encoding: "utf8" });
134
+ let tampered = [];
135
+ if (!ver.error && ver.status !== null && ver.status !== 0) {
136
+ try { tampered = JSON.parse(ver.stdout).mismatched || ["unknown"]; } catch { tampered = ["unknown"]; }
137
+ }
138
+ if (tampered.length) {
139
+ state.tamper_blocks = (state.tamper_blocks || 0) + 1;
140
+ save(state);
141
+ if (state.tamper_blocks > 3) {
142
+ console.error("kj sentinel: fail-open tras 3 bloqueos por manipulacion sin restaurar — corre kj harden y avisa a tu usuario");
143
+ process.exit(0);
144
+ }
145
+ console.error("kj sentinel: scripts del supervisor modificados fuera de kj harden (" + tampered.join(", ") + ") — restaura con kj harden antes de terminar; si lo cambiaste tu (humano), reinstalar lo deja en verde.");
146
+ process.exit(2);
147
+ }
148
+ state.tamper_blocks = 0;
149
+ const s = state.sessions?.[sid];
150
+ if (!s) process.exit(0);
151
+ const v = violations(s, branchOf());
152
+ if (!v.length) {
153
+ s.blocks = 0;
154
+ save(state);
155
+ if ((s.escapes || []).length)
156
+ console.log(JSON.stringify({ systemMessage: "kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status." }));
157
+ process.exit(0);
158
+ }
159
+ s.blocks = (s.blocks || 0) + 1;
160
+ if (s.blocks > 3) {
161
+ (s.errors ||= []).push("fail-open: 3 bloqueos consecutivos sin resolver — el sentinel se aparta para no colgar la sesion");
162
+ save(state);
163
+ console.error("kj sentinel: fail-open tras 3 bloqueos sin resolver — revisa kj sentinel status con tu usuario");
164
+ process.exit(0);
165
+ }
166
+ save(state);
167
+ console.error("kj sentinel: el turno NO puede terminar con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve las violaciones (o pide a tu usuario el escape) y termina de nuevo. Estado: kj sentinel status");
168
+ process.exit(2);
169
+ } catch { /* fail open */ }
170
+ process.exit(0);
171
+ });
172
+ `;
173
+
174
+ const PRETOOL_BODY = `#!/usr/bin/env node
175
+ // kj sentinel pretooluse gate (KJC-TSK-0714) — managed by \`kj harden\`.
176
+ // Consults the session method state BEFORE the tool runs: the rule fires
177
+ // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
178
+ // remediation); read-only tools are never wired here; every honored escape
179
+ // is recorded as an auditable event. Fails OPEN on anything unexpected.
180
+ import { relative } from "node:path";
181
+ import { spawnSync } from "node:child_process";
182
+ import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, load, violations, recordEscape } from "./sentinel-lib.mjs";
183
+ const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
184
+ const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
185
+ const PUSH = /\\bgit\\s+push\\b/;
186
+ const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
187
+ let raw = "";
188
+ process.stdin.on("data", (d) => { raw += d; });
189
+ process.stdin.on("end", () => {
190
+ try {
191
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
192
+ // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
193
+ // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
194
+ // only the human, editing outside it.
195
+ if (EDIT_TOOLS.includes(tool)) {
196
+ const target = input.file_path || input.notebook_path;
197
+ const relT = target ? relative(ROOT, String(target)).replaceAll("\\\\", "/") : "";
198
+ if (relT && PROTECTED.test(relT)) {
199
+ console.error("kj sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion.");
200
+ process.exit(2);
201
+ }
202
+ }
203
+ // Any Bash that NAMES the supervisor's files is denied — a write-verb
204
+ // blocklist is bypassable (cp, dd, one-liners), and reading them is what
205
+ // the Read/Grep tools are for. Shell indirection (variables, globs,
206
+ // substitution) cannot be resolved from command text: that vector is
207
+ // caught by the Stop gate's checksum verification, which blocks the turn.
208
+ if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\\\", "/"))) {
209
+ console.error("kj sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion.");
210
+ process.exit(2);
211
+ }
212
+ if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
213
+ if (EDIT_TOOLS.includes(tool)) {
214
+ const file = input.file_path || input.notebook_path;
215
+ const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
216
+ if (rel && CODE.test(rel) && !TESTS.test(rel)) {
217
+ const branch = branchOf();
218
+ const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
219
+ if (why) {
220
+ if (process.env.KJ_ALLOW_NO_CARD === "1") { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
221
+ console.error(why === "base"
222
+ ? "kj sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)"
223
+ : "kj sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)");
224
+ process.exit(2);
225
+ }
226
+ }
227
+ }
228
+ if (tool === "Bash") {
229
+ const cmd = String(input.command || "");
230
+ if (PUBLISH.test(cmd)) {
231
+ if (process.env.KJ_ALLOW_RELEASE === "1") { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
232
+ const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
233
+ if (res.error || res.status === null) process.exit(0);
234
+ if (res.status !== 0) {
235
+ let items = "";
236
+ try { items = (JSON.parse(res.stdout).checks || []).filter((c) => !c.ok).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
237
+ console.error("kj sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)");
238
+ process.exit(2);
239
+ }
240
+ } else if (PUSH.test(cmd)) {
241
+ const v = violations(load().sessions?.[sid], branchOf());
242
+ if (v.length) {
243
+ console.error("kj sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status");
244
+ process.exit(2);
245
+ }
246
+ }
247
+ }
248
+ } catch { /* fail open */ }
249
+ process.exit(0);
250
+ });
251
+ `;
252
+
253
+ const SCRIPT_BODIES = {
254
+ "sentinel-lib.mjs": LIB_BODY,
255
+ "posttooluse.mjs": POST_BODY,
256
+ "stop.mjs": STOP_BODY,
257
+ "pretooluse-sentinel.mjs": PRETOOL_BODY,
258
+ };
259
+
260
+ /**
261
+ * Tamper detection (KJC-TSK-0715): compare the on-disk harness scripts with
262
+ * what THIS kj install would write. The root of trust is the installed kj
263
+ * package — outside the project tree, beyond the session's tool reach —
264
+ * because nothing under .karajan can vouch for itself.
265
+ */
266
+ export function verifySentinelScripts({ projectDir } = {}) {
267
+ const dir = join(projectDir || resolveSentinelRoot(), ".karajan", "harness");
268
+ const mismatched = [];
269
+ for (const [name, body] of Object.entries(SCRIPT_BODIES)) {
270
+ try {
271
+ if (readFileSync(join(dir, name), "utf8") !== body) mismatched.push(name);
272
+ } catch {
273
+ mismatched.push(name);
274
+ }
275
+ }
276
+ return { ok: mismatched.length === 0, mismatched };
277
+ }
278
+
279
+ /** Write the sentinel scripts (shared lib + state writer + gates) and wire them. */
280
+ export function installSentinelHooks({ projectDir = process.cwd(), logger = console } = {}) {
281
+ const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) =>
282
+ writeHarnessScript(projectDir, name, body),
283
+ );
284
+ const { wired } = mergeClaudeHooks({
285
+ projectDir,
286
+ logger,
287
+ entries: [
288
+ { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
289
+ { event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
290
+ { event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
291
+ { event: "Stop", script: "stop.mjs" },
292
+ ],
293
+ });
294
+ return { scripts: [lib, post, stop, pre], wired };
295
+ }
@@ -16,6 +16,7 @@ import { parseMaybeJsonString } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
17
  import { saveVerdict } from "./verdict-store.js";
18
18
  import { detectWorkspace } from "./workspace.js";
19
+ import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
19
20
 
20
21
  // Cross-AI preference when the configured reviewer IS the host.
21
22
  const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
@@ -44,6 +45,7 @@ export async function runOneShotReview({
44
45
  hostAgent = detectHostAgent(),
45
46
  createAgentFn = createAgent,
46
47
  detectAgents = detectAvailableAgents,
48
+ candidateStatusFn = candidateStatus,
47
49
  }) {
48
50
  if (!diff || !diff.trim()) {
49
51
  throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
@@ -57,26 +59,58 @@ export async function runOneShotReview({
57
59
  }
58
60
 
59
61
  const { rules } = await resolveReviewProfile({ mode: "standard", projectDir });
60
- const prompt = await buildReviewerPrompt({
61
- task: task || "Review the following diff for correctness, security and maintainability.",
62
- diff, reviewRules: rules, mode: "standard", provider: reviewer, projectDir,
63
- });
62
+ const attempt = async (who, cfg = config) => {
63
+ const prompt = await buildReviewerPrompt({
64
+ task: task || "Review the following diff for correctness, security and maintainability.",
65
+ diff, reviewRules: rules, mode: "standard", provider: who, projectDir,
66
+ });
67
+ logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)`);
68
+ return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
69
+ };
70
+
71
+ let activeReviewer = reviewer;
72
+ let result = await attempt(activeReviewer);
73
+
74
+ // KJC-TSK-0730 — quota failover: exhausted quota is not a review failure,
75
+ // it is a provider outage. One retry with an authenticated candidate
76
+ // (loud notice, never silent), or an actionable menu. Never the host:
77
+ // the brain does not review itself.
78
+ if (!result?.ok && isQuotaExhausted(result?.error) && (config?.reviewer_options?.auto_fallback ?? true)) {
79
+ const statuses = await candidateStatusFn();
80
+ const fallback = pickQuotaFallback(statuses, { exclude: [activeReviewer, hostAgent].filter(Boolean) });
81
+ if (fallback) {
82
+ logger?.warn?.(
83
+ `⚠ reviewer ${activeReviewer} sin cuota → usando ${fallback} SOLO en esta invocación. ` +
84
+ `Para fijarlo: roles.reviewer.provider: ${fallback} (avisando, nunca en silencio — KJC-TSK-0730).`
85
+ );
86
+ activeReviewer = fallback;
87
+ // The role's model pin belongs to the exhausted provider (a codex
88
+ // model name means nothing to copilot) — the candidate runs on its
89
+ // own default model.
90
+ result = await attempt(activeReviewer, {
91
+ ...config,
92
+ roles: { ...config?.roles, reviewer: { ...config?.roles?.reviewer, model: null } },
93
+ });
94
+ } else {
95
+ throw new Error(
96
+ `reviewer ${activeReviewer} sin cuota y ningún candidato autenticado con adaptador.\n` +
97
+ formatCandidateMenu(statuses, { exclude: [hostAgent].filter(Boolean) })
98
+ );
99
+ }
100
+ }
64
101
 
65
- logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${reviewer} (cross-AI)`);
66
- const agent = createAgentFn(reviewer, config, logger);
67
- const result = await agent.reviewTask({ prompt, role: "reviewer" });
68
102
  if (!result?.ok) {
69
- throw new Error(`reviewer ${reviewer} failed: ${result?.error || "no output"}`);
103
+ throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
70
104
  }
71
105
 
72
106
  const parsed = parseMaybeJsonString(result.output);
73
107
  if (!parsed || typeof parsed.approved !== "boolean") {
74
- throw new Error(`reviewer ${reviewer} returned no parseable verdict`);
108
+ throw new Error(`reviewer ${activeReviewer} returned no parseable verdict`);
75
109
  }
76
110
 
77
111
  return saveVerdict(projectDir, diff, {
78
112
  verdict: parsed.approved ? "approved" : "rejected",
79
- reviewer,
113
+ reviewer: activeReviewer,
80
114
  host: hostAgent || null,
81
115
  // KJC-TSK-0680: where the review ran — makes any isolation claim auditable.
82
116
  workspace: await detectWorkspace(projectDir),
@@ -0,0 +1,125 @@
1
+ /**
2
+ * reviewer-fallback (KJC-TSK-0730) — running out of reviewer quota must
3
+ * never leave the method without a third party, and must never be silent:
4
+ * either an authenticated candidate takes over WITH a loud notice, or the
5
+ * user gets a menu of candidates with their tier and the exact login
6
+ * command. Born from the real codex weekly-quota outage of 2026-08-06.
7
+ *
8
+ * The registry is declarative. `install` is only present when the command
9
+ * was VERIFIED (never invent package names); auth heuristics are cheap
10
+ * local file checks — informative for the menu, while the actual failover
11
+ * only sticks if the candidate answers.
12
+ */
13
+ import { existsSync } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { checkBinary } from "../utils/agent-detect.js";
17
+
18
+ export const REVIEWER_CANDIDATES = [
19
+ {
20
+ name: "codex",
21
+ tier: "suscripción ChatGPT (cuota semanal compartida entre modelos)",
22
+ login: "codex → Sign in with ChatGPT",
23
+ install: "npm i -g @openai/codex",
24
+ authPaths: [".codex/auth.json"],
25
+ },
26
+ {
27
+ name: "copilot",
28
+ tier: "gratis (tier free de GitHub Copilot; más cuota con suscripción)",
29
+ login: "copilot → autenticación GitHub",
30
+ install: "npm i -g @github/copilot",
31
+ authPaths: [".copilot/config.json"],
32
+ },
33
+ {
34
+ name: "agy",
35
+ tier: "suscripción Google AI Pro/Ultra (sucesor del gemini CLI, retirado 06-2026)",
36
+ login: "agy → /login (cuenta Google)",
37
+ install: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
38
+ authPaths: [".gemini/antigravity-cli"],
39
+ },
40
+ {
41
+ name: "kimi",
42
+ tier: "gratis (Kimi Code, Moonshot K2.x)",
43
+ login: "kimi login (device-code; si dice 'No model configured', repítelo)",
44
+ install: null,
45
+ authPaths: [".kimi-code/credentials"],
46
+ },
47
+ {
48
+ name: "qwen",
49
+ tier: "requiere plan o API key (el tier gratis hosted se retiró en 04-2026)",
50
+ login: "qwen → OAuth o API key OpenAI-compatible",
51
+ install: "npm i -g @qwen-code/qwen-code",
52
+ authPaths: [".qwen/oauth_creds.json"],
53
+ },
54
+ {
55
+ name: "aider",
56
+ tier: "API keys propias (pago por token: OpenAI/Anthropic/OpenRouter…)",
57
+ login: "exportar OPENAI_API_KEY / ANTHROPIC_API_KEY (o ~/.aider.conf.yml)",
58
+ install: "pipx install aider-chat || pip3 install aider-chat",
59
+ authPaths: [".aider.conf.yml"],
60
+ authEnv: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"],
61
+ },
62
+ {
63
+ name: "opencode",
64
+ tier: "gratis con modelos locales (LiteLLM/Ollama) o API keys propias",
65
+ login: "provider en ~/.config/opencode/opencode.json",
66
+ install: null,
67
+ authPaths: [".config/opencode/opencode.json"],
68
+ },
69
+ ];
70
+
71
+ const QUOTA_RE = /usage limit|quota|rate.?limit|credits? (?:exhausted|left: ?0)|hit your .{0,20}limit/i;
72
+
73
+ /** True when the agent error smells like exhausted quota, not a real failure. */
74
+ export function isQuotaExhausted(text) {
75
+ return typeof text === "string" && QUOTA_RE.test(text);
76
+ }
77
+
78
+ /** installed × authenticated for every candidate, from cheap local checks. */
79
+ export async function candidateStatus({ home = os.homedir(), checkBin = checkBinary, env = process.env } = {}) {
80
+ return Promise.all(
81
+ REVIEWER_CANDIDATES.map(async (c) => {
82
+ let installed = false;
83
+ try {
84
+ installed = (await checkBin(c.name)).ok;
85
+ } catch { /* not installed */ }
86
+ // Session-file heuristics, or env keys for CLIs (aider) that carry
87
+ // no session file. Key present ≠ credit left — it is menu signal;
88
+ // the failover only sticks if the candidate actually answers.
89
+ const authenticated =
90
+ c.authPaths.some((p) => existsSync(path.join(home, p))) ||
91
+ (c.authEnv || []).some((name) => Boolean(env[name]));
92
+ return { ...c, installed, authenticated };
93
+ }),
94
+ );
95
+ }
96
+
97
+ /**
98
+ * First candidate that is installed, authenticated, has a kj adapter and is
99
+ * not excluded (the exhausted reviewer and the host — the brain NEVER
100
+ * reviews itself).
101
+ */
102
+ export function pickQuotaFallback(statuses, { exclude = [] } = {}) {
103
+ const found = statuses.find(
104
+ (s) => s.installed && s.authenticated && !s.adapterPending && !exclude.includes(s.name),
105
+ );
106
+ return found ? found.name : null;
107
+ }
108
+
109
+ /** Human/agent-readable menu: tier, state, and the exact command per candidate. */
110
+ export function formatCandidateMenu(statuses, { exclude = [] } = {}) {
111
+ const lines = statuses
112
+ .filter((s) => !exclude.includes(s.name))
113
+ .map((s) => {
114
+ let state = "no instalado";
115
+ if (s.authenticated) state = "logado";
116
+ else if (s.installed) state = "instalado, SIN login";
117
+ let action = "";
118
+ if (!s.authenticated) {
119
+ action = s.installed || !s.install ? ` — login: ${s.login}` : ` — instalar: ${s.install}`;
120
+ }
121
+ const pending = s.adapterPending ? " [adaptador kj pendiente: KJC-TSK-0729]" : "";
122
+ return ` - ${s.name} (${s.tier}) · ${state}${action}${pending}`;
123
+ });
124
+ return `Candidatos a reviewer:\n${lines.join("\n")}\nElige uno, haz su login y fija roles.reviewer.provider — o pasa --reviewer <nombre>.`;
125
+ }
@@ -9,9 +9,38 @@ const KNOWN_AGENTS = [
9
9
  { name: "aider", install: getInstallCommand("aider") },
10
10
  { name: "opencode", install: getInstallCommand("opencode") },
11
11
  { name: "qwen", install: getInstallCommand("qwen") },
12
- { name: "copilot", install: getInstallCommand("copilot") }
12
+ { name: "copilot", install: getInstallCommand("copilot") },
13
+ // KJC-TSK-0729: promoted from the observation census once its adapter
14
+ // landed — detecting is not supporting; supporting is.
15
+ { name: "kimi", install: getInstallCommand("kimi") },
16
+ { name: "agy", install: getInstallCommand("agy") }
13
17
  ];
14
18
 
19
+ /**
20
+ * KJC-TSK-0728 — observation census (from the Orca landscape): agent CLIs kj
21
+ * can SEE but does not drive. Detecting is not supporting — these never feed
22
+ * pipeline pickers; they feed the AI-surface inventory and one doctor line.
23
+ * `bin` differs from `name` when the vendor ships an umbrella binary.
24
+ */
25
+ const OBSERVED_AGENTS = [
26
+ { name: "grok", bin: "grok" },
27
+ { name: "cursor-agent", bin: "cursor-agent" },
28
+ { name: "pi", bin: "pi" },
29
+ { name: "kilocode", bin: "kilocode" },
30
+ { name: "vibe", bin: "vibe" },
31
+ { name: "rovodev", bin: "acli" },
32
+ ];
33
+
34
+ /** Probe the observation census in parallel; callers filter on `available`. */
35
+ export async function detectObservedAgents() {
36
+ return Promise.all(
37
+ OBSERVED_AGENTS.map(async (agent) => {
38
+ const check = await checkBinary(agent.bin);
39
+ return { name: agent.name, bin: agent.bin, available: check.ok, version: check.ok ? check.version : null };
40
+ })
41
+ );
42
+ }
43
+
15
44
  export async function checkBinary(name, versionArg = "--version") {
16
45
  const resolved = resolveBin(name);
17
46
  // KJC-BUG-0113: a zombie CLI that hangs on --version (seen with the
@@ -61,4 +90,4 @@ export function isHostAgent(provider) {
61
90
  return host !== null && host === provider;
62
91
  }
63
92
 
64
- export { KNOWN_AGENTS };
93
+ export { KNOWN_AGENTS, OBSERVED_AGENTS };
@@ -56,6 +56,11 @@ const INSTALL_COMMANDS = {
56
56
  linux: "npm install -g @qwen-code/qwen-code",
57
57
  windows: "npm install -g @qwen-code/qwen-code"
58
58
  },
59
+ agy: {
60
+ macos: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
61
+ linux: "curl -fsSL https://antigravity.google/cli/install.sh | bash",
62
+ windows: "irm https://antigravity.google/cli/install.ps1 | iex"
63
+ },
59
64
  copilot: {
60
65
  macos: "npm install -g @github/copilot",
61
66
  linux: "npm install -g @github/copilot",