karajan-code 4.52.0 → 4.53.1

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 (66) 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 +7 -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 +6 -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 +22 -0
  30. package/src/commands/worktree.js +15 -6
  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/harden/act-sign.js +2 -2
  35. package/src/harden/branch-protection.js +97 -0
  36. package/src/harden/phone-invite.js +15 -8
  37. package/src/harden/phone-texts.js +17 -0
  38. package/src/harden/security-level.js +18 -2
  39. package/src/harden/sentinel/sentinel-shell.mjs +59 -0
  40. package/src/harden/sentinel-hooks.js +19 -9
  41. package/src/hu/complexity-tier.js +32 -0
  42. package/src/hu/lane-prune.js +24 -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/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
  47. package/src/orchestrator/hu-sub-pipeline.js +19 -0
  48. package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
  49. package/src/orchestrator/stages/sonar-stage.js +19 -1
  50. package/src/plan/plan-schema.js +4 -1
  51. package/src/plan/rework-license.js +38 -0
  52. package/src/prompts/hu-reviewer.js +5 -2
  53. package/src/prompts/planner.js +9 -1
  54. package/src/report/model-tally.js +39 -0
  55. package/src/review/board-pending.js +42 -6
  56. package/src/review/branch-overlap.js +40 -0
  57. package/src/review/dep-cooldown.js +87 -0
  58. package/src/review/one-shot-review.js +2 -2
  59. package/src/review/parser.js +5 -0
  60. package/src/review/publish-pr.js +54 -0
  61. package/src/review/verdict-store.js +19 -7
  62. package/src/roles/sonar-role.js +4 -2
  63. package/src/sonar/api.js +7 -0
  64. package/src/utils/injection-guard.js +59 -43
  65. package/src/utils/injection-patterns.json +36 -0
  66. package/src/utils/model-selector.js +47 -3
@@ -155,6 +155,65 @@ export const stripTextOptionValues = (cmd) => cmd
155
155
  .replace(KJ_TITLE, (m, pre, head, val) => `${pre}${head}${val[0]}${val[0]}`)
156
156
  .replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
157
157
 
