karajan-code 4.54.0 → 4.56.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/agents/claude-agent.js +3 -0
  3. package/src/cli/register-pipeline.js +8 -0
  4. package/src/commands/harden.js +1 -15
  5. package/src/commands/plan/generate.js +12 -11
  6. package/src/commands/rag.js +1 -1
  7. package/src/commands/review-gate.js +14 -8
  8. package/src/config/defaults.js +2 -0
  9. package/src/config/schema.js +6 -0
  10. package/src/harden/check.js +14 -13
  11. package/src/harden/config-engine.js +25 -24
  12. package/src/harden/harden-scope.js +18 -0
  13. package/src/harden/hook-templates.js +24 -3
  14. package/src/harden/human-act.js +32 -9
  15. package/src/harden/sentinel/sentinel-shell.mjs +5 -3
  16. package/src/harden/sentinel-hooks.js +22 -87
  17. package/src/hu/coder-routing.js +66 -0
  18. package/src/hu/model-router.js +18 -20
  19. package/src/hu/routing-metrics.js +2 -1
  20. package/src/orchestrator/drivers/iteration-loop.js +2 -0
  21. package/src/orchestrator/drivers/run-hu-batch.js +4 -1
  22. package/src/orchestrator/hu-sub-pipeline.js +3 -1
  23. package/src/orchestrator/stages/coder-stage.js +4 -1
  24. package/src/prompts/reviewer.js +2 -0
  25. package/src/rag/indexer.js +2 -2
  26. package/src/report/model-tally.js +6 -2
  27. package/src/review/one-shot-review.js +9 -3
  28. package/src/review/pr-requirements.js +87 -0
  29. package/src/review/pr-security.js +70 -0
  30. package/src/review/pr-sonar.js +22 -0
  31. package/src/review/sonar-pregate.js +15 -4
  32. package/src/review/third-party-review.js +67 -0
  33. package/src/roles/agent-role.js +1 -1
  34. package/src/roles/coder-role.js +8 -2
  35. package/src/steward/gate.js +31 -0
  36. package/src/utils/tool-governor.js +31 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.54.0",
