karajan-code 4.36.0 → 4.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.36.0",
3
+ "version": "4.38.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -26,6 +26,15 @@ const seaTransformPlugin = {
26
26
  let contents = await fs.readFile(args.path, "utf8");
27
27
  let modified = false;
28
28
 
29
+ // KJC-TSK-0915 (ADR 0014): the Sentinel's guard modules are read as TEXT at
30
+ // load time, to be copied into a project's harness. A single-file bundle has
31
+ // no sibling files, so the text is inlined here, before import.meta.url goes.
32
+ const SENTINEL_READ = /readFileSync\(new URL\("(\.\/sentinel\/[\w-]+\.mjs)", import\.meta\.url\), "utf8"\)/g;
33
+ if (SENTINEL_READ.test(contents)) {
34
+ contents = contents.replace(SENTINEL_READ, (_m, rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")));
35
+ modified = true;
36
+ }
37
+
29
38
  // Replace import.meta.dirname with __dirname
30
39
  if (contents.includes("import.meta.dirname")) {
31
40
  contents = contents.replaceAll("import.meta.dirname", "__dirname");
@@ -34,7 +34,7 @@ export const ADVANCED_GROUPS = [
34
34
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
35
35
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
36
36
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
37
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
37
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward", "pr-size"] },
38
38
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
39
39
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
40
40
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -30,7 +30,7 @@ import { envInstallCommand, briefCommand } from "../commands/env.js";
30
30
  import { runReleaseCheck } from "../checks/release-check.js";
31
31
  import { agentRunCommand } from "../commands/agent-run.js";
32
32
  import { reportIssueCommand } from "../commands/report-issue.js";
33
- import { huCommand } from "../commands/hu.js";
33
+ import { huCommand, HU_STATUSES } from "../commands/hu.js";
34
34
  import { worktreeCommand } from "../commands/worktree.js";
35
35
  import { addAdr, listAdrs } from "../environment/adr.js";
36
36
  import { formatAdvancedIndex } from "../commands/advanced.js";
@@ -271,6 +271,8 @@ export function registerMeta(program, { pkgVersion }) {
271
271
  });
272
272
  });
273
273
  hu.command("move <id> <status>")
274
+ // KJC-BUG-0257: the valid states, said before the call fails.
275
+ .description(`Move a card; <status> is one of: ${HU_STATUSES.join(", ")}`)
274
276
  .option("--json", "Machine-readable output")
275
277
  .action(async (id, status, flags) => {
276
278
  await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
@@ -522,6 +524,17 @@ export function registerMeta(program, { pkgVersion }) {
522
524
  await privacyScanCommand({ paths, flags });
523
525
  });
524
526
 
527
+ // KJC-TSK-0910: the branch's size while it is written, same budget as CI.
528
+ program.command("pr-size")
529
+ .description("Lines this branch adds against its base, with the CI budget (committed, uncommitted and new files)")
530
+ .option("--json", "Machine-readable result")
531
+ .action(async (flags) => {
532
+ await withConfig(pkgVersion, "pr-size", flags, async ({ config, logger }) => {
533
+ const { prSizeCommand } = await import("../commands/pr-size.js");
534
+ await prSizeCommand({ config, logger, flags });
535
+ });
536
+ });
537
+
525
538
  const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
526
539
  rag.command("index")
527
540
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
@@ -0,0 +1,45 @@
1
+ /**
2
+ * KJC-TSK-0910 — `kj pr-size`: the branch's size while it is being written,
3
+ * with the SAME budget the CI gate applies (loc-budget.js). It counts what is
4
+ * committed, what is not yet, and new files not yet added, against the merge
5
+ * base with the base branch, so the Sentinel can warn before the gate blocks.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ import { budgetedAdded } from "../review/loc-budget.js";
12
+
13
+ const git = (projectDir, args) =>
14
+ execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
15
+
16
+ function mergeBase(projectDir, base) {
17
+ for (const ref of [`origin/${base}`, base]) {
18
+ try { return git(projectDir, ["merge-base", "HEAD", ref]).trim(); } catch { /* try the next ref */ }
19
+ }
20
+ throw new Error(`pr-size: no merge base with ${base} (nor origin/${base})`);
21
+ }
22
+
23
+ /** @returns {Promise<{added: number, exempt: number, testAdded: number, base: string}>} */
24
+ export async function branchSize({ projectDir = process.cwd(), base = "main" } = {}) {
25
+ const from = mergeBase(projectDir, base);
26
+ // Working tree against the merge base: committed and uncommitted together.
27
+ let numstat = git(projectDir, ["diff", "--numstat", from]);
28
+ // New files not yet added are where a branch grows while it is written.
29
+ for (const file of git(projectDir, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean)) {
30
+ let text;
31
+ try { text = readFileSync(join(projectDir, file), "utf8"); } catch { continue; }
32
+ if (text.includes("\0")) continue; // binary
33
+ // Lines as git counts them: an empty file adds none.
34
+ const n = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
35
+ numstat += `${n}\t0\t${file}\n`;
36
+ }
37
+ return { ...budgetedAdded(numstat), base };
38
+ }
39
+
40
+ export async function prSizeCommand({ config, logger = console, flags = {} }) {
41
+ const s = await branchSize({ projectDir: config?.projectDir || process.cwd(), base: config?.base_branch || "main" });
42
+ if (flags.json) process.stdout.write(`${JSON.stringify(s)}\n`);
43
+ else logger.info(`pr-size: ${s.added} line(s) added against ${s.base} (${s.testAdded} in tests, ${s.exempt} exempt)`);
44
+ return s;
45
+ }
@@ -14,7 +14,7 @@ import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
14
14
  import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
15
15
  import { loadGoldenQueries, runEval } from "../rag/eval.js";
16
16
  import { getKarajanHome } from "../utils/paths.js";