158
+ // KJC-BUG-0354: commands that only STORE their text: there, a quoted text value
159
+ // is never run. Anywhere else it may be (flock --command '...'), so it stays.
160
+ // git's global options that only pick where and how it runs may precede commit.
161
+ const GIT_GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
162
+ const GIT_GLOBAL_FLAG = /^(?:--no-pager|--(?:git-dir|work-tree|namespace)=\S+)$/;
163
+
164
+ const isGitCommit = (words) => {
165
+ let i = 1;
166
+ while (i < words.length) {
167
+ if (GIT_GLOBAL_WITH_VALUE.has(words[i])) i += 2;
168
+ else if (GIT_GLOBAL_FLAG.test(words[i])) i += 1;
169
+ else break;
170
+ }
171
+ return words[0] === "git" && words[i] === "commit";
172
+ };
173
+
174
+ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
175
+
176
+ /**
177
+ * KJC-BUG-0354: blank the quoted text values (stripTextOptionValues) only when the
178
+ * whole input is a text-only command, so the human-act guards stop reading a
179
+ * title or a commit message that merely mentions a guarded command.
180
+ * @param {string} cmd
181
+ */
182
+ export const stripTextOnlyCommandValues = (cmd) => {
183
+ // Only a whole input that is ONE simple text-storage command: a chained
184
+ // segment could follow a shell function that turns the stored text into code.
185
+ const bare = stripInertQuotes(cmd);
186
+ if (splitOutsideQuotes(cmd).length !== 1 || /[(){}<>`$\n]/.test(bare) || !isTextOnlyHead(cmd.trim())) return cmd;
187
+ return stripTextOptionValues(cmd);
188
+ };
189
+
190
+ /** Segments and the ; && || | between them, cut only outside quotes (the pieces join back to cmd). */
191
+ function splitOutsideQuotes(cmd) {
192
+ const parts = [];
193
+ let start = 0;
194
+ let quote = null;
195
+ let i = 0;
196
+ while (i < cmd.length) {
197
+ const c = cmd[i];
198
+ const op = quote ? null : /^(?:&&|\|\||;|\|)/.exec(cmd.slice(i))?.[0];
199
+ if (quote) {
200
+ // inside double quotes a backslash escapes the next character
201
+ if (c === "\\" && quote === '"') i += 1;
202
+ else if (c === quote) quote = null;
203
+ } else if (c === "'" || c === '"') {
204
+ quote = c;
205
+ } else if (op) {
206
+ parts.push(cmd.slice(start, i), op);
207
+ start = i + op.length;
208
+ i = start;
209
+ continue;
210
+ }
211
+ i += 1;
212
+ }
213
+ parts.push(cmd.slice(start));
214
+ return parts;
215
+ }
216
+
158
217
  /**
159
218
  * KJC-BUG-0245: the values of the named options, read by the shell reader, so a
160
219
  * `;`, `&&` or `|` right after a path ends the word instead of joining it.
@@ -88,7 +88,7 @@ export const boardGate = (sid, pend) => {
88
88
  if (!pend.length) return { blocking: [], carried: [] };
89
89
  const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
90
90
  if (!bg.error && (bg.status === 0 || bg.status === 2)) {
91
- try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [] }; } catch { /* unreadable: as always */ }
91
+ try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [], reason: d.reason || "" }; } catch { /* unreadable: as always */ }
92
92
  }
93
93
  return { blocking: pend, carried: [] };
94
94
  };
@@ -473,7 +473,7 @@ import { homedir } from "node:os";
473
473
  import { fileURLToPath } from "node:url";
474
474
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
475
475
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
476
- import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
476
+ import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
477
477
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
478
478
  import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
479
479
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -530,7 +530,8 @@ process.stdin.on("end", () => {
530
530
  // like the supervisor: a session does not loosen the rules that govern it.
531
531
  // .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
532
532
  // .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
533
- if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
533
+ // .karajan/security-level.json (KJC-TSK-1054, ADR 0020): only kj security writes it, after the human layers.
534
+ if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".karajan/security-level.json", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
534
535
  console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
535
536
  process.exit(2);
536
537
  }
@@ -605,7 +606,8 @@ process.stdin.on("end", () => {
605
606
  // — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
606
607
  // solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
607
608
  {
608
- const enrollCmd = String(input.command || "");
609
+ // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es enrolar.
610
+ const enrollCmd = stripTextOnlyCommandValues(String(input.command || ""));
609
611
  const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
610
612
  const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
611
613
  if ((tool === "Bash" && namesEnroll) || writesEnroll) {
@@ -617,11 +619,13 @@ process.stdin.on("end", () => {
617
619
  // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
618
620
  // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
619
621
  // (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
622
+ // KJC-BUG-0354: salvo en el texto entrecomillado de kj hu add, kj adr add y
623
+ // git commit, que solo guardan texto (decision del usuario, 16-oct).
620
624
  // ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
621
625
  // arriba: un env var lo pone tambien un agente — el supervisor no se
622
626
  // apaga con interruptores al alcance del supervisado.
623
627
  {
624
- const kjCmd = String(input.command || "");
628
+ const kjCmd = stripTextOnlyCommandValues(String(input.command || ""));
625
629
  if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
626
630
  console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
627
631
  process.exit(2);
@@ -1170,15 +1174,20 @@ process.stdin.on("end", () => {
1170
1174
  const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
1171
1175
  const mergeM = cmd.match(MERGE);
1172
1176
  const pend = pendingMoves(load().sessions?.[sid]);
1177
+ // KJC-BUG-0364: one board-gate decision per command; the push gate below
1178
+ // reuses it, so a second slow gh cannot contradict the first answer.
1179
+ let boardDecision = null;
1173
1180
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1174
1181
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1175
1182
  // por su primera mitad (boardGate, en la lib).
1176
- const { blocking, carried } = boardGate(sid, pend);
1183
+ boardDecision = boardGate(sid, pend);
1184
+ const { blocking, carried, reason } = boardDecision;
1177
1185
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1178
1186
  // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1179
1187
  // are already decided above (KJC-BUG-0230, 0198).
1188
+ // KJC-BUG-0363: when kj could not check, its reason travels with the block.
1180
1189
  if (blocking.length > 0) {
1181
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + doc("board-sync"));
1190
+ console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + (reason ? "\\n(" + reason + ")" : "") + doc("board-sync"));
1182
1191
  process.exit(2);
1183
1192
  }
1184
1193
  }
@@ -1224,9 +1233,10 @@ process.stdin.on("end", () => {
1224
1233
  // ADR 0015 (KJC-TSK-0925): with no escape.
1225
1234
  // KJC-BUG-0262: a carried card does not block the push the commit let through.
1226
1235
  const sess = load().sessions?.[sid];
1227
- const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
1236
+ const decision = boardDecision || boardGate(sid, pendingMoves(sess));
1237
+ const v = violations(sess, branchOf(), decision.blocking);
1228
1238
  if (v.length) {
1229
- console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1239
+ console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + (decision.reason ? "\\n(" + decision.reason + ")" : "") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1230
1240
  process.exit(2);
1231
1241
  }
1232
1242
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * KJC-TSK-0698 (MR-A): the tier (trivial, standard, deep) each HU leaves the
3
+ * decomposition with, so the coder model can be chosen per HU on an auditable
4
+ * trail. Two sources: the judgement of the model that already decomposes (same
5
+ * answer, no extra call) and a deterministic signal. The higher one wins, and
6
+ * a sensitive zone is always deep: underestimating there is the costly mistake.
7
+ */
8
+ export const TIERS = ["trivial", "standard", "deep"];
9
+
10
+ const SENSITIVE = /\b(?<zone>auth|secret|token|password|credential|crypt|security|seguridad|migrat|migraci|supervisor|sentinel|polic(?:y|ies)|permission)\w*/i;
11
+ const STANDARD_MIN_CRITERIA = 3;
12
+
13
+ /** The tier the HU's own text implies, with no model involved. */
14
+ export function deterministicTier({ title = "", goal = "", acceptanceCriteria = [] }) {
15
+ const zone = SENSITIVE.exec([title, goal, ...acceptanceCriteria].join("\n"))?.groups.zone;
16
+ if (zone) return { tier: "deep", reason: `sensitive zone: ${zone.toLowerCase()}` };
17
+ if (acceptanceCriteria.length >= STANDARD_MIN_CRITERIA) return { tier: "standard", reason: `${acceptanceCriteria.length} acceptance criteria` };
18
+ return { tier: "trivial", reason: `${acceptanceCriteria.length} acceptance criteria, no sensitive zone` };
19
+ }
20
+
21
+ /** The HU's tier: judged against signal, the higher wins, and the record says why. */
22
+ export function huComplexity(hu) {
23
+ const signal = deterministicTier(hu);
24
+ const judged = TIERS.includes(hu.tier) ? hu.tier : null;
25
+ if (!judged) return { tier: signal.tier, judged: null, signal: signal.tier, reason: `no judged tier from the decomposition; signal ${signal.tier} (${signal.reason})` };
26
+ const judgedText = `judged ${judged} (${hu.tierReason || "no reason given"})`;
27
+ const signalText = `signal ${signal.tier} (${signal.reason})`;
28
+ const rank = (t) => TIERS.indexOf(t);
29
+ if (rank(judged) === rank(signal.tier)) return { tier: judged, judged, signal: signal.tier, reason: `${judgedText}, ${signalText}` };
30
+ const reason = rank(signal.tier) > rank(judged) ? `${signalText} over ${judgedText}` : `${judgedText} over ${signalText}`;
31
+ return { tier: TIERS[Math.max(rank(judged), rank(signal.tier))], judged, signal: signal.tier, reason };
32
+ }
@@ -72,3 +72,27 @@ export async function classifyLanes({ git, laneRoot, base, processes, now = Date
72
72
  return { slug: path.basename(lane.path), path: lane.path, branch: lane.branch, reasons };
73
73
  }));
74
74
  }
75
+
76
+ /** KJC-TSK-1037: the base a lane is measured against: origin/<base> when it exists (a local base can lag far behind). */
77
+ export async function resolveBase({ git, base }) {
78
+ try {
79
+ await git(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${base}`]);
80
+ return `origin/${base}`;
81
+ } catch {
82
+ return base;
83
+ }
84
+ }
85
+
86
+ /** KJC-TSK-1037: loose kj-hu-* branches (no worktree): the empty ones, and the ones with work and how much. */
87
+ export async function looseLaneBranches({ git, base }) {
88
+ const { stdout: listed } = await git(["for-each-ref", "--format=%(refname:short) %(worktreepath)", "refs/heads/kj-hu-*"]);
89
+ const loose = listed.split("\n").map((l) => l.trim()).filter((l) => l && !l.includes(" "));
90
+ const empty = [];
91
+ const withWork = [];
92
+ for (const branch of loose) {
93
+ const { stdout: ahead } = await git(["rev-list", "--count", `${base}..${branch}`]);
94
+ if (Number(ahead) === 0) empty.push(branch);
95
+ else withWork.push({ branch, reason: `${Number(ahead)} commit(s) not in ${base}` });
96
+ }
97
+ return { empty, withWork };
98
+ }
@@ -1,3 +1,5 @@
1
+ import { getDefaultModelTiers } from "../utils/model-selector.js";
2
+
1
3
  // KJC-TSK-0405: dado un HU con complexity score, recomienda coder_model y
