karajan-code 4.47.0 → 4.48.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/package.json +1 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +11 -0
- package/src/commands/handoff.js +38 -0
- package/src/harden/handoff.js +60 -0
- package/src/harden/hook-templates.js +3 -2
- package/src/harden/sentinel/sentinel-shell.mjs +29 -2
- package/src/harden/sentinel-hooks.js +17 -6
package/package.json
CHANGED
|
@@ -35,7 +35,7 @@ export const ADVANCED_GROUPS = [
|
|
|
35
35
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
36
36
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
37
37
|
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "claims", "steward", "pr-size"] },
|
|
38
|
-
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity", "security"] },
|
|
38
|
+
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity", "security", "handoff"] },
|
|
39
39
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
40
40
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
|
41
41
|
];
|
package/src/cli/register-meta.js
CHANGED
|
@@ -9,6 +9,7 @@ import { bootstrapCommand } from "../commands/bootstrap.js";
|
|
|
9
9
|
import { PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
|
|
10
10
|
import { identityCommand } from "../commands/identity.js";
|
|
11
11
|
import { securityCommand } from "../commands/security.js";
|
|
12
|
+
import { handoffCommand } from "../commands/handoff.js";
|
|
12
13
|
import { policyCommand } from "../commands/policy.js";
|
|
13
14
|
import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
|
|
14
15
|
import { stewardSweepCommand } from "../commands/steward.js";
|
|
@@ -193,6 +194,16 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
// KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through kj.
|
|
198
|
+
const handoffCmd = program.command("handoff").description("Handoffs: what a gate denied the agent, registered with the gate's why, for the human to read and run by id");
|
|
199
|
+
const handoff = (action, flags) => withConfig(pkgVersion, `handoff-${action}`, flags, async ({ config }) => { process.exitCode = await handoffCommand({ action, config, flags }); });
|
|
200
|
+
handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you)")
|
|
201
|
+
.argument("<command...>", "the denied command, as it was run").option("--cwd <dir>", "Directory the command runs in (default: the project)")
|
|
202
|
+
.action((parts, flags) => handoff("add", { ...flags, command: parts.join(" ") }));
|
|
203
|
+
handoffCmd.command("list", { isDefault: true }).description("The handoffs registered in this project").action((flags) => handoff("list", flags));
|
|
204
|
+
handoffCmd.command("show").description("A handoff: the exact command, its sha256 and why the gate denied it")
|
|
205
|
+
.argument("<id>", "handoff id (8 hex)").option("--json", "Output the record as JSON").action((id, flags) => handoff("show", { ...flags, id }));
|
|
206
|
+
|
|
196
207
|
// KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
|
|
197
208
|
identityCmd
|
|
198
209
|
.command("phone-invite")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj handoff add|list|show (KJC-TSK-0968, HUM-E, ADR 0018): the agent registers
|
|
3
|
+
* what a gate denied it, with the gate's why; the human reads it by id.
|
|
4
|
+
*/
|
|
5
|
+
import { addHandoff, listHandoffs, readHandoff } from "../harden/handoff.js";
|
|
6
|
+
|
|
7
|
+
const line = (h) => `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}`;
|
|
8
|
+
|
|
9
|
+
/** @returns {Promise<0|1>} */
|
|
10
|
+
export async function handoffCommand({ action = "list", config, flags = {}, log = console.log }) {
|
|
11
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
12
|
+
try {
|
|
13
|
+
if (action === "add") {
|
|
14
|
+
const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir });
|
|
15
|
+
log(`encargo ${record.id} registrado: ${record.command}`);
|
|
16
|
+
log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
|
|
17
|
+
log(`tu usuario lo revisa con kj handoff show ${record.id}; nadie copia y pega el comando.`);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
if (action === "show") {
|
|
21
|
+
const h = readHandoff({ projectDir, id: flags.id });
|
|
22
|
+
if (flags.json) { log(JSON.stringify(h)); return 0; }
|
|
23
|
+
log(`encargo ${h.id} (${h.status}, ${h.createdAt})`);
|
|
24
|
+
log(`comando ${h.command}`);
|
|
25
|
+
log(`sha256 ${h.sha256}`);
|
|
26
|
+
log(`cwd ${h.cwd}`);
|
|
27
|
+
log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const all = listHandoffs({ projectDir });
|
|
31
|
+
if (all.length === 0) log("kj handoff: ningún encargo registrado");
|
|
32
|
+
for (const h of all) log(line(h));
|
|
33
|
+
return 0;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log(err.message);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through
|
|
3
|
+
* kj. When a gate denies the agent a command, the agent registers a HANDOFF with
|
|
4
|
+
* the exact command; the why is what the gate said, read from the Sentinel's
|
|
5
|
+
* record of denials, never a text of the agent. The human later reads and runs
|
|
6
|
+
* it by id (E2, E3). Handoffs live in .karajan/handoffs/<id>.json, local.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const HANDOFFS_DIR = ".karajan/handoffs";
|
|
13
|
+
export const DENIALS_FILE = ".karajan/harness/denials.jsonl";
|
|
14
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
15
|
+
|
|
16
|
+
/** The Sentinel's denials, oldest first; a line that is not JSON is skipped. */
|
|
17
|
+
export function readDenials({ projectDir }) {
|
|
18
|
+
let text;
|
|
19
|
+
try { text = readFileSync(join(projectDir, DENIALS_FILE), "utf8"); } catch { return []; }
|
|
20
|
+
return text.split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The latest denial of this EXACT Bash command, or null. */
|
|
24
|
+
export function findDenial({ projectDir, command }) {
|
|
25
|
+
return readDenials({ projectDir }).findLast((d) => d?.tool === "Bash" && d?.input?.command === command) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers a handoff for a command a gate denied. Without a denial of those
|
|
30
|
+
* exact bytes there is nothing to hand off: the agent does not write the why.
|
|
31
|
+
* @returns {{id: string, command: string, sha256: string, why: string}}
|
|
32
|
+
*/
|
|
33
|
+
export function addHandoff({ projectDir, command, cwd = projectDir }) {
|
|
34
|
+
if (typeof command !== "string" || !command.trim()) throw new Error("kj handoff add: falta el comando exacto que el gate negó");
|
|
35
|
+
const denial = findDenial({ projectDir, command });
|
|
36
|
+
if (!denial) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018)");
|
|
37
|
+
const createdAt = new Date().toISOString();
|
|
38
|
+
const digest = sha256(command);
|
|
39
|
+
const id = sha256(`${digest}:${createdAt}`).slice(0, 8);
|
|
40
|
+
const record = { id, command, sha256: digest, cwd, createdAt, status: "pending", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
|
|
41
|
+
mkdirSync(join(projectDir, HANDOFFS_DIR), { recursive: true });
|
|
42
|
+
writeFileSync(join(projectDir, HANDOFFS_DIR, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readHandoff({ projectDir, id }) {
|
|
47
|
+
if (!/^[0-9a-f]{8}$/.test(id ?? "")) throw new Error(`kj handoff: "${id}" no es un identificador de encargo (8 hex)`);
|
|
48
|
+
const path = join(projectDir, HANDOFFS_DIR, `${id}.json`);
|
|
49
|
+
if (!existsSync(path)) throw new Error(`kj handoff: no hay ningún encargo ${id}`);
|
|
50
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
51
|
+
// The bytes the human will run are the bytes that were registered, or nothing.
|
|
52
|
+
if (sha256(record.command ?? "") !== record.sha256) throw new Error(`kj handoff: el encargo ${id} no coincide con su sha256: alguien lo tocó, no se ejecuta`);
|
|
53
|
+
return record;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function listHandoffs({ projectDir }) {
|
|
57
|
+
let names;
|
|
58
|
+
try { names = readdirSync(join(projectDir, HANDOFFS_DIR)).filter((n) => /^[0-9a-f]{8}\.json$/.test(n)); } catch { return []; }
|
|
59
|
+
return names.map((n) => readHandoff({ projectDir, id: n.slice(0, 8) })).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
60
|
+
}
|
|
@@ -135,8 +135,9 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
135
135
|
"fi",
|
|
136
136
|
"# KJC-BUG-0296 (issue #1989): the host agent's runtime and local state never",
|
|
137
137
|
"# enter a commit, however they were staged (a blanket `git add -A` put a",
|
|
138
|
-
"# Claude scheduler lock into main). Checked on the staged list itself
|
|
139
|
-
"
|
|
138
|
+
"# Claude scheduler lock into main). Checked on the staged list itself,",
|
|
139
|
+
"# deletions left out: untracking a runtime file is the fix (KJC-BUG-0307).",
|
|
140
|
+
"if git diff --cached --name-only --diff-filter=d | grep -qE '^(\\.claude/[^/]*\\.lock|\\.claude/scheduled_tasks\\.lock|\\.kj/|\\.karajan/identity\\.local\\.yml|\\.karajan/harness/sentinel-state\\.json)'; then",
|
|
140
141
|
" echo 'kj harden: agent runtime files are staged (.claude/*.lock, .kj/, identity.local.yml, sentinel-state.json) — unstage them: git restore --staged <path>'; exit 1",
|
|
141
142
|
"fi",
|
|
142
143
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
|
@@ -64,6 +64,28 @@ export const headIndex = (words, heads) => {
|
|
|
64
64
|
return k < 0 ? words.length : i + k;
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* KJC-BUG-0306: a simple command that runs `gh ... issue create`, read by words
|
|
69
|
+
* (quotes, escapes and newlines as the shell reads them), not by words anywhere.
|
|
70
|
+
* @param {string} cmd
|
|
71
|
+
*/
|
|
72
|
+
export const isGhIssueCreate = (cmd) => shellSegments(cmd).some((words) => {
|
|
73
|
+
const i = headIndex(words, ["gh"]);
|
|
74
|
+
if (words[i]?.split("/").at(-1) !== "gh") return false;
|
|
75
|
+
// The subcommand words, with every flag skipped together with a value that may
|
|
76
|
+
// follow it (--repo x, --hostname y): what is left is "issue", then "create".
|
|
77
|
+
const subcommand = [];
|
|
78
|
+
let afterFlag = false;
|
|
79
|
+
for (const w of words.slice(i + 1)) {
|
|
80
|
+
if (w.startsWith("-")) { afterFlag = !w.includes("="); continue; }
|
|
81
|
+
if (afterFlag && !["issue", "create"].includes(w)) { afterFlag = false; continue; }
|
|
82
|
+
afterFlag = false;
|
|
83
|
+
subcommand.push(w);
|
|
84
|
+
if (subcommand.length === 2) break;
|
|
85
|
+
}
|
|
86
|
+
return subcommand[0] === "issue" && subcommand[1] === "create";
|
|
87
|
+
});
|
|
88
|
+
|
|
67
89
|
// KJC-BUG-0286: a $ expands only when something expandable follows it ($x, ${x},
|
|
68
90
|
// $(cmd), $1, $@...). A lone "$ " in a grep pattern is a literal dollar sign.
|
|
69
91
|
const LIVE_EXPANSION = /\$(?=[A-Za-z0-9_{(@*#?$!-])|`/;
|
|
@@ -90,11 +112,16 @@ export const stripInertQuotes = (cmd) => {
|
|
|
90
112
|
};
|
|
91
113
|
|
|
92
114
|
// Options whose value is prose, never a path (gh, git, kj).
|
|
93
|
-
|
|
115
|
+
// KJC-BUG-0306: --error and --command of kj report-issue, --scope and --tests of kj hu add.
|
|
116
|
+
const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests"]);
|
|
94
117
|
const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
|
|
118
|
+
// KJC-BUG-0306: the title kj hu add and kj adr add take as their first argument, at the start of a segment.
|
|
119
|
+
const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
|
|
95
120
|
|
|
96
121
|
/** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
|
|
97
|
-
export const stripTextOptionValues = (cmd) => cmd
|
|
122
|
+
export const stripTextOptionValues = (cmd) => cmd
|
|
123
|
+
.replace(KJ_TITLE, (m, pre, head, val) => `${pre}${head}${val[0]}${val[0]}`)
|
|
124
|
+
.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
|
|
98
125
|
|
|
99
126
|
/**
|
|
100
127
|
* KJC-BUG-0245: the values of the named options, read by the shell reader, so a
|
|
@@ -467,13 +467,13 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
467
467
|
import console from "node:console";
|
|
468
468
|
import process from "node:process";
|
|
469
469
|
import { dirname, join, relative, resolve } from "node:path";
|
|
470
|
-
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
470
|
+
import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
471
471
|
import { spawnSync } from "node:child_process";
|
|
472
472
|
import { homedir } from "node:os";
|
|
473
473
|
import { fileURLToPath } from "node:url";
|
|
474
|
-
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
474
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
475
475
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
476
|
-
import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
476
|
+
import { isGhIssueCreate, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
477
477
|
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
478
478
|
import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
|
|
479
479
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
@@ -495,8 +495,17 @@ process.stdin.on("end", () => {
|
|
|
495
495
|
try {
|
|
496
496
|
const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
|
|
497
497
|
// KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
|
|
498
|
+
// KJC-TSK-0968 (HUM-E, ADR 0018): and leaves a record next to the state, with the
|
|
499
|
+
// gate's own words: kj handoff takes the why from here, never from the agent.
|
|
500
|
+
const said = [];
|
|
501
|
+
const errOrig = console.error.bind(console);
|
|
502
|
+
console.error = (m) => { said.push(String(m)); errOrig(m); };
|
|
498
503
|
process.on("exit", (code) => {
|
|
499
|
-
if (code
|
|
504
|
+
if (code !== 2) return;
|
|
505
|
+
errOrig("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
|
|
506
|
+
// Only what a handoff needs: the exact Bash command, or the path an edit aimed at; never file contents.
|
|
507
|
+
const what = tool === "Bash" ? { command: input.command } : { file_path: input.file_path || input.notebook_path || null };
|
|
508
|
+
try { appendFileSync(join(dirname(STATE), "denials.jsonl"), JSON.stringify({ at: new Date().toISOString(), sid, tool, input: what, why: said.join("\\n") }) + "\\n"); } catch { /* the deny stands without its record */ }
|
|
500
509
|
});
|
|
501
510
|
// KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
|
|
502
511
|
// sesion toca un fichero se anota si estaba limpio: solo entonces un
|
|
@@ -574,7 +583,9 @@ process.stdin.on("end", () => {
|
|
|
574
583
|
// (sanitiza y busca duplicados), nunca por gh issue create a mano.
|
|
575
584
|
{
|
|
576
585
|
const ghIssue = String(input.command || "");
|
|
577
|
-
|
|
586
|
+
// KJC-BUG-0306: the subcommand itself (gh, its flags, issue create), not three words
|
|
587
|
+
// anywhere: "gh pr create --title 'kj report-issue ...'" is not an issue.
|
|
588
|
+
if (tool === "Bash" && isGhIssueCreate(ghIssue)) {
|
|
578
589
|
// The target: --repo/-R, an inline or inherited GH_REPO, else the
|
|
579
590
|
// checkout's own origin, which is what gh takes (review catches).
|
|
580
591
|
const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
|
|
@@ -884,7 +895,7 @@ process.stdin.on("end", () => {
|
|
|
884
895
|
// KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
|
|
885
896
|
// of a text option (--title, -m...) is not a path.
|
|
886
897
|
if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
|
|
887
|
-
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion." + doc("cross-lane"));
|
|
898
|
+
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion. Si es texto (titulo, descripcion, error) con comillas de codigo o dolar, ponlo entre comillas SIMPLES: entre dobles la shell lo ejecuta." + doc("cross-lane"));
|
|
888
899
|
process.exit(2);
|
|
889
900
|
}
|
|
890
901
|
const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
|