3
+ "version": "4.56.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -366,6 +366,9 @@ export class ClaudeAgent extends BaseAgent {
366
366
  async _runTaskExec(task, model, _role) {
367
367
  const { args, input } = buildPromptArgs(task);
368
368
  if (model) args.push("--model", model);
369
+ // KJC-TSK-1070 (MR-D2): the effort the HU's route gives the coder.
370
+ const effort = this.config?.roles?.[_role]?.effort;
371
+ if (effort) args.push("--effort", effort);
369
372
  // PL-C (KJC-TSK-0735): el rol del subproceso viaja en KJ_POLICY_ROLE —
370
373
  // si su harness corre hooks (claude -p), el tier A evalúa la policy con
371
374
  // el rol que ACTÚA. El rol del ORQUESTADOR es autoritativo: task.env no
@@ -15,6 +15,7 @@ import { statusCommand } from "../commands/status.js";
15
15
  import { loadPlan } from "../plan/plan-store.js";
16
16
  import { withConfig } from "./_shared.js";
17
17
  import { publishPrReview } from "../review/publish-pr.js";
18
+ import { thirdPartyReviewCommand } from "../review/third-party-review.js";
18
19
 
19
20
  /**
20
21
  * Register the "core pipeline" commands: everything the user runs day-to-day
@@ -257,8 +258,15 @@ export function registerPipeline(program, { pkgVersion }) {
257
258
  .option("--walked <route>", "Recorrido comprobado como lo haría una persona (repetible) — queda en el veredicto", (v, prev) => [...(prev || []), v], [])
258
259
  .option("--walked-with <tool>", "Con qué se comprobó el recorrido (por ejemplo chrome-devtools)")
259
260
  .option("--publish-pr <n>", "Publish the cross-AI verdicts of a PR's commits on it as one GitHub review")
261
+ .option("--third-party", "Review someone else's PR (--range base...head): reviewer only, no own-work gates, no verdict stored")
262
+ .option("--json", "With --third-party: print the review as one JSON document on stdout")
260
263
  .action(async (task, flags) => {
261
264
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
265
+ // KJC-TSK-1075 (#2227): a third-party PR, for reviewer agents; own work keeps every gate.
266
+ if (flags.thirdParty) {
267
+ process.exitCode = await thirdPartyReviewCommand({ flags: { ...flags, task: task ?? null }, config });
268
+ return;
269
+ }
262
270
  // KJC-TSK-0913: the verdicts kj already holds, published on the PR as one review.
263
271
  if (flags.publishPr) {
264
272
  const r = await publishPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.publishPr });
@@ -16,6 +16,7 @@ import { interactiveHarden } from "../harden/interactive.js";
16
16
  import { createWizard, isTTY } from "../utils/wizard.js";
17
17
  import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
18
18
  import { installConfigsForRoots } from "../harden/config-engine.js";
19
+ import { readHardenConfig } from "../harden/harden-scope.js";
19
20
  import { installGuidelines } from "../harden/guidelines-engine.js";
20
21
  import { maybeRulesyncGenerate } from "../utils/rulesync.js";
21
22
  import { commandsForLanguage } from "../harden/hook-commands.js";
@@ -35,21 +36,6 @@ const JS_TEST_CMD = {
35
36
  mocha: "npx mocha",
36
37
  };
37
38
 
38
- /**
39
- * Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
40
- * sub-tools (e.g. a python wrapper) excludes them once instead of on every
41
- * invocation (KJC-TSK-0564). CLI flags are merged on top.
42
- */
43
- function readHardenConfig(projectDir) {
44
- const pkgPath = join(projectDir, "package.json");
45
- if (!existsSync(pkgPath)) return {};
46
- try {
47
- return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
48
- } catch {
49
- return {};
50
- }
51
- }
52
-
53
39
  /**
54
40
  * Resolve the hook's lint/format/test commands for the primary language.
55
41
  * Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
@@ -9,7 +9,7 @@ import { promptProjectName } from "../../utils/prompt-project-name.js";
9
9
  import { deriveProjectNameFromCwd } from "../../utils/derive-project-name-from-cwd.js";
10
10
  import { applyReviewerFeedback, applyFixerPatch, patchChanged } from "../../plan/plan-fixer.js";
11
11
  import { runStructuralPass } from "../../plan/plan-structural-pass.js";
12
- import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
12
+ import { planHuModels } from "../../hu/model-router.js";
13
13
  import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
14
14
  import { buildStandbyState } from "../../brain/standby-store.js";
15
15
  import { printResumeHint } from "../../utils/display/resume-hint.js";
@@ -206,16 +206,15 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
206
206
  const symbolicId = typeof step === "object" && typeof step.id === "string" && step.id.trim()
207
207
  ? step.id.trim() : null;
208
208
  const taskType = classifyTaskType(desc);
209
- // KJC-TSK-0405 step 2: asigna coder_model + reviewer_model
210
- // automáticamente según complexity inferido del task_type. El
211
- // planner puede emitir `step.complexity` (trivial/simple/medium/complex
212
- // o score 1-5) para override más fino. Cross-provider reviewer
213
- // (claude↔codex) por defecto.
214
- const coderProvider = config?.roles?.coder?.provider || config?.coder || "claude";
215
- const complexity = (typeof step === "object" && step.complexity)
216
- ? step.complexity
217
- : complexityFromTaskType(taskType);
218
- const models = recommendModelsForHu({ complexity, coderProvider, config });
209
+ // KJC-TSK-1072 (MR-D4): coder_model comes from the HU's tier (MR-A), the
210
+ // criterion the run uses; the board can change it. Cross-provider reviewer.
211
+ const models = planHuModels({
212
+ title: desc, goal: desc,
213
+ acceptanceCriteria: tests.map((t) => (typeof t === "object" ? String(t.content ?? "") : String(t))),
214
+ tier: typeof step === "object" ? step.tier ?? null : null,
215
+ tierReason: typeof step === "object" ? step.tierReason ?? null : null,
216
+ config: config ?? {},
217
+ });
219
218
  const hu = addHu(plan, {
220
219
  title: desc.slice(0, 80),
221
220
  task_type: taskType,
@@ -232,6 +231,8 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
232
231
  // KJC-TSK-0661: provenance stamp (addHu builds its object explicitly,
233
232
  // so this goes on the returned reference, not in huData).
234
233
  hu.created_by = "planner";
234
+ // KJC-TSK-1072: the tier rides on the HU, so the run gives its coder the tier's effort.
235
+ hu.complexity = models.complexity;
235
236
  // KJC-TSK-1061: the classification travels to the HU; validatePlan judges it (KJC-TSK-0717).
236
237
  for (const key of ["change_class", "change_criterion", "hardens"]) {
237
238
  const value = typeof step === "object" ? step[key] : null;
@@ -38,7 +38,7 @@ async function indexFiles({ files, projectDir, db, embedder, slug, logger, json,
38
38
  logger.warn?.(`[rag] ${out}: not indexed`);
39
39
  continue;
40
40
  }
41
- const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
41
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
42
42
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
43
43
  }
44
44
  const deleted = totals.deleted ? `, ${totals.deleted} chunks deleted` : "";
@@ -26,6 +26,8 @@ import { loadPrivacyList, scanText } from "../privacy/scan.js";
26
26
  import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
27
27
  import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
28
28
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
29
+ import { prSizeLimit } from "../review/pr-requirements.js";
30
+ import { stewardBlocking } from "../steward/gate.js";
29
31
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
30
32
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
31
33
  import { evaluatePolicyGate } from "../review/policy-gate.js";
@@ -227,6 +229,14 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
227
229
  const card = await enforceCardFirst({ config, projectDir });
228
230
  if (!card.ok) return { verdict: "rejected", reviewer: "card-first", issues: [{ severity: "high", description: card.reason }] };
229
231
 
232
+ // KJC-TSK-1086: the Steward's block at commit too, not only where Edit starts work.
233
+ const steward = stewardBlocking({ projectDir, config });
234
+ if (steward) {
235
+ console.log(`✗ ${steward.reason}`);
236
+ process.exitCode = 1;
237
+ return { verdict: "rejected", reviewer: "steward", issues: [{ severity: "high", description: steward.reason }] };
238
+ }
239
+
230
240
  // KJC-TSK-0687 (MG-B): the verifiable half of TDD — sources without a
231
241
  // single test change warn (block via method_gates.tests_with_code).
232
242
  const changedFiles = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
@@ -246,16 +256,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
246
256
  // PL-C (KJC-TSK-0735), fuente única de umbrales: si la policy declara el
247
257
  // invariante net_lines_added, SU max es el umbral — el mismo número que
248
258
  // aplican kj policy check y el tier C de CI; method_gates queda de fallback.
249
- const pol = loadPolicy({ projectDir });
250
- const locInv = pol.errors.length === 0
251
- ? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
252
- : null;
253
259
  // KJC-BUG-0326 (#2085): two tiers. ~150 is the ideal (method_gates.pr_size_warn);
254
260
  // up to the limit (the policy's net_lines_added max, else method_gates.pr_size_max,
255
- // 200) is allowed without asking; above the limit, partition.
256
- const sizeIdeal = config?.method_gates?.pr_size_warn ?? 150;
257
- const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_max ?? 200;
258
- const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
261
+ // 200) is allowed without asking; above the limit, partition. KJC-TSK-1076: one
262
+ // source, shared with the third-party PR checks.
263
+ const pol = loadPolicy({ projectDir });
264
+ const { ideal: sizeIdeal, limit: sizeWarn, source: sizeSource } = prSizeLimit({ projectDir, config });
259
265
  if (sizeWarn > 0) {
260
266
  const numstat = await rawDiff(flags.range, ["--numstat"]);
261
267
  // KJC-BUG-0205: the budget caps CODE growth, so generated output and human
@@ -134,6 +134,8 @@ const DEFAULTS = {
134
134
  },
135
135
  sonarqube: {
136
136
  enabled: true,
137
+ // KJC-BUG-0368 (#2216): sonar-scanner runs this many reviews at once per machine.
138
+ max_parallel_scans: 2,
137
139
  host: "http://localhost:9000",
138
140
  external: false,
139
141
  container_name: "karajan-sonarqube",
@@ -208,6 +208,8 @@ const Skills = v.optional(v.looseObject({
208
208
  * forzar Sonnet en todo el proyecto, por ejemplo.
209
209
  */
210
210
  const ModelRouting = v.optional(v.looseObject({
211
+ // KJC-TSK-0701 (MR-D): the coder of each HU runs on its tier's model; false turns it off.
212
+ enabled: v.optional(v.boolean("model_routing.enabled must be true or false")),
211
213
  by_provider: v.optional(v.record(
212
214
  v.string(),
213
215
  v.looseObject({
@@ -257,6 +259,10 @@ export const ConfigSchema = v.looseObject({
257
259
  every_commits: v.optional(PositiveInt),
258
260
  })),
259
261
  coder_rules: v.optional(v.string()),
262
+ // KJC-BUG-0368 (#2216): how many sonar-scanner reviews run at once per machine.
263
+ sonarqube: v.optional(v.looseObject({
264
+ max_parallel_scans: v.optional(PositiveInt),
265
+ })),
260
266
  base_branch: v.optional(v.string()),
261
267
  coder_options: CoderOptions,
262
268
  reviewer_options: ReviewerOptions,
@@ -11,8 +11,9 @@ import { existsSync, readFileSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  import { runCommand } from "../utils/process.js";
14
- import { artifactIdForConfig, findAlternative } from "./alternatives.js";
14
+ import { configLeftAlone } from "./config-engine.js";
15
15
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
16
+ import { readHardenConfig } from "./harden-scope.js";
16
17
  import { PROFILE_HOOKS } from "./hook-templates.js";
17
18
  import { detectStackRoots } from "./stack-roots.js";
18
19
  import { qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
@@ -49,24 +50,24 @@ export async function checkHarden({ projectDir = process.cwd(), profile = "stand
49
50
  // Config + workflows ship with standard+ — and surface the greenfield gap:
50
51
  // a language now present but never hardened ⇒ its config shows as missing.
51
52
  if (profile !== "minimal") {
52
- const roots = detectStackRoots(projectDir);
53
+ // KJC-BUG-0370: the same scope (package.json kj.harden) and the same verdict on a
54
+ // missing config as kj harden, so check never calls drift what harden leaves alone.
55
+ const { only = [], exclude = [] } = readHardenConfig(projectDir);
56
+ const roots = detectStackRoots(projectDir, { only, exclude });
57
+ const configCheck = (cfg, rel, altDirs, missing) => {
58
+ const leftAlone = existsSync(join(projectDir, rel)) ? null : configLeftAlone(cfg, altDirs);
59
+ if (!leftAlone) return presence(projectDir, rel, `config:${rel}`, missing);
60
+ const detail = leftAlone.action === "covered" ? `covered by ${leftAlone.by}` : `omitted: ${leftAlone.note}`;
61
+ return { id: `config:${rel}`, ok: true, detail };
62
+ };
53
63
  for (const cfg of UNIVERSAL_CONFIGS) {
54
- checks.push(presence(projectDir, cfg.file, `config:${cfg.file}`, "missing — run kj harden"));
64
+ checks.push(configCheck(cfg, cfg.file, [projectDir], "missing — run kj harden"));
55
65
  }
56
66
  for (const { dir, language } of roots) {
57
67
  const rootDir = dir === "." ? projectDir : join(projectDir, dir);
58
68
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
59
69
  const rel = dir === "." ? cfg.file : join(dir, cfg.file);
60
- // A config covered by the user's own tool (biome.json ⇒ eslint+prettier)
61
- // is not drift — kj must not demand a second linter/formatter (KJC-TSK-0614).
62
- if (!existsSync(join(projectDir, rel))) {
63
- const alt = findAlternative([rootDir, projectDir], artifactIdForConfig(cfg));
64
- if (alt) {
65
- checks.push({ id: `config:${rel}`, ok: true, detail: `covered by ${alt.foundAt} (${alt.tool})` });
66
- continue;
67
- }
68
- }
69
- checks.push(presence(projectDir, rel, `config:${rel}`, `missing for ${language} — run kj harden`));
70
+ checks.push(configCheck(cfg, rel, [rootDir, projectDir], `missing for ${language} — run kj harden`));
70
71
  }
71
72
  }
72
73
  const expectedWf = [...WORKFLOWS];
@@ -33,6 +33,27 @@ function hasTool(dirs, tool) {
33
33
  return false;
34
34
  }
35
35
 
36
+ /**
37
+ * Why kj leaves a missing config alone, or null when it seeds it. KJC-BUG-0370:
38
+ * kj check reads the same answer, so it never calls drift what harden skips.
39
+ */
40
+ export function configLeftAlone(cfg, altDirs) {
41
+ const id = artifactIdForConfig(cfg);
42
+ const alt = findAlternative(altDirs, id);
43
+ if (alt) return { action: "covered", by: alt.foundAt };
44
+ // KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
45
+ // package.json#eslintConfig…) means the config already EXISTS — seeding
46
+ // kj's default filename next to it would silently ECLIPSE the user's
47
+ // real config (eslint resolves eslint.config.js before .mjs).
48
+ const variant = findEquivalentIn(altDirs, id);
49
+ if (variant) return { action: "covered", by: variant };
50
+ // KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
51
+ if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
52
+ return { action: "omitted", note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden` };
53
+ }
54
+ return null;
55
+ }
56
+
36
57
  function writeFile(target, content) {
37
58
  mkdirSync(dirname(target), { recursive: true });
38
59
  writeFileSync(target, content);
@@ -50,30 +71,10 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
50
71
  const exists = existsSync(target);
51
72
  const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
52
73
 
53
- if (!exists) {
54
- const alt = findAlternative(altDirs, artifactIdForConfig(cfg));
55
- if (alt) {
56
- results.push({ file, action: "covered", by: alt.foundAt });
57
- continue;
58
- }
59
- // KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
60
- // package.json#eslintConfig…) means the config already EXISTS — seeding
61
- // kj's default filename next to it would silently ECLIPSE the user's
62
- // real config (eslint resolves eslint.config.js before .mjs).
63
- const variant = findEquivalentIn(altDirs, artifactIdForConfig(cfg));
64
- if (variant) {
65
- results.push({ file, action: "covered", by: variant });
66
- continue;
67
- }
68
- // KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
69
- if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
70
- results.push({
71
- file,
72
- action: "omitted",
73
- note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden`,
74
- });
75
- continue;
76
- }
74
+ const leftAlone = exists ? null : configLeftAlone(cfg, altDirs);
75
+ if (leftAlone) {
76
+ results.push({ file, ...leftAlone });
77
+ continue;
77
78
  }
78
79
 
79
80
  if (cfg.json) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
3
+ * sub-tools (e.g. a python wrapper) excludes them once instead of on every
4
+ * invocation (KJC-TSK-0564). KJC-BUG-0370: kj harden and kj check read it from
5
+ * here, so check never asks for configs harden was told to leave out.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export function readHardenConfig(projectDir) {
11
+ const pkgPath = join(projectDir, "package.json");
12
+ if (!existsSync(pkgPath)) return {};
13
+ try {
14
+ return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
@@ -10,8 +10,9 @@
10
10
 
11
11
  import { homedir } from "node:os";
12
12
 
13
- const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
14
- const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
13
+ // KJC-TSK-1076: exported so the third-party PR checks apply the same rules.
14
+ export const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
15
+ export const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
15
16
 
16
17
  export const SHEBANG = "#!/usr/bin/env sh";
17
18
 
@@ -45,10 +46,12 @@ export function toPortableHooksDir(dir, home = homedir()) {
45
46
  function chainToGlobal(hook, globalHooksDir) {
46
47
  if (!globalHooksDir) return [];
47
48
  const dir = toPortableHooksDir(globalHooksDir);
49
+ // KJC-TSK-1085: pre-push already read the refs from stdin; the chained hook gets them back.
50
+ const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "${dir}/${hook}" "$@"; rc=$?` : `"${dir}/${hook}" "$@"; rc=$?`;
48
51
  return [
49
52
  "# Chain the machine's previous global hook (kj harden keeps it active).",
50
53
  `if [ -x "${dir}/${hook}" ]; then`,
51
- ` "${dir}/${hook}" "$@"; rc=$?`,
54
+ ` ${call}`,
52
55
  ' [ "$rc" -eq 0 ] || exit "$rc"',
53
56
  "fi",
54
57
  ];
@@ -100,6 +103,22 @@ function identityGuard(hook) {
100
103
  }
101
104
  return [
102
105
  ...head,
106
+ // KJC-TSK-1085: every commit the push brings carries the declared identity
107
+ // (a rebase, cherry-pick, merge or tag never ran pre-commit).
108
+ " kj_declared_email=$(sed -n 's/^git_email:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
109
+ ' if [ -n "$kj_declared_email" ]; then',
110
+ ' printf "%s\\n" "$kj_push_refs" | while read -r kj_lref kj_lsha kj_rref kj_rsha; do',
111
+ ' case "$kj_lsha" in *[!0]*) ;; *) continue ;; esac',
112
+ // A new ref: what the PUSH TARGET ($1, the remote git hands the hook) does not have yet.
113
+ ' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_rsha..$kj_lsha") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
114
+ " for kj_c in $kj_new; do",
115
+ ' kj_who=$(git log -1 --format="%ae %ce" "$kj_c")',
116
+ ' if [ "$kj_who" != "$kj_declared_email $kj_declared_email" ]; then',
117
+ ' echo "kj harden: identity lock — commit $(git log -1 --format=%h "$kj_c") is authored/committed as $kj_who but this clone is declared as $kj_declared_email"; exit 1',
118
+ " fi",
119
+ " done",
120
+ " done || exit 1",
121
+ " fi",
103
122
  " kj_declared_gh=$(sed -n 's/^gh_user:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
104
123
  ' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
105
124
  " kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
@@ -178,6 +197,8 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
178
197
  ].join("\n");
179
198
  case "pre-push": {
180
199
  const lines = [
200
+ // KJC-TSK-1085: git hands the pushed refs on stdin; kept for the identity check and the chained hook.
201
+ "[ -t 0 ] || kj_push_refs=$(cat)",
181
202
  "# Identity guard — never push without a configured git identity.",
182
203
  'if [ -z "$(git config user.email)" ] || [ -z "$(git config user.name)" ]; then',
183
204
  " echo 'kj harden: git user.name/user.email not set — refusing to push'; exit 1",
@@ -8,18 +8,36 @@
8
8
  * 4. a random nonce typed back, read from the real controlling terminal.
9
9
  */
10
10
  import { randomBytes } from "node:crypto";
11
- import { closeSync, openSync, readFileSync, readSync } from "node:fs";
11
+ import { execFileSync } from "node:child_process";
12
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
12
13
 
13
14
  const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
14
15
 
15
- export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
16
- const read = readProc || ((p) => {
17
- const stat = readFileSync(`/proc/${p}/stat`, "utf8");
18
- const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
19
- let cmd = "";
20
- try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
21
- return { ppid, cmd };
22
- });
16
+ const procRead = (p) => {
17
+ const stat = readFileSync(`/proc/${p}/stat`, "utf8");
18
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
19
+ let cmd = "";
20
+ try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
21
+ return { ppid, cmd };
22
+ };
23
+
24
+ // KJC-TSK-1084: without /proc (macOS) the chain comes from ps, so a human
25
+ // there is not refused for an ancestry nobody could read.
26
+ // The system binary by absolute path: a ps resolved through PATH could be the
27
+ // agent's own forgery prepended to it.
28
+ const PS_BIN = ["/bin/ps", "/usr/bin/ps"].find((p) => existsSync(p)) ?? null;
29
+ const psOutput = (p) => {
30
+ if (!PS_BIN) throw new Error("no system ps (/bin/ps, /usr/bin/ps)");
31
+ return execFileSync(PS_BIN, ["-o", "ppid=", "-o", "command=", "-p", String(p)], { encoding: "utf8", env: {} });
32
+ };
33
+ const psRead = (ps) => (p) => {
34
+ const m = /^\s*(\d+)\s+(.*)$/s.exec(String(ps(p) ?? ""));
35
+ if (!m) throw new Error(`ps gave nothing for pid ${p}`);
36
+ return { ppid: Number(m[1]), cmd: m[2].trim() };
37
+ };
38
+
39
+ export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40, hasProc = existsSync("/proc/self/stat"), ps = psOutput } = {}) {
40
+ const read = readProc || (hasProc ? procRead : psRead(ps));
23
41
  let cur = pid;
24
42
  for (let i = 0; i < maxDepth && cur > 1; i += 1) {
25
43
  let info;
@@ -37,6 +55,11 @@ export function refuseAgentSession(what, { env = process.env, tty = process.stdo
37
55
  throw new Error(`${what} es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)`);
38
56
  }
39
57
  const anc = agentAncestry(ancestry);
58
+ // KJC-TSK-1084: fail closed. Nothing else stops a human act any more (the
59
+ // Sentinel does not read its text), so an ancestry that cannot be read is no pass.
60
+ if (anc.unknown) {
61
+ throw new Error(`${what} es un acto humano y no se pudo comprobar la ascendencia de procesos — córrelo desde TU terminal (ADR 0009)`);
62
+ }
40
63
  if (anc.agent) {
41
64
  throw new Error(`${what} es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`);
42
65
  }
@@ -145,10 +145,12 @@ export const stripInertQuotes = (cmd) => {
145
145
 
146
146
  // Options whose value is prose, never a path (gh, git, kj).
147
147
  // KJC-BUG-0306: --error and --command of kj report-issue, --scope and --tests of kj hu add.
148
- const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests"]);
148
+ // KJC-BUG-0366: --why of kj handoff add.
149
+ const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests", "--why"]);
149
150
  const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
150
151
  // KJC-BUG-0306: the title kj hu add and kj adr add take as their first argument, at the start of a segment.
151
- const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
152
+ // KJC-BUG-0366: and the command kj handoff add stores for the human.
153
+ const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr|handoff)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
152
154
 
153
155
  /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
154
156
  export const stripTextOptionValues = (cmd) => cmd
@@ -171,7 +173,7 @@ const isGitCommit = (words) => {
171
173
  return words[0] === "git" && words[i] === "commit";
172
174
  };
173
175
 
174
- const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
176
+ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
175
177
 
176
178
  /**
177
179
  * KJC-BUG-0354: blank the quoted text values (stripTextOptionValues) only when the