17
- import { countProjectChunks, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
17
+ import { countProjectChunks, countProjectSources, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
18
18
  import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
19
19
 
20
20
  // KJC-TSK-0882 (ADR 0011): el indice es del proyecto, no de la maquina.
@@ -153,6 +153,11 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
153
153
  if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope, remedy })}\n`);
154
154
  return [];
155
155
  }
156
+ // KJC-BUG-0258: chunks of plans and briefs but none of the project's own files
157
+ // answer nothing about the code; said now, not when kj review --staged blocks.
158
+ if (!library && project && countProjectSources(db, project, config?.projectDir || process.cwd()) === 0) {
159
+ logger.warn("[rag] this project's index holds none of its own files (only plans or briefs): run kj rag index --with-sources");
160
+ }
156
161
  const mode = flags.mode || "hybrid";
157
162
  const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
158
163
  // Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
@@ -234,6 +239,19 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
234
239
  }
235
240
  }
236
241
 
242
+ /**
243
+ * KJC-BUG-0255: what `kj rag migrate` keeps. The project's own files pass the
244
+ * indexer's criterion of today; sources outside the tree (plans, onboarding)
245
+ * are kept as they are.
246
+ */
247
+ export function migrateKeep(projectDir, config) {
248
+ const exclude = ragExclude(config);
249
+ return (source) => {
250
+ const rel = relative(projectDir, source);
251
+ return rel.startsWith("..") || isAbsolute(rel) || indexableReason(rel, source, { exclude, projectDir }) === null;
252
+ };
253
+ }
254
+
237
255
  /**
238
256
  * KJC-TSK-0888 (RAG-P1a, ADR 0011) — `kj rag migrate`: copia los chunks de este
239
257
  * proyecto desde la base global a su propio indice, con sus embeddings, sin
@@ -241,11 +259,13 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
241
259
  */
242
260
  export async function ragMigrateCommand({ config, flags = {} }) {
243
261
  const projectDir = config?.projectDir || process.cwd();
262
+ const keep = migrateKeep(projectDir, config);
244
263
  const res = migrateProjectIndex({
245
264
  slug: projectSlug(projectDir),
246
265
  legacyPath: join(getKarajanHome(), "rag.db"),
247
266
  targetPath: projectDbPath(projectDir),
248
267
  dim: config?.rag?.embedder?.dim || 768,
268
+ keep,
249
269
  });
250
270
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
251
271
  else process.stdout.write(`rag migrate: ${res.state} — ${res.reason}\n`);
@@ -5,7 +5,7 @@
5
5
  * verdict tied to the exact diff (verdict-store), so the pre-commit hook
6
6
  * (ENV-C) can verify it. Exit code 0 = approved, 1 = rejected/stale.
7
7
  */
8
- import { existsSync } from "node:fs";
8
+ 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 { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
13
13
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
+ import { addedAreCommentsOnly } from "../review/comment-only.js";
16
17
  import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
17
18
  import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
18
19
  import { checkUiEvidence, uiBlock } from "../review/ui-evidence.js";
@@ -59,6 +60,36 @@ async function rawDiff(range, extraArgs = []) {
59
60
  return res.stdout;
60
61
  }
61
62
 
63
+ /** The file as the reviewed diff leaves it. */
64
+ async function resultingContent(range, file, projectDir) {
65
+ const show = async (spec) => {
66
+ const res = await runCommand("git", ["show", spec]);
67
+ return res.exitCode === 0 ? res.stdout : null;
68
+ };
69
+ // Staged review: the INDEX, never the working tree (unstaged edits are not reviewed).
70
+ if (!range) return show(`:${file}`);
71
+ // `a..b` / `a...b`: the right side (HEAD when omitted).
72
+ if (/\.\./.test(range)) return show(`${range.split(/\.\.\.?/)[1] || "HEAD"}:${file}`);
73
+ // `git diff <ref>` compares the ref with the working tree, so that is the result.
74
+ try { return readFileSync(join(projectDir, file), "utf8"); } catch { return null; }
75
+ }
76
+
77
+ /**
78
+ * KJC-BUG-0235: mark the files whose added lines are all comments, so a
79
+ * cleanup that corrects the comments it left lying is not taken for new code.
80
+ * Anything unreadable or of unknown syntax stays unmarked (counts as code).
81
+ */
82
+ export async function markCommentOnlyAdditions(numstat, { range, projectDir }) {
83
+ const touched = numstat.filter((n) => n.added > 0);
84
+ if (touched.length === 0) return;
85
+ const added = addedLinesByFile(await rawDiff(range, ["--unified=0"]));
86
+ for (const n of touched) {
87
+ const lines = added.get(n.file);
88
+ const content = lines ? await resultingContent(range, n.file, projectDir) : null;
89
+ n.commentOnly = content !== null && addedAreCommentsOnly(content, lines, n.file);
90
+ }
91
+ }
92
+
62
93
  // KJC-TSK-0838: a grant is the ONLY way past the sonar requirement, so it is
63
94
  // said with who gave it, its scope and until when.
64
95
  const formatSonarGrant = (g) => {
@@ -286,6 +317,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
286
317
  // exempt — deleting code adds no behavior to test.
287
318
  const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
288
319
  .map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
320
+ await markCommentOnlyAdditions(numstat, { range: flags.range, projectDir });
289
321
  const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
290
322
  if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
291
323
  if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
@@ -156,6 +156,13 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
156
156
  " echo 'kj harden: commit header must follow Conventional Commits (type: subject)'; exit 1",
157
157
  "fi",
158
158
  'if [ "${#header}" -gt 100 ]; then echo \'kj harden: commit header exceeds 100 chars\'; exit 1; fi',
159
+ "# KJC-TSK-0909: the rest of what CI's commitlint rejects, caught here.",
160
+ "if ! awk 'NR > 2 && !/^#/ && length($0) > 100 { exit 1 }' \"$msg_file\"; then",
161
+ " echo 'kj harden: a commit body line exceeds 100 chars (CI commitlint rejects it)'; exit 1",
162
+ "fi",
163
+ 'if [ -x node_modules/.bin/commitlint ]; then',
164
+ ' node_modules/.bin/commitlint --edit "$msg_file" || exit 1',
165
+ "fi",
159
166
  `if grep -qiE '${AI_ATTRIBUTION}' "$msg_file"; then`,
160
167
  " echo 'kj harden: AI attribution is not allowed in commit messages'; exit 1",
161
168
  "fi",
@@ -17,18 +17,22 @@ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
17
17
  // escáner de privacidad del pack deniega claves google con razón general, y
18
18
  // servirla desde la landing la hace además rotable sin release.
19
19
  const RELAY_CONFIG_URL = "https://karajancode.com/sign/relay.json";
20
- let relayCache = null;
20
+ // Cacheada por fetch: en produccion es siempre la misma; en tests, cada relé falso es otro.
21
+ const relayCache = new WeakMap();
21
22
  async function relayConfig(fetchFn) {
22
- if (relayCache) return relayCache;
23
+ if (relayCache.has(fetchFn)) return relayCache.get(fetchFn);
23
24
  const res = await fetchFn(RELAY_CONFIG_URL);
24
25
  if (!res.ok) throw new Error(`phone-sign: no pude cargar la config del relé (${res.status}) — revisa la red`);
25
26
  const cfg = await res.json();
26
27
  if (!cfg?.apiKey || !cfg?.projectId || !cfg?.collection) throw new Error("phone-sign: config del relé incompleta");
27
- relayCache = {
28
+ const relay = {
28
29
  apiKey: cfg.apiKey,
29
30
  url: `https://firestore.googleapis.com/v1/projects/${cfg.projectId}/databases/(default)/documents/${cfg.collection}`,
31
+ // KJC-TSK-0901: v2 solo cuando la landing (pagina + reglas) ya lo entiende.
32
+ payloadVersion: cfg.payloadVersion === 2 ? 2 : 1,
30
33
  };
31
- return relayCache;
34
+ relayCache.set(fetchFn, relay);
35
+ return relay;
32
36
  }
33
37
  const SIGN_PAGE = "https://karajancode.com/sign";
34
38
  const POLL_INTERVAL_MS = 2000;
@@ -109,9 +113,15 @@ export function enrollPhone(publicKeyBase64, { home } = {}) {
109
113
  writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
110
114
  }
111
115
 
112
- /** Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios. */
113
- export function canonicalPayload({ cid, nonce, project, files }) {
116
+ /**
117
+ * Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios.
118
+ * KJC-TSK-0901/0902: v2 firma también lo demás que el móvil enseña (versión de
119
+ * kj) y la propia vigencia (emisión y caducidad, en ms), para que nada mostrado
120
+ * quede sin firmar y la caducidad sea de lo que el humano aprobó.
121
+ */
122
+ export function canonicalPayload({ cid, nonce, project, files, v = 1, kjVersion, issuedMs, expiresMs }) {
114
123
  const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
124
+ if (v === 2) return `kj-supervisor-sign:v2:${cid}:${nonce}:${project}:${kjVersion}:${issuedMs}:${expiresMs}:${filesHash}`;
115
125
  return `kj-supervisor-sign:v1:${cid}:${nonce}:${project}:${filesHash}`;
116
126
  }
117
127
 
@@ -135,17 +145,21 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
135
145
  const drawQr = deps.qr ?? ((url) => qrcode.generate(url, { small: true }));
136
146
  const cid = randomBytes(16).toString("hex");
137
147
  const nonce = randomBytes(16).toString("hex");
148
+ const relay = await relayConfig(fetchFn);
149
+ const v = relay.payloadVersion;
150
+ const issuedMs = now();
151
+ const expiresMs = issuedMs + TTL_MS;
138
152
  const body = {
139
153
  fields: {
140
154
  nonce: { stringValue: nonce },
141
155
  project: { stringValue: project },
142
156
  kj_version: { stringValue: kjVersion },
143
157
  state: { stringValue: "pending" },
144
- createdAt: { timestampValue: new Date().toISOString() },
158
+ createdAt: { timestampValue: new Date(issuedMs).toISOString() },
145
159
  files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
160
+ ...(v === 2 ? { v: { integerValue: "2" }, expires_at: { timestampValue: new Date(expiresMs).toISOString() } } : {}),
146
161
  },
147
162
  };
148
- const relay = await relayConfig(fetchFn);
149
163
  const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
150
164
  method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
151
165
  });
@@ -157,7 +171,7 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
157
171
  logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
158
172
  logger.info?.("phone-sign: esperando la firma del móvil…");
159
173
  const spinner = deps.spinner ?? makeSpinner();