2
4
  // reviewer_model. El reviewer por defecto usa el provider alternativo al
3
5
  // coder (cross-provider review) para que dos cabezas distintas miren el
@@ -15,22 +17,11 @@ const SCORE_TO_LEVEL = {
15
17
  5: "complex",
16
18
  };
17
19
 
18
- // Defaults por provider — alineados con utils/model-selector.js DEFAULTS.
19
- // opencode: usa "coder" como alias razonable; el usuario suele definir
20
- // gemma-local/coder, ollama/coder o similar en opencode.json. Override
21
- // per-tier vía config.model_routing.by_provider.opencode si quieres
22
- // rutear distinto por complexity (ej. fast para trivial, coder para
23
- // complex). aider: null porque aider envuelve a otros providers — no
24
- // tiene "modelos propios". Si el usuario lo elige, debe configurarlo
25
- // explícito en config.model_routing.by_provider.aider o vía override
26
- // del modal por HU.
27
- const DEFAULT_TIERS = {
28
- claude: { trivial: "haiku", simple: "haiku", medium: "sonnet", complex: "opus" },
29
- codex: { trivial: "o4-mini", simple: "o4-mini", medium: "o4-mini", complex: "o3" },
30
- gemini: { trivial: "gemini-2.0-flash", simple: "gemini-2.0-flash", medium: "gemini-2.5-pro", complex: "gemini-2.5-pro" },
31
- opencode: { trivial: "coder", simple: "coder", medium: "coder", complex: "coder" },
32
- aider: { trivial: null, simple: null, medium: null, complex: null },
33
- };
20
+ // KJC-TSK-0699: los defaults por provider viven en UNA tabla, la de
21
+ // utils/model-selector.js. Quien quiera otros modelos (opencode con sus
22
+ // proveedores, aider) los pone en config.model_routing.by_provider o en el
23
+ // override del modal por HU.
24
+ const DEFAULT_TIERS = getDefaultModelTiers();
34
25
 
