@karajan-family/code 4.70.2 → 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.2",
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",
@@ -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
@@ -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
  }
@@ -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"];
@@ -85,8 +85,9 @@ process.stdin.on("end", () => {
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
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).
88
89
  const SUPERVISOR_PATHS = [".claude/settings.json", ".karajan/hooks", ".karajan/harness"];
89
- if (tool === "Bash" && PROTECTED.test(stripTextOnlyCommandValues(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)) {
90
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"));
91
92
  process.exit(2);
92
93
  }
@@ -264,5 +264,39 @@ export const withoutRedirections = (words) => {
264
264
  return rest;
265
265
  };
266
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
+
267
301
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
268
302
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
@@ -14,6 +14,7 @@ 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
 
19
20
  // KJC-BUG-0424: on the PR that brings this change GitHub runs neither YAML (the base's
@@ -73,6 +74,25 @@ function hasLintScript(projectDir) {
73
74
  }
74
75
  }
75
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
+
76
96
  export function installWorkflows({
77
97
  projectDir = process.cwd(),
78
98
  language = null,
@@ -94,14 +114,10 @@ export function installWorkflows({
94
114
  // pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
95
115
  // recurso inalcanzable en los caminos reales.
96
116
  const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
97
- const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
117
+ const policy = policyDeclared(projectDir)
98
118
  ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
99
119
  : null;
100
- // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
101
- // steward.action: true in its config; nothing is imposed by default.
102
- let stewardOn = false;
103
- try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
104
- 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;
105
121
  const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
106
122
  const results = [];
107
123
  for (const wf of all) {
@@ -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);
@@ -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,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) {