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.
- package/package.json +1 -1
- package/src/checks/ai-surface.js +9 -7
- package/src/checks/config-files.js +8 -7
- package/src/cli/register-pipeline.js +4 -0
- package/src/commands/env.js +3 -1
- package/src/commands/go.js +5 -2
- package/src/commands/harden.js +20 -6
- package/src/commands/pr-size.js +4 -3
- package/src/commands/review-gate.js +18 -13
- package/src/config/overrides.js +2 -1
- package/src/environment/board-access.js +11 -9
- package/src/git/automation.js +5 -4
- package/src/harden/branch-protection.js +36 -3
- package/src/harden/harden-engine.js +5 -21
- package/src/harden/hook-templates.js +18 -30
- package/src/harden/phone-sign.js +3 -1
- package/src/harden/published-pin.js +34 -0
- package/src/harden/sentinel/posttooluse.mjs +4 -2
- package/src/harden/sentinel/pretooluse-sentinel.mjs +22 -3
- package/src/harden/sentinel/sentinel-lib.mjs +23 -0
- package/src/harden/sentinel/sentinel-shell.mjs +109 -0
- package/src/harden/workflow-engine.js +34 -7
- package/src/mcp/handlers/hu-handlers.js +43 -20
- package/src/mcp/tools.js +4 -4
- package/src/orchestrator/stages/suite-green-stage.js +3 -1
- package/src/policy/engine.js +9 -3
- package/src/policy/supervisor-verify.js +2 -2
- package/src/review/dual-review.js +5 -4
- package/src/review/gate-hook.js +39 -0
- package/src/review/one-shot-review.js +7 -3
- package/src/review/pr-requirements.js +6 -6
- package/src/review/publish-pr.js +4 -1
- package/src/review/reviewer-fallback.js +18 -2
- package/src/review/verify-pr.js +39 -9
- package/src/sonar/scanner.js +2 -1
- package/src/utils/agent-home.js +33 -0
package/src/harden/phone-sign.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 " +
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
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", "
|
|
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
|
-
|
|
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);
|
package/src/policy/engine.js
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
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
|
-
//
|
|
19
|
-
//
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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
|