karajan-code 4.21.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.
Files changed (54) hide show
  1. package/README.md +6 -3
  2. package/package.json +5 -2
  3. package/packages/hu-board/public/app.js +1 -0
  4. package/packages/hu-board/public/index.html +2 -0
  5. package/packages/hu-board/public/styles.css +22 -0
  6. package/packages/hu-board/public/utils/governance-view.js +149 -0
  7. package/packages/hu-board/src/auth.js +29 -1
  8. package/packages/hu-board/src/routes/governance.js +140 -0
  9. package/packages/hu-board/src/server.js +2 -0
  10. package/scripts/install.js +4 -3
  11. package/scripts/postinstall.js +4 -3
  12. package/scripts/toml-value.js +18 -0
  13. package/src/audit/basal-cost.js +4 -0
  14. package/src/audit/dead-exports.js +52 -1
  15. package/src/audit/deterministic-summary.js +18 -3
  16. package/src/audit/member-reachability.js +158 -0
  17. package/src/audit/osv-findings.js +1 -0
  18. package/src/checks/ai-trash.js +1 -1
  19. package/src/checks/mcp-health.js +1 -1
  20. package/src/checks/native-build.js +2 -2
  21. package/src/checks/release-check.js +62 -2
  22. package/src/claims/cross-check.js +86 -0
  23. package/src/claims/extract.js +56 -0
  24. package/src/claims/turn.js +54 -0
  25. package/src/cli/advanced-commands.js +1 -1
  26. package/src/cli/register-meta.js +66 -5
  27. package/src/commands/claims.js +73 -0
  28. package/src/commands/hu.js +3 -1
  29. package/src/commands/init.js +1 -1
  30. package/src/commands/policy.js +84 -3
  31. package/src/commands/privacy.js +6 -2
  32. package/src/commands/resume.js +4 -0
  33. package/src/commands/review-gate.js +34 -9
  34. package/src/commands/steward.js +146 -0
  35. package/src/config/defaults.js +6 -1
  36. package/src/environment/playbook.js +2 -1
  37. package/src/guards/duplicate-members.js +86 -0
  38. package/src/harden/sentinel-hooks.js +188 -22
  39. package/src/harden/workflow-engine.js +8 -2
  40. package/src/harden/workflow-templates.js +38 -1
  41. package/src/policy/exceptions.js +14 -4
  42. package/src/policy/report.js +99 -0
  43. package/src/privacy/scan.js +23 -1
  44. package/src/review/card-first.js +4 -1
  45. package/src/review/one-shot-review.js +10 -4
  46. package/src/review/parser.js +9 -0
  47. package/src/review/sonar-pregate.js +38 -3
  48. package/src/review/tests-with-code.js +12 -1
  49. package/src/review/unparseable-verdict.js +50 -0
  50. package/src/roles/audit-role.js +19 -1
  51. package/src/sonar/scanner.js +34 -8
  52. package/src/steward/invariants.js +190 -0
  53. package/src/steward/phantom-coverage.js +137 -0
  54. package/src/steward/proposed-work.js +68 -0
@@ -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,
@@ -23,7 +23,7 @@ function createAiTrashCheck() {
23
23
  ok: false,
24
24
  severity: "warn",
25
25
  detail: "kj-trash not found — destructive ops unprotected",
26
- fix: "Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
26
+ fix: "Install karajan-code globally (npm i -g @karajan-family/code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
27
27
  };
28
28
  },
29
29
  };
@@ -109,7 +109,7 @@ export function createKarajanMcpCheck() {
109
109
  detail: result.ok
110
110
  ? result.detail
111
111
  : `karajan-mcp did not respond: ${result.detail} — OPTIONAL in v4: your agent runs kj directly; only shell-less hosts need MCP`,
112
- fix: result.ok ? undefined : "Only if you need MCP: install via npm (`npm i -g karajan-code`) — the standalone binary does not bundle it",
112
+ fix: result.ok ? undefined : "Only if you need MCP: install via npm (`npm i -g @karajan-family/code`) — the standalone binary does not bundle it",
113
113
  extra: { binPath },
114
114
  };
