karajan-code 4.51.1 → 4.53.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 (69) hide show
  1. package/package.json +2 -1
  2. package/scripts/contract-claude.mjs +24 -0
  3. package/scripts/esbuild-sea.config.mjs +7 -0
  4. package/scripts/record-agent-fixtures.mjs +98 -0
  5. package/src/agents/claude-agent.js +8 -0
  6. package/src/agents/model-registry.js +5 -0
  7. package/src/agents/opencode-agent.js +16 -1
  8. package/src/audit/issue-filter.js +19 -1
  9. package/src/checks/audit-cycle.js +41 -0
  10. package/src/checks/claude-contract-check.js +27 -0
  11. package/src/checks/method.js +3 -2
  12. package/src/checks/release-check.js +23 -1
  13. package/src/checks/sonar.js +30 -12
  14. package/src/checks/supply-chain-check.js +47 -0
  15. package/src/cli/register-meta.js +14 -1
  16. package/src/cli/register-pipeline.js +9 -0
  17. package/src/cli/register-plan.js +10 -0
  18. package/src/commands/check.js +11 -1
  19. package/src/commands/config.js +2 -0
  20. package/src/commands/harden.js +3 -0
  21. package/src/commands/hu.js +24 -3
  22. package/src/commands/identity.js +4 -1
  23. package/src/commands/init.js +34 -1
  24. package/src/commands/plan/generate.js +5 -0
  25. package/src/commands/plan/license.js +35 -0
  26. package/src/commands/plan/ready.js +1 -1
  27. package/src/commands/plan/validate.js +1 -1
  28. package/src/commands/report.js +14 -0
  29. package/src/commands/review-gate.js +26 -2
  30. package/src/commands/worktree.js +35 -2
  31. package/src/config/defaults.js +10 -1
  32. package/src/config/overrides.js +8 -0
  33. package/src/config/schema.js +7 -0
  34. package/src/environment/playbook.js +2 -0
  35. package/src/harden/act-sign.js +2 -2
  36. package/src/harden/branch-protection.js +97 -0
  37. package/src/harden/phone-invite.js +15 -8
  38. package/src/harden/phone-texts.js +17 -0
  39. package/src/harden/security-level.js +18 -2
  40. package/src/harden/sentinel-hooks.js +2 -1
  41. package/src/hu/complexity-tier.js +32 -0
  42. package/src/hu/lane-prune.js +98 -0
  43. package/src/hu/model-router.js +7 -16
  44. package/src/hu/routing-metrics.js +16 -0
  45. package/src/hu/store.js +2 -0
  46. package/src/mcp/handlers/direct-handlers.js +2 -2
  47. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
  48. package/src/orchestrator/hu-sub-pipeline.js +19 -0
  49. package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
  50. package/src/orchestrator/stages/reviewer-stage.js +8 -1
  51. package/src/orchestrator/stages/sonar-stage.js +19 -1
  52. package/src/plan/plan-schema.js +4 -1
  53. package/src/plan/rework-license.js +38 -0
  54. package/src/prompts/hu-reviewer.js +5 -2
  55. package/src/prompts/planner.js +9 -1
  56. package/src/report/model-tally.js +39 -0
  57. package/src/review/branch-overlap.js +40 -0
  58. package/src/review/correction-rounds.js +40 -0
  59. package/src/review/dep-cooldown.js +87 -0
  60. package/src/review/one-shot-review.js +5 -2
  61. package/src/review/parser.js +21 -0
  62. package/src/review/publish-pr.js +54 -0
  63. package/src/review/solomon-arbitration.js +2 -2
  64. package/src/review/verdict-store.js +19 -7
  65. package/src/roles/sonar-role.js +4 -2
  66. package/src/sonar/api.js +7 -0
  67. package/src/utils/injection-guard.js +59 -43
  68. package/src/utils/injection-patterns.json +36 -0
  69. package/src/utils/model-selector.js +47 -3
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import yaml from "js-yaml";
4
4
  import { loadConfig, validateConfig, getConfigPath, writeConfig } from "../config.js";
5
5
  import { ensureDir } from "../utils/fs.js";
6
+ import { formatModelProfile } from "../utils/model-selector.js";
6
7
  import path from "node:path";
7
8
 
