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 +1 -1
- package/src/commands/identity.js +4 -0
- package/src/commands/rules-approve.js +4 -4
- package/src/environment/contract-commit.js +13 -3
- package/src/harden/human-act.js +38 -0
- package/src/harden/phone-sign.js +12 -1
- package/src/harden/sign-changes.js +81 -0
- package/src/harden/supervisor-commit.js +11 -4
- package/src/orchestrator/drivers/post-loop.js +19 -0
- package/src/orchestrator/drivers/run-hu-batch.js +20 -1
- package/src/orchestrator/stages/acceptance-coverage-stage.js +47 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +19 -2
- package/src/prompts/acceptance-coverage.js +32 -0
- package/src/prompts/hu-reviewer.js +26 -4
- package/src/roles/acceptance-coverage-role.js +78 -0
- package/src/utils/update-check.js +24 -1
package/package.json
CHANGED
package/src/commands/identity.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/harden/human-act.js
CHANGED
|
@@ -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
|
+
}
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|