115
115
  },
@@ -45,7 +45,7 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
45
45
  ok: false,
46
46
  severity: "warn",
47
47
  detail: "better-sqlite3 native build was skipped — pnpm blocks dependency build scripts by default",
48
- fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g karajan-code`",
48
+ fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g @karajan-family/code`",
49
49
  };
50
50
  }
51
51
  return {
@@ -55,7 +55,7 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
55
55
  // modules by design — name the consequence (RAG/board/MCP need the
56
56
  // npm install) instead of implying the install is broken.
57
57
  detail: `better-sqlite3 failed to load: ${firstLine} — DB-backed features (RAG, board, MCP) unavailable; the standalone binary does not bundle native modules`,
58
- fix: "Reinstall to rebuild native modules: `npm i -g karajan-code`",
58
+ fix: "Reinstall to rebuild native modules: `npm i -g @karajan-family/code`",
59
59
  };
60
60
  }
61
61
  },
@@ -9,6 +9,37 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { isAbsolute, join } from "node:path";
10
10
  import { runCommand } from "../utils/process.js";
11
11
  import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
12
+ import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
13
+
14
+ // KJC-TSK-0769 — the effect boundary: what ships is re-evaluated against the
15
+ // policy IN FORCE now, not the one each PR was merged under. Artifact rules
16
+ // only: diff-threshold invariants are PR-scoped by definition — skipped, and
17
+ // said — so no line metric is needed (without a tag, the whole tree counts).
18
+ async function policyRangeCheck(projectDir) {
19
+ const { policy, errors } = loadPolicy({ projectDir });
20
+ if (errors.length > 0) return { name: "policy", ok: false, detail: `policy.yml invalid — ${errors[0]}` };
21
+ const described = await runCommand("git", ["-C", projectDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"]);
22
+ const tag = described.exitCode === 0 ? described.stdout.trim() : null;
23
+ const scope = tag ? `${tag}..HEAD` : "whole history (no previous tag)";
24
+ const prScoped = new Set((policy.invariants ?? []).filter((i) => i.kind === "diff-threshold").map((i) => i.id));
25
+ try {
26
+ const listed = await runCommand("git", tag
27
+ ? ["-C", projectDir, "diff", `${tag}..HEAD`, "--name-only"]
28
+ : ["-C", projectDir, "ls-tree", "-r", "--name-only", "HEAD"]);
29
+ if (listed.exitCode !== 0) throw new Error((listed.stderr || "git failed").trim());
30
+ const files = listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
31
+ const violations = checkStagedDiff(policy, { role: "coder", files, netLinesAdded: null }).filter((v) => !prScoped.has(v.rule_id));
32
+ const hard = violations.filter((v) => v.enforcement === "deny");
33
+ const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
34
+ if (hard.length > 0) {
35
+ const list = hard.map((v) => `[${v.rule_id}]${v.file ? ` ${v.file}` : ""}`).join(", ");
36
+ return { name: "policy", ok: false, detail: `${hard.length} deny violation(s) in ${scope} against the current policy: ${list}` };
37
+ }
38
+ return { name: "policy", ok: true, detail: `${scope} clean against the current policy (${violations.length} warning(s))${skipped}` };
39
+ } catch (err) {
40
+ return { name: "policy", ok: false, detail: `could not evaluate ${scope}: ${err.message}` };
41
+ }
42
+ }
12
43
 
13
44
  const semverCmp = (a, b) => {
14
45
  const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
@@ -93,10 +124,39 @@ async function declaredItems(projectDir, config, version) {
93
124
  return checks;
94
125
  }
95
126
 
127
+ /**
128
+ * MIG-B (KJC-TSK-0752, ADR 0004): while a package dual-publishes under two npm
129
+ * names, their `latest` dist-tags must move in LOCKSTEP. A torn dual-publish —
130
+ * one name released, the other not — is invisible from the repo (both installs
131
+ * "work") and every surface that teaches one name silently diverges from the
132
+ * other. The pair is read from scripts/dual-publish.mjs, which is the one
133
+ * place that knows it; no dual script, no check.
134
+ */
135
+ export async function dualPublishCheck(projectDir, pkg, run = runCommand) {
136
+ const script = join(projectDir, "scripts", "dual-publish.mjs");
137
+ if (!pkg?.name || !existsSync(script)) return null;
138
+ const src = readFileSync(script, "utf8");
139
+ const legacy = (src.match(/LEGACY_NAME = "([^"]+)"/) || [])[1];
140
+ const scoped = (src.match(/SCOPED_NAME = "([^"]+)"/) || [])[1];
141
+ if (!legacy || !scoped || pkg.name !== legacy) return null;
142
+ const latest = async (name) => {
143
+ const out = await run("npm", ["view", name, "dist-tags.latest"], { cwd: projectDir });
144
+ if (out.exitCode !== 0) throw new Error(`npm view ${name} failed`);
145
+ return (out.stdout || "").trim();
146
+ };
147
+ try {
148
+ const [a, b] = await Promise.all([latest(legacy), latest(scoped)]);
149
+ const ok = Boolean(a) && a === b;
150
+ return { name: "dual-publish", ok, detail: ok ? `${legacy} and ${scoped} both at ${a}` : `dist-tags diverge: ${legacy}@${a || "?"} vs ${scoped}@${b || "?"} — a torn dual-publish; publish the missing name before releasing on top` };
151
+ } catch (err) {
152
+ return { name: "dual-publish", ok: false, detail: `could not read npm dist-tags (${err.message}) — the lockstep cannot be verified, and unverified is not ok` };
153
+ }
154
+ }
155
+
96
156
  export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
97
157
  const { checks, version, pkg } = await genericChecks(projectDir);
98
158
  const pack = await packPrivacyCheck(projectDir, pkg);
99
- if (pack) checks.push(pack);
100
- checks.push(...await declaredItems(projectDir, config, version));
159
+ const dual = await dualPublishCheck(projectDir, pkg);
160
+ checks.push(...(pack ? [pack] : []), ...(dual ? [dual] : []), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
101
161
  return { ok: checks.every((c) => c.ok), version, checks };
102
162
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Crossing what was said against what actually ran (CLM-A, KJC-TSK-0801).
3
+ *
4
+ * The transcript is the register of sources: every command, query and read left
5
+ * its output there, so nothing has to be annotated by hand. A datum that appears
6
+ * in some output is BACKED; one that appears nowhere came out of the model's
7
+ * memory (UNBACKED); one whose own source says otherwise is DENIED — and that
8
+ * is the only verdict that blocks, because it is a proven hallucination and not
9
+ * a suspicion.
10
+ *
11
+ * What cannot be decided is NOT_CHECKABLE, never an accusation: a guard that
12
+ * cries wolf gets switched off (KJC-PCS-0082).
13
+ */
14
+ import { extractClaims } from "./extract.js";
15
+
16
+ export const BACKED = "backed";
17
+ export const UNBACKED = "unbacked";
18
+ export const DENIED = "denied";
19
+ export const NOT_CHECKABLE = "not_checkable";
20
+
21
+ const norm = (s) => String(s ?? "").toLowerCase();
22
+ const escape = (s) => s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+
24
+ /**
25
+ * Numbers must match as WHOLE tokens: searching "4" as a substring finds it inside "24.6 kB"
26
+ * and backs a figure nobody measured. Found while running this over a real message — the
27
+ * synthetic tests had passed. Ids, paths and versions are distinctive enough as substrings.
28
+ */
29
+ function appearsIn(output, claim) {
30
+ const needle = norm(claim.value);
31
+ if (claim.kind === "path" || claim.kind === "card" || claim.kind === "version") return output.includes(needle);
32
+ return new RegExp(`(?<![\\w.])${escape(needle)}(?![\\w.])`).test(output);
33
+ }
34
+
35
+ /** "no cards", "[]", "0 results" — a source that positively states emptiness. */
36
+ const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?|items?|matches|cards?|files?)\b)/i;
37
+
38
+ /**
39
+ * @param {{text: string, outputs: string[], userSaid?: string}} input
40
+ * outputs: tool outputs of the turn, in order. userSaid: what the user wrote (also a source).
41
+ * @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
42
+ */
43
+ export function crossCheck({ text, outputs = [], userSaid = "" }) {
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) }));
51
+ return {
52
+ claims,
53
+ denied: claims.filter((c) => c.status === DENIED),
54
+ unbacked: claims.filter((c) => c.status === UNBACKED),
55
+ };
56
+ }
57
+
58
+ function verdictFor(claim, haystack, sources) {
59
+ if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
60
+
61
+ // A count stated as non-zero while every OUTPUT that mentions the same noun says empty:
62
+ // that is the "four cards are waiting" case, and it is the one that blocks.
63
+ if (claim.kind === "count" && Number(claim.value) > 0) {
64
+ const noun = nounAfterCount(claim.sentence, claim.value);
65
+ if (noun) {
66
+ const mentions = sources.filter((h) => h.includes(norm(noun)));
67
+ if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
68
+ }
69
+ // Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
70
+ if (Number(claim.value) <= 3) return NOT_CHECKABLE;
71
+ }
72
+ return UNBACKED;
73
+ }
74
+
75
+ function nounAfterCount(sentence, value) {
76
+ const m = new RegExp(`${value}\\s+([a-zá-ú]{4,})`, "i").exec(sentence);
77
+ return m ? m[1] : null;
78
+ }
79
+
80
+ /** One human line per problem; the message is what makes a guard usable. */
81
+ export function formatClaimReport({ denied, unbacked }) {
82
+ const lines = [];
83
+ for (const c of denied) lines.push(`✗ "${c.value}" (${c.kind}) is DENIED by the output that should back it — ${c.sentence.slice(0, 100)}`);
84
+ for (const c of unbacked) lines.push(`? "${c.value}" (${c.kind}) has no backing in this turn — verify it or say it is from memory`);
85
+ return lines.join("\n");
86
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Hard data in what the AI says (CLM-A, KJC-TSK-0801, ADR "claims with evidence").
3
+ *
4
+ * A model states an invented figure with the same confidence as a measured one,
5
+ * and that figure travels to a PR, a card, another session or the user, where
6
+ * nobody checks it again. This module pulls the CHECKABLE data out of a text —
7
+ * counts, versions, file paths, card ids, commit SHAs — so it can be crossed
8
+ * against what actually ran. Prose is left alone: only data is verifiable.
9
+ *
10
+ * Deterministic and free: no model in the loop. Verifying must be cheaper than
11
+ * inventing, or nobody will verify.
12
+ */
13
+
14
+ /** Sentences that ALREADY admit they are unverified are respected, never reported. */
15
+ const HEDGES = /\b(de memoria|sin (comprobar|verificar)|no (lo )?he (comprobado|verificado)|creo que|puede que|quizá|quizás|probablemente|from memory|unverified|not checked|i think|probably)\b/i;
16
+
17
+ // Most specific first: a number already claimed as a PR or a version is not also a bare count.
18
+ // A number with no unit next to it (an OTP, a phone) is deliberately NOT a claim: it is not
19
+ // verifiable from prose, and some of them are secrets that must never travel into a report.
20
+ const PATTERNS = [
21
+ { kind: "card", re: /\b([A-Z]{3}-(?:TSK|BUG|PCS|SPR|PLA|PRP)-\d{4})\b/g, value: (m) => m[1] },
22
+ { kind: "path", re: /\b((?:[\w.-]+\/){1,}[\w.-]+\.\w{1,5})\b/g, value: (m) => m[1] },
23
+ { kind: "version", re: /\bv?(\d+\.\d+\.\d+(?:-[\w.]+)?)\b/g, value: (m) => m[1] },
24
+ { kind: "pr", re: /(?:^|[\s(])#(\d{2,6})\b/g, value: (m) => m[1] },
25
+ { kind: "sha", re: /\b([0-9a-f]{7,40})\b/g, value: (m) => m[1] },
26
+ // A number that means something: "1004 ficheros", "8 ocurrencias", "53 tests".
27
+ { kind: "count", re: /\b(\d[\d.,]*)\s+(?=[a-záéíóúñ]{3,})/gi, value: (m) => m[1].replaceAll(".", "").replaceAll(",", "") },
28
+ ];
29
+
30
+ /** Splits into sentences so a hedge only covers what it is attached to. */
31
+ const sentences = (text) => String(text || "").split(/(?<=[.!?\n])\s+/).filter(Boolean);
32
+
33
+ /**
34
+ * @param {string} text
35
+ * @returns {Array<{kind: string, value: string, sentence: string}>} unique claims, in order.
36
+ */
37
+ export function extractClaims(text) {
38
+ const out = [];
39
+ const seen = new Set();
40
+ for (const sentence of sentences(text)) {
41
+ if (HEDGES.test(sentence)) continue; // saying "I did not check" is the behaviour to encourage
42
+ const claimedHere = new Set();
43
+ for (const { kind, re, value } of PATTERNS) {
44
+ for (const m of sentence.matchAll(re)) {
45
+ const v = value(m);
46
+ if (claimedHere.has(v)) continue; // already claimed as something more specific
47
+ claimedHere.add(v);
48
+ const key = `${kind}:${v}`;
49
+ if (seen.has(key)) continue;
50
+ seen.add(key);
51
+ out.push({ kind, value: v, sentence: sentence.trim() });
52
+ }
53
+ }
54
+ }
55
+ return out;
56
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading one turn out of the transcript (CLM-B, KJC-TSK-0802).
3
+ *
4
+ * The transcript is the register of sources: what the AI finally said, what the
5
+ * user asked, and every tool output in between. Nothing is annotated by hand —
6
+ * this just reads what the session already wrote.
7
+ *
8
+ * A turn = from the user's last real message to the end. A "user" entry whose
9
+ * content is a tool_result is NOT the user talking: it is the machine answering.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+
13
+ const MAX_OUTPUT = 20_000; // a single huge output must not eat the whole check
14
+ const blocks = (entry) => {
15
+ const c = entry?.message?.content;
16
+ return Array.isArray(c) ? c : typeof c === "string" ? [{ type: "text", text: c }] : [];
17
+ };
18
+ const isToolResult = (entry) => blocks(entry).some((b) => b.type === "tool_result");
19
+ const textOf = (value) =>
20
+ typeof value === "string" ? value : Array.isArray(value) ? value.map((b) => b?.text ?? "").join("\n") : String(value ?? "");
21
+
22
+ /** Parses the JSONL, ignoring lines that are not valid JSON (a partial write must not throw). */
23
+ export function readEntries(path) {
24
+ const out = [];
25
+ for (const line of readFileSync(path, "utf8").split("\n")) {
26
+ if (!line.trim()) continue;
27
+ try { out.push(JSON.parse(line)); } catch { /* a half-written line is not a reason to fail */ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * @param {string} path transcript_path given by the hook
34
+ * @returns {{text: string, outputs: string[], userSaid: string}}
35
+ * text: what the AI says at the end of the turn (its final prose, no thinking).
36
+ */
37
+ export function readTurn(path) {
38
+ const entries = readEntries(path);
39
+ const startedAt = entries.findLastIndex((e) => e.type === "user" && !isToolResult(e));
40
+ const turn = startedAt >= 0 ? entries.slice(startedAt) : entries;
41
+
42
+ const userSaid = startedAt >= 0 ? textOf(entries[startedAt]?.message?.content) : "";
43
+ const outputs = [];
44
+ for (const entry of turn) {
45
+ for (const b of blocks(entry)) {
46
+ if (b.type === "tool_result") outputs.push(textOf(b.content).slice(0, MAX_OUTPUT));
47
+ }
48
+ }
49
+ // The final message is the last assistant entry that actually says something
50
+ // (one with tool_use only is the AI working, not the AI reporting).
51
+ const finals = turn.filter((e) => e.type === "assistant" && blocks(e).some((b) => b.type === "text" && b.text?.trim()));
52
+ const text = finals.length ? blocks(finals.at(-1)).filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
53
+ return { text, outputs, userSaid };
54
+ }
@@ -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"] },
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"] },