karajan-code 4.22.0 → 4.23.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.
@@ -12,7 +12,7 @@ import { checkVerdict, diffHash } from "../review/verdict-store.js";
12
12
  import { runOneShotReview } from "../review/one-shot-review.js";
13
13
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
- import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
15
+ import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
16
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
17
17
  import { checkCardFirst } from "../review/card-first.js";
18
18
  import { checkTestsWithCode } from "../review/tests-with-code.js";
@@ -162,16 +162,23 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
162
162
  const numstat = await rawDiff(flags.range, ["--numstat"]);
163
163
  const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
164
164
  if (added > sizeWarn) {
165
+ // KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
166
+ // partitioning is a decision, and it must never split code from its tests.
167
+ const testAdded = numstat.split("\n").reduce((acc, l) => {
168
+ const [a, , ...f] = l.split("\t");
169
+ return acc + (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${f.join("\t")}`) ? Number(a) || 0 : 0);
170
+ }, 0);
171
+ const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
165
172
  const sizePolicy = config?.method_gates?.pr_size || "warn";
166
173
  if (process.env.KJ_ALLOW_LARGE_PR === "1") {
167
174
  console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
168
175
  } else if (sizePolicy === "block") {
169
- const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
176
+ const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
170
177
  console.log(`✗ pr-size gate: ${reason}`);
171
178
  process.exitCode = 1;
172
179
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
173
180
  } else {
174
- console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
181
+ console.log(`⚠ pr-size: ${added} lines added${split} (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
175
182
  }
176
183
  }
177
184
  }
@@ -213,7 +220,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
213
220
  }
214
221
  }
215
222
 
216
- const tests = checkTestsWithCode({ config, stagedFiles: changedFiles });
223
+ // KJC-TSK-0795 AC1: hand the gate the numbers so a delete-only diff is
224
+ // exempt — deleting code adds no behavior to test.
225
+ const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
226
+ .map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
227
+ const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
228
+ if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
217
229
  if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
218
230
  if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
