@karajan-family/code 4.31.1 → 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.
Files changed (37) 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/role-fallback-chain.js +100 -0
  12. package/src/brain/with-brain-recovery.js +59 -3
  13. package/src/checks/action-pins.js +131 -0
  14. package/src/checks/project-checks.js +11 -0
  15. package/src/checks/repo-state.js +89 -0
  16. package/src/cli/register-pipeline.js +15 -1
  17. package/src/commands/bootstrap.js +56 -0
  18. package/src/commands/check.js +5 -0
  19. package/src/commands/code.js +57 -4
  20. package/src/commands/env.js +2 -1
  21. package/src/commands/go.js +9 -10
  22. package/src/commands/harden.js +3 -1
  23. package/src/commands/review-gate.js +28 -5
  24. package/src/environment/panel.js +52 -0
  25. package/src/environment/playbook.js +7 -6
  26. package/src/harden/config-templates.js +7 -1
  27. package/src/harden/guidelines-engine.js +7 -3
  28. package/src/harden/guidelines-templates.js +60 -10
  29. package/src/harden/hook-templates.js +4 -1
  30. package/src/privacy/diff-scope.js +58 -0
  31. package/src/privacy/scan.js +18 -0
  32. package/src/prompts/card-context.js +57 -0
  33. package/src/prompts/session-context.js +68 -0
  34. package/src/review/one-shot-review.js +5 -0
  35. package/src/review/ui-evidence.js +65 -0
  36. package/src/roles/agent-role.js +11 -2
  37. package/src/start/project-script.js +84 -0
@@ -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
+ }
@@ -23,6 +23,8 @@ import { runCommand } from "../utils/process.js";
23
23
  import { checkBinary } from "../utils/agent-detect.js";
24
24
  import { getInstallCommand } from "../utils/os-detect.js";
25
25
  import { STRATEGY } from "./types.js";
26
+ import { createRepoStateCheck } from "./repo-state.js";
27
+ import { createActionPinsCheck } from "./action-pins.js";
26
28
 