160
- const deadline = now() + TTL_MS;
174
+ const deadline = expiresMs;
161
175
  while (now() < deadline) {
162
176
  const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
163
177
  if (!res.ok) { spinner.stop(); throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`); }
@@ -171,14 +185,17 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
171
185
  if (!signerKey || !authorized.has(signerKey)) {
172
186
  return { ok: false, reason: "la publicKey que firma no está en el padrón autorizado — el doc es transporte, la verdad es el padrón" };
173
187
  }
174
- const payload = canonicalPayload({ cid, nonce, project, files });
188
+ const signed2 = v === 2 ? { v, kjVersion, issuedMs, expiresMs } : {};
189
+ const payload = canonicalPayload({ cid, nonce, project, files, ...signed2 });
175
190
  const signature = fields.signature?.stringValue ?? "";
176
191
  const good = verifyPhoneSignature({ payload, signature, publicKey: signerKey });
177
192
  if (!good) return { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
193
+ // KJC-TSK-0901: una firma válida fuera de la vigencia que ella misma firma no vale.
194
+ if (v === 2 && now() > expiresMs) return { ok: false, reason: "la firma llegó fuera de su vigencia firmada" };
178
195
  // KJC-TSK-0823: devuelve el desafío + firma para que el sello los GRABE en
179
196
  // la provenance y CI pueda re-verificar server-side (clave del padrón).
180
197
  const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
181
- return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash } };
198
+ return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash, ...signed2 } };
182
199
  }
183
200
  spinner.tick();
184
201
  await sleep(POLL_INTERVAL_MS);
@@ -0,0 +1,106 @@
1
+ // The Sentinel's bash-write guard (KJC-BUG-0237, moved here by KJC-TSK-0919,
2
+ // ADR 0014). Inside the repo, files are written through Edit/Write only, the path
3
+ // every gate guards. Copied byte for byte into .karajan/harness; `root` injected.
4
+ // The stated limit: a program you run can write files; only a sandbox stops that.
5
+ import { existsSync, realpathSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
+ import { headIndex, shellSegments } from "./sentinel-shell.mjs";
9
+
10
+ const WRITES = ["tee", "touch", "truncate", "cp", "mv", "install", "ln", "sed", "perl", "dd", "sh", "bash", "zsh", "dash", "eval"];
11
+ const RUNNERS = ["xargs", "find", "node", "python3", "python", "ruby", "php", "deno", "bun"];
12
+ // touch/truncate -s SIZE, -r REF, -d DATE, -t STAMP: the value is not a file it writes.
13
+ const VALUED = ["-s", "--size", "-r", "--reference", "-d", "--date", "-t"];
14
+
15
+ /** Redirection targets: >&N and >&- duplicate or close a descriptor; >& FILE and >&FILE write FILE. */
16
+ const redirections = (words) => {
17
+ const out = [];
18
+ words.forEach((w, k) => {
19
+ const m = /^\d*(>>|>[|]|>)(.*)$/.exec(w);
20
+ if (!m) return;
21
+ const dup = m[2].startsWith("&") ? m[2].slice(1) : null;
22
+ if (dup === null) out.push(m[2] || words[k + 1]);
23
+ else if (dup === "") out.push(words[k + 1]);
24
+ else if (!/^\d*-?$/.test(dup)) out.push(dup);
25
+ });
26
+ return out;
27
+ };
28
+
29
+ /** Destination of cp/mv/install/ln: -t DIR / --target-directory[=]DIR, else the last plain word. */
30
+ const destination = (rest, plain) => {
31
+ const t = rest.findIndex((w) => w === "-t" || w === "--target-directory");
32
+ const tEq = rest.find((w) => w.startsWith("--target-directory="));
33
+ if (t >= 0) return [rest[t + 1]];
34
+ if (tEq) return [tEq.slice(19)];
35
+ return plain.length >= 2 ? [plain.at(-1)] : [];
36
+ };
37
+
38
+ /** Files sed/perl -i edit: the script is the word after -e/-f, else the first plain word; a lone plain word is the file. */
39
+ const inPlace = (rest, plain) => {
40
+ if (!rest.some((w) => w.startsWith("--in-place") || /^-[a-zA-Z0-9]*i/.test(w))) return [];
41
+ const script = rest.findIndex((w) => ["-e", "-f", "--expression"].includes(w));
42
+ if (script >= 0) return plain.filter((w) => w !== rest[script + 1]);
43
+ return plain.length === 1 ? plain : plain.slice(1);
44
+ };
45
+
46
+ /**
47
+ * The files a simple command (a word list) writes from the shell. "$(...)"
48
+ * marks a target that only exists at run time: unknowable, so writesRepo denies it.
49
+ * @param {string[]} words
50
+ * @returns {string[]}
51
+ */
52
+ export const shellWrites = (words) => {
53
+ const out = redirections(words);
54
+ const i = headIndex(words, [...WRITES, ...RUNNERS]);
55
+ const head = (words[i] || "").split("/").at(-1);
56
+ // xargs / find -exec run a writer on targets that arrive at run time.
57
+ if (["xargs", "find"].includes(head) && words.slice(i + 1).some((w) => WRITES.includes(w.split("/").at(-1)))) out.push(`$(${head})`);
58
+ // Arguments without redirections (< << <<< > >> and a detached operand).
59
+ const rest = [];
60
+ for (let k = i + 1; k < words.length; k++) {
61
+ if (!/^\d*[<>]/.test(words[k])) rest.push(words[k]);
62
+ else if (/^\d*(<{1,3}|>>?|>[|&])$/.test(words[k])) k++;
63
+ }
64
+ // Operands: words that are not options, and EVERY word after "--" (touch -- -file).
65
+ const cut = rest.includes("--") ? rest.indexOf("--") : rest.length;
66
+ const plain = [...rest.slice(0, cut).filter((w) => !w.startsWith("-")), ...rest.slice(cut + 1)];
67
+ if (head === "tee") out.push(...plain);
68
+ if (head === "dd") out.push(...rest.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
69
+ // By position, not value (touch -d today today writes "today").
70
+ if (head === "touch" || head === "truncate") out.push(...rest.filter((w, k) => k > cut || (k < cut && !w.startsWith("-") && !VALUED.includes(rest[k - 1]))));
71
+ // sh -c / eval run a script of their own: its writes are this command's writes.
72
+ if (["sh", "bash", "zsh", "dash"].includes(head) && rest.includes("-c")) for (const seg of shellSegments(rest[rest.indexOf("-c") + 1] || "")) out.push(...shellWrites(seg));
73
+ if (head === "eval") for (const seg of shellSegments(rest.join(" "))) out.push(...shellWrites(seg));
74
+ // An inline script (node -e, python -c...) that names a write API.
75
+ const inline = rest[rest.findIndex((w) => ["-e", "-c", "--eval", "-p", "-r"].includes(w)) + 1] || "";
76
+ if (/^(node|deno|bun|python[\d.]*|ruby|perl|php)$/.test(head) && /write|append|open|copy|rename|truncate|unlink|mkdir|symlink/i.test(inline)) out.push(`$(${head})`);
77
+ if (["cp", "mv", "install", "ln"].includes(head)) out.push(...destination(rest, plain));
78
+ if (head === "sed" || head === "perl") out.push(...inPlace(rest, plain));
79
+ return out.filter(Boolean);
80
+ };
81
+
82
+ /**
83
+ * Inside the repo, or unknowable ($VAR, backtick, ~user): both deny. /dev/* and
84
+ * descriptor duplication (&1) are outside; ~/ is the home, where the repo may live.
85
+ * Real paths: a link outside (/tmp/link -> repo) points in.
86
+ * @param {string} t
87
+ * @param {string} root
88
+ */
89
+ export const writesRepo = (t, root) => {
90
+ if (t.includes("$") || t.includes("`")) return true;
91
+ if (t.startsWith("&") || t.startsWith("/dev/")) return false;
92
+ if (t.startsWith("~") && t !== "~" && !t.startsWith("~/")) return true;
93
+ let a = resolve(root, t.startsWith("~") ? homedir() + t.slice(1) : t);
94
+ let tail = "";
95
+ while (!existsSync(a) && dirname(a) !== a) {
96
+ tail = join(a.slice(dirname(a).length + 1), tail);
97
+ a = dirname(a);
98
+ }
99
+ let rel;
100
+ try {
101
+ rel = relative(realpathSync(root), join(realpathSync(a), tail));
102
+ } catch {
103
+ return true;
104
+ }
105
+ return !rel.startsWith("..") && !rel.startsWith("/");
106
+ };
@@ -0,0 +1,98 @@
1
+ // The Sentinel's discard guard (KJC-BUG-0238/0241/0242, moved here by KJC-TSK-0916,
2
+ // ADR 0014). An agent does not discard changes it did not make. Copied byte for
3
+ // byte into .karajan/harness; `root` is the project root, injected.
4
+ import { spawnSync } from "node:child_process";
5
+ import { relative, resolve } from "node:path";
6
+ import process from "node:process";
7
+ import { headIndex, shortOpts } from "./sentinel-shell.mjs";
8
+
9
+ export const DISCARD_VERBS = ["checkout", "restore", "reset", "stash", "switch", "clean"];
10
+
11
+ /**
12
+ * What a git command would discard, per simple command (a word list).
13
+ * null = discards nothing (or is not git); {stash} = drops saved work;
14
+ * {unknown} = cannot be read with certainty; {clean} = untracked files;
15
+ * {paths} = working-tree changes (":/" = the whole tree, whatever the cwd).
16
+ * @param {string[]} words
17
+ * @param {string} root
18
+ */
19
+ export const discardOf = (words, root) => {
20
+ let i = headIndex(words, ["git"]); // /usr/bin/git is git
21
+ // A $variable in command position beside a discard verb ($g checkout) cannot be read.
22
+ if (words[i]?.startsWith("$") && words.some((w) => DISCARD_VERBS.includes(w))) return { unknown: true };
23
+ if (words[i]?.split("/").at(-1) !== "git") return null;
24
+ let cwd = root;
25
+ for (i++; i < words.length && words[i].startsWith("-"); i++) {
26
+ if (words[i] === "-C") cwd = resolve(cwd, words[++i] || ".");
27
+ else if (words[i] === "-c") i++;
28
+ else if (!["--no-pager", "-P", "--paginate", "-p", "--no-optional-locks"].includes(words[i])) return words.some((w) => DISCARD_VERBS.includes(w)) ? { unknown: true } : null;
29
+ }
30
+ const sub = words[i];
31
+ const args = [];
32
+ for (let j = i + 1; j < words.length; j++) {
33
+ // Options that take a value: the value is not a flag (-e -n excludes "-n").
34
+ // Dropping clean's excludes only makes its probe list MORE files.
35
+ if (["-s", "--source", "-e", "--exclude"].includes(words[j]) || (sub === "clean" && /^-[a-zA-Z]*e$/.test(words[j]))) j++;
36
+ else args.push(words[j]);
37
+ }
38
+ const has = (...f) => f.some((x) => args.includes(x));
39
+ const dd = args.indexOf("--");
40
+ const pos = (dd < 0 ? args : args.slice(0, dd)).filter((a) => !a.startsWith("-"));
41
+ const after = dd < 0 ? [] : args.slice(dd + 1);
42
+ const ALL = { cwd, paths: [":/"] };
43
+ if (sub === "stash") return has("drop", "clear") ? { cwd, stash: true } : null;
44
+ if (sub === "reset") return has("--hard") ? ALL : null;
45
+ // Every clean but a dry run (-n in a short cluster BEFORE "--"): clean.requireForce=false deletes without -f.
46
+ const opts = dd < 0 ? args : args.slice(0, dd);
47
+ // Interactive clean decides at the prompt: nothing to probe, fail-closed.
48
+ if (sub === "clean" && opts.some((a) => a === "--interactive" || shortOpts(a).includes("i"))) return { unknown: true };
49
+ if (sub === "clean") return opts.some((a) => a === "--dry-run" || shortOpts(a).includes("n")) ? null : { cwd, clean: [opts, args.slice(opts.length)] };
50
+ if (sub === "switch") return has("--discard-changes", "-f", "--force") ? ALL : null;
51
+ if (sub === "restore") return has("--staged", "-S") && !has("--worktree", "-W") ? null : { cwd, paths: [...pos, ...after] };
52
+ if (sub !== "checkout") return null;
53
+ if (has("-f", "--force")) return ALL;
54
+ if (has("-b", "-B", "--orphan") || !(pos.length || after.length)) return null;
55
+ if (after.length) return { cwd, paths: after };
56
+ const isRef = spawnSync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${pos[0]}^{commit}`]).status === 0;
57
+ if (!isRef) return { cwd, paths: pos };
58
+ return pos.length > 1 ? { cwd, paths: pos.slice(1) } : null;
59
+ };
60
+
61
+ /**
62
+ * Files the discard would lose that the session did not own: dirty now and not
63
+ * clean at first touch. Anything unreadable, or another repo, is foreign.
64
+ * @param {object} d what discardOf returned
65
+ * @param {Record<string, string>} touch the session's first_touch ledger
66
+ * @param {string} root
67
+ * @returns {string[]}
68
+ */
69
+ export const foreignLost = (d, touch, root) => {
70
+ if (d.stash) return ["git stash"];
71
+ if (d.unknown) return ["(comando no verificable: ejecutalo como git simple)"];
72
+ const top = spawnSync("git", ["-C", d.cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
73
+ const sameRepo = top.status === 0 && resolve(String(top.stdout).trim()) === resolve(root);
74
+ const own = (f) => sameRepo && Object.hasOwn(touch, f) && touch[f] === "clean";
75
+ if (d.clean) {
76
+ // Ask git what it would remove: -n wins over -f, so the same flags (-ff included) are kept,
77
+ // minus -q (it silences the listing) and every exclude (dropping one only lists MORE).
78
+ // -n FIRST: after "--" it is a pathspec. LC_ALL=C: lines are parsed.
79
+ const [opts, rest] = d.clean;
80
+ const loud = opts.map((a) => (a.startsWith("--") ? a : `-${shortOpts(a).replaceAll("q", "")}`)).filter((a) => a !== "-" && a !== "--quiet" && !a.startsWith("--exclude"));
81
+ const r = spawnSync("git", ["-C", d.cwd, "clean", "-n", ...loud, ...rest], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } });
82
+ if (r.status !== 0) return ["(git clean -n fallo)"];
83
+ return String(r.stdout).split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => relative(root, resolve(d.cwd, l.slice(13)))).filter((f) => !own(f));
84
+ }
85
+ // Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
86
+ // be proven safe; git would refuse to check it out anyway.
87
+ if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) return [`(ruta no resoluble: ${d.paths.join(" ")})`];
88
+ const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
89
+ if (r.status !== 0) return ["(git status fallo)"];
90
+ const files = [];
91
+ const parts = String(r.stdout).split("\0");
92
+ for (let k = 0; k < parts.length; k++) {
93
+ if (parts[k].length < 4) continue;
94
+ files.push(parts[k].slice(3));
95
+ if ("RC".includes(parts[k][0])) k++;
96
+ }
97
+ return files.filter((f) => !own(f));
98
+ };
@@ -0,0 +1,57 @@
1
+ // The method's reminders (KJC-TSK-0917, ADR 0014). An agent reads its rules at
2
+ // the start and loses them when the context is compacted; a hook does not forget.
3
+ // These are reminders, not gates: never a block. Each comes AFTER the action that
4
+ // precedes the one its rule is about (git add before a commit, gh pr create before
5
+ // a merge), from PostToolUse: a PreToolUse reminder would need permissionDecision
6
+ // "allow", which skips the user's permission prompt. Copied byte for byte into
7
+ // .karajan/harness.
8
+ import { headIndex, shellSegments } from "./sentinel-shell.mjs";
9
+
10
+ /** The git or gh subcommand of a simple command, or null: "git commit" -> ["git", "commit"]. */
11
+ const verbOf = (words) => {
12
+ const i = headIndex(words, ["git", "gh"]);
13
+ const tool = (words[i] || "").split("/").at(-1);
14
+ if (tool !== "git" && tool !== "gh") return null;
15
+ let j = i + 1;
16
+ while (j < words.length && words[j].startsWith("-")) j += ["-C", "-c", "-R", "--repo"].includes(words[j]) ? 2 : 1;
17
+ return [tool, words[j] || "", words[j + 1] || ""];
18
+ };
19
+
20
+ const REMINDERS = [
21
+ {
22
+ id: "commit",
23
+ when: (verbs) => verbs.some(([t, a]) => t === "git" && a === "add"),
24
+ say: "kj, antes de commitear: cabecera de 100 caracteres como máximo con el sujeto en minúscula, líneas del cuerpo de 100 como máximo y sin atribución a IA. Valida el mensaje con npx commitlint --edit <fichero> y stagea por nombre (nunca git add -A).",
25
+ },
26
+ {
27
+ id: "gh-account",
28
+ when: (verbs) => verbs.some(([t]) => t === "gh") && !verbs.some(([t, a, b]) => t === "gh" && a === "auth" && b === "switch"),
29
+ say: "kj: gh sin cuenta explícita. La cuenta activa puede haberla cambiado otra sesión; antepón gh auth switch --user <cuenta-del-proyecto> && en el mismo comando.",
30
+ },
31
+ {
32
+ id: "merge",
33
+ when: (verbs) => verbs.some(([t, a, b]) => t === "gh" && a === "pr" && b === "create"),
34
+ say: "kj, PR abierta; antes de mergearla: si la card no está terminada, pártela ahora (lo hecho en su card, el resto en una nueva). Tras el merge, muévela con sus commits.",
35
+ },
36
+ {
37
+ id: "sync-main",
38
+ when: (verbs, segs) => segs.some((w) => w.includes("main")) && verbs.some(([t, a]) => t === "git" && ["checkout", "switch", "pull"].includes(a)),
39
+ say: "kj: tras sincronizar main, crea ya la rama de la siguiente tarea desde origin/main. En main nunca se commitea.",
40
+ },
41
+ ];
42
+
43
+ /**
44
+ * The reminders due after a Bash command ran, minus those already shown within
45
+ * the last `every` actions.
46
+ * @param {string} cmd
47
+ * @param {{seen?: Record<string, number>, step?: number, every?: number}} [opts]
48
+ * seen: action number at which each reminder was last shown; step: this action's number.
49
+ * @returns {{id: string, say: string}[]}
50
+ */
51
+ export const remindersFor = (cmd, { seen = {}, step = 0, every = 25 } = {}) => {
52
+ const segs = shellSegments(cmd);
53
+ const verbs = segs.map(verbOf).filter(Boolean);
54
+ return REMINDERS.filter((r) => r.when(verbs, segs))
55
+ .filter((r) => !Object.hasOwn(seen, r.id) || step - seen[r.id] > every)
56
+ .map(({ id, say }) => ({ id, say }));
57
+ };
@@ -0,0 +1,95 @@
1
+ // The Sentinel's shell reader (KJC-TSK-0915, ADR 0014). A real module with no
2
+ // dependencies: kj harden copies it byte for byte into .karajan/harness as
3
+ // sentinel-shell.mjs, so the hooks stay autonomous and this code is unit-tested.
4
+ // It reads command TEXT; whatever it cannot read with certainty, the guards that
5
+ // use it treat as unknowable and deny.
6
+
7
+ /**
8
+ * Simple commands as word lists, quote-aware: an operator or blank inside
9
+ * quotes belongs to the word ('user;work.txt' is one path).
10
+ * @param {string} cmd
11
+ * @returns {string[][]}
12
+ */
13
+ export const shellSegments = (cmd) => {
14
+ const segs = [[]];
15
+ let word = null;
16
+ let quote = null;
17
+ let escaped = false;
18
+ const end = () => {
19
+ if (word !== null) segs.at(-1).push(word);
20
+ word = null;
21
+ };
22
+ for (const ch of String(cmd)) {
23
+ // A backslash keeps the next char literal (an escaped blank joins the word),
24
+ // except inside single quotes.
25
+ if (escaped) { word += ch; escaped = false; continue; }
26
+ if (ch === "\\" && quote !== "'") { escaped = true; word ??= ""; continue; }
27
+ if (quote) {
28
+ if (ch === quote) quote = null;
29
+ else word += ch;
30
+ continue;
31
+ }
32
+ if (ch === "'" || ch === '"') { quote = ch; word ??= ""; continue; }
33
+ // >| and >& are redirections, not a pipe or a fork.
34
+ if ((ch === "|" || ch === "&") && word?.endsWith(">")) { word += ch; continue; }
35
+ // x>file: end "x" and start the redirection word ">" (the target follows in it).
36
+ if (ch === ">" && word !== null && !/^\d*>?$/.test(word)) { end(); word = ">"; continue; }
37
+ // ( ) { } and backticks also cut: what runs inside $( ), a subshell or a group
38
+ // is a command of its own. A backtick leaves "$" in the word it interrupts, as
39
+ // $( does: that word is no longer readable.
40
+ if (ch === "`") word = (word ?? "") + "$";
41
+ if (";&|(){}\n`".includes(ch)) { end(); segs.push([]); continue; }
42
+ if (ch.trim() === "") { end(); continue; }
43
+ word = (word ?? "") + ch;
44
+ }
45
+ end();
46
+ return segs.filter((s) => s.length);
47
+ };
48
+
49
+ const WRAPPERS = new Set(["command", "exec", "sudo", "doas", "nice", "nohup", "time", "env"]);
50
+
51
+ /**
52
+ * Index of the command word. Skips NAME=value assignments AND wrappers (env,
53
+ * sudo, command...) in any interleaving: env MODE=prod tee -> tee. After a
54
+ * wrapper with options (sudo -u root, env -i FOO=1) the command is the first of
55
+ * `heads`; none found means there is no command to read.
56
+ * @param {string[]} words
57
+ * @param {string[]} heads
58
+ */
59
+ export const headIndex = (words, heads) => {
60
+ let i = 0;
61
+ while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || WRAPPERS.has(words[i].split("/").at(-1)))) i++;
62
+ if (!words[i]?.startsWith("-")) return i;
63
+ const k = words.slice(i).findIndex((x) => heads.includes(x.split("/").at(-1)));
64
+ return k < 0 ? words.length : i + k;
65
+ };
66
+
67
+ /**
68
+ * KJC-BUG-0243: blank the quoted text that cannot run: single-quoted spans, and
69
+ * double-quoted spans with no $ or backtick. What remains is what the shell can
70
+ * still expand or execute, so operator and substitution checks read only that.
71
+ * @param {string} cmd
72
+ */
73
+ export const stripInertQuotes = (cmd) => {
74
+ let out = "";
75
+ for (let i = 0; i < cmd.length; i++) {
76
+ const q = cmd[i];
77
+ if (q !== "'" && q !== '"') { out += q; continue; }
78
+ let j = i + 1;
79
+ while (j < cmd.length && cmd[j] !== q) j += q === '"' && cmd[j] === "\\" ? 2 : 1;
80
+ if (j >= cmd.length) return out + cmd.slice(i); // unclosed: left as is, for the caller to deny
81
+ const body = cmd.slice(i + 1, j);
82
+ out += q === "'" || !/[$`]/.test(body) ? q + q : q + body + q;
83
+ i = j;
84
+ }
85
+ return out;
86
+ };
87
+
88
+ // Options whose value is prose, never a path (gh, git, kj).
89
+ const TEXT_OPTION = /(^|\s)(--title|--body|--message|-m|--notes|--description|--ac|--criteria|--reason|--decision|--context|--consequences)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
90
+
91
+ /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
92
+ export const stripTextOptionValues = (cmd) => cmd.replace(TEXT_OPTION, (_m, pre, opt, sep, val) => `${pre}${opt}${sep}${val[0]}${val[0]}`);
93
+
94
+ /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
95
+ export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // kj sentinel SessionStart hook (KJC-TSK-0918, ADR 0014), managed by `kj harden`.
3
+ // A compaction takes exactly what the agent needs most: the rules it read at the
4
+ // start. After a compaction or a resume, this gives them back, short, with the
5
+ // state of the session. A new session gets nothing: CLAUDE.md already brings them.
6
+ // Copied byte for byte into .karajan/harness; never fails a session (exit 0).
7
+ import process from "node:process";
8
+ import { CARD, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
9
+
10
+ const RULES = [
11
+ "1. Karajan gobierna y se le obedece: no cambies políticas, configuración de gates ni exclusiones para pasar un gate. Si uno parece injusto, díselo a tu usuario o usa kj report-issue.",
12
+ "2. Card antes de código; rama desde origin/main; nunca se commitea en main.",
13
+ "3. kj rag query antes de tocar código que no has consultado en esta sesión.",
14
+ "4. El test que falla va primero; la suite nunca se deja en rojo.",
15
+ "5. kj review --staged antes de cada commit: el veredicto va atado al diff exacto.",
16
+ "6. PR atómica: unas 150 líneas, 200 como máximo; mídela con kj pr-size y parte antes, no en el gate.",
17
+ "7. Conventional Commits, cabecera de 100 caracteres como máximo y sin atribución a IA.",
18
+ "8. Card sin terminar con PR mergeada: pártela antes de mergear; tras el merge, muévela con sus commits.",
19
+ "9. Dentro del repo se escribe solo con Edit/Write; los escapes KJ_ALLOW_* son de tu usuario, no tuyos.",
20
+ ];
21
+
22
+ let raw = "";
23
+ process.stdin.on("data", (d) => { raw += d; });
24
+ process.stdin.on("end", () => {
25
+ try {
26
+ const { session_id: sid = "default", source } = JSON.parse(raw || "{}");
27
+ if (source !== "compact" && source !== "resume") process.exit(0);
28
+ const branch = branchOf() || "?";
29
+ const card = CARD.exec(branch)?.[0]?.toUpperCase() ?? "ninguna en la rama";
30
+ const pending = pendingMoves(load().sessions?.[sid]).map((p) => `${p.card ?? "?"} (PR #${p.pr})`);
31
+ const state = `Estado: rama ${branch}, card ${card}${pending.length ? `; cards mergeadas sin mover: ${pending.join(", ")}` : ""}.`;
32
+ const context = ["Karajan (reglas que la compactación se lleva):", ...RULES, state].join("\n");
33
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }));
34
+ } catch { /* never fails a session */ }
35
+ process.exit(0);
36
+ });
@@ -8,7 +8,7 @@
8
8
  * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { join } from "node:path";