35
26
  // Cross-provider reviewer default: para cada coder provider, qué provider
36
27
  // usa el reviewer por defecto. Dos cabezas distintas miran el código.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * KJC-TSK-0702 (MR-E): the per-HU measurement model routing is judged by. The
3
+ * sub-pipeline puts it on the HU outcome; karajan-core's setHuOutcome keeps a
4
+ * fixed set of fields, so the measured ones are kept here, in the plan, where
5
+ * kj report reads them. Unmeasured fields stay out rather than written as null.
6
+ */
7
+ export const ROUTING_FIELDS = ["cost_usd", "tokens_in", "tokens_out", "cached_tokens", "coder", "reviewer_rejections"];
8
+
9
+ export function keepRoutingMetrics(plan, huId, outcome) {
10
+ const hu = plan?.hus?.find((h) => h.id === huId);
11
+ if (!hu?.outcome) return false;
12
+ for (const field of ROUTING_FIELDS) {
13
+ if (outcome?.[field] !== null && outcome?.[field] !== undefined) hu.outcome[field] = outcome[field];
14
+ }
15
+ return true;
16
+ }
package/src/hu/store.js CHANGED
@@ -55,6 +55,8 @@ export async function createHuBatch(sessionId, stories) {
55
55
  status: "pending",
56
56
  original: { text: s.text },
57
57
  blocked_by: s.blocked_by || [],
58
+ // KJC-TSK-0698: the tier travels with the HU; a story without one (single auto-story) carries none.
59
+ ...(s.complexity ? { complexity: s.complexity } : {}),
58
60
  certified: null,
59
61
  quality: null,
60
62
  context_requests: [],
@@ -33,6 +33,7 @@ import {
33
33
  } from "#session/mutators.js";
34
34
  import { emitProgress, makeEvent } from "#utils/events.js";
35
35
  import { listActiveRuns } from "../../../utils/run-registry.js";
36
+ import { keepRoutingMetrics } from "../../../hu/routing-metrics.js";
36
37
 
37
38
  /**
38
39
  * @param {object} args
@@ -313,6 +314,7 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
313
314
  setLiveOutcomeUpdater(session, async (huId, outcome) => {
314
315
  try {
315
316
  if (!setHuOutcomeFn(loadedPlan, huId, outcome)) return;
317
+ keepRoutingMetrics(loadedPlan, huId, outcome); // KJC-TSK-0702: the measured fields core does not keep
316
318
  await savePlanToDisk(projectDir, loadedPlan);
317
319
  if (outcome && Number.isFinite(outcome.cost_usd)) {
318
320
  try {
@@ -195,10 +195,29 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
195
195
  // can hide the chip on cold runs or providers without cache.
196
196
  cached_tokens: sumTokenField(huBudgetEntries, 'cached_tokens'),
197
197
  tokens_in: sumTokenField(huBudgetEntries, 'tokens_in'),
198
+ // KJC-TSK-0702 (MR-E): the baseline model routing is measured against.
199
+ tokens_out: sumTokenField(huBudgetEntries, 'tokens_out'),
200
+ ...routingBaseline(huBudgetEntries, status),
198
201
  finishedAt: new Date().toISOString(),
199
202
  };
200
203
  }
201
204
 
205
+ /**
206
+ * KJC-TSK-0702 (MR-E): which model the coder used and how many times the
207
+ * reviewer sent the HU back: its rounds, minus the approval that closed a done HU.
208
+ * Null when nothing was recorded, as "unmeasured".
209
+ */
210
+ function routingBaseline(entries, status) {
211
+ const list = Array.isArray(entries) ? entries : [];
212
+ const coder = list.findLast((e) => e?.role === "coder");
213
+ const reviewerRounds = list.filter((e) => e?.role === "reviewer").length;
214
+ const approvalRound = status === "done" ? 1 : 0;
215
+ return {
216
+ coder: coder ? { provider: coder.provider ?? null, model: coder.model ?? null } : null,
217
+ reviewer_rejections: reviewerRounds === 0 ? null : reviewerRounds - approvalRound,
218
+ };
219
+ }
220
+
202
221
  /**
203
222
  * Execute a single HU through the coder→sonar→reviewer sub-pipeline.
204
223
  * Extracted to support both sequential and parallel execution paths.
@@ -10,6 +10,7 @@ import { addCheckpoint } from "../../session/store.js";
10
10
  import { emitProgress, makeEvent } from "../../utils/events.js";
11
11
  import { buildDecompositionPrompt, parseDecompositionOutput, uncoveredCriteria } from "../../prompts/hu-reviewer.js";
12
12
  import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
13
+ import { huComplexity } from "../../hu/complexity-tier.js";
13
14
  import { createStallDetector } from "../../utils/stall-detector.js";
14
15
  import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../../hu/store.js";
15
16
  import { topologicalSort } from "../../hu/graph.js";
@@ -88,11 +89,17 @@ async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, sessio
88
89
  }));
89
90
  }
90
91
 
92
+ // KJC-TSK-0698 (MR-A): each HU's tier and why, from this same answer plus the deterministic signal.
93
+ for (const story of stories) {
94
+ story.complexity = huComplexity(story);
95
+ logger.info(`${story.id} tier ${story.complexity.tier}: ${story.complexity.reason}`);
96
+ }
97
+
91
98
  emitProgress(
92
99
  emitter,
93
100
  makeEvent("hu-reviewer:decompose-end", { ...eventBase, stage: "hu-reviewer" }, {
94
101
  message: `Task decomposed into ${stories.length} HUs`,
95
- detail: { count: stories.length, ids: stories.map(s => s.id) }
102
+ detail: { count: stories.length, ids: stories.map(s => s.id), tiers: Object.fromEntries(stories.map((s) => [s.id, s.complexity])) }
96
103
  })
97
104
  );
98
105
 
@@ -230,7 +237,8 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
230
237
  id: hu.id,
231
238
  text: isFirst ? fullText : skeletonText,
232
239
  blocked_by: hu.dependsOn || [],
233
- needsRefinement: !isFirst
240
+ needsRefinement: !isFirst,
241
+ complexity: hu.complexity
234
242
  };
235
243
  });
236
244
  } else {
@@ -124,6 +124,21 @@ async function handleSonarRetryLimit({ config, logger, emitter, eventBase, sessi
124
124
  return null;
125
125
  }
126
126
 
127
+ // KJC-BUG-0359 (#2158): with only the severity counts the coder guessed and
128
+ // tripped sonar_repeat. Each issue travels in the feedback, its path without
129
+ // the project key; past the cap, the rest is counted, never silently dropped.
130
+ const MAX_ISSUES_FOR_CODER = 25;
131
+ export function formatIssuesForCoder(issues) {
132
+ if (!Array.isArray(issues) || issues.length === 0) return "";
133
+ const lines = issues.slice(0, MAX_ISSUES_FOR_CODER).map((i) => {
134
+ const file = String(i.file ?? "").replace(/^[^:]*:/, "");
135
+ return `- [${i.severity}] ${i.rule} ${file}:${i.line} ${i.message}`;
136
+ });
137
+ const rest = issues.length - lines.length;
138
+ if (rest > 0) lines.push(`…and ${rest} more`);
139
+ return `\nIssues to fix:\n${lines.join("\n")}`;
140
+ }
141
+
127
142
  async function handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task, brainCtx }) {
128
143
  // If the ONLY quality gate failure is coverage, treat as non-blocking warning
129
144
  if (sonarResult.conditions) {
@@ -148,7 +163,7 @@ async function handleSonarBlocking({ sonarResult, config, logger, emitter, event
148
163
  return handleSonarStalled({ repeatDetector, logger, emitter, eventBase, session, budgetSummary });
149
164
  }
150
165
 
151
- const summary = `Sonar gate blocking (${sonarResult.gateStatus}). Resolve critical findings first.`;
166
+ const summary = `Sonar gate blocking (${sonarResult.gateStatus}). Resolve critical findings first.${formatIssuesForCoder(sonarResult.issues)}`;
152
167
  setReviewerFeedback(session, summary);
153
168
  // Brain: push sonar feedback into queue when enabled
154
169
  if (brainCtx?.enabled) {
@@ -363,6 +378,9 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
363
378
  const issuesFinal = sonarState.issuesFinal ?? sonarResult.openIssuesTotal ?? 0;
364
379
  const stageResult = {
365
380
  gateStatus: sonarResult.gateStatus,
381
+ // KJC-TSK-0839: the key and the files the scan indexed, for the verdict's per-source proof.
382
+ projectKey: sonarResult.projectKey,
383
+ indexedFiles: sonarResult.indexedFiles ?? [],
366
384
  openIssues: sonarResult.openIssuesTotal,
367
385
  issuesInitial,
368
386
  issuesFinal,
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { generatePlanId } from "./plan-id.js";
8
8
  import { findCycle } from "../hu/graph.js";
9
+ import { changeClassErrors } from "./rework-license.js";
9
10
 
10
11
  const VALID_PLAN_STATUSES = new Set(["draft", "ready", "running", "done", "failed"]);
11
12
  // KJC-TSK-0394 step 5: el modelo CANONICAL del lifecycle es
@@ -255,7 +256,7 @@ export function migratePlanV1toV2(v1plan) {
255
256
  /**
256
257
  * Validate a v2 plan. Returns { valid: true } or { valid: false, errors: string[] }.
257
258
  */
258
- export function validatePlan(plan) {
259
+ export function validatePlan(plan, { reworkMin } = {}) {
259
260
  const errors = [];
260
261
 
261
262
  if (!plan.planId) errors.push("Missing planId");
@@ -297,6 +298,8 @@ export function validatePlan(plan) {
297
298
  });
298
299
  }
299
300
  }