27
29
  /**
28
30
  * Detect signals del proyecto. Devuelve un set de tags con qué hay en el
@@ -241,5 +243,14 @@ export function getProjectChecks({ projectDir }) {
241
243
  createToolCheck({ signal: "terraform", tool: "terraform", label: "Terraform (project signal)", signals }),
242
244
  createEnvConsistencyCheck({ projectDir, signals }),
243
245
  createGhRemoteCheck({ projectDir }),
246
+ // BOOT-B (KJC-TSK-0858): the state of the repo itself, which no other
247
+ // check looks at — the order it was set up in, whether the contract
248
+ // travels, whether this clone declares who works it.
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(),
244
255
  ];
245
256
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * BOOT-B (KJC-TSK-0858, epic KJC-PCS-0088) — the state of the REPOSITORY.
3
+ *
4
+ * The cold-start map of 20-sep found projects raised outside the method, and
5
+ * nobody noticed until a gate blocked with no explanation: the harness
6
+ * installed before git, the gate marker never committed (so a clone inherits
7
+ * nothing), no identity declared (so the Sentinel later denies every git and
8
+ * gh call). None of the other checks looks at this: they check tools, ports,
9
+ * index coverage, the method's own report — never whether the repo itself was
10
+ * set up in the right order.
11
+ *
12
+ * Two rules, both learned the hard way in this codebase:
13
+ * - The bootstrap phase is NOT a defect. A repo with no commit yet is
14
+ * starting, and saying otherwise is the false alarm that teaches people to
15
+ * ignore the output.
16
+ * - Every symptom carries the literal command that repairs it. A diagnosis
17
+ * nobody can act on is a complaint.
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import { existsSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ const STRATEGY_MANUAL = "manual";
25
+
26
+ const git = (projectDir, args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
27
+ const gitOk = (projectDir, args) => { try { git(projectDir, args); return true; } catch { return false; } };
28
+
29
+ /** @internal Exported for dynamic import from tests. */
30
+ export function createRepoStateCheck({ projectDir }) {
31
+ return {
32
+ name: "repo-state",
33
+ label: "estado del repositorio",
34
+ strategy: STRATEGY_MANUAL,
35
+ describe: "Detect a project raised or carried outside the method (order, contract, identity)",
36
+ async detect() {
37
+ const dir = projectDir;
38
+ const has = (rel) => existsSync(join(dir, rel));
39
+ const hardened = has(".karajan/hooks/pre-commit") || has(".karajan/review-gate");
40
+
41
+ // 1. No repository. The gates live in git hooks, so there is nothing to
42
+ // govern yet — and if the harness is already there, it governs nothing.
43
+ if (!has(".git")) {
44
+ return {
45
+ ok: false,
46
+ severity: "warn",
47
+ detail: hardened
48
+ ? "el proyecto está sin repositorio y el harness ya está escrito: no gobierna nada"
49
+ : "el proyecto está sin repositorio, así que ningún gate del método puede actuar",
50
+ fix: "kj bootstrap (crea el repositorio y deja el método en orden), o git init si prefieres hacerlo a mano",
51
+ };
52
+ }
53
+
54
+ // 2. The harness written before git existed: nothing tracks the contract,
55
+ // so cloning inherits nothing even though this clone looks hardened.
56
+ const hasCommits = gitOk(dir, ["rev-parse", "--verify", "HEAD"]);
57
+ if (!hasCommits) {
58
+ return hardened
59
+ ? { ok: true, severity: "info", detail: "fase de arranque: el método está escrito y falta el primer commit" }
60
+ : { ok: true, severity: "info", detail: "fase de arranque: repositorio recién creado, sin método todavía" };
61
+ }
62
+
63
+ const symptoms = [];
64
+ // Against HEAD, not the index (catch de codex): a file merely STAGED is
65
+ // not inherited by a clone, which is the whole point of this symptom.
66
+ const committed = (rel) => gitOk(dir, ["cat-file", "-e", `HEAD:${rel}`]);
67
+ if (hardened && !committed(".karajan/review-gate") && !committed(".karajan/hooks/pre-commit")) {
68
+ symptoms.push({
69
+ detail: "el harness está instalado pero su contrato no está en git: quien clone no hereda ningún gate",
70
+ fix: "git add .karajan/review-gate .karajan/hooks && git commit -m \"chore: el contrato del método\"",
71
+ });
72
+ }
73
+ if (hardened && !has(".karajan/identity.local.yml")) {
74
+ symptoms.push({
75
+ detail: "este clon no declara identidad, y el Sentinel denegará cada git y cada gh hasta que la declares",
76
+ fix: "kj identity set --gh <usuario> --email <correo>",
77
+ });
78
+ }
79
+
80
+ if (symptoms.length === 0) return { ok: true, severity: "info", detail: "el repositorio está en orden" };
81
+ return {
82
+ ok: false,
83
+ severity: "warn",
84
+ detail: symptoms.map((s) => s.detail).join(" · "),
85
+ fix: symptoms.map((s) => s.fix).join(" · "),
86
+ };
87
+ },
88
+ };
89
+ }
@@ -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
 