@@ -136,6 +136,7 @@ import process from "node:process";
136
136
  import { relative } from "node:path";
137
137
  import { spawnSync } from "node:child_process";
138
138
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
139
+ import { remindersFor } from "./sentinel-reminders.mjs";
139
140
  const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG", "KJ_ALLOW_NO_VERIFY"];
140
141
  let raw = "";
141
142
  process.stdin.on("data", (d) => { raw += d; });
@@ -273,6 +274,15 @@ process.stdin.on("end", () => {
273
274
  // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
274
275
  // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
275
276
  if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
277
+ // KJC-TSK-0917 (ADR 0014): the method's reminders, after the action that precedes
278
+ // the one each rule is about; at most once every 25 Bash actions each. Never a block.
279
+ const st = load();
280
+ const ss = session(st, sid);
281
+ ss.step = (ss.step || 0) + 1;
282
+ const due = remindersFor(cmdText, { seen: ss.reminded || {}, step: ss.step });
283
+ for (const r of due) (ss.reminded ||= {})[r.id] = ss.step;
284
+ save(st);
285
+ if (due.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: due.map((r) => r.say).join(" ") } }));
276
286
  process.exit(0);
277
287
  }
278
288
  const file = input.file_path || input.notebook_path;
@@ -288,9 +298,23 @@ process.stdin.on("end", () => {
288
298
  s.escapes.push(e);
289
299
  (state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
290
300
  }
301
+ // KJC-TSK-0910: the branch size while it is written, with the CI budget
302
+ // (kj pr-size), said once per threshold crossed. Context, never a block.
303
+ let sizeNote = null;
304
+ if (bucket) {
305
+ const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
306
+ let size = null;
307
+ try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
308
+ const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
309
+ if (crossed && (s.size_warned || 0) < crossed) {
310
+ s.size_warned = crossed;
311
+ 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");
312
+ }
313
+ }
291
314
  const ids = Object.keys(state.sessions);
292
315
  if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
293
316
  save(state);
317
+ if (sizeNote) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: sizeNote } }));
294
318
  } catch { /* fail open — the sentinel never breaks a tool call */ }
