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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/agents/aider-agent.js +2 -12
  3. package/src/agents/base-agent.js +9 -20
  4. package/src/agents/claude-agent.js +2 -12
  5. package/src/agents/codex-agent.js +2 -12
  6. package/src/agents/dead-models.js +74 -0
  7. package/src/agents/gemini-agent.js +2 -12
  8. package/src/agents/model-errors.js +35 -0
  9. package/src/agents/opencode-agent.js +2 -12
  10. package/src/brain/agent-error-classifier.js +19 -1
  11. package/src/brain/one-shot-policy.js +26 -0
  12. package/src/brain/role-fallback-chain.js +100 -0
  13. package/src/brain/with-brain-recovery.js +63 -4
  14. package/src/checks/action-pins.js +131 -0
  15. package/src/checks/project-checks.js +6 -0
  16. package/src/checks/release-check.js +51 -5
  17. package/src/cli/register-meta.js +42 -2
  18. package/src/cli/register-pipeline.js +11 -1
  19. package/src/commands/check.js +5 -0
  20. package/src/commands/code.js +59 -4
  21. package/src/commands/env.js +2 -1
  22. package/src/commands/harden.js +12 -3
  23. package/src/commands/policy.js +21 -14
  24. package/src/commands/report.js +28 -0
  25. package/src/commands/review-gate.js +25 -15
  26. package/src/environment/panel.js +85 -0
  27. package/src/environment/playbook.js +7 -6
  28. package/src/harden/config-templates.js +7 -1
  29. package/src/harden/guidelines-engine.js +14 -4
  30. package/src/harden/guidelines-templates.js +69 -10
  31. package/src/harden/harness-hooks.js +6 -0
  32. package/src/harden/hook-templates.js +4 -1
  33. package/src/harden/sentinel-hooks.js +227 -24
  34. package/src/harden/supervisor-commit.js +41 -1
  35. package/src/harden/workflow-templates.js +9 -3
  36. package/src/policy/supervisor-verify.js +45 -11
  37. package/src/privacy/diff-scope.js +58 -0
  38. package/src/privacy/scan.js +18 -0
  39. package/src/prompts/card-context.js +57 -0
  40. package/src/prompts/session-context.js +68 -0
  41. package/src/review/board-pending.js +68 -0
  42. package/src/review/loc-budget.js +97 -0
  43. package/src/roles/agent-role.js +11 -2
  44. 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
+ }
@@ -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
  };
@@ -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
- const result = await agent.runTask(runArgs);
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
- return { ok: false, result: { error: result.error || result.output || "Audit failed", provider }, summary: `Audit failed: ${result.error || "unknown error"}`, usage };
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 {