8
9
  function resolveEditor() {
@@ -70,5 +71,6 @@ export async function configCommand({ json = false, edit = false }) {
70
71
 
71
72
  console.log(`Config path: ${cfgPath}`);
72
73
  console.log(`Exists: ${exists}`);
74
+ console.log(formatModelProfile(config));
73
75
  console.log(JSON.stringify(config, null, 2));
74
76
  }
@@ -24,6 +24,7 @@ import { installHarnessHooks } from "../harden/harness-hooks.js";
24
24
  import { installSentinelHooks } from "../harden/sentinel-hooks.js";
25
25
  import { detectStackRoots } from "../harden/stack-roots.js";
26
26
  import { installWorkflows } from "../harden/workflow-engine.js";
27
+ import { adviseBranchProtection } from "../harden/branch-protection.js";
27
28
  import { detectTestFramework } from "../utils/project-detect.js";
28
29
  import { ensureIdentity } from "../identity/bootstrap.js";
29
30
  import { commitContract, contractChanges } from "../environment/contract-commit.js";
@@ -250,6 +251,8 @@ export async function hardenCommand({
250
251
  out.identityCommand = id.command;
251
252
  }
252
253
  }
254
+ // KJC-TSK-0928: the method's checks only stop a merge with branch protection; kj says the exact command.
255
+ if (!dryRun) out.branchProtection = await adviseBranchProtection({ projectDir, branch: await resolveBaseBranch(projectDir), logger });
253
256
  // KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
254
257
  // del supervisor con procedencia sellada. Acto humano — el guard vive en
255
258
  // commitSupervisorRegeneration y rechaza sesiones de agente.
@@ -9,6 +9,8 @@ import { addHu, updateHuStatus } from "../plan/plan-hu-ops.js";
9
9
  import { generatePlanId } from "../plan/plan-id.js";
10
10
  import { savePlan, listPlans, loadPlan } from "../plan/plan-store.js";
11
11
  import { detectHostAgent } from "../utils/agent-detect.js";
12
+ import { readIdentity } from "../identity/store.js";
13
+ import { effectiveGitEmail } from "../identity/detect.js";
12
14
 
13
15
  // KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
14
16
  // board must never show work nobody can trace. Host agent env → that agent;
@@ -58,7 +60,10 @@ export async function backlogPlan(projectDir) {
58
60
  return loadPlan(projectDir, planId);
59
61
  }
60
62
 
61
- export async function huCommand({ config = null, action, args = [], flags = {} }) {
63
+ // KJC-TSK-1047 (TEAM-A): who works this clone, the one a started card is reserved to.
64
+ const workerOf = (projectDir) => readIdentity(projectDir)?.gh_user || effectiveGitEmail(projectDir) || null;
65
+
66
+ export async function huCommand({ config = null, action, args = [], flags = {}, deps = {} }) {
62
67
  const projectDir = config?.projectDir || process.cwd();
63
68
  const emit = (obj, human) => { console.log(flags.json ? JSON.stringify(obj) : human); return obj; };
64
69
 
@@ -79,12 +84,14 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
79
84
  // the card. Older HUs predate the stamp: shown as "?".
80
85
  created_by: h.created_by || null, created_at: h.createdAt || null,
81
86
  external_ref: h.external_ref || null,
87
+ claimed_by: h.claimed_by || null, claimed_at: h.claimed_at || null,
82
88
  });
83
89
  }
84
90
  }
85
91
  if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
86
92
  for (const r of rows) {
87
- const suffixes = [r.created_at?.slice(0, 10), r.external_ref].filter(Boolean).map((s) => ` · ${s}`).join("");
93
+ const reserved = r.status === "running" && r.claimed_by ? `reserved by ${r.claimed_by}` : null;
94
+ const suffixes = [r.created_at?.slice(0, 10), r.external_ref, reserved].filter(Boolean).map((s) => ` · ${s}`).join("");
88
95
  const origin = `${r.plan} · by ${r.created_by || "?"}${suffixes}`;
89
96
  console.log(`${(r.short_id || r.id).padEnd(28)} ${r.status.padEnd(8)} ${r.title} [${origin}]`);
90
97
  }
@@ -215,9 +222,23 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
215
222
  + `a second concurrent task should live in its own lane: kj worktree start ${slug}`
216
223
  );
217
224
  }
225
+ // KJC-TSK-1047 (TEAM-A): a running card reserved by someone else moves only with --release.
226
+ const who = deps.who === undefined ? workerOf(projectDir) : deps.who;
227
+ const label = hu.short_id || hu.id;
228
+ // An unidentified caller is not the one who reserved it.
229
+ const takenOver = hu.status === "running" && hu.claimed_by && hu.claimed_by !== who;
230
+ if (takenOver && !flags.release) {
231
+ throw new Error(`${label} is reserved by ${hu.claimed_by} since ${hu.claimed_at} — releasing it is explicit: kj hu move ${ref} ${status} --release`);
232
+ }
233
+ const starting = status === "running" && hu.status !== "running";
234
+ // A running card nobody reserved is reserved by who moves it there again, once kj knows who.
235
+ const unclaimedRunning = status === "running" && !hu.claimed_by && who;
218
236
  updateHuStatus(plan, hu.id, status);
237
+ // Starting it, or releasing it to any status, rewrites the reservation: who did it, or nobody.
238
+ if (starting || takenOver || unclaimedRunning) Object.assign(hu, { claimed_by: who ?? null, claimed_at: who ? new Date().toISOString() : null });
239
+ if (starting && !who) console.warn(`⚠ ${label} is not reserved: kj cannot tell who works this clone — declare it with kj identity set`);
219
240
  await savePlan(projectDir, plan);
220
- return emit({ id: hu.id, status }, `✓ ${hu.short_id || hu.id} → ${status}`);
241
+ return emit({ id: hu.id, status, claimed_by: hu.claimed_by ?? null }, `✓ ${label} → ${status}`);
221
242
  }
222
243
 
223
244
  throw new Error(`unknown action "${action}" — use: add | move | list`);
@@ -14,6 +14,7 @@ import { signAct } from "../harden/act-sign.js";
14
14
  import { enrollPhone, isPhoneEnrolled, readEnrolledKey, readEnrolledKeys, validatePhonePublicKey } from "../harden/phone-sign.js";
15
15
  import { admitSigner, redeemRecoveryCode } from "../harden/roster.js";
16
16
  import { phoneInvite } from "../harden/phone-invite.js";
17
+ import { readSecurityLevel } from "../harden/security-level.js";
17
18
  import { humanAct } from "../harden/human-act.js";
18
19
  import { readIdentity, writeIdentity } from "../identity/store.js";