219
231
  if (!tests.ok) {
@@ -303,15 +315,26 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
303
315
  // the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
304
316
  let task = flags.task;
305
317
  if (flags.sonar !== false) {
306
- const pre = await runSonarPregate({ config, stagedFiles: changedFiles, logger });
318
+ // KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
319
+ const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
320
+ const pre = await runSonarPregate({ config, stagedFiles: changedFiles, touchedLines, logger });
307
321
  if (!pre.available) {
308
- console.log(`⚠ sonar pre-gate skipped: ${pre.reason}`);
322
+ // KJC-BUG-0156 (issue #1543): a scan that FAILED must not read like one
323
+ // more config warning — it hid a dead quality gate for 8 straight PRs.
324
+ // Still fail-open (a laptop without Docker still commits), but SAID.
325
+ const chosen = /disabled in config/.test(pre.reason || "");
326
+ console.log(chosen
327
+ ? `⚠ sonar pre-gate skipped: ${pre.reason}`
328
+ : `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
309
329
  } else {
310
330
  const found = [...pre.blocking, ...pre.advisory];
311
331
  if (found.length > 0) {
312
- console.log(`Sonar on the changed files — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
332
+ console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
313
333
  for (const f of found) console.log(` - ${formatSonarFinding(f)}`);
314
334
  }
335
+ if ((pre.preexisting || []).length > 0) {
336
+ console.log(`⚠ sonar trend: ${pre.preexisting.length} preexisting issue(s) on untouched lines of these files — not this PR's veto, but the debt is real`);
337
+ }
315
338
  if (pre.blocking.length > 0) {
316
339
  console.log("✗ REJECTED by sonar (deterministic) — fix the blocking findings; the cross-AI reviewer was not invoked.");
317
340
  process.exitCode = 1;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `kj steward sweep` (STW-B, KJC-TSK-0790, epic KJC-PCS-0081) — a READ-ONLY
3
+ * pass over the Steward invariants that leaves the verdict IN THE REPO:
4
+ * `.karajan/steward/report.md` (a person: verdict, last evidence, and the
5
+ * command that renews each invariant) and `report.json` (a machine — and the
6
+ * BASELINE the next sweep compares against: the versioned report is the
7
+ * shared memory, never a record on one machine). Exit 1 only when something
8
+ * is BROKEN — unknown and not-observable inform with their remedy.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import {
14
+ VERDICTS, resolveFreshness, evaluateMainCi, evaluateSecurityAudit,
15
+ evaluateVulnAging, evaluateDeadCodeTrend, evaluateCoverageConfig,
16
+ evaluatePhantomCoverage, runInvariants,
17
+ } from "../steward/invariants.js";
18
+ import { loadPreviousAudit } from "../audit/basal-cost.js";
19
+ import { collectOsvFindings } from "../audit/osv-findings.js";
20
+ import { recordGateDecision } from "../policy/decisions.js";
21
+ import { syncProposedWork } from "../steward/proposed-work.js";
22
+
23
+ const stewardDir = (projectDir) => path.join(projectDir, ".karajan", "steward");
24
+
25
+ /** `gh run list` for push runs on the base branch — the sweep's default probe. */
26
+ const ghRuns = (projectDir, baseBranch) => () => {
27
+ const out = execFileSync("gh", ["run", "list", "--branch", baseBranch, "--event", "push", "--limit", "30", "--json", "name,conclusion,createdAt"], { cwd: projectDir, encoding: "utf8", timeout: 30_000 });
28
+ return JSON.parse(out).map((r) => ({ workflow: r.name, conclusion: r.conclusion, createdAt: r.createdAt }));
29
+ };
30
+
31
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
32
+
33
+ export async function stewardSweepCommand({ flags = {}, config = {}, logger = console, probes = {} } = {}) {
34
+ const projectDir = config.projectDir || process.cwd();
35
+ const baseBranch = config.base_branch || "main";
36
+ // STW-E (KJC-TSK-0793): --if-stale <days> — resuming work with a fresh
37
+ // report does not re-sweep; the report in the repo stays the ONE source.
38
+ if (flags.ifStale !== undefined) {
39
+ const prev = readJson(path.join(stewardDir(projectDir), "report.json"));
40
+ const ageMs = prev?.sweptAt ? (probes.nowMs ?? Date.now()) - Date.parse(prev.sweptAt) : Infinity;
41
+ if (ageMs < Number(flags.ifStale) * 86_400_000) {
42
+ logger.info?.(`steward: report is fresh (swept ${prev.sweptAt}) — not re-sweeping`);
43
+ return 0;
44
+ }
45
+ }
46
+ const freshness = resolveFreshness(config);
47
+ const nowMs = probes.nowMs ?? Date.now();
48
+ const runsFn = probes.runsFn ?? ghRuns(projectDir, baseBranch);
49
+
50
+ // Live osv probe (best-effort): unavailable is null → the invariant answers
51
+ // unknown with its remedy, never a clean bill.
52
+ let vulns = probes.vulns ?? null;
53
+ if (vulns === null) {
54
+ try {
55
+ const osv = await (probes.osvFn ?? collectOsvFindings)(projectDir, logger);
56
+ if (osv?.available) vulns = (osv.vulnerabilities || []).map((v) => ({ id: v.id, severity: v.severity, publishedAt: v.publishedAt ?? null }));
57
+ } catch { /* stays null — unknown */ }
58
+ }
59
+
60
+ // Dead-code baseline: the PREVIOUS report in the repo — shared memory.
61
+ const prevReport = readJson(path.join(stewardDir(projectDir), "report.json"));
62
+ const snapshot = await loadPreviousAudit(projectDir).catch(() => null);
63
+ const deadNow = typeof snapshot?.knipDeadExports?.exports === "number"
64
+ ? { deadExports: snapshot.knipDeadExports.exports + (snapshot.knipDeadExports.files || 0) }
65
+ : Array.isArray(snapshot?.deadExports) ? { deadExports: snapshot.deadExports.length } : null;
66
+
67
+ const invariants = [
68
+ { id: "main-ci", renew: "push to the base branch (or fix the red run)", evaluate: () => evaluateMainCi({ projectDir, baseBranch, freshness: freshness.values, runsFn, nowMs }) },
69
+ { id: "security-audit", renew: "kj audit --security", evaluate: () => evaluateSecurityAudit({ projectDir, freshness: freshness.values, nowMs }) },
70
+ { id: "vulnerable-deps", renew: "kj audit (osv-scanner)", evaluate: () => evaluateVulnAging({ vulns, freshness: freshness.values, nowMs }) },
71
+ { id: "dead-code-trend", renew: "kj audit (knip inventory)", evaluate: () => evaluateDeadCodeTrend({ current: deadNow, previous: prevReport?.deadCodeBaseline ?? null }) },
72
+ { id: "coverage-config", renew: "configure a coverage threshold", evaluate: () => evaluateCoverageConfig({ projectDir }) },
73
+ { id: "phantom-coverage", renew: "KJC-TSK-0800 ships the detectors", evaluate: () => evaluatePhantomCoverage() },
74
+ ];
75
+ const results = runInvariants(invariants, {}).map((r, i) => ({ ...r, renew: invariants[i].renew }));
76
+ const broken = results.filter((r) => r.verdict === VERDICTS.BROKEN);
77
+
78
+ const report = {
79
+ sweptAt: new Date(nowMs).toISOString(),
80
+ baseBranch,
81
+ freshness: { ...freshness.values, declared: freshness.declared },
82
+ invariants: results,
83
+ deadCodeBaseline: deadNow ? { ...deadNow, timestamp: new Date(nowMs).toISOString() } : (prevReport?.deadCodeBaseline ?? null),
84
+ };
85
+ const mark = { [VERDICTS.OK]: "✓", [VERDICTS.BROKEN]: "✗", [VERDICTS.UNKNOWN]: "?", [VERDICTS.NOT_OBSERVABLE]: "∅" };
86
+ const md = [
87
+ "# Steward report",
88
+ "", `Last swept: ${report.sweptAt}`, "",
89
+ `Freshness: ${freshness.declared ? "declared by the project" : "calibrated defaults (the project declared none)"} — ${Object.entries(freshness.values).map(([k, v]) => `${k}=${v}`).join(", ")}`, "",
90
+ ...results.map((r) => [
91
+ `## ${mark[r.verdict] || "?"} ${r.id} — ${r.verdict}`,
92
+ `- evidence: ${r.evidence || "(none)"}`,
93
+ ...(r.remedy ? [`- remedy: ${r.remedy}`] : []),
94
+ `- renews with: ${r.renew}`, "",
95
+ ]).flat(),
96
+ ].join("\n");
97
+
98
+ fs.mkdirSync(stewardDir(projectDir), { recursive: true });
99
+ fs.writeFileSync(path.join(stewardDir(projectDir), "report.md"), md);
100
+ fs.writeFileSync(path.join(stewardDir(projectDir), "report.json"), `${JSON.stringify(report, null, 2)}\n`);
101
+
102
+ // Every sweep is SEALED in the decision chain — the same criterion policy
103
+ // decisions already meet: recorded and verifiable, not just notified.
104
+ try {
105
+ const counts = {};
106
+ for (const r of results) counts[r.verdict] = (counts[r.verdict] || 0) + 1;
107
+ recordGateDecision(projectDir, { kind: "steward-sweep", verdicts: { ok: counts[VERDICTS.OK] || 0, broken: broken.length, unknown: counts[VERDICTS.UNKNOWN] || 0, "not-observable": counts[VERDICTS.NOT_OBSERVABLE] || 0 }, broken_ids: broken.map((b) => b.id) });
108
+ } catch (err) { logger.warn?.(`⚠ steward: the sweep could not be sealed in the decision chain (${err.message})`); }
109
+
110
+ // STW-D (KJC-TSK-0792): every break is PROPOSED work on the board the brain
111
+ // already consumes. AFTER the report and the seal: a board failure never
112
+ // costs either, and it is said loudly — never swallowed.
113
+ try {
114
+ const sync = await syncProposedWork({ projectDir, config, results, sweptAt: report.sweptAt });
115
+ if (!sync.synced) logger.warn?.(`⚠ steward: broken invariants not carded — ${sync.reason}`);
116
+ else if (sync.created || sync.updated || sync.resolved) logger.info?.(`steward board: ${sync.created} card(s) proposed, ${sync.updated} updated, ${sync.resolved} resolved`);
117
+ } catch (err) { logger.warn?.(`⚠ steward: carding the broken invariants failed (${err.message}) — the report and the seal stand`); }
118
+
119
+ // A report nobody can see is not shared state — say it, do not fix their tree.
120
+ try {
121
+ execFileSync("git", ["check-ignore", "-q", path.join(".karajan", "steward", "report.md")], { cwd: projectDir });
122
+ logger.warn?.("⚠ steward: the report path is gitignored — untrack .karajan/steward from .gitignore so the state is shared");
123
+ } catch { /* not ignored — good */ }
124
+
125
+ if (flags.json) {
126
+ process.stdout.write(`${JSON.stringify(report)}\n`);
127
+ } else {
128
+ for (const r of results) logger.info?.(`${mark[r.verdict]} ${r.id}: ${r.verdict}${r.verdict === VERDICTS.OK ? "" : ` — ${r.remedy || r.evidence}`}`);
129
+ logger.info?.(broken.length ? `steward: ${broken.length} invariant(s) BROKEN — the report names them (.karajan/steward/report.md)` : "steward: nothing broken — the full state is in .karajan/steward/report.md");
130
+ }
131
+ process.exitCode = broken.length ? 1 : 0;
132
+ return broken.length ? 1 : 0;
133
+ }
134
+
135
+ /**
136
+ * STW-E — the on-resume mode: when work resumes there is someone in front to
137
+ * review the report, which is the requirement. Only for projects that ADOPTED
138
+ * the Steward (a report exists, or method_gates.steward is declared): the
139
+ * default imposes nothing. Best-effort: a failed sweep never stops a resume.
140
+ */
141
+ export async function sweepOnResume({ config = {}, logger = console, sweepFn = stewardSweepCommand } = {}) {
142
+ const projectDir = config.projectDir || process.cwd();
143
+ const adopted = fs.existsSync(path.join(stewardDir(projectDir), "report.json")) || Boolean(config.method_gates?.steward);
144
+ if (!adopted) return;
145
+ try { await sweepFn({ flags: { ifStale: 1 }, config, logger }); } catch (err) { logger.warn?.(`⚠ steward: on-resume sweep failed (${err.message}) — resuming anyway`); }
146
+ }
@@ -80,7 +80,12 @@ const DEFAULTS = {
80
80
  tests_with_code: "warn",
81
81
  // MG-C: informative nudge when the staged diff exceeds this many
82
82
  // added lines (0 disables). The project's CI owns any hard budget.
83
- pr_size_warn: 150
83
+ pr_size_warn: 150,
84
+ // CLM-B (claims-with-evidence ADR): the Stop gate crosses the hard data of
85
+ // the turn's final message against that turn's tool outputs. "off" (adoption
86
+ // is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
87
+ // source refuses to end the turn; unbacked data is still only reported).
88
+ claims: "off"
84
89
  },
85
90
  review_rules: "./.karajan/review-rules.md",
86
91
  coder_rules: "./.karajan/coder-rules.md",
@@ -75,9 +75,24 @@ export const violations = (s, branch) => {
75
75
  if (!s || !(s.edited_sources || []).length) return v;
76
76
  if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
77
77
  else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
78
- if (!(s.edited_tests || []).length) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
78
+ if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
79
79
  return v;
80
80
  };
81
+ // KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
82
+ // source edits only REMOVED lines owes no test. Unreadable git = conservative
83
+ // (the violation stands): a gate must never stand down on a shrug.
84
+ const sessionAddsCode = (s) => {
85
+ const files = s.edited_sources || [];
86
+ // _git builds a shell string: any path outside a strict charset stays out of
87
+ // it — and the gate stays ARMED for it (conservative, and injection-proof).
88
+ if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
89
+ const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
90
+ if (!stat) return true;
91
+ return stat.split(String.fromCharCode(10)).some((l) => {
92
+ const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
93
+ return a === "-" || Number(a) > 0;
94
+ });
95
+ };
81
96
  export const recordEscape = (sid, escape, tool) => {
82
97
  const state = load();
83
98
  const s = session(state, sid);
@@ -136,7 +151,21 @@ process.stdin.on("end", () => {
136
151
  // the first real update_card did not clear its pending entry).
137
152
  const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
138
153
  const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
139
- if (cid && st && CLOSING.includes(st[1].toLowerCase())) clearPending(cid[1].toUpperCase());
154
+ if (cid && st && CLOSING.includes(st[1].toLowerCase())) {
155
+ clearPending(cid[1].toUpperCase());
156
+ // KJC-BUG-0157: a pending whose card-id NEVER existed (branch named
157
+ // before the tracker confirmed the create) could not be cleared by any
158
+ // verified route. The pending's TRUE identity is its PR: a CONFIRMED
159
+ // closing update whose request names that PR clears it too — still a
160
+ // real tracker call seen by this hook, never a promise in prose.
161
+ const prIn = Number(input?.updates?.pipelineStatus?.prCreated?.prNumber);
162
+ if (Number.isFinite(prIn) && prIn > 0) {
163
+ const state = load();
164
+ const s = session(state, sid);
165
+ s.pending_moves = (s.pending_moves || []).filter((p) => Number(p.pr) !== prIn);
166
+ save(state);
167
+ }
168
+ }
140
169
  process.exit(0);
141
170
  }
142
171
  if (tool === "Bash") {
@@ -230,7 +259,7 @@ process.stdin.on("data", (d) => { raw += d; });
230
259
  process.stdin.on("end", () => {
231
260
  try {
232
261
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
233
- const { session_id: sid = "default" } = JSON.parse(raw);
262
+ const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
234
263
  const state = load();
235
264
  // Tamper check runs regardless of session state: shell indirection leaves
236
265
  // no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
@@ -252,12 +281,31 @@ process.stdin.on("end", () => {
252
281
  process.exit(2);
253
282
  }
254
283
  state.tamper_blocks = 0;
284
+ // CLM-B (KJC-TSK-0802, claims-with-evidence ADR): the hard data of the turn's
285
+ // final message, crossed against that turn's outputs. It runs even without a
286
+ // tracked session — a turn with no edits still ends with a final message. The
287
+ // hook carries no policy: kj reads method_gates.claims and answers 0 (off,
288
+ // clean, or warn) or 2 (a datum DENIED by its own source, in block mode). A
289
+ // missing kj or an errored spawn fails open.
290
+ let claimsNote = "";
291
+ let claimsBlock = null;
292
+ if (transcript) {
293
+ const c = spawnSync("kj", ["claims", "gate", "--transcript", transcript], { cwd: ROOT, encoding: "utf8" });
294
+ if (!c.error && c.status === 2) claimsBlock = "claims: un dato del mensaje final esta DESMENTIDO por las salidas de este turno — verificalo o marcalo como no comprobado. Detalle: kj claims check --transcript " + transcript;
295
+ else if (!c.error && c.status === 0 && (c.stderr || "").trim()) claimsNote = "kj claims: " + c.stderr.trim().split(String.fromCharCode(10))[0] + " — datos sin respaldo en este turno (informa, no bloquea).";
296
+ }
255
297
  const s = state.sessions?.[sid];
256
- if (!s) process.exit(0);
298
+ if (!s) {
299
+ if (claimsBlock) { console.error("kj sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock); process.exit(2); }
300
+ if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
301
+ process.exit(0);
302
+ }
257
303
  const v = violations(s, branchOf());
304
+ if (claimsBlock) v.push(claimsBlock);
258
305
  if (!v.length) {
259
306
  s.blocks = 0;
260
307
  const notes = [];
308
+ if (claimsNote) notes.push(claimsNote);
261
309
  if ((s.escapes || []).length) notes.push("kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
262
310
  if ((s.closed_cards || []).length) {
263
311
  notes.push("kj sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
@@ -292,7 +340,7 @@ import { dirname, join, relative, resolve } from "node:path";
292
340
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
293
341
  import { spawnSync } from "node:child_process";
294
342
  import { homedir } from "node:os";
295
- import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
343
+ import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
296
344
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
297
345
  const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
298
346
  const PUSH = /\\bgit\\s+push\\b/;
@@ -301,7 +349,7 @@ let raw = "";
301
349
  process.stdin.on("data", (d) => { raw += d; });
302
350
  process.stdin.on("end", () => {
303
351
  try {
304
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
352
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
305
353
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
306
354
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
307
355
  // only the human, editing outside it.
@@ -681,6 +729,37 @@ process.stdin.on("end", () => {
681
729
  }
682
730
  }
683
731
  if (EDIT_TOOLS.includes(tool)) {
732
+ // STW-C (KJC-TSK-0791): the sweep's verdict has consequence where work
733
+ // STARTS. Inform ALWAYS, once per session and impossible to miss; BLOCK
734
+ // almost never — only security and persistent red main, and only when
735
+ // the project adopted it (method_gates.steward: block). Unknown is never
736
+ // treated as broken: nothing is asserted that is not known. No report =
737
+ // the project does not use the Steward yet — silence, adoption is explicit.
738
+ try {
739
+ const rep = JSON.parse(readFileSync(join(ROOT, ".karajan", "steward", "report.json"), "utf8"));
740
+ const bad = (rep.invariants || []).filter((r) => r.verdict === "broken");
741
+ const stale = (rep.invariants || []).filter((r) => r.verdict === "unknown");
742
+ if (bad.length || stale.length) {
743
+ const hard = bad.filter((r) => r.id === "vulnerable-deps" || r.id === "main-ci");
744
+ let blockOn = false;
745
+ try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
746
+ if (hard.length && blockOn) {
747
+ if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
748
+ else {
749
+ console.error("kj sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)");
750
+ process.exit(2);
751
+ }
752
+ }
753
+ const st = load();
754
+ const s = session(st, sid);
755
+ if (!s.steward_informed) {
756
+ s.steward_informed = true;
757
+ save(st);
758
+ const lines = [...bad.map((r) => "ROTO " + r.id + ": " + (r.remedy || r.evidence || "")), ...stale.map((r) => "desconocido " + r.id + " (no se afirma lo que no se sabe) — refresca: " + (r.renew || "kj steward sweep"))];
759
+ console.log(JSON.stringify({ systemMessage: "kj steward (barrido " + rep.sweptAt + "): " + lines.join(" | ") + " — detalle: .karajan/steward/report.md" }));
760
+ }
761
+ }
762
+ } catch { /* no report or unreadable — the Steward is not adopted here */ }
684
763
  const file = input.file_path || input.notebook_path;
685
764
  const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
686
765
  if (rel && CODE.test(rel) && !TESTS.test(rel)) {
@@ -714,6 +793,19 @@ process.stdin.on("end", () => {
714
793
  }
715
794
  // (the pending entry itself is recorded by the PostToolUse hook, only once
716
795
  // gh confirms "merged pull request #N" — a failed merge never blocks)
796
+ // CLM-C (KJC-TSK-0803): a PR body outlives the turn — an invented figure
797
+ // there misleads every future reader. When the command writes one from a
798
+ // file, kj crosses ITS data against this turn's outputs; the hook carries
799
+ // no policy (kj reads method_gates.claims; off = this check does not exist).
800
+ const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
801
+ if (bodyFile && transcript) {
802
+ const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
803
+ const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
804
+ if (!g.error && g.status === 2) {
805
+ console.error("kj sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body);
806
+ process.exit(2);
807
+ }
808
+ }
717
809
  if (PUBLISH.test(cmd)) {
718
810
  if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
719
811
  const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
@@ -12,8 +12,9 @@ import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { dirname } from "node:path";
14
14
 
15
+ import { parse as parseYaml } from "yaml";
15
16
  import { upsertManagedBlock } from "../utils/managed-markers.js";
16
- import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
17
+ import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
17
18
 
18
19
  // La versión del kj que corre harden — es la que se pinea en el fallback
19
20
  // npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
@@ -85,7 +86,12 @@ export function installWorkflows({
85
86
  const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
86
87
  ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
87
88
  : null;
88
- const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : [])];
89
+ // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
90
+ // steward.action: true in its config; nothing is imposed by default.
91
+ let stewardOn = false;
92
+ try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
93
+ const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
94
+ const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
89
95
  const results = [];
90
96
  for (const wf of all) {
91
97
  const target = join(dir, wf.file);
@@ -186,7 +186,10 @@ export const SHRINK_BUDGET_WORKFLOW = [
186
186
  " env:",
187
187
  " BASE_REF: ${{ github.base_ref }}",
188
188
  " run: |",
189
- " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' || true)",
189
+ // KJC-TSK-0795 AC4 — measured in GREBLA: a 414-line warning that was almost
190
+ // all pnpm-lock.yaml. Generated files are weight nobody wrote: the generated
191
+ // gate applies the same exclusions kj's own repo applies.
192
+ " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' || true)",
190
193
  ' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
191
194
  ' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
192
195
  ' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
@@ -272,6 +275,40 @@ export function mutationWorkflowFor(language, pm = "npm") {
272
275
  return { file: "kj-mutation.yml", blockId: "wf-mutation", body };
273
276
  }
274
277
 
278
+ // STW-E (KJC-TSK-0793): the scheduled Steward sweep — OPT-IN via
279
+ // steward.action: true. Decay happens precisely on the days nobody looks, so
280
+ // the watcher cannot live inside a session: the Action runs where the repo
281
+ // lives, publishes the SAME report file every other mode writes (one source
282
+ // of truth), and a broken invariant never stops the report from publishing.
283
+ export function stewardWorkflowFor(kjVersion, pkgName = "karajan-code") {
284
+ return [
285
+ "name: Steward sweep",
286
+ "on:",
287
+ " schedule:",
288
+ ' - cron: "17 6 * * 1"',
289
+ " workflow_dispatch: {}",
290
+ "permissions:",
291
+ " contents: write",
292
+ "jobs:",
293
+ " sweep:",
294
+ " runs-on: ubuntu-latest",
295
+ " steps:",
296
+ ` - uses: ${PINNED_ACTIONS.checkout}`,
297
+ ` - uses: ${PINNED_ACTIONS.setupNode}`,
298
+ " with:",
299
+ ' node-version: "22"',
300
+ ` - run: npm install -g ${pkgName}@${kjVersion}`,
301
+ " - name: Sweep (a broken invariant must not stop the report)",
302
+ " env:",
303
+ " GH_TOKEN: ${{ github.token }}",
304
+ " run: kj steward sweep || true",
305
+ " - name: Publish the report and the proposed cards",
306
+ " run: |",
307
+ " git add .karajan/steward .karajan/plans 2>/dev/null || true",
308
+ ' git diff --cached --quiet || { git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(steward): sweep report [skip ci]" && git push; }',
309
+ ].join("\n");
310
+ }
311
+
275
312
  /** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
276
313
  export function extraWorkflowsFor({ profile = "standard", publishable = false, pm = "npm" } = {}) {
277
314
  const extras = [];
@@ -52,12 +52,25 @@ const SECRET_HEURISTICS = [
52
52
  { type: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/gi },
53
53
  ];
54
54
 
55
+ // KJC-TSK-0797 (epic KJC-PCS-0082) — context BEFORE generics, measured twice:
56
+ // GREBLA's pinned Actions reported as phone numbers, and this repo's own
57
+ // workflow pinning flagged twelve times in one PR. A git object id is not a
58
+ // phone; an email on an RFC 2606 documentation domain is not a person. Both
59
+ // are blanked before redactPII and COUNTED, so a clean result can say
60
+ // "nothing found" or "found and discarded by context" — never the same thing.
61
+ // The personal denylist runs before this: the user's own datum always blocks.
62
+ const CONTEXT_DISCARDS = [
63
+ { type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
64
+ { type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
65
+ ];
66
+
55
67
  /**
56
68
  * Scan a text. Returns findings — denylist hits as `severity:"block"`
57
69
  * (masked value), generic PII as `severity:"warn"` (line already redacted).
58
70
  */
59
71
  export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
60
72
  const findings = [];
73
+ let discarded = 0;
61
74
  String(text).split("\n").forEach((line, i) => {
62
75
  let probe = line;
63
76
  for (const a of list.allow) probe = probe.split(a).join(" ");
@@ -86,6 +99,10 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
86
99
  re.lastIndex = m.index + m[0].length;
87
100
  }
88
101
  }
102
+ for (const { re } of CONTEXT_DISCARDS) {
103
+ re.lastIndex = 0;
104
+ probe = probe.replace(re, (m) => { discarded += 1; return " ".repeat(m.length); });
105
+ }
89
106
  const red = redactPII(probe);
90
107
  if (red.total > 0) {
91
108
  for (const [type, n] of Object.entries(red.counts)) {
@@ -93,12 +110,14 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
93
110
  }
94
111
  }
95
112
  });
113
+ findings.discardedByContext = discarded;
96
114
  return findings;
97
115
  }
98
116
 
99
117
  /** Scan files/dirs recursively (skips node_modules/.git, binaries, big files). */
100
118
  export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
101
119
  const findings = [];
120
+ let discarded = 0;
102
121
  const visit = (p) => {
103
122
  if (!existsSync(p)) return;
104
123
  // Skip symlinks: an ancestor link recurses forever; an outside link ships nothing.
@@ -113,8 +132,11 @@ export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
113
132
  if (BINARY_EXT.has(extname(p).toLowerCase()) || st.size > MAX_FILE_BYTES) return;
114
133
  const content = readFileSync(p);
115
134
  if (content.includes(0)) return; // binary sniff: NUL byte
116
- findings.push(...scanText(content.toString("utf8"), { list, source: p }));
135
+ const fileFindings = scanText(content.toString("utf8"), { list, source: p });
136
+ discarded += fileFindings.discardedByContext || 0;
137
+ findings.push(...fileFindings);
117
138
  };
118
139
  for (const p of paths) visit(p);
140
+ findings.discardedByContext = discarded;
119
141
  return findings;
120
142
  }
@@ -28,13 +28,38 @@ export function formatSonarFinding(issue) {
28
28
  return `(${issue.severity}) [${issueFile(issue)}${line}] ${issue.rule} — ${issue.message}`;
29
29
  }
30
30
 
31
+ /**
32
+ * KJC-TSK-0795 AC3: the NEW line numbers each file gains in a unified diff
33
+ * (`git diff --unified=0`). Only what the diff ADDS can be the author's fault.
34
+ * @param {string} diffText @returns {Map<string, Set<number>>}
35
+ */
36
+ export function addedLinesByFile(diffText) {
37
+ const map = new Map();
38
+ let current = null;
39
+ for (const raw of String(diffText || "").split("\n")) {
40
+ if (raw.startsWith("+++ ")) {
41
+ const p = raw.slice(4).trim();
42
+ current = p.startsWith("b/") ? p.slice(2) : p === "/dev/null" ? null : p;
43
+ continue;
44
+ }
45
+ const h = current ? /^@@ [^+]*\+(\d+)(?:,(\d+))? @@/.exec(raw) : null;
46
+ if (!h) continue;
47
+ const start = Number(h[1]);
48
+ const count = h[2] === undefined ? 1 : Number(h[2]);
49
+ if (count === 0) continue;
50
+ const set = map.get(current) ?? map.set(current, new Set()).get(current);
51
+ for (let i = 0; i < count; i++) set.add(start + i);
52
+ }
53
+ return map;
54
+ }
55
+
31
56
  /**
32
57
  * Scan the project (single-flight via the tool governor) and return the
33
58
  * open issues that live on the staged files, split by blocking severity.
34
59
  * Every failure path degrades to {available:false, reason} — the pre-gate
35
60
  * never breaks the review, it only refuses to stay silent.
36
61
  */
37
- export async function runSonarPregate({ config, stagedFiles = [], logger = null }) {
62
+ export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null }) {
38
63
  if (config?.review_gate?.sonar === false) {
39
64
  return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
40
65
  }
@@ -42,16 +67,26 @@ export async function runSonarPregate({ config, stagedFiles = [], logger = null
42
67
  try {
43
68
  lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
44
69
  const scan = await runSonarScan(config);
70
+ if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
45
71
  if (!scan.ok) {
46
72
  return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
47
73
  }
74
+ // The scan above ALWAYS runs before issues are read (single-flight): the
75
+ // verdict is about the code as it is now, never a stale server analysis
76
+ // (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
48
77
  const res = await getOpenIssues(config, scan.projectKey);
49
78
  const staged = new Set(stagedFiles);
50
79
  const onStaged = (res.issues || []).filter((i) => staged.has(issueFile(i)));
80
+ // KJC-TSK-0795 AC3: with the diff's line map, only issues on lines the PR
81
+ // ADDS can veto — a 3-line PR must not answer for 30 preexisting issues.
82
+ // No line, or an untouched line, is the file's TREND: reported, never a block.
83
+ const isTouched = (i) => !touchedLines || (i.line != null && touchedLines.get(issueFile(i))?.has(Number(i.line)));
84
+ const own = onStaged.filter(isTouched);
51
85
  return {
52
86
  available: true,
53
- blocking: onStaged.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
54
- advisory: onStaged.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
87
+ blocking: own.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
88
+ advisory: own.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
89
+ preexisting: touchedLines ? onStaged.filter((i) => !isTouched(i)) : [],
55
90
  totalProject: res.total ?? (res.issues || []).length,
56
91
  };
57
92
  } catch (err) {