@@ -239,6 +249,10 @@ export function registerPipeline(program, { pkgVersion }) {
239
249
  .option("--prune-days <n>", "TTL in days for --prune (default: 14)")
240
250
  .option("--dry-run", "With --prune: report what would be removed without deleting")
241
251
  .option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
252
+ // BOOT-D (KJC-TSK-0863): how the walkthrough gets INTO the verdict. Without
253
+ // this the rule could only complain, which is the gate with no exit.
254
+ .option("--walked <route>", "Recorrido comprobado como lo haría una persona (repetible) — queda en el veredicto", (v, prev) => [...(prev || []), v], [])
255
+ .option("--walked-with <tool>", "Con qué se comprobó el recorrido (por ejemplo chrome-devtools)")
242
256
  .action(async (task, flags) => {
243
257
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
244
258
  // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
@@ -15,16 +15,38 @@
15
15
  * first and a git that cannot run is the end of the line, not a warning.
16
16
  */
17
17
 
18
+ import { execFileSync } from "node:child_process";
18
19
  import { existsSync } from "node:fs";
19
20
  import { join } from "node:path";
20
21
  import { ensureGitRepo } from "./init.js";
21
22
  import { initCommand } from "./init.js";
22
23
  import { envInstallCommand } from "./env.js";
24
+ import { runStartScript, START_SCRIPT_CONTRACT } from "../start/project-script.js";
23
25
 
24
26
  const STEP_LABEL = {
25
27
  git: "repositorio",
26
28
  config: "configuración del proyecto",
27
29
  method: "método activo (harness, gate y RAG)",
30
+ contract: "commit del contrato",
31
+ start: "arranque del proyecto",
32
+ };
33
+
34
+ /** What kj generates and the whole team must inherit by cloning. */
35
+ const CONTRACT_PATHS = [
36
+ ".gitignore",
37
+ ".karajan/hooks",
38
+ ".karajan/review-gate",
39
+ ".karajan/adrs",
40
+ ".karajan/policy.yml",
41
+ ".claude",
42
+ "CLAUDE.md",
43
+ "AGENTS.md",
44
+ "GEMINI.md",
45
+ ];
46
+ const CONTRACT_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
47
+
48
+ const hasCommits = (projectDir, git) => {
49
+ try { git(["rev-parse", "--verify", "HEAD"]); return true; } catch { return false; }
28
50
  };
29
51
 
30
52
  /**
@@ -71,5 +93,39 @@ export async function bootstrapCommand({ config = {}, logger = console, flags =
71
93
  say("method", "done");
72
94
  }
73
95
 
96
+ // 4. The contract commit. project-new.md asked the USER for it, and it was
97
+ // the commit their own freshly installed gates rejected: the review gate
98
+ // (KJC-BUG-0165) and the branch guard (KJC-BUG-0186) both exempt it now,
99
+ // so kj can make it itself instead of leaving the person to fight it.
100
+ // Only what kj generated, only while the repo has no commit, never the
101
+ // person's own code. This is NOT the supervisor seal, which stays a human
102
+ // act with its own four layers (ADR 0009).
103
+ const git = deps.gitRun ?? ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
104
+ if (hasCommits(projectDir, git)) say("contract", "already", "el repositorio ya tiene historia");
105
+ else {
106
+ const present = CONTRACT_PATHS.filter((p) => existsSync(join(projectDir, p)));
107
+ if (present.length === 0) say("contract", "already", "no hay contrato que commitear");
108
+ else {
109
+ try {
110
+ git(["add", "--", ...present]);
111
+ git(["commit", "-m", CONTRACT_MESSAGE]);
112
+ } catch (err) {
113
+ // Nunca explotar aquí: lo más probable es que falte la identidad del
114
+ // clon, y ese cauce ya lo pide el paso anterior (KJC-BUG-0188).
115
+ return stop("contract", `git no pudo commitear el contrato: ${String(err.message).split("\n")[0]}`);
116
+ }
117
+ say("contract", "done", `${present.length} ruta(s) del contrato`);
118
+ }
119
+ }
120
+
121
+ // 5. Does the project actually run? kj verified the method; nobody verified
122
+ // the application (BOOT-C, KJC-TSK-0862). It REPORTS, never blocks: a
123
+ // start script written badly must not stop work over something unrelated,
124
+ // and a tree that was already broken has to be said BEFORE implementing.
125
+ const start = await (deps.start ?? runStartScript)({ projectDir, config });
126
+ if (start.status === "absent") say("start", "already", `sin guion de arranque — ${START_SCRIPT_CONTRACT}`);
127
+ else if (start.status === "ok") say("start", "done", "el proyecto arranca y su humo pasa");
128
+ else say("start", "already", `el árbol YA venía roto (${start.status}) — no lo ha causado este trabajo`);
129
+
74
130
  return { ok: true, pending: null, steps };
75
131
  }
@@ -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
  }
@@ -12,8 +12,7 @@ import path from "node:path";
12
12
  import { spawn } from "node:child_process";
13
13
  import { checkBinary } from "../utils/agent-detect.js";
14
14
  import { createCliAskQuestion } from "../utils/cli-ask-question.js";
15
- import { envInstallCommand } from "./env.js";
16
- import { ensureGitRepo } from "./init.js";
15
+ import { bootstrapCommand } from "./bootstrap.js";
17
16
  import { boardCommand } from "./board.js";
18
17
  // The interactive launchers a muggle can live inside (v1: the two the epic
19
18
  // names). Auth heuristics are the same cheap file checks reviewer-fallback
@@ -44,9 +43,6 @@ export function buildGoPrompt() {
44
43
  "Empieza presentándote en dos frases y preguntando qué quiere construir o cambiar hoy.",
45
44
  ].join("\n");
46
45
  }
47
- async function defaultPrepare({ config, logger }) {
48
- return envInstallCommand({ config, logger, flags: { yes: true } });
49
- }
50
46
  export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
51
47
  const port = config.hu_board?.port || 4000;
52
48
  await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
@@ -96,14 +92,17 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
96
92
  // Prepare ONCE: decisions already taken are never re-asked.
97
93
  if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
98
94
  logger.info?.("Preparando tu proyecto (solo la primera vez)…");
99
- // KJC-BUG-0191: empezar en una carpeta vacía es lo natural, y kj env
100
- // install se niega a correr sin repositorio. Lo creamos nosotros.
101
- if (!ensureGitRepo({ projectDir, logger })) {
102
- logger.error?.("No he podido preparar el control de versiones de tu proyecto (git). Instálalo y vuelve a escribir: kj go");
95
+ // BOOT-A paso 3 (KJC-TSK-0857): el orden vive en UN sitio, kj bootstrap.
96
+ // Antes esta puerta cableaba su propia secuencia (git init y env install),
97
+ // así que la puerta del maggle y la técnica podían separarse sin que nadie
98
+ // se diera cuenta. Ahora las dos hacen lo mismo.
99
+ const booted = await (deps.bootstrap ?? bootstrapCommand)({ config, logger, flags: {}, deps: {} });
100
+ if (!booted.ok) {
101
+ logger.error?.(`Falta una cosa que solo puedes hacer tú, la tienes justo arriba (${booted.pending}). Cuando la hagas, vuelve a escribir: kj go`);
103
102
  process.exitCode = 1;
104
103
  return 1;
105
104
  }
106
- const prepared = await (deps.prepare ?? defaultPrepare)({ config, logger });
105
+ const prepared = deps.prepare ? await deps.prepare({ config, logger }) : null;
107
106
  // Algo depende de tus manos (arriba tienes el detalle). Sin eso el
108
107
  // proyecto quedaría a medias, así que paramos aquí en vez de seguir.
109
108
  if (prepared?.exitCode) {
@@ -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,
@@ -15,12 +15,14 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
16
  import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
17
17
  import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
18
+ import { checkUiEvidence, uiBlock } from "../review/ui-evidence.js";
18
19
  import { readRagLedger } from "../review/rag-ledger.js";
19
20
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
20
21
  import { checkCardFirst } from "../review/card-first.js";
21
22
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
22
23
  import { checkTestsWithCode } from "../review/tests-with-code.js";
23
24
  import { loadPrivacyList, scanText } from "../privacy/scan.js";
25
+ import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
24
26
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
25
27
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
26
28
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
@@ -229,17 +231,24 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
229
231
  // don't publish. In the SEA binary the privacy module is stubbed and
230
232
  // throws: the gate degrades with a note instead of crashing.
231
233
  try {
232
- const added = diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1)).join("\n");
233
- 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
+ });
234
243
  const blocks = findings.filter((f) => f.severity === "block");
235
244
  const warns = findings.filter((f) => f.severity === "warn");
236
- 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`);
237
246
  const hardened = config?.privacy?.generic === "block" && warns.length > 0;
238
247
  if (blocks.length > 0 || hardened) {
239
248
  if (process.env.KJ_ALLOW_PII === "1") {
240
249
  console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
241
250
  } else {
242
- 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}`);
243
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)`;
244
253
  console.log(`✗ privacy gate: ${reason}`);
245
254
  process.exitCode = 1;
@@ -482,6 +491,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
482
491
  + twins.map((t) => `- ${t}`).join("\n");
483
492
  }
484
493
 
494
+ // BOOT-D (KJC-TSK-0863): green is not proof for what a person SEES. kj does
495
+ // not drive the browser (the host has it); it demands the walkthrough and
496
+ // records it in the verdict, bound to the diff like sonar and rag. It WARNS:
497
+ // the last proof, never the only one, and a gate that fires often teaches
498
+ // people to skip gates.
499
+ const walked = Array.isArray(flags.walked) ? flags.walked : [];
500
+ const uiReq = checkUiEvidence({
501
+ stagedFiles: changedFiles,
502
+ evidence: walked.length > 0 ? { walked, tool: flags.walkedWith ?? null } : null,
503
+ standingExceptions: std.standing,
504
+ });
505
+ if (uiReq.warn) console.log(`⚠ ${uiReq.reason}`);
506
+ const uiRecord = uiBlock(uiReq);
507
+
485
508
  // MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
486
509
  // SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
487
510
  // el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
@@ -504,7 +527,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
504
527
  }
505
528
  }
506
529
 
507
- const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord });
530
+ const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord, ui: uiRecord });
508
531
  printVerdict(record);
509
532
  process.exitCode = record.verdict === "approved" ? 0 : 1;
510
533
  return record;