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.
package/README.md CHANGED
@@ -34,6 +34,7 @@ Your AI agent (Claude Code, Codex, Copilot, Antigravity, Cursor…) writes the c
34
34
  - **Nothing personal ships** — every outbound boundary audits before it leaves the machine: the pre-commit rejects a staged diff carrying your denylisted personal data, hardcoded platform tokens (`ghp_`, `sk-`, `AKIA`…) block outright, `verify-pack`-style tarball scans guard the publish, and `kj privacy scan <dir>` audits any build output. Your denylist lives in `~/.karajan/privacy.yml` — the install asks and writes it for you.
35
35
  - **Installing IS activating** — `kj env install` performs the enforcement itself (git hooks, verdict gate, tool gate) instead of trusting the agent to run setup steps, and ends by printing the method into the very conversation that installed it. A commit outside the method is rejected, not narrated.
36
36
  - **The turn cannot end red — the Sentinel** — a deterministic supervisor (zero LLM) wired into the harness's synchronous hooks records the method state of the session as tools run, and a Stop hook blocks the agent from ending its turn while method violations are open. The program rules, the agent thinks. See [guarantee levels](#guarantee-levels-governed-vs-supervised).
37
+ - **Trust expires — the Steward** — `kj steward sweep` checks the project's declared guarantees against their freshness and answers one of FOUR verdicts per invariant: ok, broken, *unknown* (evidence expired → refresh it) or *not observable* (nowhere to look → instrument it) — because confusing the last two with green is how a project decays for weeks behind a passing facade. The verdict is versioned in the repo, sealed in the decision chain, and every break lands on the board as PROPOSED work that nothing executes unreviewed. Runs on demand, on `kj resume` when stale, or as an opt-in scheduled Action.
37
38
  - **The review panel never runs dry** — nine built-in agents (Claude Code, Codex, GitHub Copilot, Antigravity `agy` — the gemini successor —, Kimi Code, Qwen, OpenCode, Aider, Gemini legacy), and when the configured reviewer exhausts its quota, `kj review` switches to an authenticated candidate with a LOUD notice — or hands you the menu of candidates with their tier (free / subscription / local) and the exact login command. Never a silent failure, never the brain reviewing itself.
38
39
 
39
40
  This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.22.0",
3
+ "version": "4.23.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",
@@ -123,17 +123,18 @@
123
123
  },
124
124
  "devDependencies": {
125
125
  "@eslint/js": "^10.0.1",
126
- "smol-toml": "^1.7.0",
127
126
  "@vitest/coverage-v8": "^4.1.8",
128
127
  "esbuild": "^0.28.0",
129
128
  "eslint": "^10.4.1",
130
129
  "eslint-plugin-import-x": "^4.16.2",
130
+ "eslint-plugin-node-security": "5.2.2",
131
131
  "eslint-plugin-security": "^4.0.0",
132
132
  "globals": "^17.6.0",
133
133
  "lint-staged": "^17.0.7",
134
134
  "postject": "^1.0.0-alpha.6",
135
135
  "prettier": "^3.4.2",
136
136
  "simple-git-hooks": "^2.13.1",
137
+ "smol-toml": "^1.7.0",
137
138
  "vitest": "^4.1.8"
138
139
  }
139
140
  }
@@ -20,6 +20,8 @@
20
20
  * legacy callers keep working.
21
21
  */
22
22
 
23
+ import { timingSafeEqual } from "node:crypto";
24
+
23
25
  /** Loopback addresses we trust without auth, regardless of token state. */
