karajan-code 4.70.2 → 4.70.4

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/checks/ai-surface.js +9 -7
  3. package/src/checks/config-files.js +8 -7
  4. package/src/cli/register-pipeline.js +4 -0
  5. package/src/commands/env.js +3 -1
  6. package/src/commands/go.js +5 -2
  7. package/src/commands/harden.js +20 -6
  8. package/src/commands/pr-size.js +4 -3
  9. package/src/commands/review-gate.js +18 -13
  10. package/src/config/overrides.js +2 -1
  11. package/src/environment/board-access.js +11 -9
  12. package/src/git/automation.js +5 -4
  13. package/src/harden/branch-protection.js +36 -3
  14. package/src/harden/harden-engine.js +5 -21
  15. package/src/harden/hook-templates.js +18 -30
  16. package/src/harden/phone-sign.js +3 -1
  17. package/src/harden/published-pin.js +34 -0
  18. package/src/harden/sentinel/posttooluse.mjs +4 -2
  19. package/src/harden/sentinel/pretooluse-sentinel.mjs +22 -3
  20. package/src/harden/sentinel/sentinel-lib.mjs +23 -0
  21. package/src/harden/sentinel/sentinel-shell.mjs +109 -0
  22. package/src/harden/workflow-engine.js +34 -7
  23. package/src/mcp/handlers/hu-handlers.js +43 -20
  24. package/src/mcp/tools.js +4 -4
  25. package/src/orchestrator/stages/suite-green-stage.js +3 -1
  26. package/src/policy/engine.js +9 -3
  27. package/src/policy/supervisor-verify.js +2 -2
  28. package/src/review/dual-review.js +5 -4
  29. package/src/review/gate-hook.js +39 -0
  30. package/src/review/one-shot-review.js +7 -3
  31. package/src/review/pr-requirements.js +6 -6
  32. package/src/review/publish-pr.js +4 -1
  33. package/src/review/reviewer-fallback.js +18 -2
  34. package/src/review/verify-pr.js +39 -9
  35. package/src/sonar/scanner.js +2 -1
  36. package/src/utils/agent-home.js +33 -0
@@ -36,7 +36,9 @@ async function relayConfig(fetchFn) {
36
36
  }
37
37
  const SIGN_PAGE = "https://karajancode.com/sign";
38
38
  const POLL_INTERVAL_MS = 2000;
39
- const TTL_MS = 60000;
39
+ // KJC-BUG-0432: 60 s was too short to scan, read the files and sign, and the page still said
40
+ // "signed" after kj had given up. 150 s leaves time and stays under the relay's 3 min window.
41
+ const TTL_MS = 150000;
40
42
 
41
43
  /** Spinner en su sitio (una línea, con \r) solo en TTY; no-op fuera de TTY o en tests. */
