karajan-code 4.44.1 → 4.46.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.44.1",
3
+ "version": "4.46.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { addSigner, enrollPhone } from "../harden/phone-sign.js";
12
12
  import { phoneInvite } from "../harden/phone-invite.js";
13
+ import { humanAct } from "../harden/human-act.js";
13
14
  import { readIdentity, writeIdentity } from "../identity/store.js";
14
15
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
15
16
  import { compareIdentity } from "../identity/compare.js";
@@ -28,6 +29,9 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
28
29
  // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
29
30
  if (action === "enroll-phone") {
30
31
  try {
32
+ // KJC-TSK-0966 (HUM-C, ADR 0018): enrolar es un acto del catálogo; hasta hoy
33
+ // solo lo frenaba el Sentinel, y un kj fuera de Claude no tiene Sentinel.
34
+ humanAct("phone-enroll", { env: deps.env ?? process.env, tty: deps.ttyHuman ?? process.stdout.isTTY, ancestry: deps.ancestry ?? {}, confirm: deps.confirm });
31
35
  // KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
32
36
  // base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
33
37
  addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
@@ -12,12 +12,11 @@ import path from "node:path";
12
12
 
13
13
  import yaml from "js-yaml";
14
14
 
15
- import { confirmHuman, refuseAgentSession } from "../harden/human-act.js";
15
+ import { humanActOf } from "../harden/human-act.js";
16
16
  import { checkVerdict } from "../review/verdict-store.js";
17
17
  import { approvalView } from "../rules/approval-view.js";
18
18
  import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } from "./rules.js";
19
19
 
20
- const ACT = "kj rules approve";
21
20
 
22
21
  /**
23
22
  * @param {{projectDir: string, file?: string, home?: string, env?: object, tty?: boolean,
@@ -25,7 +24,8 @@ const ACT = "kj rules approve";
25
24
  * @returns {Promise<{code: 0|1, lines: string[]}>}
26
25
  */
27
26
  export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env, tty, deps = {}, log = console.log }) {
28
- refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
27
+ const act = humanActOf("rules-approve");
28
+ act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
29
29
  const proposal = path.resolve(projectDir, file);
30
30
  let text;
31
31
  try { text = fs.readFileSync(proposal, "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to approve`] }; }
@@ -48,7 +48,7 @@ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env
48
48
  // KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
49
49
  const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
50
50
  for (const line of view) log(line);
51
- confirmHuman(ACT, deps.confirm);
51
+ act.confirm(deps.confirm);
52
52
  // KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
53
53
  // is not versioned. Where it goes is the inventory's word, not the proposal's.
54
54
  const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
@@ -40,6 +40,8 @@ const runner = (projectDir, env) => (args) => execFileSync("git", ["-C", project
40
40
  // or under a stack root (a fullstack monorepo is hardened on every side).
41
41
  const isContract = (file) =>
42
42
  CONTRACT_PATHS.some((p) => (p.endsWith("/") || p.endsWith("-") ? file.startsWith(p) : file === p)) || GENERATED_CONFIG_FILES.has(basename(file));
43
+ /** The supervisor's files: the hooks and their provenance, sealed by the human (ADR 0009). */
44
+ const isSupervisor = (file) => file.startsWith(".karajan/hooks/") || file === ".karajan/supervisor-provenance.json";
43
45
 
44
46
  /** The contract files git sees as changed, with their porcelain code (`??` untracked). */
45
47
  function contractStatus(git) {
@@ -70,9 +72,14 @@ const branchOf = (git) => { try { return git(["symbolic-ref", "--short", "HEAD"]
70
72
  */
71
73
  export function commitContract({ projectDir, before = new Set(), baseBranch = "main", env = process.env }) {
72
74
  const git = runner(projectDir, env);
73
- const files = contractStatus(git).map(([file]) => file).filter((file) => !before.has(file)).sort();
74
- if (files.length === 0) return { committed: false, reason: "nothing of the contract to commit" };
75
75
  const history = hasCommits(git);
76
+ // KJC-BUG-0302: a sealed supervisor regenerated (tracked hooks or provenance
77
+ // that changed) is the seal's, a human act (ADR 0009), never the contract
78
+ // commit's: regenerated together after a version bump, the gate refused the one
79
+ // and the seal the other, and the person had to unstage the hook by hand. The
80
+ // first install (untracked hooks) still rides in the contract commit.
81
+ const files = contractStatus(git).filter(([file, code]) => !before.has(file) && !(history && code !== "??" && isSupervisor(file))).map(([file]) => file).sort();
82
+ if (files.length === 0) return { committed: false, reason: "nothing of the contract to commit" };
76
83
  if (history && branchOf(git) === baseBranch) {
77
84
  // kj never commits on the base branch, and will not switch the person's branch
78
85
  // for them: the files are named so the commit can be made where it belongs.
@@ -83,7 +90,10 @@ export function commitContract({ projectDir, before = new Set(), baseBranch = "m
83
90
  // --only: these paths and nothing else. What the person had staged stays staged.
84
91
  git(["commit", "--only", "-m", history ? REGEN_MESSAGE : FRESH_MESSAGE, "--", ...files]);
85
92
  } catch (err) {
86
- return { committed: false, files, reason: `git could not commit the contract: ${String(err.stderr || err.message).trim().split("\n")[0]}` };
93
+ // KJC-BUG-0302: the files stay staged, and the reason names the way to commit them.
94
+ const why = String(err.stderr || err.message).trim().split("\n")[0];
95
+ const quoted = files.map((f) => "'" + f.replaceAll("'", String.raw`'\''`) + "'").join(" ");
96
+ return { committed: false, files, reason: `git could not commit the contract: ${why}. Commit these generated files on a branch yourself: git add -- ${quoted} && kj review --staged && git commit -m "chore(kj): el contrato del método, generado por kj"` };
87
97
  }
88
98
  return { committed: true, files };
89
99
  }
@@ -68,3 +68,41 @@ export function confirmHuman(what, confirm = null) {
68
68
  const answer = confirm ? confirm(nonce) : readTty(what, nonce);
69
69
  if (answer !== nonce) throw new Error(`${what}: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
70
70
  }
71
+
72
+ /**
73
+ * KJC-TSK-0966 (HUM-C, ADR 0018): the CLOSED catalog of human acts. Every
74
+ * command that changes who governs, or that destroys work, is listed here and
75
+ * runs through this module; an architecture test fails on a module of the
76
+ * catalog that skips it and on a writer of governance files that is not here.
77
+ * `module` names the file that implements the act; `pending` names the card
78
+ * that will, for acts the ADR declares and kj does not have yet.
79
+ */
80
+ export const HUMAN_ACTS = Object.freeze({
81
+ "supervisor-seal": { label: "harden --commit", module: "src/harden/supervisor-commit.js" },
82
+ "rules-approve": { label: "kj rules approve", module: "src/commands/rules-approve.js" },
83
+ "phone-enroll": { label: "kj identity enroll-phone", module: "src/commands/identity.js" },
84
+ "handoff-run": { label: "kj handoff run", module: null, pending: "KJC-TSK-0968" },
85
+ "security-level-down": { label: "kj security normal", module: null, pending: "KJC-TSK-0967" },
86
+ });
87
+
88
+ /**
89
+ * The one mechanism, by catalog id: `refuse` (layers 1 to 3) and `confirm`
90
+ * (layer 4), apart because an act may check things in between (the seal looks
91
+ * at the stage before asking for the nonce). `humanAct` runs both.
92
+ * @param {keyof typeof HUMAN_ACTS} id
93
+ */
94
+ export function humanActOf(id) {
95
+ const act = HUMAN_ACTS[id];
96
+ if (!act) throw new Error(`"${id}" is not in the catalog of human acts (src/harden/human-act.js, ADR 0018)`);
97
+ return {
98
+ label: act.label,
99
+ refuse: (opts = {}) => refuseAgentSession(act.label, opts),
100
+ confirm: (confirm = null) => confirmHuman(act.label, confirm),
101
+ };
102
+ }
103
+
104
+ export function humanAct(id, { env, tty, ancestry, confirm } = {}) {
105
+ const act = humanActOf(id);
106
+ act.refuse({ env, tty, ancestry });
107
+ act.confirm(confirm);
108
+ }
@@ -164,7 +164,14 @@ export function provenanceSignature({ projectDir, provenance }) {
164
164
  * mala / clave ajena / TTL vencido ⇒ {ok:false, reason}.
165
165
  * @returns {Promise<{ok: boolean, reason?: string}>}
166
166
  */
167
- export async function requestPhoneSignature({ project, files, kjVersion, logger = console, deps = {} }) {
167
+ // KJC-TSK-0992 (HUM-G): what the page shows beside the sha256, as Firestore values.
168
+ const str = (s) => ({ stringValue: String(s ?? "") });
169
+ const changesValue = (changes) => ({ arrayValue: { values: changes.map((c) => ({ mapValue: { fields: {
170
+ file: str(c.file), status: str(c.status), added: { integerValue: String(c.added ?? 0) }, removed: { integerValue: String(c.removed ?? 0) },
171
+ summary: str(c.summary), diff: str(c.diff), truncated: { booleanValue: Boolean(c.truncated) },
172
+ } } })) } });
173
+
174
+ export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, logger = console, deps = {} }) {
168
175
  const fetchFn = deps.fetch ?? fetch;
169
176
  const now = deps.now ?? Date.now;
170
177
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -184,6 +191,10 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
184
191
  createdAt: { timestampValue: new Date(issuedMs).toISOString() },
185
192
  files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
186
193
  ...(v === 2 ? { v: { integerValue: "2" }, expires_at: { timestampValue: new Date(expiresMs).toISOString() } } : {}),
194
+ // KJC-TSK-0992: informative, outside the signed payload; the page says so.
195
+ ...(Array.isArray(changes) ? { changes: changesValue(changes) } : {}),
196
+ ...(branch ? { branch: str(branch) } : {}),
197
+ ...(origin ? { origin: str(origin) } : {}),
187
198
  },
188
199
  };
189
200
  const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
@@ -0,0 +1,81 @@
1
+ /**
2
+ * KJC-TSK-0992 (HUM-G, ADR 0018): what the phone is asked to sign, said.
3
+ *
4
+ * The signing page used to show a project name and a list of files with their
5
+ * sha256: nothing a person can read as "this is what changes". The signature
6
+ * still covers exactly the sha256 (payload v2); this module describes, file by
7
+ * file, what kj regenerated so the page can show it: new, modified or deleted,
8
+ * lines added and removed, a one-line summary and the unified diff, cut to a
9
+ * size the request can carry and scanned for personal data before it leaves.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ import { scanText } from "../privacy/scan.js";
16
+
17
+ export const DIFF_LIMIT_PER_FILE = 12_000;
18
+ export const DIFF_LIMIT_TOTAL = 400_000;
19
+
20
+ const defaultGit = (projectDir) => (args) => {
21
+ try {
22
+ return execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
23
+ } catch (err) {
24
+ // `git diff --no-index` exits 1 when the files differ: the diff is still on stdout.
25
+ if (args.includes("--no-index") && typeof err?.stdout === "string") return err.stdout;
26
+ throw err;
27
+ }
28
+ };
29
+
30
+ const inHead = (git, file) => { try { git(["cat-file", "-e", `HEAD:${file}`]); return true; } catch { return false; } };
31
+
32
+ /** The first comment kj added, which in a generated hook says why it changed. */
33
+ const summaryOf = (diff) => {
34
+ const comment = diff.split("\n").find((l) => /^\+\s*(#|\/\/)\s*\S/.test(l) && !l.startsWith("+++"));
35
+ return comment ? comment.replace(/^\+\s*(#|\/\/)\s*/, "").trim().slice(0, 160) : "";
36
+ };
37
+
38
+ /** A file git does not know yet: the whole file is the addition. */
39
+ const newFile = (git, projectDir, file) => ({
40
+ raw: git(["diff", "--no-index", "--", "/dev/null", join(projectDir, file)]),
41
+ added: readFileSync(join(projectDir, file), "utf8").split("\n").filter(Boolean).length,
42
+ removed: 0,
43
+ });
44
+ /** A tracked file, changed or deleted: the diff against HEAD and its numstat. */
45
+ const trackedFile = (git, file) => {
46
+ const stat = git(["diff", "--numstat", "HEAD", "--", file]).trim().split("\t");
47
+ return { raw: git(["diff", "HEAD", "--", file]), added: Number.parseInt(stat[0], 10) || 0, removed: Number.parseInt(stat[1], 10) || 0 };
48
+ };
49
+
50
+ const MARKER_ROOM = 60;
51
+ /** Cut `text` so the result, marker included, never exceeds `limit`; no room left is one bounded marker. */
52
+ const cut = (text, limit) => {
53
+ if (text.length <= limit) return { diff: text, truncated: false };
54
+ const keep = Math.max(0, limit - MARKER_ROOM);
55
+ return { diff: `${text.slice(0, keep)}\n… [cut: ${text.length - keep} more characters]`.slice(0, limit), truncated: true };
56
+ };
57
+
58
+ /**
59
+ * @param {{projectDir: string, files: Array<{file: string, deleted?: boolean}>, gitFn?: Function, scan?: Function}} opts
60
+ * @returns {Array<{file: string, status: "new"|"modified"|"deleted", added: number, removed: number, summary: string, diff: string, truncated: boolean}>}
61
+ */
62
+ export function describeSupervisorChanges({ projectDir, files, gitFn = defaultGit(projectDir), scan = scanText }) {
63
+ let budget = DIFF_LIMIT_TOTAL;
64
+ return files.map(({ file, deleted }) => {
65
+ const tracked = inHead(gitFn, file);
66
+ const present = !deleted && existsSync(join(projectDir, file));
67
+ let status = "modified";
68
+ if (deleted || !present) status = "deleted";
69
+ else if (!tracked) status = "new";
70
+ const measured = status === "new" ? newFile(gitFn, projectDir, file) : trackedFile(gitFn, file);
71
+ const { added, removed } = measured;
72
+ // What leaves the machine is scanned like anything kj publishes: a hit
73
+ // replaces the diff, never travels.
74
+ const hits = scan(measured.raw, { source: file }).filter((h) => h.severity === "block");
75
+ const raw = hits.length > 0 ? `[diff withheld: the privacy scan found ${hits.length} sensitive item(s) in ${file}]` : measured.raw;
76
+ // Per file and in total: what the request carries never exceeds the limits.
77
+ const { diff, truncated } = cut(raw, Math.min(DIFF_LIMIT_PER_FILE, Math.max(0, budget)));
78
+ budget = Math.max(0, budget - diff.length);
79
+ return { file, status, added, removed, summary: summaryOf(raw), diff, truncated };
80
+ });
81
+ }
@@ -13,8 +13,9 @@ import { basename, join } from "node:path";
13
13
  import { recordGateDecision } from "../policy/decisions.js";
14
14
  import { readIdentity } from "../identity/store.js";
15
15
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
16
- import { confirmHuman, refuseAgentSession } from "./human-act.js";
16
+ import { humanActOf } from "./human-act.js";
17
17
  import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
18
+ import { describeSupervisorChanges } from "./sign-changes.js";
18
19
 
19
20
  export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
20
21
  const HOOKS_PREFIX = ".karajan/hooks/";
@@ -28,7 +29,6 @@ const HARNESS_PREFIX = ".karajan/harness/";
28
29
 
29
30
  const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
30
31
 
31
- const ACT = "harden --commit";
32
32
 
33
33
  /** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
34
34
  /**
@@ -73,7 +73,8 @@ export async function commitSupervisorRegeneration({
73
73
  }) {
74
74
  // El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
75
75
  // Capas 1 a 3 (entorno, tty y ascendencia de procesos): src/harden/human-act.js.
76
- refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
76
+ const seal = humanActOf("supervisor-seal");
77
+ seal.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
77
78
  const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
78
79
  // KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
79
80
  // un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
@@ -86,7 +87,7 @@ export async function commitSupervisorRegeneration({
86
87
  }
87
88
  // Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
88
89
  // prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
89
- confirmHuman(ACT, deps.confirm);
90
+ seal.confirm(deps.confirm);
90
91
  const drift = supervisorDrift({ projectDir, gitFn: run });
91
92
  // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
92
93
  // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
@@ -125,10 +126,16 @@ export async function commitSupervisorRegeneration({
125
126
  const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
126
127
  let signatureBlock = null;
127
128
  if (phone.enrolled({})) {
129
+ // KJC-TSK-0992 (HUM-G): the page says what is signed, file by file.
130
+ let branch = null;
131
+ try { branch = run(["rev-parse", "--abbrev-ref", "HEAD"]).trim(); } catch { /* unborn HEAD */ }
128
132
  const signed = await phone.request({
129
133
  project: basename(projectDir),
130
134
  files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
131
135
  kjVersion,
136
+ changes: (deps.describeChanges ?? describeSupervisorChanges)({ projectDir, files: hashed }),
137
+ branch,
138
+ origin: `kj harden ${kjVersion}`,
132
139
  logger,
133
140
  });
134
141
  if (!signed.ok) {
@@ -32,6 +32,8 @@ import {
32
32
  } from "../../session/mutators.js";
33
33
  import { emitProgress, makeEvent } from "../../utils/events.js";
34
34
  import { runTesterStage, runSecurityStage, runFinalAuditStage } from "../post-loop-stages.js";
35
+ import { runAcceptanceCoverageStage } from "../stages/acceptance-coverage-stage.js";
36
+ import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
35
37
  import { invokeSolomon } from "../solomon-escalation.js";
36
38
  import { tryCiComment } from "../ci-integration.js";
37
39
  import { getIntegration } from "../integrations.js";
@@ -103,6 +105,23 @@ export async function handlePostLoopStages({ config, session, emitter, eventBase
103
105
  return { action: "continue" };
104
106
  }
105
107
 
108
+ // KJC-TSK-0987 (#1894): with a card, each acceptance criterion is read against
109
+ // the diff by a different AI before the run is approved. An uncovered one goes
110
+ // back to the coder; a judge with no verdict stops the approval, never grants it.
111
+ const coverage = await runAcceptanceCoverageStage({
112
+ config, logger, emitter, eventBase, trackBudget, iteration: i, task,
113
+ criteria: criteriaOfCard(session.pg_card), diff: postLoopDiff,
114
+ });
115
+ if (coverage.stageResult) stageResults.acceptanceCoverage = coverage.stageResult;
116
+ if (coverage.action === "retry") {
117
+ setReviewerFeedback(session, coverage.feedback);
118
+ await saveSession(session);
119
+ return { action: "continue" };
120
+ }
121
+ if (coverage.action === "fail") {
122
+ return { action: "return", result: { approved: false, sessionId: session.id, reason: "acceptance_coverage_unavailable", error: coverage.stageResult.summary } };
123
+ }
124
+
106
125
  return { action: "proceed" };
107
126
  }
108
127
 
@@ -32,6 +32,9 @@ import { runRepair } from "../repair/repair-runner.js";
32
32
  import { writeHistoryRecord } from "./post-loop.js";
33
33
  import { setReviewerFeedback } from "../../session/mutators.js";
34
34
  import { createHuSnapshot } from "../../git/hu-snapshot.js";
35
+ import { generateDiff } from "../../review/diff-generator.js";
36
+ import { runAcceptanceCoverageStage } from "../stages/acceptance-coverage-stage.js";
37
+ import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
35
38
 
36
39
  /**
37
40
  * @param {object} args
@@ -422,12 +425,28 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
422
425
  // the host reads, not only in a warn line of the log.
423
426
  const warnings = collectGitWarnings(subPipelineResult.results);
424
427
  for (const w of warnings) logger.warn(`git automation: ${w}`);
428
+ // KJC-TSK-0987 (#1894): the HUs approved themselves against their own stories;
429
+ // the card's criteria are read against the diff of the whole run before the
430
+ // run says approved. Uncovered: approved:false with the list, for the host.
431
+ let coverage = { action: "skip" };
432
+ if (subPipelineResult.approved) {
433
+ const diff = await generateDiff({ baseRef: ctx.session.head_at_start || ctx.session.session_start_sha });
434
+ coverage = await runAcceptanceCoverageStage({
435
+ config: ctx.config, logger, emitter, eventBase: ctx.eventBase, trackBudget: ctx.trackBudget,
436
+ iteration: 0, task, criteria: criteriaOfCard(ctx.session.pg_card), diff,
437
+ });
438
+ if (coverage.stageResult) ctx.stageResults.acceptanceCoverage = coverage.stageResult;
439
+ }
440
+ const uncovered = coverage.action === "retry" ? { reason: "acceptance_criteria_uncovered", uncovered: coverage.stageResult.uncovered } : {};
441
+ const unavailable = coverage.action === "fail" ? { reason: "acceptance_coverage_unavailable", error: coverage.stageResult.summary } : {};
425
442
  const finalResult = {
426
- approved: subPipelineResult.approved,
443
+ approved: subPipelineResult.approved && coverage.action !== "retry" && coverage.action !== "fail",
427
444
  sessionId: ctx.session.id,
428
445
  huResults: subPipelineResult.results,
429
446
  blockedIds: subPipelineResult.blockedIds,
430
447
  planId: ctx.session._planRef?.planId || null,
448
+ ...uncovered,
449
+ ...unavailable,
431
450
  ...(warnings.length > 0 ? { warnings } : {})
432
451
  };
433
452
  await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
@@ -0,0 +1,47 @@
1
+ // KJC-TSK-0987 (issue #1894): the acceptance-coverage stage. With a card whose
2
+ // criteria the run was meant to implement, a different AI reads each criterion
3
+ // against the diff before the run is approved. No card or no criteria: the
4
+ // stage does not run and costs nothing. An unreadable verdict is the judge's
5
+ // failure, never an approval.
6
+ import { AcceptanceCoverageRole } from "../../roles/acceptance-coverage-role.js";
7
+ import { emitProgress, makeEvent } from "../../utils/events.js";
8
+
9
+ const STAGE = "acceptance-coverage";
10
+
11
+ /** The feedback the coder gets: the criteria with no evidence in the diff. */
12
+ export const uncoveredFeedback = (uncovered) => {
13
+ const lines = uncovered.map((c) => "- [" + c.index + "] " + c.text + (c.evidence ? " (judge: " + c.evidence + ")" : ""));
14
+ return "Acceptance criteria NOT covered by the diff (implement them, with tests that exercise them):\n" + lines.join("\n");
15
+ };
16
+
17
+ /**
18
+ * @returns {Promise<{action: "skip"|"ok"|"retry"|"fail", stageResult?: object, feedback?: string}>}
19
+ */
20
+ export async function runAcceptanceCoverageStage({ config, logger, emitter, eventBase, trackBudget, iteration, task, criteria, diff, RoleClass = AcceptanceCoverageRole }) {
21
+ if (!Array.isArray(criteria) || criteria.length === 0) return { action: "skip" };
22
+ logger.setContext?.({ iteration, stage: STAGE });
23
+ emitProgress(emitter, makeEvent(`${STAGE}:start`, { ...eventBase, stage: STAGE }, {
24
+ message: `Acceptance coverage: ${criteria.length} criteria against the diff`,
25
+ detail: { criteria: criteria.length, executorType: "agent" },
26
+ }));
27
+ const role = new RoleClass({ config, logger, emitter });
28
+ await role.init({ task, iteration });
29
+ const started = Date.now();
30
+ const output = await role.run({ task, criteria, diff });
31
+ const provider = output.result?.provider || role.resolveProvider();
32
+ trackBudget?.({ role: STAGE, provider, model: config?.roles?.[STAGE]?.model || null, result: output, duration_ms: Date.now() - started });
33
+
34
+ if (!output.ok) {
35
+ const stageResult = { ok: false, summary: output.summary || "acceptance-coverage: the judge gave no verdict", provider, error: output.result?.error || null };
36
+ emitProgress(emitter, makeEvent(`${STAGE}:end`, { ...eventBase, stage: STAGE }, { status: "fail", message: stageResult.summary, detail: stageResult }));
37
+ return { action: "fail", stageResult };
38
+ }
39
+ const { criteria: verdicts, uncovered } = output.result;
40
+ const stageResult = { ok: uncovered.length === 0, summary: output.summary, provider, criteria: verdicts, uncovered };
41
+ emitProgress(emitter, makeEvent(`${STAGE}:end`, { ...eventBase, stage: STAGE }, {
42
+ status: stageResult.ok ? "ok" : "fail", message: output.summary, detail: stageResult,
43
+ }));
44
+ if (stageResult.ok) return { action: "ok", stageResult };
45
+ logger.warn(`${output.summary} — sending them back to the coder`);
46
+ return { action: "retry", stageResult, feedback: uncoveredFeedback(uncovered) };
47
+ }
@@ -8,7 +8,8 @@ import { HuReviewerRole } from "../../roles/hu-reviewer-role.js";
8
8
  import { createAgent } from "../../agents/index.js";
9
9
  import { addCheckpoint } from "../../session/store.js";
10
10
  import { emitProgress, makeEvent } from "../../utils/events.js";
11
- import { buildDecompositionPrompt, parseDecompositionOutput } from "../../prompts/hu-reviewer.js";
11
+ import { buildDecompositionPrompt, parseDecompositionOutput, uncoveredCriteria } from "../../prompts/hu-reviewer.js";
12
+ import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
12
13
  import { createStallDetector } from "../../utils/stall-detector.js";
13
14
  import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../../hu/store.js";
14
15
  import { topologicalSort } from "../../hu/graph.js";
@@ -20,7 +21,10 @@ import { detectAndApplySplits } from "./hu-reviewer-phases/detect-and-apply-spli
20
21
  */
21
22
  async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
22
23
  const provider = config?.roles?.hu_reviewer?.provider || coderRole.provider;
23
- const prompt = buildDecompositionPrompt(session.task);
24
+ // KJC-TSK-0989 (#1894): the card's criteria go into the decomposition, numbered,
25
+ // so each one lands in an HU; the ones no HU covers are said and kept, never lost.
26
+ const criteria = criteriaOfCard(session.pg_card);
27
+ const prompt = buildDecompositionPrompt(session.task, { criteria });
24
28
 
25
29
  emitProgress(
26
30
  emitter,
@@ -70,6 +74,19 @@ async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, sessio
70
74
  logger.info("HU decomposition returned < 2 stories, falling back to single auto-story");
71
75
  return null;
72
76
  }
77
+ const uncovered = uncoveredCriteria(stories, criteria);
78
+ if (uncovered.length > 0) {
79
+ const last = stories.at(-1);
80
+ const tags = uncovered.map((c) => `[AC-${c.index}]`);
81
+ last.acceptanceCriteria.push(...uncovered.map((c, i) => `${tags[i]} ${c.text}`));
82
+ last.covers.push(...uncovered.map((c) => c.index));
83
+ logger.warn(`HU decomposition left ${uncovered.length} card criteria with no HU; added to ${last.id}: ${tags.join(" ")}`);
84
+ emitProgress(emitter, makeEvent("hu-reviewer:criteria-uncovered", { ...eventBase, stage: "hu-reviewer" }, {
85
+ status: "warn",
86
+ message: `${uncovered.length} card criteria had no HU and were added to ${last.id}`,
87
+ detail: { uncovered, addedTo: last.id }
88
+ }));
89
+ }
73
90
 
74
91
  emitProgress(
75
92
  emitter,
@@ -0,0 +1,32 @@
1
+ // KJC-TSK-0987 (issue #1894): the prompt of the acceptance-coverage judge. A run
2
+ // ended approved with none of the card's three criteria implemented; this judge
3
+ // reads each criterion against the diff and says, one by one, whether it is
4
+ // covered and where.
5
+
6
+ const PREAMBLE = "IMPORTANT: You are running as a Karajan sub-agent. "
7
+ + "Do NOT use any MCP tools, do NOT mention Karajan. "
8
+ + "Your job is to judge whether a diff covers a card's acceptance criteria.";
9
+
10
+ /**
11
+ * @param {{criteria: string[], diff: string, task?: string, instructions?: string|null}} opts
12
+ * @returns {string}
13
+ */
14
+ export function buildAcceptanceCoveragePrompt({ criteria, diff, task = "", instructions = null }) {
15
+ const list = criteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
16
+ const body = [
17
+ "You are the AcceptanceCoverage judge for Karajan Code.",
18
+ "Input: (a) the acceptance criteria of the task's card, numbered, and (b) the diff the run produced.",
19
+ "For EACH criterion decide whether the diff implements it. Covered means the behavior the",
20
+ "criterion describes exists in the diff (code, config or tests that exercise it), not that",
21
+ "something related was touched. Point to the evidence: file and what it does. A criterion",
22
+ "with no evidence in the diff is NOT covered, whatever the task description says.",
23
+ "Return a single valid JSON object and nothing else.",
24
+ 'Schema: {"criteria":[{"index":number,"covered":boolean,"evidence":string}]}',
25
+ "One entry per criterion, in order, index starting at 1. Do not add or drop criteria.",
26
+ ].join("\n");
27
+ const sections = [PREAMBLE];
28
+ if (instructions) sections.push(instructions);
29
+ if (task) sections.push(`## Task\n${task}`);
30
+ sections.push(body, `## Acceptance criteria (${criteria.length})\n${list}`, `## Diff\n${diff || "(empty diff)"}`);
31
+ return sections.join("\n\n");
32
+ }
@@ -136,7 +136,7 @@ export function normalizeAcceptanceCriteria(criteria) {
136
136
  * @param {string} task - The complex task description to decompose.
137
137
  * @returns {string} The assembled decomposition prompt.
138
138
  */
139
- export function buildDecompositionPrompt(task) {
139
+ export function buildDecompositionPrompt(task, { criteria = [] } = {}) {
140
140
  const sections = [SUBAGENT_PREAMBLE];
141
141
 
142
142
  sections.push("## Task Decomposition");
@@ -147,20 +147,41 @@ export function buildDecompositionPrompt(task) {
147
147
 
148
148
  sections.push(`## Task to Decompose\n${task}`);
149
149
 
150
+ // KJC-TSK-0989 (#1894): the card's criteria are the contract; each one must
151
+ // land in an HU, by number, or the run implements stories of its own invention.
152
+ if (criteria.length > 0) {
153
+ const numbered = criteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
154
+ sections.push(`## Acceptance criteria of the card (the contract)\n${numbered}`);
155
+ }
156
+
150
157
  sections.push(
151
158
  "Return a single valid JSON object and nothing else.",
152
- `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string]}]}`,
159
+ `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number]}]}`,
153
160
  "Rules:",
154
161
  "- Generate between 2 and 5 HUs",
155
162
  "- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
156
163
  "- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
157
164
  "- acceptanceCriteria should be concrete, testable statements",
158
- "- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)"
165
+ "- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)",
166
+ ...(criteria.length > 0 ? [
167
+ "- covers lists the numbers of the card's acceptance criteria this HU implements; EVERY card criterion must appear in the covers of at least one HU, and an HU's acceptanceCriteria must include each criterion it covers, verbatim",
168
+ ] : ["- covers is an empty array"])
159
169
  );
160
170
 
161
171
  return sections.join("\n\n");
162
172
  }
163
173
 
174
+ /**
175
+ * KJC-TSK-0989: the card's criteria no HU covers, by number and text.
176
+ * @param {Array<{covers?: number[]}>} stories
177
+ * @param {string[]} criteria
178
+ * @returns {Array<{index: number, text: string}>}
179
+ */
180
+ export function uncoveredCriteria(stories, criteria) {
181
+ const covered = new Set(stories.flatMap((s) => s.covers ?? []));
182
+ return criteria.map((text, i) => ({ index: i + 1, text })).filter((c) => !covered.has(c.index));
183
+ }
184
+
164
185
  /**
165
186
  * Parse the output from a decomposition prompt.
166
187
  * @param {string} raw - Raw text output from the agent.
@@ -179,7 +200,8 @@ export function parseDecompositionOutput(raw) {
179
200
  goal: s.goal || s.title,
180
201
  benefit: s.benefit || "",
181
202
  acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
182
- dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : []
203
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
204
+ covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : []
183
205
  }));
184
206
 
185
207
  return stories.length > 0 ? stories : null;
@@ -0,0 +1,78 @@
1
+ // KJC-TSK-0987 (issue #1894): AcceptanceCoverageRole, the judge that reads each
2
+ // acceptance criterion of the card against the diff before the run is approved.
3
+ // Judged by a different AI than the coder (the reviewer's provider by default).
4
+ // Role + helpers ship here; the stage wires it (same PR), the loops next.
5
+
6
+ import { AgentRole } from "./agent-role.js";
7
+ import { buildAcceptanceCoveragePrompt } from "../prompts/acceptance-coverage.js";
8
+ import { extractFirstJson } from "../utils/json-extract.js";
9
+
10
+ /**
11
+ * The card's acceptance criteria as plain sentences, one per criterion.
12
+ * Structured Given/When/Then first; else the free text, one per line or bullet.
13
+ * @param {object|null} card
14
+ * @returns {string[]}
15
+ */
16
+ export function criteriaOfCard(card) {
17
+ const structured = Array.isArray(card?.acceptanceCriteriaStructured) ? card.acceptanceCriteriaStructured : [];
18
+ const fromStructured = structured
19
+ .map((ac) => (ac?.given && ac?.when && ac?.then ? `Given ${ac.given}, when ${ac.when}, then ${ac.then}` : String(ac?.raw || "").trim()))
20
+ .filter(Boolean);
21
+ if (fromStructured.length > 0) return fromStructured;
22
+ return String(card?.acceptanceCriteria || "")
23
+ .split("\n")
24
+ .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim())
25
+ .filter(Boolean);
26
+ }
27
+
28
+ const normalizeVerdicts = (parsed, criteria) => criteria.map((text, i) => {
29
+ const v = Array.isArray(parsed?.criteria) ? parsed.criteria.find((c) => Number(c?.index) === i + 1) : null;
30
+ return {
31
+ index: i + 1,
32
+ text,
33
+ // No entry for a criterion is no evidence for it: not covered, never assumed.
34
+ covered: v ? Boolean(v.covered) : false,
35
+ evidence: typeof v?.evidence === "string" ? v.evidence.trim() : "",
36
+ };
37
+ });
38
+
39
+ export class AcceptanceCoverageRole extends AgentRole {
40
+ constructor(opts) {
41
+ super({ ...opts, name: "acceptance-coverage" });
42
+ }
43
+
44
+ // A different AI than the coder: the reviewer's, unless the role has its own.
45
+ resolveProvider() {
46
+ return this.config?.roles?.["acceptance-coverage"]?.provider
47
+ || this.config?.roles?.reviewer?.provider
48
+ || this.config?.reviewer
49
+ || "codex";
50
+ }
51
+
52
+ extractInput(input) {
53
+ return { ...super.extractInput(input), criteria: Array.isArray(input?.criteria) ? input.criteria : [], diff: String(input?.diff || "") };
54
+ }
55
+
56
+ async buildPrompt({ task, criteria, diff }) {
57
+ this._criteria = criteria;
58
+ return { prompt: buildAcceptanceCoveragePrompt({ criteria, diff, task, instructions: this.instructions }) };
59
+ }
60
+
61
+ parseOutput(raw) { return extractFirstJson(raw); }
62
+
63
+ // A verdict with no `criteria` array is not a verdict: the reviewer's failure.
64
+ isSuccessful(parsed) { return Array.isArray(parsed?.criteria); }
65
+
66
+ buildSuccessResult(parsed, provider) {
67
+ const criteria = normalizeVerdicts(parsed, this._criteria || []);
68
+ const uncovered = criteria.filter((c) => !c.covered);
69
+ return { criteria, uncovered, provider, error: Array.isArray(parsed?.criteria) ? undefined : "no criteria array in the verdict" };
70
+ }
71
+
72
+ buildSummary(parsed) {
73
+ if (!Array.isArray(parsed?.criteria)) return "acceptance-coverage: unreadable verdict (no criteria array)";
74
+ const total = (this._criteria || []).length;
75
+ const uncovered = normalizeVerdicts(parsed, this._criteria || []).filter((c) => !c.covered).length;
76
+ return uncovered === 0 ? `acceptance-coverage: ${total}/${total} criteria covered` : `acceptance-coverage: ${uncovered} of ${total} criteria NOT covered`;
77
+ }
78
+ }
@@ -3,8 +3,22 @@ import { readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isSea } from "node:sea";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { getKarajanHome } from "./paths.js";
7
8
 
9
+ // KJC-BUG-0301: `kj update` on a kj linked from a source tree (npm link) said
10
+ // "Updating 4.44.0 → 4.43.0", installed the registry's older version globally
11
+ // and replaced the development link. Two facts decide now, before any install.
12
+ /** Whether this kj runs from a source tree (npm link), not from a node_modules install. */
13
+ export const isSourceTree = (selfPath = fileURLToPath(import.meta.url)) => !selfPath.split(path.sep).includes("node_modules");
14
+ const numeric = (v) => String(v).split(/[.-]/, 3).map((n) => Number.parseInt(n, 10) || 0);
15
+ /** Whether version `a` is strictly newer than `b` (major.minor.patch; a prerelease tag is ignored). */
16
+ export const isNewerVersion = (a, b) => {
17
+ const [x, y] = [numeric(a), numeric(b)];
18
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
19
+ return false;
20
+ };
21
+
8
22
  const CACHE_FILE = "update-check.json";
9
23
  // KJC-TSK-0690: 6h — kj can ship several releases a day; a session should
10
24
  // not work a full day blind. KJ_NO_UPDATE_CHECK=1 opts out (CI).
@@ -183,7 +197,7 @@ export async function printUpdateNotice(currentVersion) {
183
197
  * @param {typeof fetch} [opts.fetchFn] - injectable fetch (registry lookup + installer download)
184
198
  * @returns {Promise<{ ok: boolean, alreadyLatest?: boolean, latest?: string, manual?: boolean }>}
185
199
  */
186
- export async function performSelfUpdate({ currentVersion, exec, logger = console, channel, fetchFn = fetch } = {}) {
200
+ export async function performSelfUpdate({ currentVersion, exec, logger = console, channel, fetchFn = fetch, sourceTree = isSourceTree() } = {}) {
187
201
  const run = exec || (async (cmd, args) => (await import("execa")).execa(cmd, args));
188
202
  const ch = channel || detectInstallChannel();
189
203
  logger.log(`Current version: ${currentVersion}`);
@@ -207,6 +221,15 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
207
221
  logger.log(`Already on the latest version (${currentVersion}).`);
208
222
  return { ok: true, alreadyLatest: true, latest };
209
223
  }
224
+ // KJC-BUG-0301: never a downgrade, and never over a development link.
225
+ if (isNewerVersion(currentVersion, latest)) {
226
+ logger.log(`This kj (${currentVersion}) is newer than the registry's latest (${latest}): nothing to update.`);
227
+ return { ok: true, newerThanRegistry: true, latest };
228
+ }
229
+ if (ch === "npm" && sourceTree) {
230
+ logger.log(`Update available: ${currentVersion} → ${latest}, but this kj runs from a source tree (npm link) and kj update will not replace it. Pull or rebuild that tree, or unlink it first: npm unlink -g ${packageName()}`);
231
+ return { ok: true, latest, manual: true };
232
+ }
210
233
 
211
234
  if (ch === "sea" && process.platform === "win32") {
212
235
  // Executing a downloaded .ps1 from inside the binary is not supported