@karajan-family/code 4.70.1 → 4.70.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karajan-family/code",
3
- "version": "4.70.1",
3
+ "version": "4.70.3",
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",
@@ -9,8 +9,43 @@
9
9
  * WARN in `kj doctor` with the exact remedy instead of a cryptic runtime crash.
10
10
  */
11
11
 
12
+ import { execFileSync } from "node:child_process";
13
+ import { resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
12
16
  import { STRATEGY } from "./types.js";
13
17
 
18
+ // kj's own package root: where npm keeps its dependencies' approvals and builds.
19
+ const OWN_PKG_DIR = resolve(fileURLToPath(new URL("../..", import.meta.url)));
20
+
21
+ /** npm's major version, or null when npm is not there to ask. */
22
+ function defaultNpmMajor() {
23
+ try {
24
+ return Number.parseInt(execFileSync("npm", ["--version"], { encoding: "utf8", timeout: 10_000 }), 10) || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * KJC-BUG-0426 (issue #2670): npm 12 runs a dependency's install script only when
32
+ * allowScripts covers it; reinstalling blocks it again. Approve, then rebuild, in kj's dir.
33
+ */
34
+ export const npmAllowScriptsRemedy = (pkgDir = OWN_PKG_DIR) =>
35
+ `cd "${pkgDir}" && npm install-scripts approve better-sqlite3 && npm install-scripts approve node-pty && npm rebuild better-sqlite3 node-pty`;
36
+
37
+ // The addon is installed but its native build is missing (a missing package needs a reinstall, not this).
38
+ const UNBUILT_ADDON = /bindings file|better_sqlite3\.node/;
39
+
40
+ /** A missing better-sqlite3 addon becomes an error that says how to build it; any other error passes as it is. */
41
+ export function explainNativeLoadError(err, { npmMajor = defaultNpmMajor, pkgDir = OWN_PKG_DIR } = {}) {
42
+ if (!UNBUILT_ADDON.test(String(err?.message ?? err))) return err;
43
+ const remedy = (npmMajor() ?? 0) >= 12
44
+ ? `npm 12 skipped its install script (allowScripts); build it with: ${npmAllowScriptsRemedy(pkgDir)}`
45
+ : "run kj doctor for the exact fix on this install";
46
+ return new Error(`better-sqlite3 native addon is not built, so the RAG cannot open its index — ${remedy}`, { cause: err });
47
+ }
48
+
14
49
  /** pnpm lays packages out under a `.pnpm` virtual store and symlinks them in. */
15
50
  function isPnpmInstall() {
16
51
  return (
@@ -25,7 +60,7 @@ async function defaultLoadDb() {
25
60
  new Database(":memory:").close();
26
61
  }
27
62
 
28
- export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpmInstall } = {}) {
63
+ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpmInstall, npmMajor = defaultNpmMajor, pkgDir = OWN_PKG_DIR } = {}) {
29
64
  return {
30
65
  name: "native-build",
31
66
  label: "native modules (better-sqlite3)",
@@ -48,6 +83,14 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
48
83
  fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g @karajan-family/code`",
49
84
  };
50
85
  }
86
+ if ((npmMajor() ?? 0) >= 12) {
87
+ return {
88
+ ok: false,
89
+ severity: "warn",
90
+ detail: `better-sqlite3 failed to load: ${firstLine} — npm 12 skips dependency install scripts that allowScripts does not cover, so the native build never ran`,
91
+ fix: npmAllowScriptsRemedy(pkgDir),
92
+ };
93
+ }
51
94
  return {
52
95
  ok: false,
53
96
  severity: "warn",
@@ -292,6 +292,7 @@ export function registerPipeline(program, { pkgVersion }) {
292
292
  const r = await verifyPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.verifyPr });
293
293
  if (r.ok) logger.info(`✓ PR #${flags.verifyPr}: ${r.commits} commit(s), each with its cross-AI verdict`);
294
294
  else logger.error(`✗ PR #${flags.verifyPr}:\n - ${r.problems.join("\n - ")}`);
295
+ if (r.sealCommits) logger.info(` seal commit(s) signed by the roster, a human act with no AI verdict: ${r.sealCommits.join(", ")}`);
295
296
  process.exitCode = r.ok ? 0 : 1;
296
297
  return;
297
298
  }
@@ -25,7 +25,7 @@ import { installHarnessHooks } from "../harden/harness-hooks.js";
25
25
  import { installSentinelHooks } from "../harden/sentinel-hooks.js";
26
26
  import { installSandbox } from "../harden/sandbox.js";
27
27
  import { detectStackRoots } from "../harden/stack-roots.js";
28
- import { installWorkflows } from "../harden/workflow-engine.js";
28
+ import { installWorkflows, resolveKjPin } from "../harden/workflow-engine.js";
29
29
  import { adviseBranchProtection } from "../harden/branch-protection.js";
30
30
  import { detectTestFramework } from "../utils/project-detect.js";
31
31
  import { ensureIdentity } from "../identity/bootstrap.js";
@@ -97,6 +97,7 @@ export async function hardenCommand({
97
97
  // KJC-BUG-0289: the contract files already dirty before anything was generated
98
98
  // (kj init captures them before writing its own). What is dirty NOW is the person's.
99
99
  contractBefore = null,
100
+ fetchFn = fetch,
100
101
  logger = console,
101
102
  } = {}) {
102
103
  const dirtyBefore = contractBefore ?? contractChanges(projectDir);
@@ -177,8 +178,11 @@ export async function hardenCommand({
177
178
  const withConfig = config && profile !== "minimal";
178
179
  const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
179
180
  const withCi = ci && profile !== "minimal";
181
+ // KJC-BUG-0420: consumer CI installs kj from npm, so the workflows pin a published version.
182
+ const pin = withCi ? await resolveKjPin({ projectDir, fetchFn }) : null;
183
+ if (pin?.note) logger.warn?.(`⚠ ${pin.note}`);
180
184
  const wf = withCi
181
- ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun })
185
+ ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun, kjVersion: pin?.version ?? null })
182
186
  : null;
183
187
  const withGuidelines = guidelines && profile !== "minimal";
184
188
  // KJC-BUG-0199 (issue #1773): the same detected language the workflows and
@@ -219,7 +223,10 @@ export async function hardenCommand({
219
223
  const note = c.note ? ` (${c.note})` : "";
220
224
  logger.info?.(` • ${c.file}: ${c.action}${note}`);
221
225
  }
222
- for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
226
+ for (const w of out.workflows) {
227
+ logger.info?.(` • ${w.file}: ${w.action}`);
228
+ if (w.note) logger.warn?.(`⚠ ${w.note}`);
229
+ }
223
230
  for (const g of out.guidelines) {
224
231
  logger.info?.(` • ${g.file}: ${g.action}`);
225
232
  // KJC-BUG-0206: quitar reglas no puede leerse como "updated" y nada más.
@@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process";
8
8
  import { readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
- import { budgetedAdded } from "../review/loc-budget.js";
11
+ import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
12
12
 
13
13
  const git = (projectDir, args) =>
14
14
  execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -40,12 +40,13 @@ export async function branchSize({ projectDir = process.cwd(), base = "main", ex
40
40
  const n = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
41
41
  numstat += `${n}\t0\t${file}\n`;
42
42
  }
43
- return { ...budgetedAdded(numstat), base };
43
+ // KJC-BUG-0434: `net` is what the CI gate and the policy measure; `added` stays for its readers.
44
+ return { ...budgetedAdded(numstat), net: budgetedNet(numstat).net, base };
44
45
  }
45
46
 
46
47
  export async function prSizeCommand({ config, logger = console, flags = {} }) {
47
48
  const s = await branchSize({ projectDir: config?.projectDir || process.cwd(), base: config?.base_branch || "main" });
48
49
  if (flags.json) process.stdout.write(`${JSON.stringify(s)}\n`);
49
- else logger.info(`pr-size: ${s.added} line(s) added against ${s.base} (${s.testAdded} in tests, ${s.exempt} exempt)`);
50
+ else logger.info(`pr-size: ${s.net} net line(s) against ${s.base}, as the CI gate counts them (${s.added} added, ${s.testAdded} in tests, ${s.exempt} exempt)`);
50
51
  return s;
51
52
  }
@@ -268,26 +268,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
268
268
  // documentation are exempt — the same rule CI applies. Counting everything
269
269
  // here warned 234 lines on a doc-only PR that CI counted at 104, and two
270
270
  // implementations of one rule that disagree teach people to trust neither.
271
- const { added, exempt, testAdded } = budgetedAdded(numstat);
271
+ // KJC-BUG-0434: measured NET, as CI and the policy's net_lines_added do — a rewrite in place is not growth.
272
+ const { added, testAdded } = budgetedAdded(numstat);
273
+ const { net, exempt } = budgetedNet(numstat);
272
274
  // Said out loud: an exemption nobody sees is indistinguishable from a gate
273
275
  // that is not looking.
274
276
  if (exempt > 0) console.log(`ℓ pr-size: ${exempt} line(s) exempt from the budget (generated output and human docs; AI-rule files always count)`);
275
- if (added > sizeIdeal && added <= sizeWarn) {
276
- console.log(`ℓ pr-size: ${added} lines added, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
277
+ if (net > sizeIdeal && net <= sizeWarn) {
278
+ console.log(`ℓ pr-size: ${net} net lines, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
277
279
  }
278
- if (added > sizeWarn) {
280
+ if (net > sizeWarn) {
279
281
  // KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
280
282
  // partitioning is a decision, and it must never split code from its tests.
281
- const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
283
+ const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} test lines added — partition by feature, never code from its tests)` : "";
282
284
  const sizePolicy = config?.method_gates?.pr_size || "warn";
283
285
  // ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
284
286
  if (sizePolicy === "block") {
285
- const reason = `${added} lines added${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
287
+ const reason = `${net} net lines${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
286
288
  console.log(`✗ pr-size gate: ${reason}`);
287
289
  process.exitCode = 1;
288
290
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
289
291
  } else {
290
- console.log(`⚠ pr-size: ${added} lines added${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
292
+ console.log(`⚠ pr-size: ${net} net lines${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
291
293
  }
292
294
  }
293
295
  }
@@ -119,7 +119,9 @@ export async function loadConfig(projectDir) {
119
119
  // a .karajan dir from another repo, edited the wrong file, etc.) and
120
120
  // would silently behave like a global config without the defaults
121
121
  // people expect. Refuse with an actionable message.
122
- if (projectConfig && !globalExists) {
122
+ // KJC-BUG-0428 (issue #2694): a CI runner never has a global config; there the
123
+ // team's committed project config sits on the defaults instead.
124
+ if (projectConfig && !globalExists && !process.env.CI) {
123
125
  throw new Error(
124
126
  `Local config found at ${getProjectConfigPath(projectDir)} but no global config exists.\n` +
125
127
  "Local configs are merged ON TOP of the global one — run `kj init --global` first to create the base."
@@ -348,10 +348,11 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
348
348
  } catch (err) {
349
349
  logger.warn(`pr-size: the branch could not be measured (${err.message}); the budget is not checked for this commit`);
350
350
  }
351
- if (size && size.added > limit) {
352
- logger.warn(`pr-size: ${size.added} line(s) added against ${gitCtx.baseBranch}, over the budget of ${limit}: nothing committed. Split the step (several commits, several HUs); the changes stay in the tree`);
353
- await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, limit });
354
- return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, limit } };
351
+ // KJC-BUG-0434: net, as the CI gate counts it.
352
+ if (size && size.net > limit) {
353
+ logger.warn(`pr-size: ${size.net} net line(s) against ${gitCtx.baseBranch}, over the budget of ${limit}: nothing committed. Split the step (several commits, several HUs); the changes stay in the tree`);
354
+ await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, net: size.net, limit });
355
+ return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, net: size.net, limit } };
355
356
  }
356
357
  // ENV-F1: this path only runs after the pipeline's reviewer approved,
357
358
  // so stamp that verdict for the staged diff — the v4 pre-commit gate
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
 
13
13
  import { upsertManagedBlock } from "../utils/managed-markers.js";
14
14
  import { runCommand } from "../utils/process.js";
15
- import { hookBody, PROFILE_HOOKS, SHEBANG, toPortableHooksDir } from "./hook-templates.js";
15
+ import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
16
16
 
17
17
  const BLOCK_VERSION = 1;
18
18
  const HOOKS_DIR = join(".karajan", "hooks");
@@ -29,12 +29,12 @@ export function isGitRepo(projectDir) {
29
29
  * compara por hash: un fichero con una sola coma manual ya no coincide.
30
30
  */
31
31
  export function renderCanonicalHook(hook, generation = {}) {
32
- const { cmds = {}, baseBranch = null, globalHooksDir = null } = generation;
32
+ const { cmds = {}, baseBranch = null } = generation;
33
33
  const { content } = upsertManagedBlock({
34
34
  source: HOOK_PREAMBLE,
35
35
  blockId: `hook:${hook}`,
36
36
  version: BLOCK_VERSION,
37
- body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
37
+ body: hookBody(hook, cmds, { baseBranch }),
38
38
  style: "hash",
39
39
  });
40
40
  return content;
@@ -57,22 +57,6 @@ export async function installHooks({
57
57
  const hooks = PROFILE_HOOKS[profile];
58
58
  if (!hooks) throw new Error(`Unknown harden profile: ${profile}`);
59
59
 
60
- // KJC-TSK-0645: setting a repo-local hooksPath ECLIPSES the machine's
61
- // global hooks dir — personal guards (AI-attribution commit-msg, protected
62
- // branch pre-push) would silently stop applying here. Detect the previous
63
- // global dir and have every generated hook chain to it. `~` is emitted as
64
- // `$HOME` so the committed hook stays portable across machines (the -x
65
- // guard silences it where the dir doesn't exist).
66
- let globalHooksDir = null;
67
- const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
68
- const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
69
- if (rawGlobal && rawGlobal !== HOOKS_DIR) {
70
- // KJC-BUG-0169: normalize to a machine-independent form HERE (generation,
71
- // on the human's machine) so the provenance stores the portable dir and CI
72
- // recomputes the SAME bytes — an absolute home path is not verifiable there.
73
- globalHooksDir = toPortableHooksDir(rawGlobal);
74
- }
75
-
76
60
  const absHooksDir = join(projectDir, HOOKS_DIR);
77
61
  const results = [];
78
62
  for (const hook of hooks) {
@@ -82,7 +66,7 @@ export async function installHooks({
82
66
  source,
83
67
  blockId: `hook:${hook}`,
84
68
  version: BLOCK_VERSION,
85
- body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
69
+ body: hookBody(hook, cmds, { baseBranch }),
86
70
  style: "hash",
87
71
  });
88
72
  results.push({ hook, target, action });
@@ -99,5 +83,5 @@ export async function installHooks({
99
83
 
100
84
  // KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
101
85
  // provenance necesita registrar para que CI pueda recomputar y comparar.
102
- return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch, globalHooksDir } };
86
+ return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch } };
103
87
  }
@@ -8,8 +8,6 @@
8
8
  * is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
9
9
  */
10
10
 
11
- import { homedir } from "node:os";
12
-
13
11
  // KJC-TSK-1076: exported so the third-party PR checks apply the same rules.
14
12
  export const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
15
13
  export const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
@@ -25,32 +23,21 @@ export const PROFILE_HOOKS = {
25
23
 
26
24
  /** Build the managed body for a single hook. */
27
25
  // KJC-TSK-0645: `core.hooksPath .karajan/hooks` eclipses the user's global
28
- // hooks dir, silently disabling personal guards. When a previous global dir
29
- // is known, every generated hook ends by chaining its namesake there —
30
- // guarded, so machines without it are unaffected.
31
- // KJC-BUG-0161 / 0169 (ADR 0009): a committed hook must not bake a machine's
32
- // absolute home path — a dir under the home resolves through $HOME so the same
33
- // generated content is valid on every machine and its provenance stays
34
- // verifiable IN CI (a different home), with no local filesystem layout leaking
35
- // into a public repo. Normalization happens ONCE, at generation, on the human's
36
- // machine; the stored/rendered value is already portable, so a render on any
37
- // other home (CI) reproduces identical bytes. `home` is injectable for tests.
38
- export function toPortableHooksDir(dir, home = homedir()) {
39
- if (!dir) return dir;
40
- if (dir.startsWith("$HOME")) return dir;
41
- if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
42
- if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
43
- return dir;
44
- }
45
-
46
- function chainToGlobal(hook, globalHooksDir) {
47
- if (!globalHooksDir) return [];
48
- const dir = toPortableHooksDir(globalHooksDir);
26
+ // hooks dir, silently disabling personal guards. Every generated hook ends by
27
+ // chaining its namesake in the machine's global dir.
28
+ // KJC-BUG-0161 / 0169 (ADR 0009), HU-2762: the dir is looked up when the hook
29
+ // RUNS, never when kj harden ran — so the generated bytes do not depend on the
30
+ // machine (HOME, GIT_CONFIG_GLOBAL), the provenance verifies in CI, and no
31
+ // local filesystem layout leaks into a public repo. `|| true`: the preamble
32
+ // sets `set -e`. `--type=path` expands `~`; the pwd -P test stops a hook from
33
+ // calling itself when the global setting is this repo's own hooks dir.
34
+ function chainToGlobal(hook) {
49
35
  // KJC-TSK-1085: pre-push already read the refs from stdin; the chained hook gets them back.
50
- const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "${dir}/${hook}" "$@"; rc=$?` : `"${dir}/${hook}" "$@"; rc=$?`;
36
+ const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "$kj_global/${hook}" "$@"; rc=$?` : `"$kj_global/${hook}" "$@"; rc=$?`;
51
37
  return [
52
- "# Chain the machine's previous global hook (kj harden keeps it active).",
53
- `if [ -x "${dir}/${hook}" ]; then`,
38
+ "# Chain the machine's global hook, looked up when the hook runs, not when kj harden ran (KJC-TSK-0645, HU-2762).",
39
+ "kj_global=$(git config --global --type=path --get core.hooksPath 2>/dev/null || true)",
40
+ `if [ -n "$kj_global" ] && [ -x "$kj_global/${hook}" ] && [ "$(cd "$kj_global" 2>/dev/null && pwd -P)" != "$(cd "$(dirname "$0")" && pwd -P)" ]; then`,
54
41
  ` ${call}`,
55
42
  ' [ "$rc" -eq 0 ] || exit "$rc"',
56
43
  "fi",
@@ -109,8 +96,9 @@ function identityGuard(hook) {
109
96
  ' if [ -n "$kj_declared_email" ]; then',
110
97
  ' printf "%s\\n" "$kj_push_refs" | while read -r kj_lref kj_lsha kj_rref kj_rsha; do',
111
98
  ' case "$kj_lsha" in *[!0]*) ;; *) continue ;; esac',
112
- // A new ref: what the PUSH TARGET ($1, the remote git hands the hook) does not have yet.
113
- ' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_rsha..$kj_lsha") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
99
+ // What the PUSH TARGET ($1, the remote git hands the hook) does not have yet. KJC-BUG-0436:
100
+ // an existing ref too, or merging main into a pushed branch judges main's own merges.
101
+ ' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_lsha" --not "$kj_rsha" --remotes="${1:-origin}") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
114
102
  " for kj_c in $kj_new; do",
115
103
  ' kj_who=$(git log -1 --format="%ae %ce" "$kj_c")',
116
104
  ' if [ "$kj_who" != "$kj_declared_email $kj_declared_email" ]; then',
@@ -129,8 +117,8 @@ function identityGuard(hook) {
129
117
  ];
130
118
  }
131
119
 
132
- export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch = null } = {}) {
133
- const chain = chainToGlobal(hook, globalHooksDir);
120
+ export function hookBody(hook, cmds = {}, { baseBranch = null } = {}) {
121
+ const chain = chainToGlobal(hook);
134
122
  switch (hook) {
135
123
  case "pre-commit": {
136
124
  const lines = [...baseBranchGuard(baseBranch), ...identityGuard("pre-commit"), "# Lint + format the working tree with the project's native tools."];
@@ -36,7 +36,9 @@ async function relayConfig(fetchFn) {
36
36
  }
37
37
  const SIGN_PAGE = "https://karajancode.com/sign";
38
38
  const POLL_INTERVAL_MS = 2000;
39
- const TTL_MS = 60000;
39
+ // KJC-BUG-0432: 60 s was too short to scan, read the files and sign, and the page still said
40
+ // "signed" after kj had given up. 150 s leaves time and stays under the relay's 3 min window.
41
+ const TTL_MS = 150000;
40
42
 
41
43
  /** Spinner en su sitio (una línea, con \r) solo en TTY; no-op fuera de TTY o en tests. */
42
44
  function makeSpinner() {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * published-pin — the karajan-code version a seeded workflow may pin (KJC-BUG-0420).
3
+ *
4
+ * A consumer repo's CI installs kj from npm, so the pin must be a published
5
+ * version. A checkout ahead of npm pins the newest published version that is
6
+ * not newer than itself; when the registry cannot confirm anything, the
7
+ * running version stays and the note says so.
8
+ */
9
+
10
+ import { isNewerVersion } from "../utils/update-check.js";
11
+
12
+ const packumentUrl = (name) => `https://registry.npmjs.org/${encodeURIComponent(name)}`;
13
+
14
+ async function publishedVersions(name, fetchFn) {
15
+ const res = await fetchFn(packumentUrl(name), { headers: { Accept: "application/vnd.npm.install-v1+json" } });
16
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
17
+ return Object.keys((await res.json()).versions ?? {});
18
+ }
19
+
20
+ /** @returns {Promise<{version: string, note?: string}>} */
21
+ export async function publishedPin({ name, version, fetchFn = fetch }) {
22
+ const unconfirmed = (why) => ({ version, note: `could not confirm on npm that ${name}@${version} is published (${why}); the workflow pins it as is` });
23
+ let published;
24
+ try {
25
+ published = await publishedVersions(name, fetchFn);
26
+ } catch (err) {
27
+ return unconfirmed(err.message);
28
+ }
29
+ if (published.includes(version)) return { version };
30
+ const older = published.filter((v) => !v.includes("-") && !isNewerVersion(v, version));
31
+ const newest = older.reduce((best, v) => (best && !isNewerVersion(v, best) ? best : v), null);
32
+ if (!newest) return unconfirmed("no published version at or below it");
33
+ return { version: newest, note: `${name}@${version} is not on npm yet; the workflow pins ${newest}, the newest published one` };
34
+ }
@@ -196,10 +196,12 @@ process.stdin.on("end", () => {
196
196
  const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
197
197
  let size = null;
198
198
  try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
199
- const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
199
+ // KJC-BUG-0435: net lines, as the CI gate counts them; a kj older than 0434 reports only added.
200
+ const lines = size ? (size.net ?? size.added) : 0;
201
+ const crossed = size ? [200, 150].find((t) => lines > t) : undefined;
200
202
  if (crossed && (s.size_warned || 0) < crossed) {
201
203
  s.size_warned = crossed;
202
- sizeNote = "karajan sentinel: la rama ya suma " + size.added + " lineas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
204
+ sizeNote = "karajan sentinel: la rama ya suma " + lines + " lineas netas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
203
205
  }
204
206
  }
205
207
  const ids = Object.keys(state.sessions);
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
15
15
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
16
- import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
16
+ import { isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
17
17
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
18
18
  import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
19
19
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -84,8 +84,10 @@ process.stdin.on("end", () => {
84
84
  // substitution) cannot be resolved from command text: that vector is
85
85
  // caught by the Stop gate's checksum verification, which blocks the turn.
86
86
  // KJC-TSK-1114: where the host sandbox denies writing them, the sandbox guards them instead.
87
+ // KJC-BUG-0425 (ADR 0036): text a command only publishes or stores (a PR description) is not a write.
88
+ // KJC-BUG-0429: nor is a command made only of known readers (an allow-list, not a write blocklist).
87
89
  const SUPERVISOR_PATHS = [".claude/settings.json", ".karajan/hooks", ".karajan/harness"];
88
- if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
90
+ if (tool === "Bash" && !isReadOnlyCommand(String(input.command || "")) && PROTECTED.test(stripTextOnlyCommandValues(String(input.command || "")).replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
89
91
  console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
90
92
  process.exit(2);
91
93
  }
@@ -174,7 +174,22 @@ const isGitCommit = (words) => {
174
174
  return words[0] === "git" && words[i] === "commit";
175
175
  };
176
176
 
177
- const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
177
+ // KJC-BUG-0425: gh pr/issue/release commands only publish their text to GitHub.
178
+ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) || /^gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review)\b/.test(seg) || isGitCommit(seg.split(/\s+/));
179
+
180
+ // KJC-BUG-0425: a heredoc whose delimiter is quoted expands nothing; read from stdin
181
+ // by a text-only command, it is the text stored or published. The FIRST line equal to
182
+ // the delimiter must be the last one, or the shell would run what follows it.
183
+ const HEREDOC_HEAD = /^([^\n]*?)[ \t]*<<(-?)[ \t]*(['"])([A-Za-z_]\w*)\3[ \t]*$/;
184
+ const READS_STDIN = /(^|\s)(?:--body-file|--notes-file|--file|-F)(?:=|\s+)-(?=\s|$)/;
185
+ const quotedHeredocHead = (cmd) => {
186
+ const lines = cmd.replace(/\n$/, "").split("\n");
187
+ const m = lines.length > 1 ? HEREDOC_HEAD.exec(lines[0]) : null;
188
+ if (!m) return null;
189
+ const [, head, dash, , delim] = m;
190
+ const end = lines.findIndex((l, i) => i > 0 && (dash ? l.replace(/^\t+/, "") : l) === delim);
191
+ return end === lines.length - 1 && READS_STDIN.test(head) ? head : null;
192
+ };
178
193
 
179
194
  /**
180
195
  * KJC-BUG-0354: blank the quoted text values (stripTextOptionValues) only when the
@@ -185,9 +200,10 @@ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) ||
185
200
  export const stripTextOnlyCommandValues = (cmd) => {
186
201
  // Only a whole input that is ONE simple text-storage command: a chained
187
202
  // segment could follow a shell function that turns the stored text into code.
188
- const bare = stripInertQuotes(cmd);
189
- if (splitOutsideQuotes(cmd).length !== 1 || /[(){}<>`$\n]/.test(bare) || !isTextOnlyHead(cmd.trim())) return cmd;
190
- return stripTextOptionValues(cmd);
203
+ const line = quotedHeredocHead(cmd) ?? cmd;
204
+ const bare = stripInertQuotes(line);
205
+ if (splitOutsideQuotes(line).length !== 1 || /[(){}<>`$\n]/.test(bare) || !isTextOnlyHead(line.trim())) return cmd;
206
+ return stripTextOptionValues(line);
191
207
  };
192
208
 
193
209
  /** Segments and the ; && || | between them, cut only outside quotes (the pieces join back to cmd). */
@@ -248,5 +264,39 @@ export const withoutRedirections = (words) => {
248
264
  return rest;
249
265
  };
250
266
 
267
+ // KJC-BUG-0429 (ADR 0036): the guards decide by write verb. These readers were picked
268
+ // because no option of theirs writes a file or runs a program; a command made only of
269
+ // them, with output to the session, /dev/null or another descriptor, writes nothing.
270
+ // KJC-BUG-0437: never git, whose reads run what its config names (filters, textconv,
271
+ // diff.external, fsmonitor); the Read tool reads those files instead.
272
+ const READERS = new Set(["cat", "head", "tail", "grep", "egrep", "fgrep", "ls", "wc", "diff", "cmp", "stat", "jq", "sha256sum", "sha1sum", "md5sum"]);
273
+
274
+ const REDIRECTION = /^(\d*)(>>|>&|>\||>|<)(.*)$/;
275
+ const quietRedirections = (words) => words.every((w, i) => {
276
+ const m = REDIRECTION.exec(w);
277
+ if (!m) return true;
278
+ const [, , op, glued] = m;
279
+ const target = glued || words[i + 1] || "";
280
+ if (op === "<") return target !== "";
281
+ if (op === ">&") return /^\d$/.test(target);
282
+ return (op === ">" || op === ">>") && target === "/dev/null";
283
+ });
284
+
285
+ /**
286
+ * Whether the command only reads: every simple command is a known reader and no
287
+ * output goes to a file. Substitution, subshells, heredocs, expansion, newlines
288
+ * or assignments are not provably that.
289
+ * @param {string} cmd
290
+ */
291
+ export const isReadOnlyCommand = (cmd) => {
292
+ const bare = stripInertQuotes(String(cmd));
293
+ if (/[(){}`\n]|<</.test(bare) || LIVE_EXPANSION.test(bare)) return false;
294
+ const segs = shellSegments(cmd);
295
+ return segs.length > 0 && segs.every((words) => {
296
+ const rest = withoutRedirections(words);
297
+ return quietRedirections(words) && READERS.has(rest[0]);
298
+ });
299
+ };
300
+
251
301
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
252
302
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
@@ -14,8 +14,16 @@ import { dirname } from "node:path";
14
14
 
15
15
  import { parse as parseYaml } from "yaml";
16
16
  import { upsertManagedBlock } from "../utils/managed-markers.js";
17
+ import { publishedPin } from "./published-pin.js";
17
18
  import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
18
19
 
20
+ // KJC-BUG-0424: on the PR that brings this change GitHub runs neither YAML (the base's
21
+ // listens to pull_request, the new one to pull_request_target), so policy-check never reports.
22
+ export const POLICY_TRIGGER_MOVE_NOTE =
23
+ "kj-policy.yml moves from pull_request to pull_request_target (judged from the base branch). " +
24
+ "The PR that brings this change gets no policy-check run: if policy-check is a required check, " +
25
+ "merge that one PR as an admin after the other checks pass; from then on the base runs the judge.";
26
+
19
27
  // La versión del kj que corre harden — es la que se pinea en el fallback
20
28
  // npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
21
29
  // bundle SEA no hay package.json junto al módulo y una lectura eager a
@@ -66,6 +74,25 @@ function hasLintScript(projectDir) {
66
74
  }
67
75
  }
68
76
 
77
+ const policyDeclared = (projectDir) => existsSync(join(projectDir, ".karajan", "policy.yml"));
78
+
79
+ // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
80
+ // steward.action: true in its config; nothing is imposed by default.
81
+ function stewardActionOn(projectDir) {
82
+ try {
83
+ return parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true;
84
+ } catch {
85
+ return false; // no config — no action
86
+ }
87
+ }
88
+
89
+ /** KJC-BUG-0420: the published kj version the policy and steward workflows pin; null when neither is seeded. */
90
+ export async function resolveKjPin({ projectDir, fetchFn } = {}) {
91
+ const own = ownPkg();
92
+ if (!own || !(policyDeclared(projectDir) || stewardActionOn(projectDir))) return null;
93
+ return publishedPin({ name: own.name, version: own.version, fetchFn });
94
+ }
95
+
69
96
  export function installWorkflows({
70
97
  projectDir = process.cwd(),
71
98
  language = null,
@@ -87,14 +114,10 @@ export function installWorkflows({
87
114
  // pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
88
115
  // recurso inalcanzable en los caminos reales.
89
116
  const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
90
- const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
117
+ const policy = policyDeclared(projectDir)
91
118
  ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
92
119
  : null;
93
- // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
94
- // steward.action: true in its config; nothing is imposed by default.
95
- let stewardOn = false;
96
- try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
97
- const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
120
+ const steward = stewardActionOn(projectDir) ?{ file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
98
121
  const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
99
122
  const results = [];
100
123
  for (const wf of all) {
@@ -125,7 +148,9 @@ export function installWorkflows({
125
148
  // pre-commit rechaza el fichero recién generado.
126
149
  writeFileSync(target, content.endsWith("\n") ? content : `${content}\n`);
127
150
  }
128
- results.push({ file: label, action });
151
+ // KJC-BUG-0424: the PR that moves the trigger cannot report policy-check.
152
+ const movesTrigger = wf.blockId === "wf-policy" && action !== "unchanged" && /^\s*pull_request:\s*$/m.test(source);
153
+ results.push({ file: label, action, ...(movesTrigger ? { note: POLICY_TRIGGER_MOVE_NOTE } : {}) });
129
154
  }
130
155
  return { dryRun, workflows: results };
131
156
  }
@@ -39,7 +39,9 @@ export async function runSuiteGreenStage({ config, logger, emitter, eventBase, s
39
39
  }
40
40
  const timeout = Number(config?.development?.test_timeout_ms) > 0 ? Number(config.development.test_timeout_ms) : DEFAULT_TIMEOUT_MS;
41
41
  const run = runTestsImpl || (async () => {
42
- const res = await runCommand("bash", ["-lc", command], { cwd: projectDir, timeout });
42
+ // KJC-BUG-0427: never a login shell, which re-reads the person's profile and may rewrite
43
+ // PATH (nvm drops node when npm_config_prefix is set): the suite runs in kj's own environment.
44
+ const res = await runCommand("bash", ["-c", command], { cwd: projectDir, timeout });
43
45
  return { exitCode: res.exitCode ?? 1, output: `${res.stdout || ""}\n${res.stderr || ""}` };
44
46
  });
45
47
  const res = await run(command);
package/src/policy/add.js CHANGED
@@ -11,6 +11,7 @@ import yaml from "js-yaml";
11
11
  import { parsePolicy } from "@karajan-family/governance";
12
12
  import { createAgent } from "../agents/index.js";
13
13
  import { resolveRole } from "../config/role-resolver.js";
14
+ import { agentAnswerText } from "../review/parser.js";
14
15
 
15
16
  const PROMPT = (text) => `Traduce esta regla de proyecto hablada a un FRAGMENTO YAML del vocabulario
16
17
  cerrado v1 de policy de Karajan. Vocabulario COMPLETO (nada fuera de esto):
@@ -27,7 +28,8 @@ async function defaultTranslate({ text, config, logger }) {
27
28
  const role = resolveRole(config, "reviewer");
28
29
  const res = await createAgent(role.provider, config, logger).reviewTask({ prompt: PROMPT(text), role: "reviewer" });
29
30
  if (!res?.ok) throw new Error(`el agente traductor (${role.provider}) falló: ${res?.error || "sin salida"}`);
30
- return String(res.output || "").replace(/^```[a-z]*\n?|```\s*$/g, "").trim();
31
+ // KJC-BUG-0430 (issue #2695): claude answers with its stream-json; the answer is its final result.
32
+ return String(agentAnswerText(res.output) || "").replace(/^```[a-z]*\n?|```\s*$/g, "").trim();
31
33
  }
32
34
 
33
35
  // Merge conservador: las listas se CONCATENAN (añadir jamás borra), los
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
12
+ import { isReadOnlyCommand, stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
13
13
  import {
14
14
  ALLOW_VERDICT,
15
15
  checkArtifacts,
@@ -36,7 +36,8 @@ export const SUPERVISOR_DEFAULTS = [
36
36
  // it is USED (commit, CI, release), whatever wrote it: only the human's seal lifts it.
37
37
  id: "defaults.governance.write",
38
38
  // KJC-BUG-0400: the CI workflows too; a PR could rewrite the check that judges it.
39
- pattern: /(^|\/)\.karajan\/(policy|rules)\.yml\b|(^|\/)\.github\/workflows\/[^/\s]+\.ya?ml\b/,
39
+ // KJC-BUG-0429: in shell text a relative path follows a blank or a quote, not a slash.
40
+ pattern: /(^|[^\w.-])\.karajan\/(policy|rules)\.yml\b|(^|[^\w.-])\.github\/workflows\/[^/\s]+\.ya?ml\b/,
40
41
  message: "es configuración de gobierno: solo entra sellada por la persona (kj harden --commit, firmado desde su móvil)",
41
42
  },
42
43
  ];
@@ -84,7 +85,12 @@ export function evalToolCall(policy, { role = "coder", tool, input = {}, root =
84
85
  // KJC-BUG-0343: the inert prose of a text option (--title, --error, -m...) is
85
86
  // not a target: a report naming a supervisor path is not a write to it. A path
86
87
  // anywhere else, or a value that can expand, is still judged.
87
- if (tool === "Bash") return evalShell(policy, role, stripTextOptionValues(String(input.command ?? "")));
88
+ if (tool === "Bash") {
89
+ const command = stripTextOptionValues(String(input.command ?? ""));
90
+ // KJC-BUG-0429 (ADR 0036): a command that only reads may name the guarded files;
91
+ // the role's own shell rules still judge it.
92
+ return evalShell(isReadOnlyCommand(command) ? { ...policy, defaults: [] } : policy, role, command);
93
+ }
88
94
  if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return ALLOW_VERDICT;
89
95
  return denyVerdict(`roles.${role}.tools`, `tool "${tool}" fuera del registro de la policy — no verificable para el rol declarado ${role}`);
90
96
  }
@@ -15,8 +15,8 @@ import { HOST_SETTINGS, isSealedConfig, PROVENANCE_FILE } from "../harden/superv
15
15
 
16
16
  const HOOKS_PREFIX = ".karajan/hooks/";
17
17
  const HARNESS_PREFIX = ".karajan/harness/";
18
- // globalHooksDir es el único texto libre que se interpola en el render:
19
- // vocabulario cerrado de ruta o la provenance no verifica NADA.
18
+ // HU-2762: el render ya no interpola globalHooksDir; se conserva el guard para
19
+ // provenances antiguas, donde era texto libre: vocabulario cerrado o no verifica NADA.
20
20
  const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
21
21
 
22
22
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
@@ -9,7 +9,8 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { sep } from "node:path";
11
11
 
12
- import { openVecStore, insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
12
+ import { insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
13
+ import { openVecStoreExplained as openVecStore } from "./project-store.js";
13
14
 
14
15
  export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
15
16
 
@@ -17,6 +17,7 @@ import { execFileSync } from "node:child_process";
17
17
  import path from "node:path";
18
18
 
19
19
  import { openVecStore } from "./vec-store.js";
20
+ import { explainNativeLoadError } from "../checks/native-build.js";
20
21
  import { getKarajanHome } from "../utils/paths.js";
21
22
 
22
23
  function projectRoot(dir) {
@@ -39,10 +40,20 @@ export function libraryDbPath() {
39
40
  return process.env.KJ_LIBRARY_DB || path.join(getKarajanHome(), "library.db");
40
41
  }
41
42
 
43
+ // KJC-BUG-0426: an unbuilt better-sqlite3 addon says how to build it, not "bindings file".
44
+ // Every opening of a RAG store goes through here.
45
+ export function openVecStoreExplained(opts) {
46
+ try {
47
+ return openVecStore(opts);
48
+ } catch (err) {
49
+ throw explainNativeLoadError(err);
50
+ }
51
+ }
52
+
42
53
  export function openLibraryStore({ dim = 768 } = {}) {
43
- return openVecStore({ dim, path: libraryDbPath() });
54
+ return openVecStoreExplained({ dim, path: libraryDbPath() });
44
55
  }
45
56
 
46
57
  export function openProjectStore({ projectDir = process.cwd(), dim = 768 } = {}) {
47
- return openVecStore({ dim, path: projectDbPath(projectDir) });
58
+ return openVecStoreExplained({ dim, path: projectDbPath(projectDir) });
48
59
  }
@@ -9,7 +9,7 @@ import { join } from "node:path";
9
9
  import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
10
10
  import { loadPolicy } from "../policy/engine.js";
11
11
  import { runCommand } from "../utils/process.js";
12
- import { budgetedAdded } from "./loc-budget.js";
12
+ import { budgetedNet } from "./loc-budget.js";
13
13
  import { checkTestsWithCode } from "./tests-with-code.js";
14
14
 
15
15
  const HEADER_MAX = 100;
@@ -37,13 +37,13 @@ export function prSizeLimit({ projectDir, config }) {
37
37
  };
38
38
  }
39
39
 
40
- /** Lines added that count against the budget (generated output and human docs exempt). */
40
+ /** Net lines that count against the budget, as the CI gate counts them (generated output and human docs exempt). */
41
41
  export function sizeCheck({ numstat, limit }) {
42
- const { added, exempt } = budgetedAdded(numstat);
42
+ const { net, exempt } = budgetedNet(numstat);
43
43
  const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
44
- return added > limit
45
- ? { id: "size", ok: false, detail: `${added} lines added${exemptNote}, over the ${limit}-line limit: partition the PR` }
46
- : { id: "size", ok: true, detail: `${added} lines added${exemptNote}, within the ${limit}-line limit` };
44
+ return net > limit
45
+ ? { id: "size", ok: false, detail: `${net} net lines${exemptNote}, over the ${limit}-line limit: partition the PR` }
46
+ : { id: "size", ok: true, detail: `${net} net lines${exemptNote}, within the ${limit}-line limit` };
47
47
  }
48
48
 
49
49
  /** Conventional Commits headers (at most 100 characters) and no AI attribution in base..head. */
@@ -9,6 +9,8 @@ import { runCommand } from "../utils/process.js";
9
9
  import { diffHash } from "./verdict-store.js";
10
10
  import { MARKER } from "./publish-pr.js";
11
11
  import { isCleanMerge } from "./clean-merge.js";
12
+ import { verifiedSupervisorFiles } from "../policy/supervisor-verify.js";
13
+ import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
12
14
 
13
15
  const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
14
16
  // The marker of a kj review for any head: what MARKER writes up to the head itself.
@@ -38,10 +40,33 @@ async function hashesOf(projectDir, sha, run) {
38
40
  return hashes;
39
41
  }
40
42
 
43
+ // KJC-BUG-0388: kj harden --commit is the human's act, signed from the phone, and no AI
44
+ // reviews it. The proof is the signature, never the message: a commit that only touches
45
+ // files the roster-signed seal vouches for at the head is that act.
46
+ async function isSealCommit(projectDir, sha, run, sealed) {
47
+ if (sealed.signed !== true) return false;
48
+ const res = await run("git", ["-C", projectDir, "diff", "--name-only", `${sha}^`, sha]);
49
+ if (res.exitCode !== 0) throw new Error(`git diff --name-only ${sha.slice(0, 7)} failed: ${(res.stderr || "").trim()}`);
50
+ const files = (res.stdout || "").split("\n").filter(Boolean);
51
+ // KJC-BUG-0438: the seal act writes the signed provenance; a commit without it only touched files.
52
+ if (!files.includes(PROVENANCE_FILE)) return false;
53
+ if (!files.every((f) => sealed.files.has(f) || (f === PROVENANCE_FILE && sealed.complete === true))) return false;
54
+ // What the commit leaves is byte for byte what the signed seal vouches for at the head: a
55
+ // precursor whose provenance or hooks a later commit replaced is not the seal act.
56
+ for (const f of files) if ((await blobAt(projectDir, sha, f, run)) !== (await blobAt(projectDir, "HEAD", f, run))) return false;
57
+ return true;
58
+ }
59
+
60
+ /** The blob id of a file at a revision, or null when the file is absent there. */
61
+ async function blobAt(projectDir, rev, file, run) {
62
+ const res = await run("git", ["-C", projectDir, "rev-parse", "--verify", "--quiet", `${rev}:${file}`]);
63
+ return res.exitCode === 0 ? String(res.stdout || "").trim() : null;
64
+ }
65
+
41
66
  /**
42
- * @returns {Promise<{ok: boolean, commits: number, problems: string[]}>}
67
+ * @returns {Promise<{ok: boolean, commits: number, problems: string[], sealCommits?: string[]}>}
43
68
  */
44
- export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
69
+ export async function verifyPrReview({ projectDir, pr, run = runCommand, sealed = null }) {
45
70
  const info = await json(run, ["pr", "view", String(pr), "--json", "headRefOid,commits"], projectDir);
46
71
  const commits = info.commits ?? [];
47
72
  // One review per line, every page: --slurp is missing from the gh that distros ship.
@@ -52,15 +77,21 @@ export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
52
77
  // today's head: the diff hash of each commit is what proves it, and a commit pushed after
53
78
  // the review is simply not listed. "Update branch" no longer leaves the check red.
54
79
  const kjReviews = reviews.filter((r) => TRUSTED.has(r.author_association) && String(r.body ?? "").includes(MARKER_PREFIX));
55
- if (kjReviews.length === 0) {
56
- return { ok: false, commits: commits.length, problems: [`no kj review published by a member: run kj review --publish-pr ${pr}`] };
57
- }
58
80
  const listed = new Map(kjReviews.flatMap((r) => [...r.body.matchAll(LISTED)].map((m) => [m[1], m[2]])));
59
81
  const problems = [];
82
+ const sealCommits = [];
83
+ let seal = sealed;
60
84
  for (const c of commits) {
61
85
  if (await isCleanMerge(projectDir, c.oid, run)) continue;
62
86
  const hash = listed.get(c.oid.slice(0, 7));
63
- if (!hash || !(await hashesOf(projectDir, c.oid, run)).has(hash)) problems.push(`${c.oid.slice(0, 7)} (${c.messageHeadline}): no approved verdict for the diff it brings`);
87
+ if (hash && (await hashesOf(projectDir, c.oid, run)).has(hash)) continue;
88
+ seal ??= verifiedSupervisorFiles({ projectDir, trackedOnly: true });
89
+ if (await isSealCommit(projectDir, c.oid, run, seal)) sealCommits.push(c.oid.slice(0, 7));
90
+ else problems.push(`${c.oid.slice(0, 7)} (${c.messageHeadline}): no approved verdict for the diff it brings`);
64
91
  }
65
- return { ok: problems.length === 0, commits: commits.length, problems };
92
+ // A PR that only brings the seal needs no review; anything else without one (a clean merge
93
+ // included, KJC-BUG-0438) is told to publish it.
94
+ const onlySeals = sealCommits.length > 0 && sealCommits.length === commits.length;
95
+ if (kjReviews.length === 0 && !onlySeals) problems.splice(0, problems.length, `no kj review published by a member: run kj review --publish-pr ${pr}`);
96
+ return { ok: problems.length === 0, commits: commits.length, problems, ...(sealCommits.length > 0 ? { sealCommits } : {}) };
66
97
  }
@@ -198,7 +198,8 @@ async function maybeRunCoverage(config) {
198
198
 
199
199
  const command = String(coverage.command || "").trim();
200
200
  const timeout = Number(coverage.timeout_ms) > 0 ? Number(coverage.timeout_ms) : 5 * 60 * 1000;
201
- const run = await runCommand("bash", ["-lc", command], { timeout });
201
+ // KJC-BUG-0427: a plain shell in kj's environment; a login one may rewrite PATH.
202
+ const run = await runCommand("bash", ["-c", command], { timeout });
202
203
 
203
204
  if (run.exitCode !== 0) {
204
205
  if (blockOnFailure) {