42
44
  function makeSpinner() {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * published-pin — the karajan-code version a seeded workflow may pin (KJC-BUG-0420).
3
+ *
4
+ * A consumer repo's CI installs kj from npm, so the pin must be a published
5
+ * version. A checkout ahead of npm pins the newest published version that is
6
+ * not newer than itself; when the registry cannot confirm anything, the
7
+ * running version stays and the note says so.
8
+ */
9
+
10
+ import { isNewerVersion } from "../utils/update-check.js";
11
+
12
+ const packumentUrl = (name) => `https://registry.npmjs.org/${encodeURIComponent(name)}`;
13
+
14
+ async function publishedVersions(name, fetchFn) {
15
+ const res = await fetchFn(packumentUrl(name), { headers: { Accept: "application/vnd.npm.install-v1+json" } });
16
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
17
+ return Object.keys((await res.json()).versions ?? {});
18
+ }
19
+
20
+ /** @returns {Promise<{version: string, note?: string}>} */
21
+ export async function publishedPin({ name, version, fetchFn = fetch }) {
22
+ const unconfirmed = (why) => ({ version, note: `could not confirm on npm that ${name}@${version} is published (${why}); the workflow pins it as is` });
23
+ let published;
24
+ try {
25
+ published = await publishedVersions(name, fetchFn);
26
+ } catch (err) {
27
+ return unconfirmed(err.message);
28
+ }
29
+ if (published.includes(version)) return { version };
30
+ const older = published.filter((v) => !v.includes("-") && !isNewerVersion(v, version));
31
+ const newest = older.reduce((best, v) => (best && !isNewerVersion(v, best) ? best : v), null);
32
+ if (!newest) return unconfirmed("no published version at or below it");
33
+ return { version: newest, note: `${name}@${version} is not on npm yet; the workflow pins ${newest}, the newest published one` };
34
+ }
@@ -196,10 +196,12 @@ process.stdin.on("end", () => {
196
196
  const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
197
197
  let size = null;
198
198
  try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
199
- const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
199
+ // KJC-BUG-0435: net lines, as the CI gate counts them; a kj older than 0434 reports only added.
200
+ const lines = size ? (size.net ?? size.added) : 0;
201
+ const crossed = size ? [200, 150].find((t) => lines > t) : undefined;
200
202
  if (crossed && (s.size_warned || 0) < crossed) {
201
203
  s.size_warned = crossed;
202
- sizeNote = "karajan sentinel: la rama ya suma " + size.added + " lineas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
204
+ sizeNote = "karajan sentinel: la rama ya suma " + lines + " lineas netas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
203
205
  }
204
206
  }
205
207
  const ids = Object.keys(state.sessions);
@@ -11,9 +11,9 @@ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { homedir } from "node:os";
13
13
  import { fileURLToPath } from "node:url";
14
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
14
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText, stagedVerdict } from "./sentinel-lib.mjs";
15
15
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
16
- import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
16
+ import { commitStaging, isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
17
17
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
18
18
  import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
19
19
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -85,8 +85,9 @@ process.stdin.on("end", () => {
85
85
  // caught by the Stop gate's checksum verification, which blocks the turn.
86
86
  // KJC-TSK-1114: where the host sandbox denies writing them, the sandbox guards them instead.
87
87
  // KJC-BUG-0425 (ADR 0036): text a command only publishes or stores (a PR description) is not a write.
88
+ // KJC-BUG-0429: nor is a command made only of known readers (an allow-list, not a write blocklist).
88
89
  const SUPERVISOR_PATHS = [".claude/settings.json", ".karajan/hooks", ".karajan/harness"];
89
- if (tool === "Bash" && PROTECTED.test(stripTextOnlyCommandValues(String(input.command || "")).replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
90
+ if (tool === "Bash" && !isReadOnlyCommand(String(input.command || "")) && PROTECTED.test(stripTextOnlyCommandValues(String(input.command || "")).replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
90
91
  console.error("karajan 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." + doc("supervisor"));
91
92
  process.exit(2);
92
93
  }
@@ -634,6 +635,24 @@ process.stdin.on("end", () => {
634
635
  console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
635
636
  process.exit(2);
636
637
  }
638
+ // KJC-BUG-0455 (#2933): the verdict of the staged diff, checked HERE as well as in
639
+ // the pre-commit hook: a repo whose hook does not run the gate, or a review piped into
640
+ // tail that hid its failure, let a commit through with no verdict at all.
641
+ const plan = commitStaging(cmd);
642
+ if (plan.commits) {
643
+ // 4.x: the hook payload carries no cwd; the session works in the project root.
644
+ const verdict = stagedVerdict(plan.dir ? resolve(ROOT, plan.dir) : ROOT);
645
+ if (verdict.gate && plan.stages) {
646
+ console.error("karajan sentinel: stagea y commitea en comandos separados — el veredicto se comprueba sobre lo que ya esta stageado, y este comando stagea a la vez que commitea (git add/rm/mv, -a o rutas en git commit). Stagea por nombre, pasa kj review --staged y luego git commit." + doc("commit-verdict"));
647
+ process.exit(2);
648
+ }
649
+ // Only an approved verdict passes, or nothing staged (a merge: git itself decides).
650
+ if (verdict.gate && verdict.state !== "approved" && verdict.state !== "empty") {
651
+ const why = { rejected: "el review de este diff fue RECHAZADO por " + (verdict.reviewer || "el reviewer") + " — corrige y vuelve a pasar kj review --staged", unknown: "git no pudo leer el diff stageado, asi que el veredicto no se puede comprobar" }[verdict.state] || "no hay veredicto para el diff stageado — pasa kj review --staged (solo, sin tuberias que oculten si falla)";
652
+ console.error("karajan sentinel: commit sin veredicto aprobado: " + why + "." + doc("commit-verdict"));
653
+ process.exit(2);
654
+ }
655
+ }
637
656
  // KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
638
657
  // git add -A / git add . / git add -u put a Claude scheduler lock into
639
658
  // main; and the agent's runtime files (the .claude locks, .kj/, the local
@@ -4,6 +4,7 @@
4
4
  import process from "node:process";
5
5
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { execSync, spawnSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
7
8
  import { basename, dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  const here = dirname(fileURLToPath(import.meta.url));
@@ -65,6 +66,28 @@ export const bootstrapPhase = () => {
65
66
  try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
66
67
  catch { return true; }
67
68
  };
69
+ // KJC-BUG-0455 (#2933): the cross-AI verdict of the diff staged in `dir`, read the way
70
+ // `kj review --check` reads it (sha256 of `git diff --cached`, trailing blanks trimmed,
71
+ // in <top>/.karajan/reviews). gate:false when the repo has not committed the gate marker.
72
+ // state: approved | rejected | missing | empty (nothing staged) | unknown (git failed).
73
+ export const stagedVerdict = (dir) => {
74
+ const git = (args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
75
+ // No commit yet (or no repo): no committed marker can exist, as the hook reads it.
76
+ if (git(["rev-parse", "--verify", "-q", "HEAD"]).status !== 0) return { gate: false };
77
+ // A git error is not a missing marker: it fails closed.
78
+ const marker = git(["ls-tree", "--name-only", "HEAD", ".karajan/review-gate"]);
79
+ if (marker.status !== 0) return { gate: true, state: "unknown" };
80
+ if (!marker.stdout.trim()) return { gate: false };
81
+ const top = git(["rev-parse", "--show-toplevel"]).stdout?.trim();
82
+ const diff = git(["diff", "--cached"]);
83
+ if (!top || diff.status !== 0) return { gate: true, state: "unknown" };
84
+ if (!diff.stdout.trim()) return { gate: true, state: "empty" };
85
+ const hash = createHash("sha256").update(diff.stdout.trimEnd(), "utf8").digest("hex");
86
+ try {
87
+ const v = JSON.parse(readFileSync(join(top, ".karajan", "reviews", hash + ".json"), "utf8"));
88
+ return { gate: true, state: v.verdict === "approved" ? "approved" : "rejected", reviewer: v.reviewer || null };
89
+ } catch { return { gate: true, state: "missing" }; }
90
+ };
68
91
  // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
69
92
  // open. kj decides with that checkable fact, the same answer for commit and push
70
93
  // (the Stop gate still holds the turn: the board owes the move); when kj does not
@@ -264,5 +264,114 @@ export const withoutRedirections = (words) => {
264
264
  return rest;
265
265
  };
266
266
 
267
+ // KJC-BUG-0429 (ADR 0036): the guards decide by write verb. These readers were picked
268
+ // because no option of theirs writes a file or runs a program; a command made only of
269
+ // them, with output to the session, /dev/null or another descriptor, writes nothing.
270
+ // KJC-BUG-0437: never git, whose reads run what its config names (filters, textconv,
271
+ // diff.external, fsmonitor); the Read tool reads those files instead.
272
+ const READERS = new Set(["cat", "head", "tail", "grep", "egrep", "fgrep", "ls", "wc", "diff", "cmp", "stat", "jq", "sha256sum", "sha1sum", "md5sum"]);
273
+
274
+ const REDIRECTION = /^(\d*)(>>|>&|>\||>|<)(.*)$/;
275
+ const quietRedirections = (words) => words.every((w, i) => {
276
+ const m = REDIRECTION.exec(w);
277
+ if (!m) return true;
278
+ const [, , op, glued] = m;
279
+ const target = glued || words[i + 1] || "";
280
+ if (op === "<") return target !== "";
281
+ if (op === ">&") return /^\d$/.test(target);
282
+ return (op === ">" || op === ">>") && target === "/dev/null";
283
+ });
284
+
285
+ /**
286
+ * Whether the command only reads: every simple command is a known reader and no
287
+ * output goes to a file. Substitution, subshells, heredocs, expansion, newlines
288
+ * or assignments are not provably that.
289
+ * @param {string} cmd
290
+ */
291
+ export const isReadOnlyCommand = (cmd) => {
292
+ const bare = stripInertQuotes(String(cmd));
293
+ if (/[(){}`\n]|<</.test(bare) || LIVE_EXPANSION.test(bare)) return false;
294
+ const segs = shellSegments(cmd);
295
+ return segs.length > 0 && segs.every((words) => {
296
+ const rest = withoutRedirections(words);
297
+ return quietRedirections(words) && READERS.has(rest[0]);
298
+ });
299
+ };
300
+
267
301
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
268
302
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
303
+
304
+ // KJC-BUG-0455 (#2933): git commit's options that take a value, and the git verbs that
305
+ // change the index. A commit that stages in the same command commits a diff nobody
306
+ // could have reviewed yet: the verdict is checked against what is staged BEFORE it runs.
307
+ const COMMIT_VALUE_OPTS = new Set(["-m", "-F", "-C", "-c", "-t", "--message", "--file", "--reuse-message", "--reedit-message", "--template", "--author", "--date", "--cleanup", "--fixup", "--squash", "--trailer", "--pathspec-from-file"]);
308
+ const COMMIT_VALUE_SHORT = new Set(["m", "F", "C", "c", "t"]);
309
+ const INDEX_VERBS = new Set(["add", "rm", "mv", "stage", "restore", "reset", "apply", "update-index"]);
310
+
311
+ /** Where `git` is in a segment and its verb, skipping git's global options; null when not git. */
312
+ const gitVerbOf = (words) => {
313
+ if (words[0]?.slice(words[0].lastIndexOf("/") + 1) !== "git") return null;
314
+ let i = 1;
315
+ let dir = null;
316
+ while (i < words.length) {
317
+ const w = words[i];
318
+ if (GIT_GLOBAL_WITH_VALUE.has(w)) {
319
+ if (w === "-C") {
320
+ dir = words[i + 1] ?? null;
321
+ }
322
+ i += 2; // the option and its value
323
+ } else if (GIT_GLOBAL_FLAG.test(w)) {
324
+ i += 1;
325
+ } else {
326
+ break;
327
+ }
328
+ }
329
+ return { verb: words[i], at: i, dir };
330
+ };
331
+
332
+ /** How many words a git commit argument takes, and whether it makes the commit stage on its own. */
333
+ const commitArg = (a) => {
334
+ if (a === "--all") return { stages: true, words: 1 };
335
+ if (a.startsWith("--")) {
336
+ // --message=x carries its value; --message x takes the next word.
337
+ return { stages: false, words: COMMIT_VALUE_OPTS.has(a) ? 2 : 1 };
338
+ }
339
+ if (/^-[a-zA-Z]/.test(a)) {
340
+ const cluster = a.slice(1);
341
+ const valueAt = [...cluster].findIndex((c) => COMMIT_VALUE_SHORT.has(c));
342
+ const flags = valueAt < 0 ? cluster : cluster.slice(0, valueAt);
343
+ if (flags.includes("a")) return { stages: true, words: 1 };
344
+ // -m x takes the next word; -mx carries its value.
345
+ return { stages: false, words: valueAt === cluster.length - 1 ? 2 : 1 };
346
+ }
347
+ return { stages: true, words: 1 }; // a pathspec
348
+ };
349
+
350
+ /** -a/--all, or a path (with or without --), makes git commit stage on its own. */
351
+ const commitStagesItself = (args) => {
352
+ let i = 0;
353
+ while (i < args.length) {
354
+ if (args[i] === "--") {
355
+ return i + 1 < args.length;
356
+ }
357
+ const { stages, words } = commitArg(args[i]);
358
+ if (stages) {
359
+ return true;
360
+ }
361
+ i += words;
362
+ }
363
+ return false;
364
+ };
365
+
366
+ /**
367
+ * Does the command run git commit, and does it stage in the same command?
368
+ * @param {string} cmd
369
+ * @returns {{commits: boolean, stages: boolean, dir: string|null}}
370
+ */
371
+ export const commitStaging = (cmd) => {
372
+ const gits = shellSegments(cmd).map((words) => ({ words, g: gitVerbOf(words) })).filter((s) => s.g);
373
+ const commits = gits.filter((s) => s.g.verb === "commit");
374
+ if (commits.length === 0) return { commits: false, stages: false, dir: null };
375
+ const stages = gits.some((s) => INDEX_VERBS.has(s.g.verb)) || commits.some((s) => commitStagesItself(s.words.slice(s.g.at + 1)));
376
+ return { commits: true, stages, dir: commits[0].g.dir };
377
+ };
@@ -14,6 +14,7 @@ import { dirname } from "node:path";
14
14
 
15
15
  import { parse as parseYaml } from "yaml";
16
16
  import { upsertManagedBlock } from "../utils/managed-markers.js";
17
+ import { publishedPin } from "./published-pin.js";
17
18
  import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
18
19
 
19
20
  // KJC-BUG-0424: on the PR that brings this change GitHub runs neither YAML (the base's
@@ -56,9 +57,20 @@ export function isPublishableNpm(projectDir) {
56
57
  }
57
58
  }
58
59
 
59
- /** Lockfile-detected package manager (KJC-BUG-0131): npm ci kills pnpm/yarn CI. */
60
+ const KNOWN_PMS = new Set(["npm", "pnpm", "yarn", "bun"]);
61
+
62
+ /**
63
+ * The project's package manager (KJC-BUG-0131: npm ci kills pnpm/yarn CI).
64
+ * KJC-BUG-0450 (#2789): the same answer for hooks and workflows; the
65
+ * packageManager field of package.json first, then the lockfile.
66
+ */
60
67
  export function detectPackageManager(projectDir) {
68
+ try {
69
+ const declared = String(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).packageManager || "").split("@")[0];
70
+ if (KNOWN_PMS.has(declared)) return declared;
71
+ } catch { /* no or unreadable package.json: the lockfile decides */ }
61
72
  if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
73
+ if (existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))) return "bun";
62
74
  if (existsSync(join(projectDir, "yarn.lock"))) return "yarn";
63
75
  return "npm";
64
76
  }
@@ -73,6 +85,25 @@ function hasLintScript(projectDir) {
73
85
  }
74
86
  }
75
87
 
88
+ const policyDeclared = (projectDir) => existsSync(join(projectDir, ".karajan", "policy.yml"));
89
+
90
+ // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
91
+ // steward.action: true in its config; nothing is imposed by default.
92
+ function stewardActionOn(projectDir) {
93
+ try {
94
+ return parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true;
95
+ } catch {
96
+ return false; // no config — no action
97
+ }
98
+ }
99
+
100
+ /** KJC-BUG-0420: the published kj version the policy and steward workflows pin; null when neither is seeded. */
101
+ export async function resolveKjPin({ projectDir, fetchFn } = {}) {
102
+ const own = ownPkg();
103
+ if (!own || !(policyDeclared(projectDir) || stewardActionOn(projectDir))) return null;
104
+ return publishedPin({ name: own.name, version: own.version, fetchFn });
105
+ }
106
+
76
107
  export function installWorkflows({
77
108
  projectDir = process.cwd(),
78
109
  language = null,
@@ -94,14 +125,10 @@ export function installWorkflows({
94
125
  // pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
95
126
  // recurso inalcanzable en los caminos reales.
96
127
  const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
97
- const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
128
+ const policy = policyDeclared(projectDir)
98
129
  ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
99
130
  : null;
100
- // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
101
- // steward.action: true in its config; nothing is imposed by default.
102
- let stewardOn = false;
103
- try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
104
- const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
131
+ const steward = stewardActionOn(projectDir) ?{ file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
105
132
  const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
106
133
  const results = [];
107
134
  for (const wf of all) {
@@ -4,44 +4,67 @@
4
4
  */
5
5
 
6
6
  import { handleSuggestion } from "../suggest-handler.js";
7
+ import { runKjCommand } from "../run-kj.js";
7
8
  import {
8
9
  resolveProjectDir,
9
10
  failPayload,
10
11
  } from "../shared-helpers.js";
11
12
 
13
+ export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
14
+
15
+ /**
16
+ * KJC-BUG-0452 (#2815): kj_hu used the old kj run store, so an agent saw an empty
17
+ * board while `kj hu list` showed every card. It runs the CLI's own `kj hu` in a
18
+ * subprocess: the same board (local, shared or a lane's), and stdout, which is
19
+ * this server's protocol channel, stays clean.
20
+ */
21
+ async function kjHu(projectDir, args) {
22
+ const res = await runKjCommand({ command: "hu", commandArgs: [...args, "--json"], options: { projectDir } });
23
+ if (!res.ok) return { error: `kj hu ${args[0]} failed: ${res.errorSummary || res.stderr || "no output"}` };
24
+ const line = String(res.stdout || "").trim().split("\n").reverse().find((l) => /^[[{]/.test(l.trim()));
25
+ try { return { data: JSON.parse(line) }; } catch { return { error: `kj hu ${args[0]} answered no JSON: ${String(res.stdout || "").slice(0, 200)}` }; }
26
+ }
27
+
28
+ const notOnBoard = (id) => `HU ${id} is not on the board — kj_hu list shows its cards`;
29
+
30
+ async function createHu(projectDir, a) {
31
+ if (!a.title) return failPayload("Missing required field: title (required for create)");
32
+ const args = ["add", a.title, ...(a.acceptanceCriteria ? ["--ac", a.acceptanceCriteria] : []), ...(a.ref ? ["--ref", a.ref] : [])];
33
+ const added = await kjHu(projectDir, args);
34
+ if (added.error) return failPayload(added.error);
35
+ if (!a.status || a.status === "pending") return { ok: true, hu: added.data };
36
+ return moveHu(projectDir, added.data.short_id || added.data.id, a.status);
37
+ }
38
+
39
+ async function moveHu(projectDir, huId, status) {
40
+ if (!HU_STATUSES.includes(status)) return failPayload(`Unknown status "${status}": use ${HU_STATUSES.join(", ")}`);
41
+ const moved = await kjHu(projectDir, ["move", huId, status]);
42
+ return moved.error ? failPayload(moved.error) : { ok: true, hu: moved.data };
43
+ }
44
+
12
45
  export async function handleHu(a, server) {
13
46
  const action = a.action;
14
47
  if (!action) return failPayload("Missing required field: action");
15
-
16
48
  const projectDir = await resolveProjectDir(server, a.projectDir);
17
- const { createManualHu, listHus, getHu, updateHuStatus } = await import("../../hu/store.js");
18
49
 
19
50
  switch (action) {
20
- case "create": {
21
- if (!a.title) return failPayload("Missing required field: title (required for create)");
22
- const hu = await createManualHu(projectDir, {
23
- title: a.title,
24
- description: a.description,
25
- status: a.status,
26
- acceptanceCriteria: a.acceptanceCriteria
27
- });
28
- return { ok: true, hu };
29
- }
51
+ case "create":
52
+ return createHu(projectDir, a);
30
53
  case "list": {
31
- const hus = await listHus(projectDir);
32
- return { ok: true, hus };
54
+ const listed = await kjHu(projectDir, ["list"]);
55
+ return listed.error ? failPayload(listed.error) : { ok: true, hus: listed.data };
33
56
  }
34
57
  case "get": {
35
58
  if (!a.huId) return failPayload("Missing required field: huId (required for get)");
36
- const hu = await getHu(projectDir, a.huId);
37
- return { ok: true, hu };
59
+ const listed = await kjHu(projectDir, ["list"]);
60
+ if (listed.error) return failPayload(listed.error);
61
+ const hu = listed.data.find((h) => h.id === a.huId || h.short_id === a.huId);
62
+ return hu ? { ok: true, hu } : failPayload(notOnBoard(a.huId));
38
63
  }
39
- case "update": {
64
+ case "update":
40
65
  if (!a.huId) return failPayload("Missing required field: huId (required for update)");
41
66
  if (!a.status) return failPayload("Missing required field: status (required for update)");
42
- const hu = await updateHuStatus(projectDir, a.huId, a.status);
43
- return { ok: true, hu };
44
- }
67
+ return moveHu(projectDir, a.huId, a.status);
45
68
  default:
46
69
  return failPayload(`Unknown hu action: ${action}`);
47
70
  }
package/src/mcp/tools.js CHANGED
@@ -335,17 +335,17 @@ export const tools = [
335
335
  },
336
336
  {
337
337
  name: "kj_hu",
338
- description: "Manage user stories (HUs) in the local board",
338
+ description: "Manage user stories (HUs) in the HU Board, the same board `kj hu` uses (local or shared)",
339
339
  inputSchema: {
340
340
  type: "object",
341
341
  required: ["action"],
342
342
  properties: {
343
343
  action: { type: "string", enum: ["create", "update", "list", "get"], description: "Action to perform" },
344
344
  title: { type: "string", description: "HU title (required for create)" },
345
- description: { type: "string", description: "HU description" },
346
- acceptanceCriteria: { type: "string", description: "Acceptance criteria" },
345
+ acceptanceCriteria: { type: "string", description: "Acceptance criteria, one per line" },
346
+ ref: { type: "string", description: "Id of this card's replica in the external tracker (Planning Game, Jira, Linear...)" },
347
347
  huId: { type: "string", description: "HU ID (required for update/get)" },
348
- status: { type: "string", description: "HU status (for create/update)", enum: ["pending", "coding", "reviewing", "done", "failed", "blocked"] },
348
+ status: { type: "string", description: "HU status (for create/update)", enum: ["pending", "running", "done", "failed", "skipped"] },
349
349
  projectDir: { type: "string", description: "Project directory (defaults to cwd)" }
350
350
  }
351
351
  }
@@ -39,7 +39,9 @@ export async function runSuiteGreenStage({ config, logger, emitter, eventBase, s
39
39
  }
40
40
  const timeout = Number(config?.development?.test_timeout_ms) > 0 ? Number(config.development.test_timeout_ms) : DEFAULT_TIMEOUT_MS;
41
41
  const run = runTestsImpl || (async () => {
42
- const res = await runCommand("bash", ["-lc", command], { cwd: projectDir, timeout });
42
+ // KJC-BUG-0427: never a login shell, which re-reads the person's profile and may rewrite
43
+ // PATH (nvm drops node when npm_config_prefix is set): the suite runs in kj's own environment.
44
+ const res = await runCommand("bash", ["-c", command], { cwd: projectDir, timeout });
43
45
  return { exitCode: res.exitCode ?? 1, output: `${res.stdout || ""}\n${res.stderr || ""}` };
44
46
  });
45
47
  const res = await run(command);
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
12
+ import { isReadOnlyCommand, stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
13
13
  import {
14
14
  ALLOW_VERDICT,
15
15
  checkArtifacts,
@@ -36,7 +36,8 @@ export const SUPERVISOR_DEFAULTS = [
36
36
  // it is USED (commit, CI, release), whatever wrote it: only the human's seal lifts it.
37
37
  id: "defaults.governance.write",
38
38
  // KJC-BUG-0400: the CI workflows too; a PR could rewrite the check that judges it.
39
- pattern: /(^|\/)\.karajan\/(policy|rules)\.yml\b|(^|\/)\.github\/workflows\/[^/\s]+\.ya?ml\b/,
39
+ // KJC-BUG-0429: in shell text a relative path follows a blank or a quote, not a slash.
40
+ pattern: /(^|[^\w.-])\.karajan\/(policy|rules)\.yml\b|(^|[^\w.-])\.github\/workflows\/[^/\s]+\.ya?ml\b/,
40
41
  message: "es configuración de gobierno: solo entra sellada por la persona (kj harden --commit, firmado desde su móvil)",
41
42
  },
42
43
  ];
@@ -84,7 +85,12 @@ export function evalToolCall(policy, { role = "coder", tool, input = {}, root =
84
85
  // KJC-BUG-0343: the inert prose of a text option (--title, --error, -m...) is
85
86
  // not a target: a report naming a supervisor path is not a write to it. A path
86
87
  // anywhere else, or a value that can expand, is still judged.
87
- if (tool === "Bash") return evalShell(policy, role, stripTextOptionValues(String(input.command ?? "")));
88
+ if (tool === "Bash") {
89
+ const command = stripTextOptionValues(String(input.command ?? ""));
90
+ // KJC-BUG-0429 (ADR 0036): a command that only reads may name the guarded files;
91
+ // the role's own shell rules still judge it.
92
+ return evalShell(isReadOnlyCommand(command) ? { ...policy, defaults: [] } : policy, role, command);
93
+ }
88
94
  if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return ALLOW_VERDICT;
89
95
  return denyVerdict(`roles.${role}.tools`, `tool "${tool}" fuera del registro de la policy — no verificable para el rol declarado ${role}`);
90
96
  }
@@ -15,8 +15,8 @@ import { HOST_SETTINGS, isSealedConfig, PROVENANCE_FILE } from "../harden/superv
15
15
 
16
16
  const HOOKS_PREFIX = ".karajan/hooks/";
17
17
  const HARNESS_PREFIX = ".karajan/harness/";
18
- // globalHooksDir es el único texto libre que se interpola en el render:
19
- // vocabulario cerrado de ruta o la provenance no verifica NADA.
18
+ // HU-2762: el render ya no interpola globalHooksDir; se conserva el guard para
19
+ // provenances antiguas, donde era texto libre: vocabulario cerrado o no verifica NADA.
20
20
  const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
21
21
 
22
22
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
@@ -47,10 +47,11 @@ function verdictOf(sides, findings, coverage) {
47
47
  if (blockers.length) return { status: "rejected", approved: false, reason: `confirmed blocker(s): ${blockers.map(where).join(", ")}`, summary };
48
48
  const rejecting = sides.filter((s) => !s.verdict?.approved).map((s) => s.reviewer);
49
49
  if (rejecting.length === 0 && !coverage.truncated) return { status: "approved", approved: true, summary };
50
- const reason = rejecting.length
51
- ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family`
52
- : "not every file was reviewed";
53
- return { status: "partial", approved: false, reason, summary };
50
+ const dispute = rejecting.length ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family` : null;
51
+ // KJC-BUG-0453 (#2921): "partial" only when files were left out (they are in coverage.unreviewed);
52
+ // everything reviewed and the reviewers disagreeing is "disputed".
53
+ if (coverage.truncated) return { status: "partial", approved: false, reason: ["not every file was reviewed", dispute].filter(Boolean).join("; "), summary };
54
+ return { status: "disputed", approved: false, reason: dispute, summary };
54
55
  }
55
56
 
56
57
  /** The two sides (reviewer, model, verdict, coverage, findings, outcomes, verification) as one result. */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * KJC-BUG-0447 (issue #2655): is the review gate actually ENFORCED? The marker
3
+ * only declares it; what refuses a commit is a pre-commit hook that runs
4
+ * `kj review --check`. Checked where git will look (core.hooksPath, local or
5
+ * global, or .git/hooks), never assumed from a hooks path being configured.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ // The contract: a line that RUNS the check and stops the commit when it fails, as the
12
+ // pre-commit kj harden writes (`kj review --check || { ...; exit 1; }`) or `|| exit 1`.
13
+ // A comment, the phrase inside an echo, `|| true`, `|| exit 0` or a group that does
14
+ // not END in `exit N` (N > 0) enforce nothing. Right after `||`, a bare `exit` keeps
15
+ // the failing check's status; inside a group it would take the previous command's.
16
+ const GATE_LINE = /^\s*kj review --check\s*\|\|\s*(?:exit(?:\s+[1-9]\d*)?\s*(?:;|#|$)|\{(?:[^}]*;)?\s*exit\s+[1-9]\d*\s*;?\s*\})/;
17
+ const runsGate = (body) => body.split("\n").some((line) => GATE_LINE.test(line));
18
+
19
+ /** @returns {{enforced: boolean, hooksDir: string|null, reason: string|null}} */
20
+ export function gateHookStatus(projectDir) {
21
+ let hooksDir;
22
+ try {
23
+ // --git-path hooks honours core.hooksPath (repo or global) and worktrees.
24
+ const rel = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
25
+ hooksDir = path.resolve(projectDir, rel);
26
+ } catch {
27
+ return { enforced: false, hooksDir: null, reason: "not a git repository: no hook can enforce the gate" };
28
+ }
29
+ const hook = path.join(hooksDir, "pre-commit");
30
+ let body;
31
+ try { body = fs.readFileSync(hook, "utf8"); } catch {
32
+ return { enforced: false, hooksDir, reason: `no pre-commit hook in ${hooksDir}, where git looks for hooks` };
33
+ }
34
+ if (!runsGate(body)) return { enforced: false, hooksDir, reason: `${hook} does not run kj review --check` };
35
+ try { fs.accessSync(hook, fs.constants.X_OK); } catch {
36
+ return { enforced: false, hooksDir, reason: `${hook} is not executable, so git skips it` };
37
+ }
38
+ return { enforced: true, hooksDir, reason: null };
39
+ }
@@ -14,12 +14,13 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
14
14
  import { resolveReviewProfile } from "./profiles.js";
15
15
  import { parseReviewVerdict } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
+ import { accountLabel } from "../utils/agent-home.js";
17
18
  import { saveVerdict, diffHash } from "./verdict-store.js";
18
19
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
19
20
  import { reportUnparseableVerdict } from "./unparseable-verdict.js";
20
21
  import { detectWorkspace } from "./workspace.js";
21
22
  import { branchOf } from "./correction-rounds.js";
22
- import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
23
+ import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu, reviewerFailureMessage } from "./reviewer-fallback.js";
23
24
 
24
25
  // Cross-AI preference when the configured reviewer IS the host.
25
26
  const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
@@ -60,6 +61,7 @@ export async function runOneShotReview({
60
61
  // review can never unlock a commit of one's own.
61
62
  reviewerOverride = null,
62
63
  persist = true,
64
+ env = process.env,
63
65
  }) {
64
66
  if (!diff || !diff.trim()) {
65
67
  throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
@@ -79,7 +81,9 @@ export async function runOneShotReview({
79
81
  // KJC-BUG-0347: generated files summarized; the verdict still binds to the full diff.
80
82
  diff: summarizeGeneratedFiles(diff), reviewRules: rules, mode: "standard", provider: who, projectDir,
81
83
  });
82
- logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)`);
84
+ // KJC-TSK-1344: with two subscriptions per CLI, say which account reviews.
85
+ const account = accountLabel(who, { env });
86
+ logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)${account ? ` [${account}]` : ""}`);
83
87
  return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
84
88
  };
85
89
 
@@ -115,7 +119,7 @@ export async function runOneShotReview({
115
119
  }
116
120
 
117
121
  if (!result?.ok) {
118
- throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
122
+ throw new Error(reviewerFailureMessage(activeReviewer, result?.error));
119
123
  }
120
124
 
121
125
  // KJC-BUG-0146, second half: parseMaybeJsonString only PARSES — it returns whatever JSON came