19
20
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
@@ -75,7 +76,9 @@ export async function identityCommand({ action = "show", config, kjVersion = "un
75
76
  // KJC-TSK-0986 (HUM-F): the once-per-version invitation to enroll the phone,
76
77
  // printed for the SessionStart hook (which cannot import kj) and for scripts.
77
78
  if (action === "phone-invite") {
78
- const text = phoneInvite({ home: deps.home, version: flags.version });
79
+ // KJC-BUG-0360: the text depends on the level; at max (the default) the acts are refused.
80
+ const { level } = readSecurityLevel({ projectDir: deps.projectDir ?? process.cwd(), home: deps.home });
81
+ const text = phoneInvite({ home: deps.home, version: flags.version, level });
79
82
  if (text) log(text);
80
83
  return 0;
81
84
  }
@@ -26,6 +26,7 @@ import { installQmd } from "../utils/qmd-install.js";
26
26
  import { registerQmdCollections } from "../utils/qmd-collection.js";
27
27
  import { isGitRepo } from "../harden/harden-engine.js";
28
28
  import { isPhoneEnrolled } from "../harden/phone-sign.js";
29
+ import { identityCommand } from "./identity.js";
29
30
  import { hardenCommand } from "./harden.js";
30
31
  import { contractChanges } from "../environment/contract-commit.js";
31
32
  import { detectProjectStack } from "../utils/stack-detect.js";
@@ -300,7 +301,36 @@ export function tellPhoneStep(logger, { enrolled = isPhoneEnrolled() } = {}) {
300
301
  logger.info(" 1. Open https://karajancode.com/sign on the phone: it creates a key pair and shows the PUBLIC key (the private one never leaves it).");
301
302
  logger.info(" 2. From YOUR terminal, never from an agent session: kj identity enroll-phone <public key> --label <name>");
302
303
  logger.info(" 3. Keep the recovery code it prints, off this machine: it replaces a lost phone (--recovery <code>).");
303
- logger.info(" Prefer the four layers alone? kj security normal lowers the level, signed from the phone too (ADR 0018).");
304
+ logger.info(" Prefer the four layers alone? kj security normal lowers the level from your own terminal while no phone is enrolled (ADR 0020).");
305
+ }
306
+
307
+ /**
308
+ * KJC-TSK-1046 (ADR 0020): with a person at the terminal and no phone, init
309
+ * offers to enroll it now. The wizard only collects the public key: enrolling
310
+ * reads a typed code from /dev/tty, so it runs once the wizard is closed.
311
+ */
312
+ export async function offerPhoneEnrollment(wizard, logger, { enrolled = isPhoneEnrolled() } = {}) {
313
+ if (enrolled) return null;
314
+ const now = await wizard.confirm("Enroll your phone now? (at the max level, seals and approvals are refused without it)", true);
315
+ const key = now ? String(await wizard.input(" Open https://karajancode.com/sign on the phone and paste the PUBLIC key it shows:", "")).trim() : "";
316
+ if (key) return key;
317
+ logger.info(" -> Phone: not now. Enroll it later from your own terminal: kj identity enroll-phone <public key> --label <name>");
318
+ return null;
319
+ }
320
+
321
+ const enrollThroughIdentity = (key) => identityCommand({ action: "enroll-phone", config: { projectDir: process.cwd() }, flags: { publicKeyBase64: key, label: os.hostname() } });
322
+
323
+ /** Enrolls the key the wizard collected, with every check of the identity command; a failure is said and init goes on. */
324
+ export async function enrollPendingPhone(key, { enroll = enrollThroughIdentity, logger }) {
325
+ if (!key) return false;
326
+ let reason = "see the message above";
327
+ try {
328
+ if ((await enroll(key)) === 0) return true;
329
+ } catch (err) {
330
+ reason = err.message;
331
+ }
332
+ logger.info(` -> Phone not enrolled (${reason}). Try again from your own terminal: kj identity enroll-phone <public key>`);
333
+ return false;
304
334
  }
305
335
 
306
336
  /**
@@ -355,6 +385,7 @@ async function runWizard(config, logger) {
355
385
  logger.info("");
356
386
 
357
387
  const wizard = createWizard();
388
+ let pendingPhoneKey;
358
389
  try {
359
390
  const { coder, reviewer } = await askAgents(wizard, available, config, logger);
360
391
 
@@ -418,10 +449,12 @@ async function runWizard(config, logger) {
418
449
 
419
450
  logger.info("");
420
451
  tellPhoneStep(logger);
452
+ pendingPhoneKey = await offerPhoneEnrollment(wizard, logger);
421
453
  logger.info("");
422
454
  } finally {
423
455
  wizard.close();
424
456
  }
457
+ await enrollPendingPhone(pendingPhoneKey, { logger });
425
458
 
426
459
  return config;
427
460
  }
@@ -232,6 +232,11 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
232
232
  // KJC-TSK-0661: provenance stamp (addHu builds its object explicitly,
233
233
  // so this goes on the returned reference, not in huData).
234
234
  hu.created_by = "planner";
235
+ // KJC-TSK-1061: the classification travels to the HU; validatePlan judges it (KJC-TSK-0717).
236
+ for (const key of ["change_class", "change_criterion", "hardens"]) {
237
+ const value = typeof step === "object" ? step[key] : null;
238
+ if (typeof value === "string" && value.trim()) hu[key] = value.trim();
239
+ }
235
240
  if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
236
241
  const deps = typeof step === "object" && Array.isArray(step.dependencies) ? step.dependencies : [];
237
242
  stepDeps.push(deps);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * kj plan license <planId> <huId> --module <path> (KJC-TSK-0717, SPD-A): scores
3
+ * the module with kj mutate and records the REWORK licence on the HU, with the
4
+ * threshold in force. validatePlan judges it again from the score.
5
+ */
6
+ import { loadPlan, savePlan } from "../../plan/plan-store.js";
7
+ import { licenseFor, DEFAULT_REWORK_MIN } from "../../plan/rework-license.js";
8
+ import { mutateCommand } from "../mutate.js";
9
+
10
+ /** The module's mutation score in percent, or null when kj mutate gives none. */
11
+ async function scoreModule(projectDir, module) {
12
+ let report = null;
13
+ const logger = { info: (line) => { try { report = JSON.parse(line); } catch { /* a progress line */ } } };
14
+ await mutateCommand({ projectDir, path: module, json: true, maxSurvivors: Number.POSITIVE_INFINITY, logger });
15
+ return Number.isFinite(report?.score) ? report.score : null;
16
+ }
17
+
18
+ export async function planLicenseCommand({ config, planId, huId, module, deps = {} }) {
19
+ const say = deps.say ?? console.log;
20
+ const projectDir = config.projectDir || process.cwd();
21
+ if (!module) { say("kj plan license: --module <path> is required (the module the REWORK regenerates)"); return 1; }
22
+ const plan = await (deps.loadPlan ?? loadPlan)(projectDir, planId);
23
+ if (!plan) { say(`Plan not found: ${planId}`); return 1; }
24
+ const hu = plan.hus?.find((h) => h.id === huId);
25
+ if (!hu) { say(`HU not found: ${huId}`); return 1; }
26
+ const score = await (deps.scoreModule ?? scoreModule)(projectDir, module);
27
+ if (score === null) { say(`kj mutate could not score ${module}: no licence recorded (run kj mutate ${module} to see why)`); return 1; }
28
+ const threshold = config?.method_gates?.rework_mutation_min ?? DEFAULT_REWORK_MIN;
29
+ hu.rework_license = licenseFor({ module, score, threshold });
30
+ await (deps.savePlan ?? savePlan)(projectDir, plan);
31
+ const pct = `${Math.round(threshold * 100)}%`;
32
+ if (hu.rework_license.granted) { say(`✓ REWORK licence granted for ${module} on ${huId}: mutation score ${score}% at or over ${pct}`); return 0; }
33
+ say(`✗ REWORK licence denied for ${module} on ${huId}: mutation score ${score}% under ${pct}. Plan two phases: add an HU with hardens: ${module} (kill the survivors first) and put it in ${huId}'s blocked_by`);
34
+ return 1;
35
+ }
@@ -10,7 +10,7 @@ export async function planReadyCommand({ config, planId }) {
10
10
  const plan = await loadPlan(projectDir, planId);
11
11
  if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
12
12
 
13
- const validation = validatePlan(plan);
13
+ const validation = validatePlan(plan, { reworkMin: config?.method_gates?.rework_mutation_min });
14
14
  if (!validation.valid) {
15
15
  console.error("Plan validation failed:");
16
16
  for (const err of validation.errors) console.error(` - ${err}`);
@@ -9,7 +9,7 @@ export async function planValidateCommand({ config, planId }) {
9
9
  const plan = await loadPlan(projectDir, planId);
10
10
  if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
11
11
 
12
- const result = validatePlan(plan);
12
+ const result = validatePlan(plan, { reworkMin: config?.method_gates?.rework_mutation_min });
13
13
  if (result.valid) {
14
14
  console.log(`✅ Plan ${planId} is valid (${plan.hus.length} HUs, status: ${plan.status})`);
15
15
  } else {
@@ -4,6 +4,8 @@ import { exists } from "../utils/fs.js";
4
4
  import { getSessionRoot } from "../utils/paths.js";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { DEFAULTS } from "../config/defaults.js";
7
+ import { listPlans, loadPlan } from "../plan/plan-store.js";
8
+ import { formatModelTally, modelTally } from "../report/model-tally.js";
7
9
 
8
10
  function parseBudgetFromActivityLog(logText) {
9
11
  if (!logText) {
@@ -269,6 +271,18 @@ async function printSentinelEscapes({ projectDir = process.cwd(), format } = {})
269
271
  console.log(tally);
270
272
  }
271
273
  } catch { /* no decision log — no panel to tally */ }
274
+ // KJC-TSK-1057 (MR-E2): what each coder model cost and how often it failed first time.
275
+ try {
276
+ const hus = [];
277
+ for (const meta of await listPlans(projectDir)) hus.push(...((await loadPlan(projectDir, meta.planId))?.hus ?? []));
278
+ const tally = formatModelTally(modelTally(hus));
279
+ if (tally) {
280
+ console.log("");
281
+ console.log(tally);
282
+ }
283
+ } catch (err) {
284
+ console.log(`\nCoder models: the plans could not be read (${err.message})`);
285
+ }
272
286
  }
273
287
 
274
288
  function formatDuration(ms) {
@@ -29,6 +29,9 @@ import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
29
29
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
30
30
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
31
31
  import { evaluatePolicyGate } from "../review/policy-gate.js";
32
+ import { correctionNote, reviewCorrectionRounds } from "../review/correction-rounds.js";
33
+ import { cooldownAdvisories, cooldownGate, DEFAULT_COOLDOWN_HOURS, formatCooldown } from "../review/dep-cooldown.js";
34
+ import { branchOverlaps, formatOverlaps } from "../review/branch-overlap.js";
32
35
 
33
36
  // KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
34
37
  // (before spending sonar/reviewer effort) AND on --check — the pre-commit
@@ -111,11 +114,12 @@ export const formatExempted = (e) => (e.standing
111
114
  ? `⚠ policy standing [${e.rule_id}] — excepción permanente${e.standing.origin === "global" ? " GLOBAL" : ""} viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
112
115
  : `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
113
116
 
114
- function printVerdict(record) {
117
+ function printVerdict(record, rounds = 0) {
115
118
  if (record.verdict === "approved") {
116
119
  const ws = record.workspace ? ` [${record.workspace}]` : "";
117
120
  console.log(`✓ APPROVED by ${record.reviewer} (diff ${record.diffHash.slice(0, 12)})${ws}`);
118
121
  if (record.summary) console.log(` ${record.summary}`);
122
+ if (rounds > 0) console.log(` ${correctionNote(rounds)}`);
119
123
  return;
120
124
  }
121
125
  console.log(`✗ REJECTED by ${record.reviewer} — ${record.issues.length} blocking issue(s):`);
@@ -576,8 +580,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
576
580
  }
577
581
  }
578
582
 
583
+ // KJC-TSK-1052 (TEAM-F): another branch in progress touching the same files is said, never blocks.
584
+ if (flags.staged && !flags.range) {
585
+ for (const line of formatOverlaps(await branchOverlaps({ projectDir, stagedFiles: changedFiles, base: config?.base_branch || "main" }))) console.log(line);
586
+ }
587
+
588
+ // KJC-TSK-0722: a dependency published under the cooldown is said, and weighed by the reviewer.
589
+ const cooldownHours = config?.supply_chain?.cooldown_hours ?? DEFAULT_COOLDOWN_HOURS;
590
+ const cooldownResult = await cooldownAdvisories(diff, { thresholdHours: cooldownHours });
591
+ const cooldown = formatCooldown(cooldownResult, cooldownHours);
592
+ for (const line of cooldown.lines) console.log(line);
593
+ // KJC-TSK-1056: method_gates.dep_cooldown: block stops it here, before any reviewer token.
594
+ const cooldownStop = cooldownGate({ result: cooldownResult, mode: config?.method_gates?.dep_cooldown, standingExceptions: std.standing });
595
+ if (cooldownStop.grant) console.log(`⚠ dep_cooldown lifted by a HUMAN grant [method.dep_cooldown] until ${cooldownStop.grant.expiresAt}`);
596
+ if (cooldownStop.block) {
597
+ console.log(`✗ ${cooldownStop.reason}`);
598
+ process.exitCode = 1;
599
+ return { verdict: "rejected", reviewer: "dep-cooldown", issues: cooldownResult.young.map((p) => ({ severity: "high", description: `${p.name}@${p.version} published ${p.hours} h ago` })) };
600
+ }
601
+ if (cooldown.note) task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n${cooldown.note}`;
602
+
579
603
  const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord, ui: uiRecord });
580
- printVerdict(record);
604
+ printVerdict(record, record.verdict === "approved" ? await reviewCorrectionRounds(projectDir, record) : 0);
581
605
  process.exitCode = record.verdict === "approved" ? 0 : 1;
582
606
  return record;
583
607
  }
@@ -11,13 +11,15 @@ import path from "node:path";
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { bootstrapWorktree } from "../hu/worktree-bootstrap.js";
14
+ import { classifyLanes, liveProcesses, looseLaneBranches, resolveBase } from "../hu/lane-prune.js";
15
+ import { recordGateDecision } from "../policy/decisions.js";
14
16
 
15
17
  const execFileAsync = promisify(execFile);
16
18
  const WORKTREE_DIR = ".kj/worktrees"; // shared with the headless lanes
17
19
 
18
20
  const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19
21
 
20
- export async function worktreeCommand({ config = null, action, args = [], flags = {} }) {
22
+ export async function worktreeCommand({ config = null, action, args = [], flags = {}, deps = {} }) {
21
23
  const projectDir = config?.projectDir || process.cwd();
22
24
  const git = (gitArgs, cwd = projectDir) => execFileAsync("git", gitArgs, { cwd });
23
25
  const lanePath = (slug) => path.join(projectDir, WORKTREE_DIR, slug);
@@ -72,5 +74,36 @@ export async function worktreeCommand({ config = null, action, args = [], flags
72
74
  return { removed: true, slug };
73
75
  }
74
76
 
75
- throw new Error(`unknown action "${action}" — use: start | list | done`);
77
+ if (action === "prune") {
78
+ const { processes = liveProcesses() } = deps;
79
+ const base = await resolveBase({ git, base: config?.base_branch || "main" });
80
+ const lanes = await classifyLanes({ git, laneRoot: path.join(projectDir, WORKTREE_DIR), base, processes, now: deps.now });
81
+ const abandoned = lanes.filter((l) => l.reasons.length === 0);
82
+ const kept = lanes.filter((l) => l.reasons.length > 0);
83
+ for (const l of kept) console.log(`kept ${l.slug.padEnd(24)} ${l.reasons.join("; ")}`);
84
+ if (lanes.length === 0) console.log("no kj lanes");
85
+ // KJC-TSK-1037: loose kj-hu-* branches of old runs, measured against the same base.
86
+ const { empty: branches, withWork } = await looseLaneBranches({ git, base });
87
+ for (const b of withWork) console.log(`kept branch ${b.branch.padEnd(17)} ${b.reason}`);
88
+ if (flags.dryRun) {
89
+ for (const l of abandoned) console.log(`would retire ${l.slug} (${l.branch ?? "detached"})`);
90
+ for (const b of branches) console.log(`would delete branch ${b}`);
91
+ return { wouldRemove: abandoned, kept, wouldRemoveBranches: branches, branchesKept: withWork };
92
+ }
93
+ for (const b of branches) {
94
+ await git(["branch", "-D", b]);
95
+ console.log(`✓ deleted branch ${b}`);
96
+ }
97
+ for (const l of abandoned) {
98
+ await git(["worktree", "remove", l.path]);
99
+ if (l.branch) await git(["branch", "-D", l.branch]);
100
+ console.log(`✓ retired ${l.slug} (${l.branch ?? "detached"})`);
101
+ }
102
+ if (abandoned.length > 0 || branches.length > 0) {
103
+ recordGateDecision(projectDir, { decision: "allow", chokepoint: "tool", kind: "worktree-prune", lanes: abandoned.map((l) => l.slug), branches });
104
+ }
105
+ return { removed: abandoned, kept, branchesRemoved: branches, branchesKept: withWork };
106
+ }
107
+
108
+ throw new Error(`unknown action "${action}" — use: start | list | done | prune`);
76
109
  }
@@ -90,8 +90,17 @@ const DEFAULTS = {
90
90
  // the turn's final message against that turn's tool outputs. "off" (adoption
91
91
  // is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
92
92
  // source refuses to end the turn; unbacked data is still only reported).
93
- claims: "off"
93
+ claims: "off",
94
+ // KJC-TSK-0717 (SPD-A): the module mutation score a REWORK needs (0.8 = 80 %).
95
+ rework_mutation_min: 0.8,
96
+ // KJC-TSK-1056: a dependency published under supply_chain.cooldown_hours
97
+ // ago: "warn" (said in kj review) | "block" (stops the review; a human
98
+ // grant on method.dep_cooldown lifts it). Unverifiable ones never block.
99
+ dep_cooldown: "warn"
94
100
  },
101
+ // KJC-TSK-0719 (SPD-C): kj check says the audit is due after this many
102
+ // distinct cards or work commits since the last scored audit.
103
+ audit_cycle: { every_cards: 10, every_commits: 60 },
95
104
  review_rules: "./.karajan/review-rules.md",
96
105
  coder_rules: "./.karajan/coder-rules.md",
97
106
  base_branch: "main",
@@ -17,6 +17,7 @@
17
17
  import { mergeDeep } from "./loader.js";
18
18
  import { DEFAULTS } from "./defaults.js";
19
19
  import { safeParseConfig, formatConfigIssues } from "./schema.js";
20
+ import { applyModelProfile } from "../utils/model-selector.js";
20
21
 
21
22
  const ROLE_PROVIDER_FLAGS = [
22
23
  ["planner", "planner"], ["coder", "coder"], ["reviewer", "reviewer"],
@@ -99,6 +100,13 @@ function applyRoleOverrides(out, flags) {
99
100
  }
100
101
  // reviewerModel also updates reviewer_options
101
102
  if (flags.reviewerModel) out.reviewer_options.model = String(flags.reviewerModel);
103
+
104
+ // KJC-TSK-0699: a profile fills only the models nothing above has set.
105
+ const profile = flags.profile ?? out.model_profile;
106
+ if (profile) {
107
+ applyModelProfile(out, String(profile));
108
+ out.model_profile = String(profile);
109
+ }
102
110
  }
103
111
 
104
112
  function applyPipelineOverrides(out, flags) {
@@ -242,6 +242,8 @@ export const ConfigSchema = v.looseObject({
242
242
  ))),
243
243
  // ENV-D1 (KJC-TSK-0642): where work items live — the integrated HU Board
244
244
  // or the user's Planning Game. The v4 playbook renders per backend.
245
+ // KJC-TSK-0699 (MR-B): the default model profile; kj run --profile overrides it.
246
+ model_profile: v.optional(v.picklist(["frugal", "standard", "deep"], "model_profile must be frugal, standard or deep")),
245
247
  state_backend: v.optional(v.picklist(
246
248
  // KJC-TSK-0709 caught the drift: "external" shipped in v4.5.0 (any
247
249
  // board via the agent's own MCP/tools) but this picklist never learned it.
@@ -249,6 +251,11 @@ export const ConfigSchema = v.looseObject({
249
251
  "state_backend must be \"hu-board\", \"planning-game\" or \"external\""
250
252
  )),
251
253
  review_rules: v.optional(v.string()),
254
+ // KJC-TSK-0719 (SPD-C): kj check says the audit is due past these counts.
255
+ audit_cycle: v.optional(v.looseObject({
256
+ every_cards: v.optional(PositiveInt),
257
+ every_commits: v.optional(PositiveInt),
258
+ })),
252
259
  coder_rules: v.optional(v.string()),
253
260
  base_branch: v.optional(v.string()),
254
261
  coder_options: CoderOptions,
@@ -70,6 +70,8 @@ Invariants (the git gates enforce these — they are not suggestions):
70
70
  - Every diff is reviewed by a DIFFERENT AI before it is committed
71
71
  (\`kj review --staged\`): verdicts bind to the exact diff — change the code
72
72
  and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
73
+ A correction from the other AI is the method working: apply it and move
74
+ on, no apology, and never report it as your mistake.
73
75
  ${panel ? `${panel}\n` : ""}- Security findings are never overridable — not even by arbitration. You
74
76
  absorb the security role: \`kj brief security\` states what must be true.
75
77
  Task touches auth, user input, secrets, network or deps? Run
@@ -10,7 +10,7 @@
10
10
  import { basename } from "node:path";
11
11
 
12
12
  import { humanActOf } from "./human-act.js";
13
- import { PHONE_INVITE } from "./phone-invite.js";
13
+ import { PHONE_ENROLL, PHONE_INVITE } from "./phone-texts.js";
14
14
  import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
15
15
  import { readSecurityLevel } from "./security-level.js";
16
16
 
@@ -53,7 +53,7 @@ export async function signAct(id, { projectDir, home, kjVersion, files, changes
53
53
  const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
54
54
  if (!phone.enrolled({ home })) {
55
55
  if (level.level === "max" && !bootstrap) {
56
- throw new Error(`${act.label}: con seguridad máxima este acto se firma con el móvil y no hay ninguno enrolado. ${PHONE_INVITE}`);
56
+ throw new Error(`${act.label}: con seguridad máxima este acto se firma con el móvil y no hay ninguno enrolado. ${PHONE_ENROLL}`);
57
57
  }
58
58
  logger.warn?.(`${act.label}: sin móvil enrolado solo valen las cuatro capas y la garantía es menor (ADR 0018). ${PHONE_INVITE}`);
59
59
  return { level, signature: null };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * KJC-TSK-0928 (M15-B): the method's CI checks become required on the base
3
+ * branch, where no agent reaches. Branch protection needs admin rights, so kj
4
+ * never applies it: it reads what is there and says the exact command.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { parse as parseYaml } from "yaml";
9
+ import { runCommand } from "../utils/process.js";
10
+
11
+ // Scheduled or advisory kj workflows: they never block a PR, so they are never required.
12
+ const NOT_REQUIRED = new Set(["kj-mutation.yml", "kj-steward.yml"]);
13
+
14
+ /** The GitHub check name of each job: its `name:` when it has one, its id otherwise. */
15
+ function jobChecks(text) {
16
+ const jobs = parseYaml(text)?.jobs ?? {};
17
+ return Object.entries(jobs).map(([id, job]) => (typeof job?.name === "string" ? job.name : id));
18
+ }
19
+
20
+ /** The checks kj harden's own workflows publish, from the files installed in the repo. */
21
+ export function requiredChecksFrom(projectDir) {
22
+ const dir = path.join(projectDir, ".github", "workflows");
23
+ if (!fs.existsSync(dir)) return [];
24
+ return fs.readdirSync(dir)
25
+ .filter((f) => /^kj-.*\.ya?ml$/.test(f) && !NOT_REQUIRED.has(f))
26
+ .toSorted()
27
+ .flatMap((f) => jobChecks(fs.readFileSync(path.join(dir, f), "utf8")));
28
+ }
29
+
30
+ const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
31
+
32
+ // A base branch such as release/1.0 is one path segment of the REST endpoint.
33
+ const protectionPath = ({ owner, repo, branch }) => `repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`;
34
+
35
+ function protectCommand({ checks, ...target }) {
36
+ const contexts = checks.map((c) => `-f ${shellQuote(`required_status_checks[contexts][]=${c}`)}`).join(" ");
37
+ return `gh api -X PUT ${protectionPath(target)} -F 'required_status_checks[strict]=false' ${contexts} -F enforce_admins=false -F required_pull_request_reviews=null -F restrictions=null -F allow_force_pushes=false`;
38
+ }
39
+
40
+ const addCommand = ({ missing, ...target }) =>
41
+ `gh api -X POST ${protectionPath(target)}/required_status_checks/contexts ${missing.map((c) => `-f ${shellQuote(`contexts[]=${c}`)}`).join(" ")}`;
42
+
43
+ const settingsUrl = ({ owner, repo }) => `https://github.com/${owner}/${repo}/settings/branches`;
44
+
45
+ /**
46
+ * What the base branch needs for the checks to be required, and how. A command
47
+ * is given only when it cannot undo anything: the full PUT for a branch with no
48
+ * protection at all, the additive contexts call otherwise. Protection without
49
+ * status checks, or unreadable, gets the settings page: a PUT there would wipe
50
+ * the reviews and restrictions already set. Never applies anything, never throws.
51
+ * @returns {Promise<{status: "ok"|"missing"|"unprotected"|"no-checks"|"unverified", command?: string, settings?: string, missing?: string[], reason?: string}>}
52
+ */
53
+ export async function protectionAdvice({ owner, repo, branch, checks, run = runCommand }) {
54
+ const target = { owner, repo, branch };
55
+ let res;
56
+ try {
57
+ res = await run("gh", ["api", protectionPath(target)]);
58
+ } catch (err) {
59
+ return { status: "unverified", reason: err.message, settings: settingsUrl(target), missing: checks };
60
+ }
61
+ if (res.exitCode !== 0) {
62
+ if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
63
+ return { status: "unverified", reason: (res.stderr || res.stdout || "gh failed").trim(), settings: settingsUrl(target), missing: checks };
64
+ }
65
+ const statusChecks = JSON.parse(res.stdout || "{}").required_status_checks;
66
+ if (!statusChecks) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
67
+ const existing = new Set(statusChecks.contexts ?? []);
68
+ const missing = checks.filter((c) => !existing.has(c));
69
+ return missing.length === 0 ? { status: "ok" } : { status: "missing", missing, command: addCommand({ ...target, missing }) };
70
+ }
71
+
72
+ // Owner and repo go into a command an admin copies: only the characters GitHub allows in them.
73
+ const GITHUB_REMOTE = /github\.com[^:/]*[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
74
+
75
+ /** kj harden's last step: on a GitHub remote, what the base branch needs and who runs it. */
76
+ export async function adviseBranchProtection({ projectDir, branch, logger, run = runCommand }) {
77
+ const checks = requiredChecksFrom(projectDir);
78
+ if (checks.length === 0) return null;
79
+ const remote = await run("git", ["-C", projectDir, "remote", "get-url", "origin"]).catch(() => null);
80
+ const match = remote?.exitCode === 0 ? GITHUB_REMOTE.exec(remote.stdout.trim()) : null;
81
+ if (!match) return null;
82
+ const advice = await protectionAdvice({ owner: match[1], repo: match[2], branch, checks, run });
83
+ if (advice.status === "ok") {
84
+ logger.info?.(` • branch protection: ${branch} requires the ${checks.length} kj check(s)`);
85
+ return advice;
86
+ }
87
+ const list = advice.missing?.join(", ");
88
+ const why = {
89
+ unprotected: `${branch} is not protected: the method's checks do not stop a merge. An admin runs (kj never does):`,
90
+ missing: `${branch} does not require ${advice.missing?.length} kj check(s) (${list}). An admin runs (kj never does):`,
91
+ "no-checks": `${branch} is protected but requires no status checks. An admin enables "Require status checks" and adds ${list} in:`,
92
+ unverified: `could not read the protection of ${branch} (${advice.reason}). An admin checks that ${list} are required in:`,
93
+ }[advice.status];
94
+ logger.warn?.(` ⚠ branch protection: ${why}`);
95
+ logger.warn?.(` ${advice.command ?? advice.settings}`);
96
+ return advice;
97
+ }
@@ -10,10 +10,11 @@ import { homedir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
12
12
  import { isPhoneEnrolled } from "./phone-sign.js";
13
+ import { phoneTextFor } from "./phone-texts.js";
14
+ import { readSecurityLevel } from "./security-level.js";
13
15
  import { STRATEGY } from "../checks/types.js";
14
16
 
15
- export const PHONE_INVITE =
16
- "El móvil no está enrolado: los sellos (kj harden --commit), las aprobaciones y los comandos del catálogo se firman hoy con un código tecleado, con menos garantía. Enrólalo con kj identity enroll-phone <clave pública de la app>: cada acto queda atado a tu clave privada, que nunca sale del móvil (ADR 0018).";
17
+ export { PHONE_ENROLL, PHONE_INVITE, PHONE_REFUSED_AT_MAX } from "./phone-texts.js";
17
18
 
18
19
  const markerPath = (home) => join(home ?? homedir(), ".karajan", "phone-invite.json");
19
20
 
@@ -21,10 +22,10 @@ const markerPath = (home) => join(home ?? homedir(), ".karajan", "phone-invite.j
21
22
  * The invitation, once per kj version, or null (phone enrolled, or already said
22
23
  * for this version). Saying it records it; a marker that cannot be written
23
24
  * never silences the invitation.
24
- * @param {{home?: string, version?: string}} [opts]
25
+ * @param {{home?: string, version?: string, level?: "max"|"normal"}} [opts]
25
26
  * @returns {string|null}
26
27
  */
27
- export function phoneInvite({ home, version = "unknown" } = {}) {
28
+ export function phoneInvite({ home, version = "unknown", level = "max" } = {}) {
28
29
  if (isPhoneEnrolled({ home })) return null;
29
30
  let seen = null;
30
31
  try { seen = JSON.parse(readFileSync(markerPath(home), "utf8")).version; } catch { /* never invited */ }
@@ -33,11 +34,16 @@ export function phoneInvite({ home, version = "unknown" } = {}) {
33
34
  mkdirSync(dirname(markerPath(home)), { recursive: true });
34
35
  writeFileSync(markerPath(home), `${JSON.stringify({ version, at: new Date().toISOString() })}\n`, "utf8");
35
36
  } catch { /* the invitation still goes out */ }
36
- return PHONE_INVITE;
37
+ return phoneTextFor(level);
37
38
  }
38
39
 
39
- /** `kj doctor`: WARN while the phone is not enrolled, with the same text. */
40
- export function createPhoneCheck({ home } = {}) {
40
+ const projectLevel = (projectDir, home) => readSecurityLevel({ projectDir, home }).level;
41
+
42
+ /**
43
+ * `kj doctor`: without a phone, a FAILURE at the max level (the default, where
44
+ * the acts are refused) and a warning at the normal level (KJC-BUG-0360).
45
+ */
46
+ export function createPhoneCheck({ home, projectDir = process.cwd(), readLevel = () => projectLevel(projectDir, home) } = {}) {
41
47
  return {
42
48
  name: "phone-enrolled",
43
49
  label: "móvil enrolado (firma de sellos y aprobaciones)",
@@ -45,7 +51,8 @@ export function createPhoneCheck({ home } = {}) {
45
51
  describe: "Whether a phone's public key is enrolled for the supervisor seal and approvals (ADR 0018)",
46
52
  async detect() {
47
53
  if (isPhoneEnrolled({ home })) return { ok: true, severity: "info", detail: "clave pública en ~/.karajan/supervisor-phone.json" };
48
- return { ok: false, severity: "warn", detail: PHONE_INVITE, fix: "kj identity enroll-phone <clave pública de la app>" };
54
+ const level = await readLevel();
55
+ return { ok: false, severity: level === "normal" ? "warn" : "fail", detail: phoneTextFor(level), fix: "kj identity enroll-phone <clave pública de la app>" };
49
56
  },
50
57
  };
51
58
  }