karajan-code 4.32.0 → 4.33.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * KJC-TSK-0870 — the maintenance that pinning moved onto kj.
3
+ *
4
+ * Issue #1374 was right: `kj harden` generated workflows with moving tags
5
+ * (`actions/checkout@v4`), and kj's own `kj audit --security` flagged them, so
6
+ * five of the project's seven warnings came from files kj had just written.
7
+ * They are pinned to commit SHAs now.
8
+ *
9
+ * Pinning trades one risk for another: nobody can move the tag under you, and
10
+ * nobody delivers the upstream patch either. A pin that nobody revisits is a
11
+ * vulnerability with a long shelf life, and since kj writes those lines, kj is
12
+ * the one that has to notice. That is this check.
13
+ *
14
+ * It lives in `kj doctor` (a person asking about their environment) and NOT in
15
+ * `kj check` (a gate that CI runs): a gate that needs the network is a gate
16
+ * that fails on a plane, and turning red over an unreachable API is how a gate
17
+ * loses its credibility.
18
+ */
19
+
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+
23
+ import { PINNED_ACTIONS } from "../harden/workflow-templates.js";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ // Nobody waits for a diagnostic. Both clients are capped, and the pins are
27
+ // asked in parallel, so an unreachable GitHub costs one timeout and not one
28
+ // per action.
29
+ const CALL_TIMEOUT_MS = 5_000;
30
+
31
+ const STRATEGY_MANUAL = "manual";
32
+ const API = "https://api.github.com/repos";
33
+
34
+ /** `owner/repo@sha # tag` as written in the templates. */
35
+ export function parsePin(pin) {
36
+ const m = /^([^@\s]+)@([0-9a-f]{40})\s*#\s*(\S+)$/i.exec(String(pin || "").trim());
37
+ return m ? { action: m[1], sha: m[2].toLowerCase(), tag: m[3] } : null;
38
+ }
39
+
40
+ const SHA_RE = /^[0-9a-f]{40}$/;
41
+ const clean = (s) => String(s || "").trim().toLowerCase();
42
+
43
+ /**
44
+ * The SHA a tag points at today, asked through `gh` when it is there.
45
+ *
46
+ * Measured, not assumed: plain unauthenticated `fetch` answered 403 on the
47
+ * first real run, because GitHub allows 60 calls an hour PER IP and any shared
48
+ * or NAT-ed address burns that between everyone behind it. A check that
49
+ * answers "could not check" almost always is decorative. `gh` carries the
50
+ * user's own credentials (5000/hour) and kj already relies on it elsewhere;
51
+ * `fetch` stays as the fallback for whoever has no gh.
52
+ *
53
+ * @returns {Promise<string|null>} null when the answer cannot be trusted.
54
+ */
55
+ export async function currentSha(action, tag, { fetchFn = fetch, ghFn = ghSha } = {}) {
56
+ const viaGh = await ghFn(action, tag);
57
+ if (viaGh) return viaGh;
58
+ const res = await fetchFn(`${API}/${action}/commits/${encodeURIComponent(tag)}`, {
59
+ headers: { Accept: "application/vnd.github.sha", "User-Agent": "karajan-code" },
60
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
61
+ });
62
+ if (!res?.ok) return null;
63
+ const body = clean(await res.text());
64
+ return SHA_RE.test(body) ? body : null;
65
+ }
66
+
67
+ /** @returns {Promise<string|null>} null when gh is absent, unauthenticated or unhappy. */
68
+ async function ghSha(action, tag) {
69
+ try {
70
+ const { stdout } = await execFileAsync("gh", ["api", `repos/${action}/commits/${tag}`, "--jq", ".sha"], {
71
+ encoding: "utf8",
72
+ timeout: CALL_TIMEOUT_MS,
73
+ });
74
+ const sha = clean(stdout);
75
+ return SHA_RE.test(sha) ? sha : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** @internal Exported for dynamic import from tests. */
82
+ export function createActionPinsCheck({ pins = PINNED_ACTIONS, deps = {} } = {}) {
83
+ return {
84
+ name: "action-pins",
85
+ label: "actions fijadas por SHA",
86
+ strategy: STRATEGY_MANUAL,
87
+ describe: "Detect a pinned GitHub Action whose tag has moved on without it",
88
+ async detect() {
89
+ const entries = Object.entries(pins).map(([key, pin]) => ({ key, ...(parsePin(pin) || {}) }));
90
+ const malformed = entries.filter((e) => !e.sha);
91
+ const asked = await Promise.all(
92
+ entries.filter((x) => x.sha).map(async (e) => {
93
+ // offline, rate limited, DNS down, gh missing: not an answer
94
+ const latest = await currentSha(e.action, e.tag, deps).catch(() => null);
95
+ return { ...e, latest };
96
+ }),
97
+ );
98
+ const stale = asked.filter((e) => e.latest && e.latest !== e.sha);
99
+ const unchecked = asked.filter((e) => !e.latest).length;
100
+
101
+ if (malformed.length > 0) {
102
+ return {
103
+ ok: false,
104
+ severity: "warn",
105
+ detail: `pin mal formado: ${malformed.map((m) => m.key).join(", ")} — un pin que no se puede leer no se puede comprobar`,
106
+ fix: "revisa PINNED_ACTIONS en src/harden/workflow-templates.js: owner/repo@<sha de 40> # <tag>",
107
+ };
108
+ }
109
+ if (stale.length > 0) {
110
+ return {
111
+ ok: false,
112
+ severity: "warn",
113
+ detail: stale.map((s) => `${s.action} ${s.tag} apunta hoy a ${s.latest.slice(0, 12)} y kj fija ${s.sha.slice(0, 12)}`).join(" · "),
114
+ fix: stale.map((s) => `${s.action}@${s.latest} # ${s.tag}`).join(" · "),
115
+ };
116
+ }
117
+ // Never claim what was not looked at (the card's own acceptance
118
+ // criterion): an unreachable API is said, not passed off as green.
119
+ if (unchecked > 0) {
120
+ return {
121
+ ok: true,
122
+ severity: "info",
123
+ detail: unchecked === entries.length
124
+ ? "no se pudo comprobar ninguna action (¿sin red o con la API de GitHub limitando?)"
125
+ : `${unchecked} action(s) sin comprobar (¿sin red o con la API de GitHub limitando?)`,
126
+ };
127
+ }
128
+ return { ok: true, severity: "info", detail: `${entries.length} action(s) fijadas y al día` };
129
+ },
130
+ };
131
+ }
@@ -24,6 +24,7 @@ import { checkBinary } from "../utils/agent-detect.js";
24
24
  import { getInstallCommand } from "../utils/os-detect.js";
25
25
  import { STRATEGY } from "./types.js";
26
26
  import { createRepoStateCheck } from "./repo-state.js";
27
+ import { createActionPinsCheck } from "./action-pins.js";
27
28
 
28
29
  /**
29
30
  * Detect signals del proyecto. Devuelve un set de tags con qué hay en el
@@ -246,5 +247,10 @@ export function getProjectChecks({ projectDir }) {
246
247
  // check looks at — the order it was set up in, whether the contract
247
248
  // travels, whether this clone declares who works it.
248
249
  createRepoStateCheck({ projectDir }),
250
+ // KJC-TSK-0870: pinning the actions by SHA (issue #1374) moved their
251
+ // maintenance onto kj. Doctor asks upstream whether a tag has moved on
252
+ // without its pin; `kj check` deliberately does not, because a gate that
253
+ // needs the network is a gate that fails on a plane.
254
+ createActionPinsCheck(),
249
255
  ];
250
256
  }
@@ -179,11 +179,21 @@ export function registerPipeline(program, { pkgVersion }) {
179
179
  .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
180
180
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
181
181
  .option("--coder <name>")
182
+ // KJC-TSK-0864: --coder is the pipeline's word for it, --agent is the
183
+ // session's. Same knob, two vocabularies; naming both and disagreeing is
184
+ // an error, never a silent precedence.
185
+ .option("--agent <name>", "The agent that writes (alias of --coder)")
186
+ .option("--card <ref>", "Card/HU the work belongs to — its statement and criteria reach the coder")
182
187
  .option("--coder-model <name>")
183
188
  .action(async (task, flags) => {
189
+ if (flags.agent && flags.coder && flags.agent !== flags.coder) {
190
+ console.error(`kj code: --agent ${flags.agent} and --coder ${flags.coder} name different agents — pick one`);
191
+ process.exit(2);
192
+ }
193
+ if (flags.agent) flags.coder = flags.agent;
184
194
  await withConfig(pkgVersion, "code", flags, async ({ config, logger }) => {
185
195
  const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
186
- await codeCommand({ task: resolvedTask, config, logger });
196
+ await codeCommand({ task: resolvedTask, config, logger, flags });
187
197
  });
188
198
  });
189
199
 
@@ -9,6 +9,7 @@ import { collectMethodStats, formatMethodStats } from "../checks/method.js";
9
9
  import { createRagCoverageCheck } from "../checks/rag-coverage.js";
10
10
  import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
11
11
  import { detectObservedAgents } from "../utils/agent-detect.js";
12
+ import { panelSummary } from "../environment/panel.js";
12
13
  import { loadConfig } from "../config.js";
13
14
 
14
15
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
@@ -44,6 +45,10 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
44
45
  if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
45
46
  logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
46
47
  if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
48
+ // KJC-TSK-0865: who writes and who reviews is the user's choice, so it is
49
+ // said out loud next to everything else that describes the project. Never
50
+ // a check: it has no ok/fail, it informs.
51
+ logger.info?.(` ${panelSummary(config)}`);
47
52
  if (ok) logger.info?.("Harness OK.");
48
53
  else if (result.ok) logger.info?.("RAG index drift detected — a gate cannot protect what it cannot see.");
49
54
  else logger.info?.("Harness drift detected — run `kj harden` to repair.");
@@ -2,13 +2,35 @@ import fs from "node:fs/promises";
2
2
  import { createAgent } from "../agents/index.js";
3
3
  import { assertAgentsAvailable } from "../agents/availability.js";
4
4
  import { buildCoderPrompt } from "../prompts/coder.js";
5
+ import { resolveCardContext } from "../prompts/card-context.js";
6
+ import { composeTask, resolveRagContext } from "../prompts/session-context.js";
5
7
  import { resolveRole } from "../config.js";
8
+ import { withBrainRecovery, DEFAULT_RECOVERY_POLICY } from "../brain/with-brain-recovery.js";
9
+ import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
10
+ import { ERROR_CLASS } from "../brain/agent-error-classifier.js";
11
+
12
+ // KJC-TSK-0859: the agents no longer substitute a dead model behind your back,
13
+ // so the command needs the declared chain. A one-shot CLI must not inherit the
14
+ // pipeline's standby either: sleeping five hours inside `kj code` would be
15
+ // worse than the failure. A quota wall takes the next candidate at once, and
16
+ // with none left it stops and says what it tried.
17
+ const ONE_SHOT_POLICY = Object.freeze({
18
+ ...DEFAULT_RECOVERY_POLICY,
19
+ classes: {
20
+ ...DEFAULT_RECOVERY_POLICY.classes,
21
+ [ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
22
+ [ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
23
+ },
24
+ });
6
25
  import { withCliRunLog } from "../utils/cli-run-log.js";
7
26
  import { createCliProgressReporter } from "../utils/cli-progress.js";
8
27
 
9
- export async function codeCommand({ task, config, logger }) {
28
+ export async function codeCommand({ task, config, logger, flags = {} }) {
10
29
  return withCliRunLog("code", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
11
30
  const coderRole = resolveRole(config, "coder");
31
+ // KJC-TSK-0864: no quota, not authenticated or simply absent? This THROWS
32
+ // with the agent named. The one thing it never does is quietly fall back
33
+ // to whoever happens to be reachable.
12
34
  await assertAgentsAvailable([coderRole.provider]);
13
35
  logger.info(`Coder (${coderRole.provider}) starting...`);
14
36
  runLog.logText(`[coder] provider=${coderRole.provider}`);
@@ -21,11 +43,39 @@ export async function codeCommand({ task, config, logger }) {
21
43
  try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
22
44
  }
23
45
  }
24
- const prompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology || "tdd" });
46
+ const card = await resolveCardContext({ projectDir: config.projectDir, ref: flags.card || null });
47
+ if (card?.external) logger.warn(`card ${card.huId} lives on a board kj cannot read — passing the reference, not its text`);
48
+ else if (card) logger.info(`Card ${card.huId}: ${card.title}`);
49
+ // The method's first invariant is that the RAG answers before you assume.
50
+ // The session asks on the coder's behalf: a subprocess coder cannot run
51
+ // `kj rag query` itself, so it used to guess the codebase instead.
52
+ const rag = await resolveRagContext({ task, config, logger });
53
+ // The pipeline always passed projectDir (it arms the directory-boundary
54
+ // rule and the skills section) and the provider. `kj code` did not, so the
55
+ // declared coder got a weaker prompt from the session than from `kj run`.
56
+ const prompt = await buildCoderPrompt({
57
+ task: composeTask(task, [card?.section, rag?.section]),
58
+ coderRules,
59
+ methodology: config.development?.methodology || "tdd",
60
+ projectDir: config.projectDir,
61
+ provider: coderRole.provider,
62
+ huId: card?.huId ?? null,
63
+ acceptanceTests: card?.acceptanceTests ?? null,
64
+ serenaEnabled: Boolean(config.serena?.enabled),
65
+ rtkAvailable: Boolean(config.rtk?.available),
66
+ });
25
67
  const progress = createCliProgressReporter({ role: "coder" });
26
68
  let result;
27
69
  try {
28
- result = await coder.runTask({ prompt, onOutput: progress.onOutput, role: "coder" });
70
+ result = await withBrainRecovery({
71
+ agent: { runTask: (args) => coder.runTask(args), provider: coderRole.provider, model: coderRole.model },
72
+ taskArgs: { prompt, onOutput: progress.onOutput, role: "coder" },
73
+ role: "coder",
74
+ provider: coderRole.provider,
75
+ logger,
76
+ policy: ONE_SHOT_POLICY,
77
+ fallback: buildRoleFallbackChain({ config, role: "coder", logger }),
78
+ });
29
79
  progress.finish(result.ok ? "done" : "failed");
30
80
  } catch (err) { progress.finish("failed"); throw err; }
31
81
  if (!result.ok) {
@@ -39,7 +89,10 @@ export async function codeCommand({ task, config, logger }) {
39
89
  logger.warn(result.error);
40
90
  }
41
91
  logger.info(`Coder completed (exit ${result.exitCode})`);
92
+ // The work is in the tree, not committed: the gate is the next step and a
93
+ // DIFFERENT AI runs it. Saying so here is what keeps the panel honest.
94
+ logger.info(`The work is in the tree. Review it before committing: kj review --staged (a different AI than ${coderRole.provider}).`);
42
95
  runLog.logText(`[coder] finished (exit=${result.exitCode})`);
43
- return { ok: true };
96
+ return { ok: true, provider: coderRole.provider, card: card?.huId ?? null, ragSources: rag?.sources ?? [] };
44
97
  });
45
98
  }
@@ -71,6 +71,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
71
71
  projectDir, target: flags.target || "all",
72
72
  stateBackend: config?.state_backend || "hu-board",
73
73
  boardName: config?.board?.name || null,
74
+ config, // KJC-TSK-0865: the panel travels with the method
74
75
  });
75
76
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
76
77
 
@@ -213,7 +214,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
213
214
  // CLAUDE.md, but the running session loaded its context BEFORE — nobody
214
215
  // re-reads it. Print the method so it enters THIS conversation now.
215
216
  console.log("\n— The Karajan method below is IN EFFECT from this very message. If your session started before this install, apply it from now on:\n");
216
- console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null }));
217
+ console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null, config }));
217
218
  }
218
219
  return result;
219
220
  }
@@ -171,7 +171,9 @@ export async function hardenCommand({
171
171
  ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, mutation, dryRun })
172
172
  : null;
173
173
  const withGuidelines = guidelines && profile !== "minimal";
174
- const gl = withGuidelines ? installGuidelines({ projectDir, dryRun }) : null;
174
+ // KJC-BUG-0199 (issue #1773): the same detected language the workflows and
175
+ // the configs already use — a Python project must not be told to use `const`.
176
+ const gl = withGuidelines ? installGuidelines({ projectDir, language: roots[0]?.language ?? null, dryRun }) : null;
175
177
  const out = {
176
178
  ok: true,
177
179
  ...result,
@@ -22,6 +22,7 @@ import { checkCardFirst } from "../review/card-first.js";
22
22
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
23
23
  import { checkTestsWithCode } from "../review/tests-with-code.js";
24
24
  import { loadPrivacyList, scanText } from "../privacy/scan.js";
25
+ import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
25
26
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
26
27
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
27
28
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
@@ -230,17 +231,24 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
230
231
  // don't publish. In the SEA binary the privacy module is stubbed and
231
232
  // throws: the gate degrades with a note instead of crashing.
232
233
  try {
233
- const added = diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1)).join("\n");
234
- const findings = scanText(added, { list: loadPrivacyList(), source: "<staged diff>" });
234
+ // KJC-BUG-0203: per file, so a finding can name where it lives and so
235
+ // build output is judged as what it is. Generic heuristics are silenced
236
+ // there (nobody typed a minified bundle); a denylist hit is not, because
237
+ // the incident behind this scanner was personal data inside a build.
238
+ const list = loadPrivacyList();
239
+ const findings = splitAddedByFile(diff).flatMap(({ file, added }) => {
240
+ const found = scanText(added, { list, source: file });
241
+ return isGeneratedPath(file) ? found.filter((f) => f.severity === "block") : found;
242
+ });
235
243
  const blocks = findings.filter((f) => f.severity === "block");
236
244
  const warns = findings.filter((f) => f.severity === "warn");
237
- for (const f of warns) console.log(`⚠ privacy: [${f.type}] added line ${f.line} → ${f.masked} — personal data? move it out before it ships`);
245
+ for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
238
246
  const hardened = config?.privacy?.generic === "block" && warns.length > 0;
239
247
  if (blocks.length > 0 || hardened) {
240
248
  if (process.env.KJ_ALLOW_PII === "1") {
241
249
  console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
242
250
  } else {
243
- for (const f of blocks) console.log(`✗ privacy: [${f.type}] on added line ${f.line} → ${f.masked}`);
251
+ for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
244
252
  const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (KJ_ALLOW_PII=1 to override consciously)`;
245
253
  console.log(`✗ privacy gate: ${reason}`);
246
254
  process.exitCode = 1;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The project's panel, said out loud (KJC-TSK-0865).
3
+ *
4
+ * The user chooses who writes and who reviews. `kj run` honoured that choice
5
+ * because it launched the agents itself; since v4 the HOST agent orchestrates,
6
+ * and nothing carried the choice into the session, so the host wrote the code
7
+ * and the user found out by accident.
8
+ *
9
+ * The panel line travels with the playbook, which is what every host reads at
10
+ * session start. It ANNOUNCES, it never blocks: the host may still write the
11
+ * code, and then it says so instead of staying quiet.
12
+ */
13
+ import { resolveRole } from "../config/role-resolver.js";
14
+
15
+ /** Roles worth announcing: who writes, who reviews, who arbitrates. */
16
+ export const PANEL_ROLES = ["coder", "reviewer", "solomon"];
17
+
18
+ /**
19
+ * @returns {{coder: string|null, reviewer: string|null, solomon: string|null}}
20
+ * the provider declared (or inherited) for each panel role.
21
+ */
22
+ export function resolvePanel(config) {
23
+ const panel = {};
24
+ for (const role of PANEL_ROLES) panel[role] = resolveRole(config, role).provider || null;
25
+ return panel;
26
+ }
27
+
28
+ /**
29
+ * One agent-facing line naming the panel, or null when the project declares
30
+ * no coder and no reviewer. A project without a panel gets no line at all:
31
+ * announcing "coder: nobody" would spend attention to say nothing.
32
+ */
33
+ export function panelLine(config) {
34
+ const { coder, reviewer, solomon } = resolvePanel(config);
35
+ if (!coder && !reviewer) return null;
36
+ const parts = [`coder=${coder || "unset"}`, `reviewer=${reviewer || "unset"}`];
37
+ if (solomon && solomon !== coder) parts.push(`solomon=${solomon}`);
38
+ return [
39
+ `- The panel is your user's, not yours: ${parts.join(", ")}.`,
40
+ " When the coder is not you, the writing is ITS job; write it yourself",
41
+ " anyway and say so in your closing message, never in silence.",
42
+ ].join("\n");
43
+ }
44
+
45
+ /** Human-facing one-liner for `kj check` and friends. */
46
+ export function panelSummary(config) {
47
+ const { coder, reviewer, solomon } = resolvePanel(config);
48
+ if (!coder && !reviewer) return "panel: not declared (no coder, no reviewer)";
49
+ const parts = [`coder ${coder || "unset"}`, `reviewer ${reviewer || "unset"}`];
50
+ if (solomon && solomon !== coder) parts.push(`solomon ${solomon}`);
51
+ return `panel: ${parts.join(" · ")}`;
52
+ }
@@ -12,6 +12,7 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { upsertManagedBlock } from "../utils/managed-markers.js";
15
+ import { panelLine } from "./panel.js";
15
16
 
16
17
  // AB-A (KJC-TSK-0650): any agent can be the brain. AGENTS.md is the
17
18
  // emerging standard (Codex, Cursor and most new CLIs read it); GEMINI.md
@@ -48,7 +49,7 @@ function trackingLine(stateBackend, boardName) {
48
49
  // AB-C2 (KJC-TSK-0653): outcome-first — invariants over step scripts.
49
50
  // Frontier models choose their own path best; what they need explicit are
50
51
  // the limits. The git gates enforce these regardless of what any brain does.
51
- const playbookBody = (stateBackend, boardName) => `# Karajan method (v4)
52
+ const playbookBody = (stateBackend, boardName, panel) => `# Karajan method (v4)
52
53
 
53
54
  You are the orchestrator; Karajan governs. A task is DONE when its
54
55
  done-statement is literally true, the full suite is green, and every commit
@@ -68,7 +69,7 @@ Invariants (the git gates enforce these — they are not suggestions):
68
69
  - Every diff is reviewed by a DIFFERENT AI before it is committed
69
70
  (\`kj review --staged\`): verdicts bind to the exact diff — change the code
70
71
  and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
71
- - Security findings are never overridable — not even by arbitration. You
72
+ ${panel ? `${panel}\n` : ""}- Security findings are never overridable — not even by arbitration. You
72
73
  absorb the security role: \`kj brief security\` states what must be true.
73
74
  Task touches auth, user input, secrets, network or deps? Run
74
75
  \`kj audit --security\` (zero tokens) and remediate BEFORE the review.
@@ -100,15 +101,15 @@ kj announces a newer version? Tell your user what it brings and ask —
100
101
  never run \`kj update\` on your own.
101
102
  `;
102
103
 
103
- export function renderPlaybook({ stateBackend = "hu-board", boardName = null } = {}) {
104
- return playbookBody(stateBackend, boardName);
104
+ export function renderPlaybook({ stateBackend = "hu-board", boardName = null, config = null } = {}) {
105
+ return playbookBody(stateBackend, boardName, panelLine(config));
105
106
  }
106
107
 
107
108
  /**
108
109
  * Install/refresh the playbook block in the target agent files.
109
110
  * User content outside the managed block is never touched.
110
111
  */
111
- export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board", boardName = null }) {
112
+ export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board", boardName = null, config = null }) {
112
113
  if (!PLAYBOOK_TARGETS.includes(target)) {
113
114
  throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
114
115
  }
@@ -118,7 +119,7 @@ export async function installPlaybook({ projectDir, target = "all", version = "1
118
119
  let source = "";
119
120
  try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
120
121
  const { content, action } = upsertManagedBlock({
121
- source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName }), style: "html",
122
+ source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName, config }), style: "html",
122
123
  note: "do not edit: regenerated by kj env install",
123
124
  });
124
125
  if (action !== "unchanged") await fs.writeFile(fullPath, content);
@@ -90,9 +90,14 @@ export const GOLANGCI_BODY = [
90
90
  ].join("\n");
91
91
 
92
92
  /** Language-agnostic configs (every stack gets these). */
93
+ // KJC-BUG-0201 (issue #1774, reported from the field): commitlint used to live
94
+ // here, so a Python repo needed a Node runtime just to validate commit
95
+ // messages. The generated commit-msg hook ALREADY enforces that contract in
96
+ // pure sh (Conventional Commits, the 100-char cap, the AI-attribution ban), so
97
+ // nothing is lost by scoping the tool to the stack that already has Node. The
98
+ // GUARANTEE is language-agnostic; the tool does not have to be.
93
99
  export const UNIVERSAL_CONFIGS = [
94
100
  { file: ".editorconfig", blockId: "editorconfig", style: "hash", body: EDITORCONFIG_BODY },
95
- { file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
96
101
  ];
97
102
 
98
103
  /**
@@ -102,6 +107,7 @@ export const UNIVERSAL_CONFIGS = [
102
107
  * tool is decorative, and kj never generates a demand it didn't satisfy.
103
108
  */
104
109
  export const JS_CONFIGS = [
110
+ { file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
105
111
  { file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY, requires: "eslint" },
106
112
  { file: ".prettierrc.json", json: true, body: PRETTIER_BODY, requires: "prettier" },
107
113
  ];
@@ -9,7 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
11
  import { upsertManagedBlock } from "../utils/managed-markers.js";
12
- import { GUIDELINES_BODY } from "./guidelines-templates.js";
12
+ import { guidelinesBody } from "./guidelines-templates.js";
13
13
 
14
14
  const BLOCK_VERSION = 1;
15
15
  const TARGETS = ["AGENTS.md", "CLAUDE.md"];
@@ -28,7 +28,11 @@ export function stripDevHooksBlock(source) {
28
28
  return { content: `${before}${joiner}${after}`, migrated: true };
29
29
  }
30
30
 
31
- export function installGuidelines({ projectDir = process.cwd(), dryRun = false } = {}) {
31
+ export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
32
+ // KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
33
+ // be the rules of THIS project's language. kj harden already detects it for
34
+ // the configs; the guidelines just never asked.
35
+ const body = guidelinesBody(language);
32
36
  const results = [];
33
37
  for (const file of TARGETS) {
34
38
  const target = join(projectDir, file);
@@ -38,7 +42,7 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false }
38
42
  source,
39
43
  blockId: "guidelines",
40
44
  version: BLOCK_VERSION,
41
- body: GUIDELINES_BODY,
45
+ body,
42
46
  style: "html",
43
47
  });
44
48
  if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
@@ -7,14 +7,21 @@
7
7
  * bloat dilutes the signal. Distilled from the dev-toolkit guidelines.
8
8
  */
9
9
 
10
- export const GUIDELINES_BODY = [
10
+ // KJC-BUG-0199 (issue #1773, reported from the field): this used to be ONE
11
+ // fixed text, so a Python project was told to use `const`, target ES2025 and
12
+ // prefer ES modules over require. These rules enter the agent's context on
13
+ // EVERY run, so the noise is not harmless: it dilutes the signal and states
14
+ // things that do not apply. The configs were already language-aware (ruff for
15
+ // Python, golangci for Go); the guidelines had not caught up.
16
+ //
17
+ // Shape: a CORE that holds in any language, plus one block per language. A
18
+ // language kj does not know gets the core ALONE, never another language's
19
+ // rules, which is the failure this fixes.
20
+ const CORE = [
11
21
  "# Project guidelines (kj harden)",
12
22
  "",
13
23
  "## Code",
14
- "- SOLID, DRY, KISS, YAGNI. `const` by default; arrow callbacks; template literals.",
15
- "- ES2025 target: never `var`, `document.write`, `alert`/`confirm`/`prompt`, `escape`/`unescape`, `substr`.",
16
- " Prefer modern APIs (`structuredClone`, `Object.groupBy`, `Array.at`/`findLast`/`toSorted`, optional chaining, `??`).",
17
- "- ES modules (`import`/`export`), never `require` in new code. Names in English, descriptive.",
24
+ "- SOLID, DRY, KISS, YAGNI. Names in English, descriptive.",
18
25
  "- No silent fallbacks: the system works or fails loudly. Validate and sanitize all input.",
19
26
  "",
20
27
  "## Commits & PRs",
@@ -26,11 +33,54 @@ export const GUIDELINES_BODY = [
26
33
  "- Test-first. Run the tests after each meaningful change. Never skip tests.",
27
34
  "",
28
35
  "## Security",
29
- "- Never commit secrets, keys or tokens. Parameterized queries; sanitize output against XSS.",
30
- "",
31
- "## UI/UX",
32
- "- No native `alert`/`confirm`/`prompt` — use the app's modal system. Loading states; accessible; mobile-first.",
36
+ "- Never commit secrets, keys or tokens. Parameterized queries; sanitize output against injection.",
33
37
  "",
34
38
  "## Files",
35
39
  "- Edit existing files in place; never overwrite a whole file to make a small change.",
36
- ].join("\n");
40
+ ];
41
+
42
+ /** Per-language rules. A language absent from here gets the core alone. */
43
+ export const LANGUAGE_GUIDELINES = {
44
+ javascript: [
45
+ "",
46
+ "## JavaScript / TypeScript",
47
+ "- `const` by default; arrow callbacks; template literals.",
48
+ "- ES2025 target: never `var`, `document.write`, `alert`/`confirm`/`prompt`, `escape`/`unescape`, `substr`.",
49
+ " Prefer modern APIs (`structuredClone`, `Object.groupBy`, `Array.at`/`findLast`/`toSorted`, optional chaining, `??`).",
50
+ "- ES modules (`import`/`export`), never `require` in new code. Avoid `any` in TypeScript.",
51
+ "",
52
+ "## UI/UX",
53
+ "- No native `alert`/`confirm`/`prompt` — use the app's modal system. Loading states; accessible; mobile-first.",
54
+ ],
55
+ python: [
56
+ "",
57
+ "## Python",
58
+ "- PEP 8, and type hints on every public signature. Prefer `pathlib` over string paths.",
59
+ "- Never a bare `except:` — catch what you can handle and let the rest fail loudly.",
60
+ "- f-strings over concatenation or `%`. Comprehensions when they read better than a loop, not by default.",
61
+ "- Tooling and dependencies declared in `pyproject.toml`; no other language's runtime imposed on the project.",
62
+ ],
63
+ go: [
64
+ "",
65
+ "## Go",
66
+ "- `gofmt` is not negotiable. Errors are values: wrap with `%w` and handle them where the caller can decide.",
67
+ "- No naked returns in long functions; accept interfaces, return structs.",
68
+ "- Concurrency with a purpose: a goroutine with no way to stop it is a leak.",
69
+ ],
70
+ rust: [
71
+ "",
72
+ "## Rust",
73
+ "- `cargo fmt` and `cargo clippy` clean before a commit.",
74
+ "- No `unwrap()` outside tests: propagate with `?` and let the type say what can fail.",
75
+ "- `unsafe` needs a comment naming the invariant that makes it sound.",
76
+ ],
77
+ };
78
+
79
+ /** The guidelines body for a language (unknown or absent ⇒ the core alone). */
80
+ export function guidelinesBody(language = null) {
81
+ const extra = LANGUAGE_GUIDELINES[String(language || "").toLowerCase()] ?? [];
82
+ return [...CORE, ...extra].join("\n");
83
+ }
84
+
85
+ /** Back-compat for callers that predate the language split. */
86
+ export const GUIDELINES_BODY = guidelinesBody("javascript");
@@ -126,7 +126,10 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
126
126
  "# KJC-BUG-0170: the generated supervisor hooks (.karajan/hooks/pre-commit,",
127
127
  "# commit-msg) also CONTAIN the pattern by design — the bootstrap commit",
128
128
  "# that versions them must not self-detect in the consumer repo.",
129
- `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
129
+ "# KJC-BUG-0200 (issue #1772): the same is true of .karajan/harness — the",
130
+ "# whole DIRECTORY is excluded, not one file, so a supervisor script added",
131
+ "# later cannot bring the bug back.",
132
+ `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' ':(exclude).karajan/harness/' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
130
133
  " echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
131
134
  "fi",
132
135
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",