24
26
  const LOOPBACK_ADDRESSES = new Set([
25
27
  "127.0.0.1",
@@ -41,7 +43,7 @@ export function authMiddleware() {
41
43
  if (isLoopback(req)) return next();
42
44
 
43
45
  const token = extractToken(req);
44
- if (token === expected) return next();
46
+ if (tokensMatch(token, expected)) return next();
45
47
 
46
48
  return res.status(401).json({
47
49
  error: "Unauthorized",
@@ -53,6 +55,32 @@ export function authMiddleware() {
53
55
  };
54
56
  }
55
57
 
58
+ /**
59
+ * Constant-time comparison of a presented token against the expected one.
60
+ *
61
+ * `===` on strings stops at the first differing byte, so the time it takes to
62
+ * reject a token leaks how much of a correct prefix was supplied. Over the
63
+ * loopback interface that is noise — but this middleware exists precisely for
64
+ * the case the header above describes, where the board is bound beyond
65
+ * loopback and the peer is on the LAN. There the difference is measurable, and
66
+ * the token can be recovered a byte at a time.
67
+ *
68
+ * `timingSafeEqual` throws when the buffers differ in length, so the length is
69
+ * checked first. That is not a leak worth closing: the length of the expected
70
+ * token is fixed by whatever wrote `~/.karajan/hu-board/token`, and a token of
71
+ * the wrong length is wrong regardless of its contents.
72
+ *
73
+ * @param {string|undefined} presented
74
+ * @param {string} expected
75
+ * @returns {boolean}
76
+ */
77
+ function tokensMatch(presented, expected) {
78
+ if (typeof presented !== "string") return false;
79
+ const left = Buffer.from(presented, "utf8");
80
+ const right = Buffer.from(expected, "utf8");
81
+ return left.length === right.length && timingSafeEqual(left, right);
82
+ }
83
+
56
84
  /**
57
85
  * @param {import('express').Request} req
58
86
  * @returns {boolean}
@@ -272,10 +272,14 @@ export async function saveAuditSnapshot(projectDir, metrics) {
272
272
 
273
273
  export function computeGrowthDelta(current, previous) {
274
274
  if (!previous) return null;
275
+ // AC8 (KJC-TSK-0794): "not measured before" must never read as "unchanged" —
276
+ // the derivative only exists when BOTH snapshots actually measured it.
277
+ const bothMeasured = Array.isArray(current.deadExports) && Array.isArray(previous.deadExports);
275
278
  return {
276
279
  lines: current.totalLines - previous.totalLines,
277
280
  files: current.totalFiles - previous.totalFiles,
278
281
  deps: current.dependencies.total - (previous.dependencies?.total ?? 0),
282
+ deadExports: bothMeasured ? current.deadExports.length - previous.deadExports.length : null,
279
283
  since: previous.timestamp || null
280
284
  };
281
285
  }
@@ -16,6 +16,7 @@
16
16
  import { execFile } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
18
  import { createRequire } from "node:module";
19
+ import os from "node:os";
19
20
  import path from "node:path";
20
21
  import fs from "node:fs/promises";
21
22
  import { filterFalsePositives } from "./issue-filter.js";
@@ -26,6 +27,42 @@ const SCAN_TIMEOUT_MS = 120_000;
26
27
  const MAX_DEAD_EXPORTS_REPORTED = 100;
27
28
  const MAX_UNUSED_FILES_REPORTED = 50;
28
29
 
30
+ // KJC-TSK-0794 AC7: a Firebase callable or a declared global-setup reported as
31
+ // dead is the false positive that gets the inventory switched off. When the
32
+ // project declares nothing itself, kj declares FOR it what it can read.
33
+ const KNIP_OWN_CONFIGS = ["knip.json", "knip.jsonc", ".knip.json", "knip.js", "knip.ts", "knip.config.js", "knip.config.ts"];
34
+ // knip's default root entry patterns, restated because an explicit `entry`
35
+ // REPLACES them (package.json main/bin/exports always count on their own).
36
+ const KNIP_DEFAULT_ENTRY = ["{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}", "src/{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}"];
37
+
38
+ async function hasOwnKnipConfig(projectDir) {
39
+ for (const f of KNIP_OWN_CONFIGS) {
40
+ try { await fs.access(path.join(projectDir, f)); return true; } catch { /* keep looking */ }
41
+ }
42
+ try { return Boolean(JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).knip); } catch { return false; }
43
+ }
44
+
45
+ /** Entrypoints kj can vouch for: config.audit.entrypoints (the user's word) and
46
+ * firebase.json's functions.source (the platform's word). Null when nothing. */
47
+ async function resolveProjectEntrypoints(projectDir, config) {
48
+ const declared = (config?.audit?.entrypoints || []).filter((e) => typeof e === "string");
49
+ const firebase = [];
50
+ try {
51
+ const fb = JSON.parse(await fs.readFile(path.join(projectDir, "firebase.json"), "utf8"));
52
+ const fns = Array.isArray(fb.functions) ? fb.functions : fb.functions ? [fb.functions] : [];
53
+ for (const f of fns) if (typeof f?.source === "string") firebase.push(f.source);
54
+ } catch { /* no firebase.json — nothing to declare */ }
55
+ return declared.length || firebase.length ? { declared, firebase } : null;
56
+ }
57
+
58
+ function buildEphemeralKnipConfig({ declared, firebase }) {
59
+ const root = { entry: [...KNIP_DEFAULT_ENTRY, ...declared] };
60
+ if (!firebase.length) return root;
61
+ const workspaces = { ".": root };
62
+ for (const src of firebase) workspaces[src] = { entry: ["index.{js,ts}", "src/index.{js,ts}"] };
63
+ return { workspaces };
64
+ }
65
+
29
66
  function hasJsTsStack(stack) {
30
67
  if (!stack) return false;
31
68
  const lang = (stack.language || "").toLowerCase();
@@ -78,11 +115,24 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
78
115
  return { available: false, reason: `knip binary not resolvable (${err?.message || err}). In the SEA binary install karajan-code via npm to enable.` };
79
116
  }
