karajan-code 4.32.0 → 4.34.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/agents/aider-agent.js +2 -12
- package/src/agents/base-agent.js +9 -20
- package/src/agents/claude-agent.js +2 -12
- package/src/agents/codex-agent.js +2 -12
- package/src/agents/dead-models.js +74 -0
- package/src/agents/gemini-agent.js +2 -12
- package/src/agents/model-errors.js +35 -0
- package/src/agents/opencode-agent.js +2 -12
- package/src/brain/agent-error-classifier.js +19 -1
- package/src/brain/one-shot-policy.js +26 -0
- package/src/brain/role-fallback-chain.js +100 -0
- package/src/brain/with-brain-recovery.js +63 -4
- package/src/checks/action-pins.js +131 -0
- package/src/checks/project-checks.js +6 -0
- package/src/checks/release-check.js +51 -5
- package/src/cli/register-meta.js +42 -2
- package/src/cli/register-pipeline.js +11 -1
- package/src/commands/check.js +5 -0
- package/src/commands/code.js +59 -4
- package/src/commands/env.js +2 -1
- package/src/commands/harden.js +12 -3
- package/src/commands/policy.js +21 -14
- package/src/commands/report.js +28 -0
- package/src/commands/review-gate.js +25 -15
- package/src/environment/panel.js +85 -0
- package/src/environment/playbook.js +7 -6
- package/src/harden/config-templates.js +7 -1
- package/src/harden/guidelines-engine.js +14 -4
- package/src/harden/guidelines-templates.js +69 -10
- package/src/harden/harness-hooks.js +6 -0
- package/src/harden/hook-templates.js +4 -1
- package/src/harden/sentinel-hooks.js +227 -24
- package/src/harden/supervisor-commit.js +41 -1
- package/src/harden/workflow-templates.js +9 -3
- package/src/policy/supervisor-verify.js +45 -11
- package/src/privacy/diff-scope.js +58 -0
- package/src/privacy/scan.js +18 -0
- package/src/prompts/card-context.js +57 -0
- package/src/prompts/session-context.js +68 -0
- package/src/review/board-pending.js +68 -0
- package/src/review/loc-budget.js +97 -0
- package/src/roles/agent-role.js +11 -2
- package/src/roles/audit-role.js +28 -2
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coder gets the CARD, not just a sentence (KJC-TSK-0864).
|
|
3
|
+
*
|
|
4
|
+
* `kj code` handed the agent a bare task string: no card, no acceptance
|
|
5
|
+
* criteria, not even the project boundary the pipeline always passed. That is
|
|
6
|
+
* the gap the user hit, because when the host orchestrates instead of
|
|
7
|
+
* `kj run`, `kj code` IS the way the declared coder gets invoked, and a coder
|
|
8
|
+
* without the card writes to a sentence instead of to a contract.
|
|
9
|
+
*
|
|
10
|
+
* A local HU is read from the board. A reference kj cannot read (an external
|
|
11
|
+
* board lives in the host's own MCP, not here) travels as the reference it is
|
|
12
|
+
* and says so: the coder learns the card exists and where to ask for it,
|
|
13
|
+
* instead of receiving a silently empty context.
|
|
14
|
+
*/
|
|
15
|
+
import { getHu } from "../hu/store.js";
|
|
16
|
+
|
|
17
|
+
/** Ids minted by the HU Board; anything else belongs to an external board. */
|
|
18
|
+
const LOCAL_HU = /^HU-/i;
|
|
19
|
+
|
|
20
|
+
const externalSection = (ref) =>
|
|
21
|
+
[
|
|
22
|
+
`## Card ${ref}`,
|
|
23
|
+
"",
|
|
24
|
+
`This work is tracked as ${ref} on the project's own board, which kj cannot read from here.`,
|
|
25
|
+
"Treat the task below as the card's statement, and if something the card should answer is missing, ASK instead of guessing.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
const localSection = (hu) => {
|
|
29
|
+
const lines = [`## Card ${hu.id} — ${hu.title}`, ""];
|
|
30
|
+
if (hu.description?.trim()) lines.push(hu.description.trim(), "");
|
|
31
|
+
lines.push(`Status on the board: ${hu.status}. The card is the contract: done means its statement is literally true.`);
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{projectDir: string, ref: string|null, deps?: {getHu?: Function}}} args
|
|
37
|
+
* @returns {Promise<null|{huId: string, title: string|null, section: string,
|
|
38
|
+
* acceptanceTests: Array|null, external: boolean}>}
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveCardContext({ projectDir, ref, deps = {} }) {
|
|
41
|
+
if (!ref?.trim()) return null;
|
|
42
|
+
const id = ref.trim();
|
|
43
|
+
if (!LOCAL_HU.test(id)) {
|
|
44
|
+
return { huId: id, title: null, section: externalSection(id), acceptanceTests: null, external: true };
|
|
45
|
+
}
|
|
46
|
+
// A missing local HU is an error, never an empty context: the caller asked
|
|
47
|
+
// for a card by id and kj either delivers it or says it does not exist.
|
|
48
|
+
const hu = await (deps.getHu || getHu)(projectDir, id);
|
|
49
|
+
const criteria = hu.acceptanceCriteria?.trim();
|
|
50
|
+
return {
|
|
51
|
+
huId: hu.id,
|
|
52
|
+
title: hu.title,
|
|
53
|
+
section: localSection(hu),
|
|
54
|
+
acceptanceTests: criteria ? [{ type: "gherkin", content: criteria }] : null,
|
|
55
|
+
external: false,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the coder knows before it writes (KJC-TSK-0864).
|
|
3
|
+
*
|
|
4
|
+
* The method's first invariant is "the RAG answers before you assume", and
|
|
5
|
+
* `kj run` honours it through the researcher stage. `kj code` did not: the
|
|
6
|
+
* declared coder started from a sentence and guessed the codebase. So the
|
|
7
|
+
* session asks the index on the coder's behalf and hands it what came back.
|
|
8
|
+
*
|
|
9
|
+
* Retrieval is best-effort and LOUD: an empty or unreachable index warns and
|
|
10
|
+
* the coder works without project context, because blocking here would stop
|
|
11
|
+
* work in a repo that simply has no index yet. What it never does is stay
|
|
12
|
+
* quiet about it.
|
|
13
|
+
*/
|
|
14
|
+
import { ragQueryCommand } from "../commands/rag.js";
|
|
15
|
+
|
|
16
|
+
/** Hits to carry and how much of each: the prompt pays for every line. */
|
|
17
|
+
const TOP_K = 5;
|
|
18
|
+
const EXCERPT = 400;
|
|
19
|
+
|
|
20
|
+
const label = (hit) =>
|
|
21
|
+
hit.metadata?.symbol || hit.metadata?.hu_id || hit.metadata?.headingPath?.join(" > ") || hit.kind || "block";
|
|
22
|
+
|
|
23
|
+
/** @returns {string|null} the agent-facing section, or null when there is nothing to say. */
|
|
24
|
+
export function ragSection(hits) {
|
|
25
|
+
if (!Array.isArray(hits) || hits.length === 0) return null;
|
|
26
|
+
const lines = [
|
|
27
|
+
"## What the project's RAG index answers about this task",
|
|
28
|
+
"",
|
|
29
|
+
"Retrieved for you, so you do not guess what the codebase does. These are excerpts: open the file before changing it.",
|
|
30
|
+
"",
|
|
31
|
+
];
|
|
32
|
+
for (const hit of hits) {
|
|
33
|
+
const text = hit.text || "";
|
|
34
|
+
lines.push(`### ${hit.source} · ${label(hit)}`);
|
|
35
|
+
lines.push("```", text.length > EXCERPT ? `${text.slice(0, EXCERPT)}…` : text, "```", "");
|
|
36
|
+
}
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {Promise<null|{section: string|null, sources: string[]}>}
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveRagContext({ task, config, logger, deps = {} }) {
|
|
44
|
+
const run = deps.ragQueryCommand || ragQueryCommand;
|
|
45
|
+
// The index warns on stdout in CLI mode; here the hits are the product, so
|
|
46
|
+
// its narration is swallowed and only OUR verdict reaches the user.
|
|
47
|
+
const quiet = { info: () => {}, warn: () => {}, error: () => {} };
|
|
48
|
+
let hits;
|
|
49
|
+
try {
|
|
50
|
+
hits = await run({ text: task, config, logger: quiet, flags: { topK: TOP_K } });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
logger?.warn?.(`rag context unavailable (${err.message}) — the coder writes without project context`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!hits?.length) {
|
|
56
|
+
logger?.warn?.("the RAG index returned nothing for this task — the coder writes without project context");
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const sources = [...new Set(hits.map((h) => h.source))];
|
|
60
|
+
logger?.info?.(`RAG context: ${sources.length} file(s) — ${sources.join(", ")}`);
|
|
61
|
+
return { section: ragSection(hits), sources };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The task the coder reads: everything it was given, then its own statement. */
|
|
65
|
+
export function composeTask(task, sections = []) {
|
|
66
|
+
const kept = sections.filter(Boolean);
|
|
67
|
+
return kept.length ? `${kept.join("\n\n")}\n\n## Task\n\n${task}` : task;
|
|
68
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pending board moves actually block (KJC-BUG-0198).
|
|
3
|
+
*
|
|
4
|
+
* board-sync blocked every advance until a merged PR's card reached a closing
|
|
5
|
+
* state, which assumes one PR per card while the project's rule splits any card
|
|
6
|
+
* over ~150 lines. With KJC-BUG-0197 (#1802 and #1803) that demanded a move that
|
|
7
|
+
* would have been a lie, and the way out was `KJ_ALLOW_BOARD=1`, which switches
|
|
8
|
+
* the whole gate off. A card still being delivered is not a lying board; a TURN
|
|
9
|
+
* ending with work nobody recorded is, and that stays the Stop gate's job.
|
|
10
|
+
*
|
|
11
|
+
* One verifiable fact carries a pending, never a promise in prose: the same card
|
|
12
|
+
* has another OPEN pull request. A branch named after the card is not enough,
|
|
13
|
+
* because the merged branch carries that name too, so accepting it would forgive
|
|
14
|
+
* every merge made from its own lane.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { CARD_REF_RE } from "./card-first.js";
|
|
22
|
+
|
|
23
|
+
const cardOf = (text) => {
|
|
24
|
+
const m = CARD_REF_RE.exec(String(text || ""));
|
|
25
|
+
return m ? m[0].toUpperCase() : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `openPrHeads` null means the list could not be read, and then nothing is
|
|
30
|
+
* forgiven. `openPrNumbers` pairs with it so a pending never forgives itself.
|
|
31
|
+
*
|
|
32
|
+
* @returns {{blocking: Array<object>, carried: Array<{card: string, why: string}>, reason?: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [] }) {
|
|
35
|
+
if (pendings.length === 0) return { blocking: [], carried: [] };
|
|
36
|
+
if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta (gh no respondió) — se bloquea" };
|
|
37
|
+
const openCards = new Set();
|
|
38
|
+
for (const [i, head] of openPrHeads.entries()) {
|
|
39
|
+
// A pending must never forgive itself: the PR it came from is merged, and if
|
|
40
|
+
// a stale listing still shows it, it does not count as "still open".
|
|
41
|
+
if (openPrNumbers[i] !== undefined && pendings.some((p) => Number(p.pr) === Number(openPrNumbers[i]))) continue;
|
|
42
|
+
const card = cardOf(head);
|
|
43
|
+
if (card) openCards.add(card);
|
|
44
|
+
}
|
|
45
|
+
const blocking = [], carried = [];
|
|
46
|
+
for (const p of pendings) {
|
|
47
|
+
const card = p.card ? String(p.card).toUpperCase() : null;
|
|
48
|
+
if (!card) { blocking.push(p); continue; } // nothing to check against
|
|
49
|
+
if (openCards.has(card)) carried.push({ card, why: "sigue entregándose en una PR abierta" });
|
|
50
|
+
else blocking.push(p);
|
|
51
|
+
}
|
|
52
|
+
return { blocking, carried };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
|
|
56
|
+
|
|
57
|
+
/** The decision the guard asks for, gathering the facts here so the rule ships
|
|
58
|
+
* with npm instead of waiting for the human to reseal the harness. */
|
|
59
|
+
export function boardGate({ projectDir = process.cwd(), sessionId = "default", deps = {} } = {}) {
|
|
60
|
+
const state = deps.readState?.() ?? readJson(join(projectDir, ".karajan", "harness", "sentinel-state.json"));
|
|
61
|
+
const pendings = state?.sessions?.[sessionId]?.pending_moves;
|
|
62
|
+
if (!Array.isArray(pendings) || pendings.length === 0) return { blocking: [], carried: [] };
|
|
63
|
+
const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }));
|
|
64
|
+
let open = null;
|
|
65
|
+
try { open = JSON.parse(run("gh", ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,headRefName"])); } catch { /* fail closed */ }
|
|
66
|
+
const listed = Array.isArray(open) ? open : null;
|
|
67
|
+
return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [] });
|
|
68
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What counts toward the PR size budget (KJC-BUG-0205).
|
|
3
|
+
*
|
|
4
|
+
* The budget exists to cap CODE growth. Two implementations of that rule had
|
|
5
|
+
* drifted apart: CI excluded lockfiles, build output and the docs tree, while
|
|
6
|
+
* the local gate summed every line of the diff. A doc-only PR was warned at 234
|
|
7
|
+
* lines locally and counted 104 in CI, and a gate that cries wolf in one place
|
|
8
|
+
* and not the other teaches people to believe neither.
|
|
9
|
+
*
|
|
10
|
+
* Two rules decide what is exempt, and they are not the same rule:
|
|
11
|
+
* - GENERATED output never counts: nobody wrote it, and the reviewer reads the
|
|
12
|
+
* source that produced it.
|
|
13
|
+
* - HUMAN documentation never counts either. Rationing docs is how a project
|
|
14
|
+
* ends up shipping features nobody can discover (which is exactly what
|
|
15
|
+
* happened in 4.33.0, see KJC-TSK-0871).
|
|
16
|
+
*
|
|
17
|
+
* And one thing that DOES count, on purpose: AI-rule files (CLAUDE.md,
|
|
18
|
+
* AGENTS.md, templates/**). They enter the agent's context on every run, so
|
|
19
|
+
* unbounded growth there dilutes the signal the agent receives.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const EXEMPT = [
|
|
23
|
+
// Generated or vendored: nobody typed it.
|
|
24
|
+
/(^|\/)dist\//,
|
|
25
|
+
/(^|\/)build\//,
|
|
26
|
+
/(^|\/)coverage\//,
|
|
27
|
+
/(^|\/)node_modules\//,
|
|
28
|
+
/(^|\/)\.astro\//,
|
|
29
|
+
/(^|\/)public\/docs\//,
|
|
30
|
+
/\.min\.(js|css)$/,
|
|
31
|
+
/\.map$/,
|
|
32
|
+
/\.snap(shot)?$/,
|
|
33
|
+
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|npm-shrinkwrap\.json)$/,
|
|
34
|
+
/\.lock$/,
|
|
35
|
+
/(^|\/)tests\/_diet\//,
|
|
36
|
+
// Human documentation, wherever the project keeps it. The landing's docs
|
|
37
|
+
// moved into the monorepo (MONO-3) and the old root-anchored `docs/` rule
|
|
38
|
+
// stopped matching them, which is how documenting started costing budget.
|
|
39
|
+
/(^|\/)docs\/.*\.(md|mdx|txt|rst)$/,
|
|
40
|
+
/^(CHANGELOG|README|CODE_OF_CONDUCT|CONTRIBUTING|SECURITY)(\.[a-z-]+)?\.md$/,
|
|
41
|
+
/^(MIGRATION|TODO).*\.md$/,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// The exceptions to the exception: these ARE the agent's context.
|
|
45
|
+
const AI_RULES = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^GEMINI\.md$/, /(^|\/)templates\//];
|
|
46
|
+
|
|
47
|
+
/** @param {string} file @returns {boolean} */
|
|
48
|
+
export function countsTowardBudget(file) {
|
|
49
|
+
if (!file) return false;
|
|
50
|
+
if (AI_RULES.some((re) => re.test(file))) return true;
|
|
51
|
+
return !EXEMPT.some((re) => re.test(file));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* KJC-BUG-0208 — the policy invariant `net_lines_added` summed the whole
|
|
56
|
+
* numstat, so the same diff printed 174 in the size gate and 202 in the policy,
|
|
57
|
+
* two lines apart. One rule, one number: the metric is netted over the files
|
|
58
|
+
* that count, and what does not count is still said out loud.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} numstat output of `git diff --numstat`
|
|
61
|
+
* @returns {{net: number, exempt: number}}
|
|
62
|
+
*/
|
|
63
|
+
export function budgetedNet(numstat) {
|
|
64
|
+
let net = 0;
|
|
65
|
+
let exempt = 0;
|
|
66
|
+
for (const line of String(numstat || "").split("\n")) {
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
const [a, r, ...rest] = line.split("\t");
|
|
69
|
+
const added = Number(a);
|
|
70
|
+
const removed = Number(r);
|
|
71
|
+
if (!Number.isFinite(added) || !Number.isFinite(removed)) continue; // binary
|
|
72
|
+
if (!countsTowardBudget(rest.join("\t"))) { exempt += added; continue; }
|
|
73
|
+
net += added - removed;
|
|
74
|
+
}
|
|
75
|
+
return { net, exempt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} numstat output of `git diff --numstat`
|
|
80
|
+
* @returns {{added: number, exempt: number, testAdded: number}}
|
|
81
|
+
*/
|
|
82
|
+
export function budgetedAdded(numstat) {
|
|
83
|
+
let added = 0;
|
|
84
|
+
let exempt = 0;
|
|
85
|
+
let testAdded = 0;
|
|
86
|
+
for (const line of String(numstat || "").split("\n")) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
const [a, , ...rest] = line.split("\t");
|
|
89
|
+
const n = Number(a);
|
|
90
|
+
if (!Number.isFinite(n)) continue; // binary files emit '-'
|
|
91
|
+
const file = rest.join("\t");
|
|
92
|
+
if (!countsTowardBudget(file)) { exempt += n; continue; }
|
|
93
|
+
added += n;
|
|
94
|
+
if (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${file}`)) testAdded += n;
|
|
95
|
+
}
|
|
96
|
+
return { added, exempt, testAdded };
|
|
97
|
+
}
|
package/src/roles/agent-role.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { BaseRole } from "./base-role.js";
|
|
17
17
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
|
18
18
|
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
19
|
+
import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Silence timeout (ms) for an agent subprocess, from
|
|
@@ -154,10 +155,18 @@ export class AgentRole extends BaseRole {
|
|
|
154
155
|
// reviewer usa reviewTask) también funcionan: el wrapper sólo necesita
|
|
155
156
|
// un objeto con la firma `runTask(args) → { ok, output, error, exitCode }`.
|
|
156
157
|
const result = await withBrainRecovery({
|
|
157
|
-
agent: { runTask: (args) => agent[this.agentMethod](args), provider },
|
|
158
|
+
agent: { runTask: (args) => agent[this.agentMethod](args), provider, model: this.config?.roles?.[this.name]?.model ?? null },
|
|
158
159
|
taskArgs: runArgs,
|
|
159
160
|
role: this.name,
|
|
160
161
|
provider,
|
|
162
|
+
// KJC-TSK-0859: the chain the user declared under roles.<role>.fallback.
|
|
163
|
+
// The schema and the walker existed since KJC-TSK-0415; nobody built it,
|
|
164
|
+
// so every declared chain was dead config. This is the single point that
|
|
165
|
+
// covers every role inheriting from AgentRole.
|
|
166
|
+
fallback: buildRoleFallbackChain({
|
|
167
|
+
config: this.config, role: this.name, agentMethod: this.agentMethod,
|
|
168
|
+
createAgentFn: this._createAgent, logger: this.logger,
|
|
169
|
+
}),
|
|
161
170
|
emitter: this.emitter,
|
|
162
171
|
logger: this.logger,
|
|
163
172
|
// KJC: sessionState lets withBrainRecovery persist a hibernating run
|
|
@@ -171,7 +180,7 @@ export class AgentRole extends BaseRole {
|
|
|
171
180
|
const recoveryNote = result.recovery?.class ? ` [${result.recovery.class}]` : "";
|
|
172
181
|
return {
|
|
173
182
|
ok: false,
|
|
174
|
-
result: { error: result.error || result.output || `${this.name} failed${recoveryNote}`, provider, recovery: result.recovery, action: result.action, standbyFile: result.standbyFile || null },
|
|
183
|
+
result: { error: result.error || result.output || `${this.name} failed${recoveryNote}`, provider, recovery: result.recovery, action: result.action, standbyFile: result.standbyFile || null, tried: result.tried || null },
|
|
175
184
|
summary: `${this.name} failed${recoveryNote}: ${result.recovery?.message || result.error || "unknown error"}`,
|
|
176
185
|
usage: result.usage
|
|
177
186
|
};
|
package/src/roles/audit-role.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { AgentRole } from "./agent-role.js";
|
|
4
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
5
|
+
import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
|
|
6
|
+
import { ONE_SHOT_POLICY } from "../brain/one-shot-policy.js";
|
|
4
7
|
import { securityAuditMarkerPath } from "../steward/invariants.js";
|
|
5
8
|
import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
|
|
6
9
|
import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
|
|
@@ -193,13 +196,36 @@ export class AuditRole extends AgentRole {
|
|
|
193
196
|
const runArgs = { prompt, role: "audit" };
|
|
194
197
|
if (onOutput) runArgs.onOutput = onOutput;
|
|
195
198
|
const startedAt = Date.now();
|
|
196
|
-
|
|
199
|
+
// KJC-BUG-0194: this used to call agent.runTask() directly, which made
|
|
200
|
+
// audit the ONE role that bypassed the brain. No classification (so an MCP
|
|
201
|
+
// caller saw a quota wall as category "unknown") and no declared chain
|
|
202
|
+
// (so roles.audit.fallback was ignored even after KJC-TSK-0859 wired it,
|
|
203
|
+
// because the wiring lives in AgentRole.execute and audit overrides it).
|
|
204
|
+
// The one-shot policy matters here: `kj audit` must take the next
|
|
205
|
+
// candidate at once rather than hibernate for hours inside a command.
|
|
206
|
+
const result = await withBrainRecovery({
|
|
207
|
+
agent: { runTask: (args) => agent.runTask(args), provider, model: this.config?.roles?.audit?.model ?? null },
|
|
208
|
+
taskArgs: runArgs,
|
|
209
|
+
role: "audit",
|
|
210
|
+
provider,
|
|
211
|
+
logger: this.logger,
|
|
212
|
+
emitter: this.emitter,
|
|
213
|
+
policy: ONE_SHOT_POLICY,
|
|
214
|
+
fallback: buildRoleFallbackChain({ config: this.config, role: "audit", createAgentFn: this._createAgent, logger: this.logger }),
|
|
215
|
+
});
|
|
197
216
|
const durationMs = Date.now() - startedAt;
|
|
198
217
|
|
|
199
218
|
const usage = extractUsage(result, { provider, durationMs });
|
|
200
219
|
|
|
201
220
|
if (!result.ok) {
|
|
202
|
-
|
|
221
|
+
// The classification and the itinerary travel with the failure, so a
|
|
222
|
+
// caller (CLI or MCP) can say WHY it stopped and what was tried.
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
result: { error: result.error || result.output || "Audit failed", provider, recovery: result.recovery || null, tried: result.tried || null },
|
|
226
|
+
summary: `Audit failed: ${result.recovery?.class ? `${result.recovery.class} — ` : ""}${result.error || "unknown error"}`,
|
|
227
|
+
usage,
|
|
228
|
+
};
|
|
203
229
|
}
|
|
204
230
|
|
|
205
231
|
try {
|