301
+ // KJC-TSK-0717 (SPD-A): EXTEND/REWORK/REBUILD with its criterion, and REWORK only with a licence.
302
+ errors.push(...changeClassErrors(hu, plan.hus || [], { reworkMin }));
300
303
  // Check blocked_by references exist
301
304
  for (const dep of hu.blocked_by || []) {
302
305
  if (!huIds.has(dep) && !(plan.hus || []).some(h => h.id === dep)) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * KJC-TSK-0717 (SPD-A): a plan that changes code already written classifies
3
+ * each HU: EXTEND (edit it), REWORK (regenerate the module with its tests as
4
+ * the contract) or REBUILD (the subsystem), with the criterion written down.
5
+ * REWORK needs a licence: the module's mutation score (kj mutate <path>) at or
6
+ * over the threshold. Under it the plan goes in two phases, an HU that hardens
7
+ * the module first: regenerating against a thin contract is a roulette.
8
+ */
9
+ export const CHANGE_CLASSES = ["extend", "rework", "rebuild"];
10
+ // The maintainer's choice (2026-10-16): method_gates.rework_mutation_min overrides it.
11
+ export const DEFAULT_REWORK_MIN = 0.8;
12
+
13
+ /** The licence for a module, from its mutation score in percent. */
14
+ export function licenseFor({ module, score, threshold = DEFAULT_REWORK_MIN }) {
15
+ return { module, mutation_score: score, threshold, granted: score / 100 >= threshold };
16
+ }
17
+
18
+ /**
19
+ * What is wrong with one HU's classification; empty when nothing is. The
20
+ * licence is judged again from its score against the threshold in force: a
21
+ * stored `granted` (or threshold) is a record, never the authority.
22
+ */
23
+ export function changeClassErrors(hu, hus, { reworkMin = DEFAULT_REWORK_MIN } = {}) {
24
+ if (hu.change_class === undefined) return [];
25
+ if (!CHANGE_CLASSES.includes(hu.change_class)) return [`HU ${hu.id}: change_class must be ${CHANGE_CLASSES.join("|")}, got ${hu.change_class}`];
26
+ if (typeof hu.change_criterion !== "string" || !hu.change_criterion.trim()) return [`HU ${hu.id}: change_criterion is required with change_class (why this class, written down)`];
27
+ if (hu.change_class !== "rework") return [];
28
+ const licence = hu.rework_license;
29
+ if (!licence) return [`HU ${hu.id}: REWORK needs a licence: kj plan license <plan> ${hu.id} --module <path>`];
30
+ const score = licence.mutation_score;
31
+ if (typeof licence.module !== "string" || !licence.module || !Number.isFinite(score) || score < 0 || score > 100) {
32
+ return [`HU ${hu.id}: rework_license must carry a module and a mutation_score between 0 and 100: kj plan license <plan> ${hu.id} --module <path>`];
33
+ }
34
+ if (score / 100 >= reworkMin) return [];
35
+ const hardened = (hu.blocked_by ?? []).some((id) => hus.find((h) => h.id === id)?.hardens === licence.module);
36
+ if (hardened) return [];
37
+ return [`HU ${hu.id}: REWORK licence denied for ${licence.module}: mutation score ${score}% under ${Math.round(reworkMin * 100)}%. Plan two phases: an HU with hardens: ${licence.module} in this HU's blocked_by, then the REWORK`];
38
+ }
@@ -156,12 +156,13 @@ export function buildDecompositionPrompt(task, { criteria = [] } = {}) {
156
156
 
157
157
  sections.push(
158
158
  "Return a single valid JSON object and nothing else.",
159
- `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number]}]}`,
159
+ `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number],"tier":"trivial"|"standard"|"deep","tierReason":string}]}`,
160
160
  "Rules:",
161
161
  "- Generate between 2 and 5 HUs",
162
162
  "- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
163
163
  "- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
164
164
  "- acceptanceCriteria should be concrete, testable statements",
165
+ "- tier is how hard the HU is to implement right (trivial: a local change; standard: several files or one module; deep: design decisions, many modules, or security/migrations), and tierReason says why in one line",
165
166
  "- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)",
166
167
  ...(criteria.length > 0 ? [
167
168
  "- covers lists the numbers of the card's acceptance criteria this HU implements; EVERY card criterion must appear in the covers of at least one HU, and an HU's acceptanceCriteria must include each criterion it covers, verbatim",
@@ -201,7 +202,9 @@ export function parseDecompositionOutput(raw) {
201
202
  benefit: s.benefit || "",
202
203
  acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
203
204
  dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
204
- covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : []
205
+ covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : [],
206
+ tier: s.tier ?? null,
207
+ tierReason: s.tierReason ?? null
205
208
  }));
206
209
 
207
210
  return stories.length > 0 ? stories : null;
@@ -257,10 +257,18 @@ export function buildPlannerPrompt({ task, context, architectContext, productCon
257
257
  ' "spec_section": "<see rules below>",',
258
258
  ' "acceptance_tests": [ /* see rules below */ ],',
259
259
  ' "dependencies": [ "<symbolic ids of OTHER steps this one waits for>" ],',
260
- ' "reuse": [ "<symbolic ids of OTHER steps whose functionality this one piggy-backs on instead of reimplementing>" ]',
260
+ ' "reuse": [ "<symbolic ids of OTHER steps whose functionality this one piggy-backs on instead of reimplementing>" ],',
261
+ ' "change_class": "<extend | rework | rebuild: only when the step changes code already written>",',
262
+ ' "change_criterion": "<one line: why this class>",',
263
+ ' "hardens": "<module path, only on a step that strengthens a module\'s tests so it can be reworked>"',
261
264
  "}",
262
265
  "```",
263
266
  "",
267
+ "### `change_class` (KJC-TSK-0717: only for steps that change code already written; omit it for new code)",
268
+ "- `extend`: edit the module in place. `rework`: regenerate the module with its tests as the contract. `rebuild`: replace the subsystem.",
269
+ "- `change_criterion` says why, in one line. A `rework` is the greenfield alternative (what you would build if this code did not exist) winning on price and criterion: say that, do not restate the whole alternatives clause.",
270
+ "- A `rework` needs a licence, the module's mutation score: it is computed after planning with `kj plan license <plan> <hu> --module <path>`. When the module's tests look thin, add a step first with `hardens: <module>` and list it in the rework step's `dependencies`.",
271
+ "",
264
272
  "### `description` (REQUIRED — MUST start with `[EPICA] ` prefix)",
265
273
  "- The first 80 chars of `description` become the HU's title on the board, so the prefix is the user's only at-a-glance signal of \"which area of the plan does this card belong to\".",
266
274
  "- Identify the EPICA from the task text — typically a heading like `### Épica PROFILE`, `## Auth`, `# Layer N — name`, or a categorical noun the user uses repeatedly (e.g. `INFRA`, `SHARED`, `UI`, `API`).",
@@ -0,0 +1,39 @@
1
+ /**
2
+ * KJC-TSK-1057 (MR-E2): what each coder model cost across the plan's HUs and
3
+ * how often its HUs failed first time (rejected by the reviewer, or failed),
4
+ * from the per-HU measurement of KJC-TSK-0702. HUs run before it are the
5
+ * baseline: counted apart, never as zeros.
6
+ */
7
+ const sum = (rows, field) => rows.reduce((acc, r) => acc + (Number.isFinite(r.outcome[field]) ? r.outcome[field] : 0), 0);
8
+ const round = (n, digits) => Math.round(n * 10 ** digits) / 10 ** digits;
9
+
10
+ export function modelTally(hus) {
11
+ const measured = (hus ?? []).filter((h) => h?.outcome?.coder?.model);
12
+ const byModel = Object.groupBy(measured, (h) => h.outcome.coder.model);
13
+ const models = Object.entries(byModel).map(([model, rows]) => {
14
+ const withIterations = rows.filter((r) => Number.isFinite(r.outcome.iterations));
15
+ const failed = rows.filter((r) => r.outcome.status !== "done" || r.outcome.reviewer_rejections > 0).length;
16
+ return {
17
+ model,
18
+ hus: rows.length,
19
+ cost_usd: round(sum(rows, "cost_usd"), 6),
20
+ tokens_in: sum(rows, "tokens_in"),
21
+ tokens_out: sum(rows, "tokens_out"),
22
+ mean_iterations: withIterations.length ? round(sum(withIterations, "iterations") / withIterations.length, 2) : null,
23
+ failure_rate: round(failed / rows.length, 4),
24
+ };
25
+ });
26
+ return { models, unmeasured: (hus ?? []).length - measured.length };
27
+ }
28
+
29
+ /** The kj report block, or null when no HU was run at all. */
30
+ export function formatModelTally({ models, unmeasured }) {
31
+ if (models.length === 0 && unmeasured === 0) return null;
32
+ const lines = ["Coder models (per HU, from the plans):"];
33
+ for (const m of models) {
34
+ const iterations = m.mean_iterations === null ? "-" : m.mean_iterations.toFixed(1);
35
+ lines.push(` ${m.model.padEnd(24)} ${m.hus} HU · $${m.cost_usd.toFixed(4)} · ${m.tokens_in} in / ${m.tokens_out} out · ${iterations} iter · ${Math.round(m.failure_rate * 100)}% failed first time`);
36
+ }
37
+ if (unmeasured > 0) lines.push(` ${unmeasured} HU(s) before the measurement (baseline, unmeasured)`);
38
+ return lines.join("\n");
39
+ }