80
117
 
118
+ const args = [knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"];
119
+ let declaredEntrypoints = null;
120
+ try {
121
+ const eps = await resolveProjectEntrypoints(projectDir, config);
122
+ // a project with its OWN knip config already decided — it is respected
123
+ if (eps && !(await hasOwnKnipConfig(projectDir))) {
124
+ const cfgPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kj-knip-")), "knip.json");
125
+ await fs.writeFile(cfgPath, JSON.stringify(buildEphemeralKnipConfig(eps)));
126
+ args.push("--config", cfgPath);
127
+ declaredEntrypoints = eps;
128
+ }
129
+ } catch { /* declaring is best-effort; the default scan still runs */ }
130
+
81
131
  let raw;
82
132
  try {
83
133
  const { stdout } = await execFileAsync(
84
134
  process.execPath,
85
- [knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"],
135
+ args,
86
136
  { cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
87
137
  );
88
138
  raw = stdout;
@@ -163,6 +213,7 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
163
213
  available: true,
164
214
  total: kept.length,
165
215
  suppressedCount: suppressed.length,
216
+ declaredEntrypoints,
166
217
  exports: keptExports,
167
218
  files: keptFiles,
168
219
  suppressed,
@@ -185,9 +185,20 @@ function formatDeadExportsBlock(deadExports) {
185
185
  const lines = ["### Dead Code (knip)"];
186
186
  const exportsTotal = (deadExports.exports || []).length;
187
187
  const filesTotal = (deadExports.files || []).length;
188
+ const reported = exportsTotal + filesTotal;
189
+ const suppressed = deadExports.suppressedCount || 0;
190
+ // AC8 (KJC-TSK-0794): derivative first; then how many ENTERED and how many
191
+ // were FILTERED — a shrinking number must never hide a shrinking scan.
192
+ const prev = deadExports.previous;
193
+ if (prev && typeof prev.exports === "number") {
194
+ const d = reported - (prev.exports + (prev.files || 0));
195
+ lines.push(`- Δ dead code: ${d >= 0 ? "+" : ""}${d} since ${prev.timestamp || "last audit"} (now ${reported})`);
196
+ }
197
+ lines.push(`- ${reported + suppressed} entered the scan, ${suppressed} filtered as declared false positives, ${reported} reported`);
198
+ const eps = deadExports.declaredEntrypoints;
199
+ if (eps) lines.push(`- Declared entrypoints honored: ${[...eps.declared, ...eps.firebase.map((s) => `${s} (firebase functions)`)].join(", ")}`);
188
200
  lines.push(`- Unused exports/types: ${exportsTotal}`);
189
201
  lines.push(`- Unused files: ${filesTotal}`);
190
- if (deadExports.suppressedCount) lines.push(`- Suppressed (FP filter): ${deadExports.suppressedCount}`);
191
202
  const allItems = [...(deadExports.exports || []), ...(deadExports.files || [])];
192
203
  if (allItems.length > 0) {
193
204
  const groups = groupDeadExportsBySeverity(allItems);
@@ -234,8 +245,12 @@ function formatBasalCostBlock(basalCost, growthDelta) {
234
245
  }
235
246
 
236
247
  const dead = Array.isArray(basalCost.deadExports) ? basalCost.deadExports : [];
248
+ // AC8 (KJC-TSK-0794): the DERIVATIVE leads — "went from N to M" is what a
249
+ // reader acts on; the absolute is the secondary datum.
250
+ const dDelta = growthDelta?.deadExports;
251
+ const lead = typeof dDelta === "number" ? `${dDelta >= 0 ? "+" : ""}${dDelta} since last audit — ` : "";
237
252
  if (dead.length > 0) {
238
- lines.push(`- Dead exports: ${dead.length}`);
253
+ lines.push(`- Dead exports: ${lead}${dead.length} total`);
239
254
  for (const de of dead.slice(0, MAX_SAMPLE_DEAD_EXPORTS)) {
240
255
  lines.push(` - \`${de.name}\` in ${de.file}`);
241
256
  }
@@ -243,7 +258,7 @@ function formatBasalCostBlock(basalCost, growthDelta) {
243
258
  lines.push(` - ... and ${dead.length - MAX_SAMPLE_DEAD_EXPORTS} more`);
244
259
  }
245
260
  } else {
246
- lines.push("- Dead exports: 0");
261
+ lines.push(`- Dead exports: ${lead}0`);
247
262
  }
248
263
 
249
264
  if (growthDelta) {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Member reachability (KJC-TSK-0794, epic KJC-PCS-0082) — which class members
3
+ * no entrypoint can reach, said ONLY inside the perimeter validated with known
4
+ * truth (GREBLA: 31/139 unreachable at dd5a91a checked by hand, 0/108 on their
5
+ * cleaned main): one file, a recognized framework contract, no dynamic dispatch.
6
+ * Everything outside comes out NOT OBSERVABLE with its reason, never as clean —
7
+ * an inflated inventory gets switched off, and then nobody reads the real one.
8
+ */
9
+ import { parse } from "@babel/parser";
10
+ const plugins = (file) => [
11
+ ...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
12
+ ...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
13
+ "decorators",
14
+ ];
15
+ // Entrypoints are what the FRAMEWORK calls — declared per framework, VERSIONED
16
+ // (bump on any list change), never a hand-kept list in a run. v1 covers Lit;
17
+ // other frameworks stay NOT OBSERVABLE until their contract is declared here.
18
+ export const ENTRYPOINT_CATALOG = {
19
+ version: 2, // v2: each list says WHICH slot the framework touches — static and instance are different worlds
20
+ lit: {
21
+ instance: ["constructor", "render", "connectedCallback", "disconnectedCallback", "attributeChangedCallback", "adoptedCallback", "firstUpdated", "updated", "willUpdate", "shouldUpdate", "performUpdate", "getUpdateComplete", "createRenderRoot"],
22
+ static: ["properties", "styles", "observedAttributes"],
23
+ },
24
+ };
25
+ const BASES = new Map([["LitElement", "lit"]]);
26
+ function walk(node, visit) {
27
+ if (!node || typeof node.type !== "string") return;
28
+ visit(node);
29
+ for (const key of Object.keys(node)) {
30
+ const child = node[key];
31
+ if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
32
+ else if (child && typeof child.type === "string") walk(child, visit);
33
+ }
34
+ }
35
+ const keyName = (m) => {
36
+ if (m.key?.type === "PrivateName") return `#${m.key.id.name}`;
37
+ if (!m.computed && m.key?.type === "Identifier") return m.key.name;
38
+ return m.key?.type === "StringLiteral" || m.key?.type === "NumericLiteral" ? String(m.key.value) : null;
39
+ };
40
+ const ACCESSOR_KINDS = new Map([["get", "getter"], ["set", "setter"]]);
41
+ const kindOf = (m) => ACCESSOR_KINDS.get(m.kind) ?? (/Method/.test(m.type) ? "method" : "field");
42
+ /** `this.x` / `this['x']` / `this.#x` inside a node, prefixed with the slot the
43
+ * context can actually reach ("static " inside static members, "" otherwise).
44
+ * Over-collecting through functions that rebind `this` only makes members MORE
45
+ * alive — never dead. */
46
+ const thisRefs = (node, prefix = "") => {
47
+ const refs = new Set();
48
+ walk(node, (n) => {
49
+ if (n.type !== "MemberExpression" || n.object?.type !== "ThisExpression") return;
50
+ if (n.property.type === "PrivateName") refs.add(`${prefix}#${n.property.id.name}`);
51
+ else if (!n.computed && n.property.type === "Identifier") refs.add(prefix + n.property.name);
52
+ else if (n.property.type === "StringLiteral" || n.property.type === "NumericLiteral") refs.add(prefix + String(n.property.value));
53
+ });
54
+ return refs;
55
+ };
56
+ const notObs = (name, line, reason) => ({ name, line, observable: false, reason, unreachable: [] });
57
+
58
+ function analyzeClass(node, { staticUses, thisPropCount }) {
59
+ const name = node.id?.name ?? "(anonymous)";
60
+ const line = node.loc.start.line;
61
+ const sup = node.superClass;
62
+ if (!sup) return notObs(name, line, "no framework contract — members may be called from outside the file");
63
+ if (sup.type !== "Identifier") return notObs(name, line, "mixin or computed base class — inherited entrypoints cannot be resolved");
64
+ const framework = BASES.get(sup.name);
65
+ if (!framework) return notObs(name, line, `unknown base class ${sup.name} — its contract is not in the entrypoint catalog (v${ENTRYPOINT_CATALOG.version})`);
66
+
67
+ const cat = ENTRYPOINT_CATALOG[framework];
68
+ const entries = new Set([...cat.instance, ...cat.static.map((s) => `static ${s}`)]);
69
+ const members = new Map(); // slot → { refs, spots, entry }
70
+ // reached at class-definition time (static blocks, static field initializers)
71
+ // or by ClassName.X anywhere in the file — a use is a use, wherever it sits
72
+ const roots = new Set([...(staticUses.get(name) ?? [])].map((s) => `static ${s}`));
73
+ const ctorFields = new Map(); // this._x = v in the constructor: no AST member exists
74
+ for (const m of node.body.body) {
75
+ if (m.type === "StaticBlock") { thisRefs(m, "static ").forEach((r) => roots.add(r)); continue; }
76
+ if (m.type === "TSDeclareMethod" || m.type === "TSIndexSignature") continue;
77
+ if (m.static && /Property/.test(m.type) && m.value) thisRefs(m.value, "static ").forEach((r) => roots.add(r));
78
+ const n = keyName(m);
79
+ if (n === null) return notObs(name, line, `a member name is computed at line ${m.loc.start.line} — the inventory cannot name what it cannot see`);
80
+ const slot = m.static ? `static ${n}` : n;
81
+ const refs = thisRefs(m, m.static ? "static " : "");
82
+ refs.delete(slot); // recursion does not keep itself alive
83
+ const rec = members.get(slot) ?? { refs: new Set(), spots: [], entry: false };
84
+ refs.forEach((r) => rec.refs.add(r));
85
+ rec.spots.push({ line: m.loc.start.line, endLine: m.loc.end.line, kind: kindOf(m) });
86
+ // a decorated member is registered by the framework: it may call or expose it
87
+ if (entries.has(slot) || (m.decorators?.length ?? 0) > 0) rec.entry = true;
88
+ members.set(slot, rec);
89
+ if (n === "constructor" && !m.static) collectCtorAssignments(m, ctorFields);
90
+ }
91
+
92
+ const alive = new Set();
93
+ const queue = [...members.keys()].filter((k) => members.get(k).entry || roots.has(k));
94
+ while (queue.length) {
95
+ const k = queue.pop();
96
+ if (alive.has(k) || !members.has(k)) continue;
97
+ alive.add(k);
98
+ members.get(k).refs.forEach((r) => { if (members.has(r) && !alive.has(r)) queue.push(r); });
99
+ }
100
+ const unreachable = [...members.entries()]
101
+ .filter(([k]) => !alive.has(k))
102
+ .flatMap(([k, rec]) => rec.spots.map((s) => ({ name: k, ...s })))
103
+ .sort((a, b) => a.line - b.line);
104
+ // HEURISTIC, reported apart (AC5): a constructor field whose name appears in
105
+ // `this.X` form exactly ONCE in the whole file exists only to be initialized.
106
+ const constructorFields = [...ctorFields.entries()]
107
+ .filter(([n]) => !members.has(n) && thisPropCount.get(n) === 1)
108
+ .map(([n, l]) => ({ name: n, line: l, heuristic: "single this-appearance in file" }));
109
+ // memberNames: the class's member slots — phantom-coverage (KJC-TSK-0800)
110
+ // needs to tell "a call to a member of THIS class" from any other call.
111
+ return { name, line, observable: true, reason: null, framework, catalogVersion: ENTRYPOINT_CATALOG.version, total: members.size, memberNames: [...members.keys()], unreachable, constructorFields };
112
+ }
113
+
114
+ /** `this.x = …` statements inside the constructor body (first line wins). */
115
+ function collectCtorAssignments(ctor, out) {
116
+ walk(ctor.body, (n) => {
117
+ if (n.type !== "AssignmentExpression" || n.left?.type !== "MemberExpression") return;
118
+ const l = n.left;
119
+ if (l.object?.type !== "ThisExpression" || l.computed || l.property.type !== "Identifier") return;
120
+ if (!out.has(l.property.name)) out.set(l.property.name, n.loc.start.line);
121
+ });
122
+ }
123
+
124
+ /**
125
+ * @param {string} source
126
+ * @param {{file?: string}} [where]
127
+ * @returns {{file: string, observable: boolean, reason: string|null, classes: Array<object>}}
128
+ */
129
+ export function analyzeMemberReachability(source, { file = "<source>" } = {}) {
130
+ let tree;
131
+ try {
132
+ tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
133
+ } catch (err) {
134
+ return { file, observable: false, reason: `could not be parsed (${err.message}) — not read as clean`, classes: [] };
135
+ }
136
+ // String dispatch is the most dangerous false positive: ONE computed access
137
+ // on `this` that is not a literal makes the whole file not observable — a
138
+ // list with garbage is worth less than an honest "I do not know".
139
+ let dynamic = null;
140
+ const staticUses = new Map(); // ClassName → Set of properties used as ClassName.X
141
+ const thisPropCount = new Map(); // property → how many `this.X` appearances in the file
142
+ walk(tree, (n) => {
143
+ if (n.type !== "MemberExpression") return;
144
+ if (n.object?.type === "Identifier" && !n.computed && n.property.type === "Identifier") {
145
+ (staticUses.get(n.object.name) ?? staticUses.set(n.object.name, new Set()).get(n.object.name)).add(n.property.name);
146
+ }
147
+ if (n.object?.type !== "ThisExpression") return;
148
+ if (n.computed && n.property.type !== "StringLiteral" && n.property.type !== "NumericLiteral") { dynamic ??= n.loc.start.line; return; }
149
+ if (!n.computed && n.property.type === "Identifier") thisPropCount.set(n.property.name, (thisPropCount.get(n.property.name) ?? 0) + 1);
150
+ });
151
+ if (dynamic !== null) return { file, observable: false, reason: `computed access on this at line ${dynamic} — string dispatch cannot be followed`, classes: [] };
152
+
153
+ const classes = [];
154
+ walk(tree, (node) => {
155
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") classes.push(analyzeClass(node, { staticUses, thisPropCount }));
156
+ });
157
+ return { file, observable: true, reason: null, classes };
158
+ }
@@ -104,6 +104,7 @@ export function parseOsvOutput(raw) {
104
104
  aliases: vuln.aliases || [],
105
105
  severity: extractSeverity(vuln, groups),
106
106
  summary: vuln.summary || vuln.details?.split("\n")[0] || "",
107
+ publishedAt: vuln.published || null, // the ADVISORY's date — the Steward ages by it (KJC-TSK-0789 AC6)
107
108
  package: name,
108
109
  version,
109
110
  ecosystem,
@@ -41,8 +41,13 @@ const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?
41
41
  * @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
42
42
  */
43
43
  export function crossCheck({ text, outputs = [], userSaid = "" }) {
44
- const haystack = [...outputs.map(norm), norm(userSaid)];
45
- const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack) }));
44
+ // Backing can come from an output OR from the user (repeating their datum is not
45
+ // inventing). The DENIED analysis reads only the OUTPUTS: the user asking "how
46
+ // many cards are left?" mentions the noun without saying anything about emptiness,
47
+ // and must not veto a denial — found by the stop-gate wiring test.
48
+ const sources = outputs.map(norm);
49
+ const haystack = [...sources, norm(userSaid)];
50
+ const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack, sources) }));
46
51
  return {
47
52
  claims,
48
53
  denied: claims.filter((c) => c.status === DENIED),
@@ -50,15 +55,15 @@ export function crossCheck({ text, outputs = [], userSaid = "" }) {
50
55
  };
51
56
  }
52
57
 
53
- function verdictFor(claim, haystack) {
58
+ function verdictFor(claim, haystack, sources) {
54
59
  if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
55
60
 
56
- // A count stated as non-zero while every source that mentions the same noun says empty:
61
+ // A count stated as non-zero while every OUTPUT that mentions the same noun says empty:
57
62
  // that is the "four cards are waiting" case, and it is the one that blocks.
58
63
  if (claim.kind === "count" && Number(claim.value) > 0) {
59
64
  const noun = nounAfterCount(claim.sentence, claim.value);
60
65
  if (noun) {
61
- const mentions = haystack.filter((h) => h.includes(norm(noun)));
66
+ const mentions = sources.filter((h) => h.includes(norm(noun)));
62
67
  if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
63
68
  }
64
69
  // Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
30
30
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
31
31
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
32
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims"] },
33
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
34
34
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
35
35
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
36
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -6,7 +6,8 @@ import { onboardCommand } from "../commands/onboard.js";
6
6
  import { startCommand } from "../commands/start.js";
7
7
  import { identityCommand } from "../commands/identity.js";
8
8
  import { policyCommand } from "../commands/policy.js";
9
- import { claimsCommand } from "../commands/claims.js";
9
+ import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
10
+ import { stewardSweepCommand } from "../commands/steward.js";
10
11
  import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
11
12
  import { qmdQueryCommand } from "../commands/qmd.js";
12
13
  import { ragMcpCommand } from "../commands/rag-mcp.js";
@@ -314,12 +315,32 @@ export function registerMeta(program, { pkgVersion }) {
314
315
 
315
316
  // KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
316
317
  // CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
317
- program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)")
318
- .command("check")
318
+ const stewardCmd = program.command("steward").description("El Steward: gobierna el ESTADO del proyecto — invariantes con caducidad y cuatro veredictos (épica claims/steward)");
319
+ stewardCmd.command("sweep")
320
+ .description("Barrido read-only de los invariantes: deja el informe versionado en .karajan/steward/ (md + json) y sale con 1 solo si algo está ROTO — unknown y not-observable informan con su remedio")
321
+ .option("--if-stale <days>", "Solo barre si el informe tiene más de N días — retomar trabajo con informe fresco no re-barre")
322
+ .option("--json", "Machine-readable")
323
+ .action(async (flags) => {
324
+ await withConfig(pkgVersion, "steward-sweep", flags, async ({ config }) => {
325
+ process.exitCode = await stewardSweepCommand({ flags, config });
326
+ });
327
+ });
328
+ const claimsCmd = program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)");
329
+ claimsCmd.command("check")
319
330
  .description("Cruza el mensaje final del turno con sus salidas — exit 2 solo si un dato está DESMENTIDO por su propia fuente; falla abierto si no puede leer el transcript")
320
331
  .requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
332
+ .option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
321
333
  .option("--json", "Machine-readable")
322
334
  .action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
335
+ claimsCmd.command("gate")
336
+ .description("El mismo cruce, gobernado por method_gates.claims del proyecto (off|warn|block) — lo invocan los hooks; off = silencio, block = exit 2 solo con un dato desmentido")
337
+ .requiredOption("--transcript <path>", "Ruta del transcript de la sesión")
338
+ .option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
339
+ .action(async (flags) => {
340
+ await withConfig(pkgVersion, "claims-gate", flags, async ({ config }) => {
341
+ process.exitCode = await claimsGateCommand({ flags, config });
342
+ });
343
+ });
323
344
 
324
345
  const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
325
346
  policyCmd.command("eval")
@@ -9,10 +9,41 @@
9
9
  * It fails OPEN. A verifier that cannot read the transcript says so and gets out
10
10
  * of the way: a broken check must never hold a session hostage.
11
11
  */
12
+ import { readFileSync } from "node:fs";
12
13
  import { readTurn } from "../claims/turn.js";
13
14
  import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
14
15
 
15
- export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn } = {}) {
16
+ /**
17
+ * `kj claims gate` — the same check, run by the Stop hook with the PROJECT's
18
+ * say-so. The hook stays policy-free: kj reads `method_gates.claims` and
19
+ * decides. "off" (default: adoption is explicit) exits 0 in silence; "warn"
20
+ * reports and never blocks; "block" refuses only a datum DENIED by its own
21
+ * source — unbacked data is reported either way, per the accepted ADR:
22
+ * inform always, block almost never.
23
+ */
24
+ export async function claimsGateCommand({ flags = {}, config = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
25
+ const mode = config?.method_gates?.claims ?? "off";
26
+ if (mode !== "warn" && mode !== "block") return 0;
27
+ let turn;
28
+ try {
29
+ turn = readTurnFn(flags.transcript);
30
+ // CLM-C: with --file the ARTIFACT is what gets checked — a PR body, a card, a
31
+ // note — against the same turn's outputs. The final message is what outlives
32
+ // the turn least; the artifact is what outlives it most.
33
+ if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
34
+ } catch {
35
+ return 0; // not observable: a gate that cannot read its inputs gets out of the way
36
+ }
37
+ const result = crossCheck(turn);
38
+ if (result.denied.length && mode === "block") {
39
+ logger.error(formatClaimReport(result));
40
+ return 2;
41
+ }
42
+ if (result.denied.length || result.unbacked.length) logger.error(formatClaimReport(result));
43
+ return 0;
44
+ }
45
+
46
+ export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
16
47
  const path = flags.transcript;
17
48
  if (!path) {
18
49
  logger.error("kj claims check: --transcript <path> is required");
@@ -21,6 +52,7 @@ export async function claimsCommand({ flags = {}, logger = console, readTurnFn =
21
52
  let turn;
22
53
  try {
23
54
  turn = readTurnFn(path);
55
+ if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
24
56
  } catch (err) {
25
57
  // Not observable: the transcript could not be read. Never reported as clean.
26
58
  const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
@@ -43,7 +43,9 @@ export function findTitleMatches(title, allHus) {
43
43
  return { identical, similar };
44
44
  }
45
45
 
46
- async function backlogPlan(projectDir) {
46
+ // Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
47
+ // invariants land in the same backlog the brain already consumes.
48
+ export async function backlogPlan(projectDir) {
47
49
  const plans = await listPlans(projectDir);
48
50
  const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
49
51
  if (existing) return loadPlan(projectDir, existing.planId);
@@ -28,9 +28,12 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
28
28
  const warns = findings.filter((f) => f.severity === "warn");
29
29
  const ok = blocks.length === 0;
30
30
  process.exitCode = ok ? 0 : 1;
31
+ // KJC-TSK-0797 AC4: "nothing found" and "found but explained by context"
32
+ // are different truths — the report says which one it is.
33
+ const discarded = findings.discardedByContext || 0;
31
34
  // --json keeps stdout machine-clean: exactly one JSON document, no prose.
32
35
  if (flags.json) {
33
- process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, findings })}\n`);
36
+ process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, discardedByContext: discarded, findings })}\n`);
34
37
  return { ok, findings };
35
38
  }
36
39
  for (const f of blocks) logger.error?.(`✗ BLOCK [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
@@ -38,8 +41,9 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
38
41
  if (!list.present) {
39
42
  logger.info?.(`hint: no personal denylist found — create ${privacyConfigPath()} (personal: [...], allow: [...]) so YOUR data blocks, not just warns`);
40
43
  }
44
+ const context = discarded > 0 ? `; ${discarded} candidate(s) discarded by context — git SHAs / documentation domains` : "";
41
45
  logger.info?.(ok
42
- ? `privacy scan: clean of denylist hits (${warns.length} generic warning(s))`
46
+ ? `privacy scan: clean of denylist hits (${warns.length} generic warning(s)${context})`
43
47
  : `privacy scan: ${blocks.length} personal-data hit(s) — this must not ship`);
44
48
  return { ok, findings };
45
49
  }
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { resumeFlow } from "../orchestrator.js";
3
+ import { sweepOnResume } from "./steward.js";
3
4
  import { createActivityLog } from "../activity-log.js";
4
5
  import { printEvent } from "../utils/display/event-handlers.js";
5
6
  import { withCliRunLog } from "../utils/cli-run-log.js";
@@ -9,6 +10,9 @@ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
9
10
  export async function resumeCommand({ sessionId, answer, config, logger, flags }) {
10
11
  const jsonMode = flags?.json;
11
12
  const quietMode = config.output?.quiet !== false;
13
+ // STW-E (KJC-TSK-0793): resuming work is when someone is in front to review
14
+ // the state — sweep if the report went stale; adoption stays explicit.
15
+ await sweepOnResume({ config, logger });
12
16
 
13
17
  // Same wrapper as every other CLI command — without it `.kj/run.log`
14
18
  // is never opened during the resume and `kj-tail` stays silent