295
319
  process.exit(0);
296
320
  });
@@ -431,6 +455,10 @@ import { spawnSync } from "node:child_process";
431
455
  import { homedir } from "node:os";
432
456
  import { fileURLToPath } from "node:url";
433
457
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
458
+ // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
459
+ import { shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
460
+ import { DISCARD_VERBS, discardOf, foreignLost } from "./sentinel-discard.mjs";
461
+ import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
434
462
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
435
463
  // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
436
464
  // (firebase --account a@b --project p deploy --only hosting:main), asi que la
@@ -449,6 +477,10 @@ process.stdin.on("data", (d) => { raw += d; });
449
477
  process.stdin.on("end", () => {
450
478
  try {
451
479
  const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
480
+ // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
481
+ process.on("exit", (code) => {
482
+ if (code === 2) console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
483
+ });
452
484
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
453
485
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
454
486
  // only the human, editing outside it.
@@ -459,6 +491,28 @@ process.stdin.on("end", () => {
459
491
  console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
460
492
  process.exit(2);
461
493
  }
494
+ // KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
495
+ // like the supervisor: a session does not loosen the rules that govern it.
496
+ if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
497
+ console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
498
+ process.exit(2);
499
+ }
500
+ // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
501
+ // sesion toca un fichero se anota si estaba limpio: solo entonces un
502
+ // descarte posterior pierde nada mas que lo de la sesion. Antes de
503
+ // cualquier escape; git que falla cuenta como sucio (fail-closed).
504
+ if (relT && !relT.startsWith("..") && !relT.startsWith("/")) {
505
+ const st = load();
506
+ const touch = (session(st, sid).first_touch ||= {});
507
+ // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
508
+ if (!Object.hasOwn(touch, relT)) {
509
+ // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
510
+ const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
511
+ const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
512
+ Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
513
+ save(st);
514
+ }
515
+ }
462
516
  }
463
517
  // Any Bash that NAMES the supervisor's files is denied — a write-verb
464
518
  // blocklist is bypassable (cp, dd, one-liners), and reading them is what
@@ -521,6 +575,28 @@ process.stdin.on("end", () => {
521
575
  process.exit(2);
522
576
  }
523
577
  }
578
+ // KJC-BUG-0238 (#1886): un agente no descarta cambios que no hizo. Sin
579
+ // escape y antes de KJ_SENTINEL_OFF: es perdida de datos del usuario, y
580
+ // guardarlo (git stash push) nunca pierde nada.
581
+ if (tool === "Bash") {
582
+ const cmdD = String(input.command || "");
583
+ // A git discard nested in $( ), <( ), backticks, eval, xargs or sh -c cannot be read: fail-closed.
584
+ const nested = ["$(", "<(", ">(", String.fromCharCode(96)].some((n) => cmdD.includes(n)) || /(^|[ ;&|])(eval|xargs|bash|sh|zsh|env)( |$)/.test(cmdD);
585
+ if (nested && /(^|[^a-z])git([^a-z]|$)/.test(cmdD) && DISCARD_VERBS.some((v) => cmdD.includes(v))) {
586
+ console.error("karajan sentinel: un descarte git dentro de $( ), backticks, eval, xargs o sh -c no es verificable — ejecutalo como comando simple." + doc("discard"));
587
+ process.exit(2);
588
+ }
589
+ const touch = load().sessions?.[sid]?.first_touch || {};
590
+ for (const words of shellSegments(cmdD)) {
591
+ const d = discardOf(words, ROOT);
592
+ const lost = d ? foreignLost(d, touch, ROOT) : [];
593
+ if (lost.length === 0) continue;
594
+ console.error(d.stash
595
+ ? "karajan sentinel: git stash drop/clear borra trabajo guardado que puede no ser de esta sesion — pideselo a tu usuario." + doc("discard")
596
+ : "karajan sentinel: ese comando descarta cambios que esta sesion no hizo (" + lost.slice(0, 5).join(", ") + (lost.length > 5 ? ", +" + (lost.length - 5) : "") + ") — pueden ser trabajo sin commitear de tu usuario. Si estorban, guardalos recuperables (git stash push -- <ficheros>) y avisale; si el cambio es tuyo sobre su trabajo, deshazlo con Edit. Sin escape: es perdida de datos." + doc("discard"));
597
+ process.exit(2);
598
+ }
599
+ }
524
600
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
525
601
  // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
526
602
  // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
@@ -733,7 +809,8 @@ process.stdin.on("end", () => {
733
809
  // (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
734
810
  // carril los caza el escaner de abajo.)
735
811
  const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame))\\b[^;|&<>$\`(){}\\n\\r]*$/;
736
- if (!READONLY.test(cmd)) {
812
+ // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
813
+ if (!READONLY.test(stripInertQuotes(cmd))) {
737
814
  // cd/pushd invalida TODO razonamiento textual de rutas posteriores
738
815
  // (carrera de bypasses confirmada en review: destino bare, con $,
739
816
  // relativas post-cd...): en un comando NO-read-only, cambiar de
@@ -824,7 +901,9 @@ process.stdin.on("end", () => {
824
901
  if (q !== null) return true; // comilla sin cerrar: no verificable
825
902
  return flush();
826
903
  };
827
- if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
904
+ // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
905
+ // of a text option (--title, -m...) is not a path.
906
+ if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
828
907
  if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
829
908
  else {
830
909
  console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
@@ -854,6 +933,21 @@ process.stdin.on("end", () => {
854
933
  }
855
934
  }
856
935
  }
936
+ // KJC-BUG-0237 (#1886): the repo is written through Edit/Write only, the path every gate
937
+ // guards. After the lane guard, whose message is the precise one for another lane.
938
+ if (tool === "Bash") {
939
+ let moved = false; // after cd/pushd/popd, a relative target cannot be placed: fail-closed
940
+ // $( ) and backticks run even inside double quotes: their bodies, read from the raw text, are commands too.
941
+ const raw = String(input.command || "");
942
+ const subs = [...[...raw.matchAll(/[$][(]([^()]*)[)]/g)].map((m) => m[1]), ...raw.split(String.fromCharCode(96)).filter((_, k) => k % 2 === 1)];
943
+ for (const words of [...shellSegments(raw), ...subs.flatMap((s) => shellSegments(s))]) {
944
+ if (["cd", "pushd", "popd"].includes(words[0])) moved = true;
945
+ const inRepo = shellWrites(words).filter((t) => writesRepo(t, ROOT) || (moved && !t.startsWith("/")));
946
+ if (inRepo.length === 0) continue;
947
+ console.error("karajan sentinel: escribir ficheros del repo desde Bash (" + inRepo.slice(0, 3).join(", ") + ") se salta los gates de Edit/Write — usa la tool Edit/Write; para renombrar, git mv; fuera del repo (/tmp, scratchpad) Bash es libre." + doc("bash-write"));
948
+ process.exit(2);
949
+ }
950
+ }
857
951
  // KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
858
952
  // la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
859
953
  // PL-A); los defaults del supervisor viven en el motor y el check
@@ -1129,6 +1223,14 @@ const SCRIPT_BODIES = {
1129
1223
  "posttooluse.mjs": POST_BODY,
1130
1224
  "stop.mjs": STOP_BODY,
1131
1225
  "pretooluse-sentinel.mjs": PRETOOL_BODY,
1226
+ // KJC-TSK-0915 (ADR 0014): real modules, copied byte for byte under the SAME name
1227
+ // (so they can import each other in both places). Being here, the installed
1228
+ // record, the tamper check and the human seal cover them as well.
1229
+ "sentinel-shell.mjs": readFileSync(new URL("./sentinel/sentinel-shell.mjs", import.meta.url), "utf8"),
1230
+ "sentinel-discard.mjs": readFileSync(new URL("./sentinel/sentinel-discard.mjs", import.meta.url), "utf8"),
1231
+ "sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
1232
+ "sentinel-reminders.mjs": readFileSync(new URL("./sentinel/sentinel-reminders.mjs", import.meta.url), "utf8"),
1233
+ "sessionstart.mjs": readFileSync(new URL("./sentinel/sessionstart.mjs", import.meta.url), "utf8"),
1132
1234
  };
1133
1235
 
1134
1236
  /**
@@ -1232,6 +1334,7 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1232
1334
  // first attempt at this fix, and the review was right to reject it.
1233
1335
  const sealed = sealedByPath(root, gitShowFn);
1234
1336
  const ownRecord = readInstalledRecord(dir);
1337
+ const hasRecord = existsSync(join(dir, INSTALLED_RECORD));
1235
1338
  const drift = [];
1236
1339
  const tampered = [];
1237
1340
  const regenerated = [];
@@ -1240,6 +1343,10 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1240
1343
  const hash = text === undefined ? null : sha256(text);
1241
1344
  if (hash && sealed.get(`.karajan/harness/${name}`) === hash) drift.push(name);
1242
1345
  else if (hash && ownRecord[name] === hash) regenerated.push(name);
1346
+ // KJC-TSK-0915: absent from an install kj RECORDED (installed.json exists), never
1347
+ // recorded and never sealed = a guard newer than this install, not a deleted
1348
+ // one. Writing it only adds. A dir with no record is not a kj install at all.
1349
+ else if (!hash && hasRecord && !Object.hasOwn(ownRecord, name) && !sealed.has(`.karajan/harness/${name}`)) regenerated.push(name);
1243
1350
  else tampered.push(name);
1244
1351
  }
1245
1352
  // KJC-BUG-0224 caso 1: lo que kj escribio y nadie sello ni toco se pone al
@@ -1319,7 +1426,13 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1319
1426
  // edit matchers wired, the board-sync recorder never ran in production.
1320
1427
  { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
1321
1428
  { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
1429
+ // KJC-BUG-0236 (#1886): the rag-first ledger records MCP answers here;
1430
+ // unwired, the official tool never satisfied the gate.
1431
+ { event: "PostToolUse", matcher: "mcp__.*__kj_rag_query", script: "posttooluse.mjs" },
1322
1432
  { event: "Stop", script: "stop.mjs" },
1433
+ // KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
1434
+ { event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
1435
+ { event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
1323
1436
  ],
1324
1437
  });
1325
1438
  return { scripts: [lib, post, stop, pre], wired, deferred };
@@ -117,6 +117,16 @@ export async function commitSupervisorRegeneration({
117
117
  `harden --commit es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`,
118
118
  );
119
119
  }
120
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
121
+ // KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
122
+ // un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
123
+ // card equivocada. Se para ANTES de pedir nada al humano y se nombra.
124
+ const foreign = run(["diff", "--cached", "--name-only"]).split("\n").filter((f) => f && !f.startsWith(HOOKS_PREFIX) && f !== PROVENANCE_FILE);
125
+ if (foreign.length > 0) {
126
+ throw new Error(
127
+ `harden --commit: hay cambios en el stage que no son del supervisor (${foreign.slice(0, 5).join(", ")}${foreign.length > 5 ? ", …" : ""}): commitéalos en su rama o sácalos del stage (git restore --staged <ficheros>) antes de sellar`,
128
+ );
129
+ }
120
130
  // Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
121
131
  // prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
122
132
  // Un alimentador ciego no conoce el código; automatizar su lectura exige
@@ -126,7 +136,6 @@ export async function commitSupervisorRegeneration({
126
136
  if (answer !== nonce) {
127
137
  throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
128
138
  }
129
- const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
130
139
  const drift = supervisorDrift({ projectDir, gitFn: run });
131
140
  // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
132
141
  // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
@@ -8,7 +8,8 @@
8
8
  * text windows (chunkSource already does).
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
- import { closeSync, lstatSync, openSync, readSync } from "node:fs";
11
+ import { closeSync, lstatSync, openSync, readFileSync, readSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
12
13
 
13
14
  import { matchesAny } from "@karajan-family/governance";
14
15
 
@@ -40,6 +41,35 @@ const skipsFor = (projectDir) => {
40
41
  return skipCache.get(key);
41
42
  };
42
43
 
44
+ /**
45
+ * KJC-TSK-0900: `.ragignore` at the project root, gitignore syntax, versioned so
46
+ * the whole team inherits it. `dir/` is a folder anywhere, `*.csv` an extension
47
+ * anywhere, `/data` anchored to the root, a path with a slash is from the root.
48
+ * `!` negation is not supported (said in the docs). Re-read when it changes.
49
+ */
50
+ const ragignoreCache = new Map();
51
+ export function ragignoreGlobs(projectDir) {
52
+ if (!projectDir) return [];
53
+ const file = join(projectDir, ".ragignore");
54
+ let mtime;
55
+ try { mtime = statSync(file).mtimeMs; } catch { return []; }
56
+ const hit = ragignoreCache.get(file);
57
+ if (hit?.mtime === mtime) return hit.globs;
58
+ const globs = [];
59
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
60
+ let p = raw.trim();
61
+ if (!p || p.startsWith("#") || p.startsWith("!")) continue;
62
+ const dirOnly = p.endsWith("/");
63
+ p = p.replace(/^\/+|\/+$/g, "");
64
+ if (!p) continue;
65
+ const base = raw.trim().startsWith("/") || p.includes("/") ? p : `**/${p}`;
66
+ globs.push(`${base}/**`);
67
+ if (!dirOnly) globs.push(base);
68
+ }
69
+ ragignoreCache.set(file, { mtime, globs });
70
+ return globs;
71
+ }
72
+
43
73
  /** What the project keeps out of its index: `rag.exclude`, repo-relative globs. */
44
74
  export const ragExclude = (config) => (Array.isArray(config?.rag?.exclude) ? config.rag.exclude : []);
45
75
 
@@ -63,6 +93,7 @@ function looksBinary(abs) {
63
93
  export function indexableReason(rel, abs, { exclude = [], projectDir = null, skip = skipsFor(projectDir) } = {}) {
64
94
  if (skip(rel)) return `${rel} lives under a path the indexer always skips`;
65
95
  if (matchesAny(rel, exclude)) return `${rel} is excluded by the project's rag.exclude`;
96
+ if (matchesAny(rel, ragignoreGlobs(projectDir))) return `${rel} is excluded by the project's .ragignore`;
66
97
  if (SENSITIVE.some((re) => re.test(rel))) return `${rel} may hold secrets, and the embedder can be a remote service`;
67
98
  if (GENERATED.some((re) => re.test(rel))) return `${rel} is a generated file (lockfile, minified, sourcemap or snapshot)`;
68
99
  let st;
@@ -7,11 +7,22 @@
7
7
  * toca: borrarla es decision del usuario, no efecto de una migracion.
8
8
  */
9
9
  import { existsSync } from "node:fs";
10
+ import { sep } from "node:path";
10
11
 
11
12
  import { openVecStore, insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
12
13
 
13
14
  export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
14
15
 
16
+ /**
17
+ * KJC-BUG-0258: chunks of the project's OWN files (sources under projectDir), as
18
+ * opposed to its plans and briefs. A prefix compare, not LIKE: paths may hold % or _.
19
+ */
20
+ export const countProjectSources = (db, slug, projectDir) => {
21
+ // path.sep: sources are stored as native paths (backslashes on Windows).
22
+ const prefix = projectDir.replace(/[\\/]+$/, "") + sep;
23
+ return db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ? AND substr(source, 1, ?) = ?").get(slug, prefix.length, prefix).n;
24
+ };
25
+
15
26
  /**
16
27
  * KJC-TSK-0882: que decir cuando el indice del proyecto no tiene nada suyo.
17
28
  * Si la base global tiene sus chunks, migrar (segundos); si no, indexar.
@@ -29,9 +40,12 @@ export function emptyIndexRemedy({ slug, legacyPath, dim = 768 }) {
29
40
  }
30
41
 
31
42
  /**
32
- * @returns {{state: "migrated"|"already"|"nothing", migrated: number, reason: string}}
43
+ * KJC-BUG-0255: `keep(source)` is the indexer's criterion today (generated,
44
+ * excluded, .ragignore, sensitive, gone). The global index was filled before
45
+ * those rules, so copying it blind brought vendor/*.min.js along as noise.
46
+ * @returns {{state: "migrated"|"already"|"nothing", migrated: number, skipped?: number, reason: string}}
33
47
  */
34
- export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 }) {
48
+ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768, keep = () => true }) {
35
49
  const nothing = { state: "nothing", migrated: 0, reason: `the global index holds nothing for ${slug}: kj rag index --with-sources` };
36
50
  if (!existsSync(legacyPath)) return nothing;
37
51
 
@@ -49,8 +63,10 @@ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 })
49
63
  const rows = legacy.prepare("SELECT id, source, kind, text, metadata, content_hash FROM chunks WHERE project_slug = ?").all(slug);
50
64
  if (rows.length === 0) return nothing;
51
65
  const vecOf = legacy.prepare("SELECT embedding FROM vec_chunks WHERE rowid = ?");
66
+ let skipped = 0;
52
67
  const copy = target.transaction(() => {
53
68
  for (const r of rows) {
69
+ if (!keep(r.source)) { skipped++; continue; }
54
70
  const raw = vecOf.get(BigInt(r.id))?.embedding;
55
71
  if (!raw) continue; // un chunk sin vector no se puede buscar: no se inventa
56
72
  const embedding = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
@@ -63,7 +79,8 @@ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 })
63
79
  const migrated = countProjectChunks(target, slug);
64
80
  const parts = [`${migrated} chunks of ${slug} copied with their embeddings`];
65
81
  if (stamp) parts.push(`indexed at ${stamp.slice(0, 9)}`);
66
- return { state: "migrated", migrated, reason: parts.join(", ") };
82
+ if (skipped) parts.push(`${skipped} left out (the indexer would not take them today: generated, excluded or gone)`);
83
+ return { state: "migrated", migrated, skipped, reason: parts.join(", ") };
67
84
  } finally {
68
85
  legacy.close();
69
86
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * KJC-BUG-0235 — are the lines a diff ADDS only comments? A cleanup deletes
3
+ * code and corrects the comments that were left lying about it; those added
4
+ * lines carry no behavior to test. Decided by scanning the resulting file, not
5
+ * by a line's first characters: " * factor" can continue a product. Strings are
6
+ * kept as code, so a docstring or a `//` inside a string is never a comment.
7
+ * An unknown syntax is not judged: everything counts as code (conservative).
8
+ */
9
+ import { extname } from "node:path";
10
+
11
+ // JSX/TSX and PHP are out: a `//` in a JSX text child or outside `<?php` is
12
+ // rendered content. JSX inside a .js/.ts file is detected below.
13
+ const C_STYLE = new Set([".js", ".mjs", ".cjs", ".ts", ".java", ".cs", ".go", ".kt", ".swift", ".c", ".cc", ".cpp", ".h", ".hpp", ".rs", ".scala"]);
14
+ // Shell and Ruby are left out on purpose: a `#` line inside a heredoc is
15
+ // content, not a comment, and this scanner does not parse heredocs.
16
+ const HASH_STYLE = new Set([".py"]);
17
+ const OPENER_AFTER = new Set([";", "{", "}", "(", ")", ",", "=", ":", "?", "!", "&", "|"]);
18
+
19
+ /** @returns {Set<number>|null} 1-based lines holding code, or null for an unknown syntax */
20
+ export function codeLines(content, file) {
21
+ const ext = extname(file).toLowerCase();
22
+ const cStyle = C_STYLE.has(ext);
23
+ if (!cStyle && !HASH_STYLE.has(ext)) return null;
24
+ const lines = new Set();
25
+ let line = 1;
26
+ let state = "code"; // code | line | block | string
27
+ let quote = "";
28
+ const s = String(content);
29
+ let step; // characters consumed by the current iteration (set in the body)
30
+ let prev = ""; // last non-space character seen in code on this line
31
+ let word = ""; // identifier being read in code
32
+ let lastWord = ""; // last complete identifier in code
33
+ for (let i = 0; i < s.length; i += step) {
34
+ const c = s[i];
35
+ const next = s[i + 1];
36
+ step = 1;
37
+ if (c === "\n") prev = "";
38
+ if (c === "\n") {
39
+ line++;
40
+ if (state === "line") state = "code";
41
+ // A line inside a multi-line string is part of its value, even if blank.
42
+ if (state === "string") lines.add(line);
43
+ } else if (state === "block") {
44
+ if (c === "*" && next === "/") { state = "code"; step = 2; }
45
+ } else if (state === "string") {
46
+ lines.add(line);
47
+ if (c === "\\") {
48
+ // An escaped newline continues the string on the next physical line: count it.
49
+ if (next === "\n") { line++; lines.add(line); }
50
+ step = 2;
51
+ } else if (c === quote) {
52
+ state = "code";
53
+ }
54
+ } else if (state === "code") {
55
+ // A comment opens only where one can: line start or after a separator.
56
+ // After `\`, `[` or an identifier a slash may belong to a regex literal
57
+ // (/a\/*b/, /[/*]/), so it is taken as code: in doubt, code.
58
+ const canOpen = prev === "" || OPENER_AFTER.has(prev);
59
+ if (/[\w$]/.test(c)) word += c;
60
+ else if (word) { lastWord = word; word = ""; }
61
+ // A tag where an expression starts (`= <div>`, `return <App />`) is JSX:
62
+ // its text children are rendered, so this file is not judged.
63
+ if (cStyle && c === "<" && /[A-Za-z/>]/.test(next ?? "") && (canOpen || lastWord === "return")) return null;
64
+ if (cStyle && canOpen && c === "/" && next === "/") state = "line";
65
+ else if (cStyle && canOpen && c === "/" && next === "*") { state = "block"; step = 2; }
66
+ else if (!cStyle && c === "#") state = "line";
67
+ else if (c === '"' || c === "'" || c === "`") { state = "string"; quote = c; lines.add(line); }
68
+ else if (!/\s/.test(c)) lines.add(line);
69
+ if (state === "code" && !/\s/.test(c)) prev = c;
70
+ }
71
+ }
72
+ return lines;
73
+ }
74
+
75
+ const COMMENT_START = /^(\/\/|\/\*|\*|#)/;
76
+
77
+ /**
78
+ * Every added line (1-based, in the resulting file) is a comment or blank.
79
+ * Two checks must agree: the scanner says no code on the line AND the line
80
+ * itself starts like a comment (or is blank). The second one is a net against
81
+ * a scanner mistake, and it costs a knowingly accepted false negative: a block
82
+ * comment continuation without a leading `*` counts as code. Agreed criterion
83
+ * (KJC-BUG-0235): better a cleanup that still asks for tests than code let through.
84
+ */
85
+ export function addedAreCommentsOnly(content, addedLines, file) {
86
+ const code = codeLines(content, file);
87
+ if (!code) return false;
88
+ const text = String(content).split("\n");
89
+ return [...addedLines].every((n) => {
90
+ const t = (text[n - 1] ?? "").trim();
91
+ return !code.has(n) && (t === "" || COMMENT_START.test(t));
92
+ });
93
+ }
@@ -42,8 +42,11 @@ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = nu
42
42
  // cleanup PRs). Callers that only know names keep the old behavior.
43
43
  if (Array.isArray(numstat)) {
44
44
  const bySource = numstat.filter((n) => sources.includes(n.file));
45
- if (bySource.length === sources.length && bySource.every((n) => (n.added || 0) === 0)) {
46
- return { ok: true, mode: "delete-only", sources, reason: "every touched source only removes lines — deleting is not new behavior" };
45
+ // KJC-BUG-0235: a cleanup also corrects the comments the deletion left
46
+ // lying; lines the caller PROVED to be comments carry no behavior either.
47
+ const cleanup = (n) => (n.added || 0) === 0 || n.commentOnly === true;
48
+ if (bySource.length === sources.length && bySource.every(cleanup)) {
49
+ return { ok: true, mode: "delete-only", sources, reason: "every touched source only removes lines or corrects comments — deleting is not new behavior" };
47
50
  }
48
51
  }
49
52