karajan-code 4.8.0 → 4.10.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.
@@ -0,0 +1,40 @@
1
+ # Logical clocks & happened-before (Lamport, 1978)
2
+
3
+ ## Problem signature
4
+
5
+ Events across processes/services need a consistent order, and wall clocks
6
+ disagree: "last write wins" flip-flops between replicas, audit logs interleave
7
+ impossibly, a consumer sees an update before the create it depends on.
8
+
9
+ ## Reach for it when
10
+
11
+ - Two or more writers/emitters produce events whose CAUSAL order matters
12
+ (A caused B must never be observed as B before A).
13
+ - You are tempted to "fix it with NTP" or timestamps — clock skew makes
14
+ timestamp ordering a race, not an order.
15
+ - You need to detect concurrent (conflicting) updates, not just order them:
16
+ that is the vector-clock extension (one counter per node; compare
17
+ component-wise; incomparable = concurrent — see Dynamo's usage).
18
+
19
+ ## Do NOT reach for it when
20
+
21
+ - A single writer (or a single database) already totally orders the events —
22
+ its sequence/autoincrement/WAL IS the clock. Most CRUD apps live here.
23
+ - You only need "roughly recent first" for humans (feeds, logs for reading):
24
+ wall-clock timestamps are fine and far simpler.
25
+ - You need a TOTAL order agreed by all nodes: logical clocks give a partial
26
+ causal order; total order needs consensus (Raft/Paxos) or a sequencer.
27
+
28
+ ## Trade-offs
29
+
30
+ - Lamport clocks are one counter: tiny, but they cannot tell concurrent from
31
+ ordered. Vector clocks can, at O(nodes) metadata per event that must be
32
+ carried, stored and pruned.
33
+ - Causality tracking pushes conflict RESOLUTION to the reader (semantic
34
+ merge, CRDTs, or ask-the-user) — ordering was the easy half.
35
+
36
+ ## Canonical source
37
+
38
+ Leslie Lamport, "Time, Clocks, and the Ordering of Events in a Distributed
39
+ System", Communications of the ACM, 1978. The happened-before relation and
40
+ the clock condition come verbatim from it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.8.0",
3
+ "version": "4.10.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",
@@ -48,6 +48,7 @@
48
48
  "files": [
49
49
  "src/",
50
50
  "bin/",
51
+ "library/",
51
52
  "templates/",
52
53
  "scripts/",
53
54
  "vendor/tree-sitter-grammars/",
@@ -108,7 +109,7 @@
108
109
  "express-rate-limit": "^8.5.0",
109
110
  "helmet": "^8.1.0",
110
111
  "js-yaml": "^4.2.0",
111
- "karajan-core": "^1.0.0",
112
+ "karajan-core": "^1.4.0",
112
113
  "karajan-rag": "^1.2.0",
113
114
  "knip": "^6.15.0",
114
115
  "madge": "^8.0.0",
@@ -146,7 +146,8 @@ const huBoardStubPlugin = {
146
146
  const ragStubPlugin = {
147
147
  name: "rag-stub",
148
148
  setup(build) {
149
- build.onResolve({ filter: /[\\/](rag[\\/].+|commands[\\/](rag|watch))\.js$/ }, (args) => ({
149
+ // KJC-TSK-0704 — src/privacy/* rides this stub too: its engine is redactPII.
150
+ build.onResolve({ filter: /[\\/](rag[\\/].+|privacy[\\/].+|commands[\\/](rag|watch|privacy))\.js$/ }, (args) => ({
150
151
  path: args.path, namespace: "rag-stub",
151
152
  }));
152
153
  build.onLoad({ filter: /.*/, namespace: "rag-stub" }, () => ({
@@ -192,6 +193,10 @@ const ragStubPlugin = {
192
193
  // reached from \`kj rag install-hooks\`, so it degrades like the rest.
193
194
  maybeAutoUpdate: async () => ({ skipped: true }),
194
195
  installPostMergeHook: notAvailable,
196
+ // KJC-TSK-0704 — privacy scan (engine = karajan-rag redactPII).
197
+ privacyScanCommand: notAvailable, loadPrivacyList: notAvailable,
198
+ scanText: notAvailable, scanPaths: notAvailable, privacyConfigPath: notAvailable,
199
+ ensurePrivacyList: async () => ({ created: false, reason: "sea-stub" }),
195
200
  // KJC-BUG-0100 — the doctor rag-hooks check imports this; it throws
196
201
  // here and the check swallows it (degrades to a benign info result).
197
202
  resolveHooksDir: notAvailable,
@@ -85,6 +85,23 @@ try {
85
85
  }
86
86
  console.log(`verify-pack: kj --version → ${versionOut} ✓`);
87
87
 
88
+ // 1.5 Privacy boundary (KJC-TSK-0706): nothing personal or secret-shaped
89
+ // ships inside the tarball. Scans the INSTALLED package with the same
90
+ // engine as the pre-commit gate: denylist + token shapes abort the
91
+ // publish; generic PII only counts (code trees always carry a few
92
+ // pattern lookalikes).
93
+ const { scanPaths, loadPrivacyList } = await import(
94
+ path.join(repoRoot, "src", "privacy", "scan.js")
95
+ );
96
+ const shippedDir = path.join(tmpDir, "node_modules", "karajan-code");
97
+ const pFindings = scanPaths([shippedDir], { list: loadPrivacyList() });
98
+ const pBlocks = pFindings.filter((f) => f.severity === "block");
99
+ if (pBlocks.length > 0) {
100
+ for (const f of pBlocks) console.error(`✗ [${f.type}] ${path.relative(shippedDir, f.source)}:${f.line} → ${f.masked}`);
101
+ fail(`${pBlocks.length} personal-data/secret hit(s) INSIDE the tarball — this must not publish`);
102
+ }
103
+ console.log(`verify-pack: tarball privacy scan ✓ (${pFindings.length} generic warning(s))`);
104
+
88
105
  // 2. kj --help must exit 0 (exercises the command tree wiring).
89
106
  try {
90
107
  run(binPath, ["--help"]);
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
30
30
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan"] },
31
31
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
32
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar"] },
33
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy"] },
34
34
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby"] },
35
35
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
36
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -134,6 +134,7 @@ export function registerMeta(program, { pkgVersion }) {
134
134
  .description("Install/refresh the Karajan playbook for any host agent (CLAUDE.md, AGENTS.md, GEMINI.md)")
135
135
  .option("--target <target>", "claude | codex | gemini | all", "all")
136
136
  .option("--no-rag", "Skip building the RAG index when the project has none (ENV-E1)")
137
+ .option("--no-enforce", "Skip installing the git hooks + verdict gate (KJC-BUG-0133 — installing IS activating; skipping this leaves the method narratable)")
137
138
  .action(async (flags) => {
138
139
  await withConfig(pkgVersion, "env-install", flags, async ({ config, logger }) => {
139
140
  const r = await envInstallCommand({ config, logger, flags });
@@ -261,6 +262,17 @@ export function registerMeta(program, { pkgVersion }) {
261
262
  });
262
263
  });
263
264
 
265
+ // KJC-TSK-0704 — the outbound privacy boundary: audit before anything ships.
266
+ const privacy = program.command("privacy").description("Personal-data (PII) auditing of outbound boundaries: staged diffs, build outputs, docs trees");
267
+ privacy.command("scan [paths...]")
268
+ .description("Scan files/dirs for personal data, always masked in output: denylist hits (~/.karajan/privacy.yml) block with exit 1, generic PII (email/phone/DNI/NIE/IBAN/card via karajan-rag redactPII) warns")
269
+ .option("--staged", "Scan the ADDED lines of the staged diff instead of paths")
270
+ .option("--json", "Machine-readable result (stdout carries exactly one JSON document)")
271
+ .action(async (paths, flags) => {
272
+ const { privacyScanCommand } = await import("../commands/privacy.js");
273
+ await privacyScanCommand({ paths, flags });
274
+ });
275
+
264
276
  const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
265
277
  rag.command("index")
266
278
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
@@ -294,6 +306,7 @@ export function registerMeta(program, { pkgVersion }) {
294
306
  .option("--rag-mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
295
307
  .option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
296
308
  .option("--where <clause>", "Metadata filter, e.g. 'symbol=loadConfig' or 'hu_id=HU-003 AND kind=plan'")
309
+ .option("--library", "Query the distilled engineering canon (pattern cards shipped with kj + ~/.karajan/library + project .karajan/library) instead of the project corpus. Cards carry problem signatures, when-NOT-to-apply and canonical citations (KJC-TSK-0697).")
297
310
  .option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
298
311
  .option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
299
312
  .option("--json", "Output the hits as JSON")
@@ -4,7 +4,7 @@
4
4
  * files (CLAUDE.md, AGENTS.md), and make its step 1 real: when the project
5
5
  * has no RAG index yet, build it (default ON, `--no-rag` opts out).
6
6
  */
7
- import { installPlaybook } from "../environment/playbook.js";
7
+ import { installPlaybook, renderPlaybook } from "../environment/playbook.js";
8
8
  import { renderBrief, listBriefs } from "../environment/briefs.js";
9
9
  import { existsSync } from "node:fs";
10
10
  import { openVecStore, projectSlug, getLastIndexedCommit, dbPath } from "../rag/vec-store.js";
@@ -12,6 +12,11 @@ import { ragIndexCommand } from "./rag.js";
12
12
  import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
13
13
  import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
14
14
  import { verifyBoardAccess } from "../environment/board-access.js";
15
+ import { ensurePrivacyList } from "../privacy/onboarding.js";
16
+ import { createWizard } from "../utils/wizard.js";
17
+ import { hardenCommand } from "./harden.js";
18
+ import { reviewGateCommand } from "./review-gate.js";
19
+ import { join } from "node:path";
15
20
 
16
21
  function hasRagIndex(config, projectDir) {
17
22
  // KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
@@ -66,6 +71,35 @@ export async function envInstallCommand({ config = null, logger = null, flags =
66
71
  }
67
72
  if (access.backend !== "hu-board") console.log(`✓ board access verified: ${access.via}`);
68
73
 
74
+ // KJC-BUG-0133 — installing IS activating. The setup flow used to order
75
+ // harden + the verdict gate as TEXT steps the agent runs (or narrates);
76
+ // a session that skipped them got a decorative method — field case
77
+ // 2026-08-02: installed karajan, then wrote everything by hand with
78
+ // nothing to stop it. env install now does them itself, idempotently.
79
+ if (flags.enforce !== false) {
80
+ if (!existsSync(join(projectDir, ".git"))) {
81
+ result.exitCode = PENDING_EXIT_CODE;
82
+ console.log(renderPendingBlock(
83
+ [{ tool: "git", action: "needs-user", reason: "the enforcement gates live in git hooks — run `git init` (Karajan's guarantees do not exist without git)" }],
84
+ { retry: "kj env install" },
85
+ ));
86
+ return result;
87
+ }
88
+ try {
89
+ const h = await hardenCommand({ projectDir, logger: console });
90
+ if (h?.ok === false) throw new Error(h.error || "kj harden failed");
91
+ await reviewGateCommand({ config, flags: { installGate: true } });
92
+ console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
93
+ } catch (err) {
94
+ result.exitCode = PENDING_EXIT_CODE;
95
+ console.log(renderPendingBlock(
96
+ [{ tool: "enforcement", action: "needs-user", reason: `hooks/gate could not be installed: ${err.message} — without them the method is narratable` }],
97
+ { retry: "kj env install" },
98
+ ));
99
+ return result;
100
+ }
101
+ }
102
+
69
103
  // ENV-E1: RAG-first — the playbook orders "query the RAG before coding",
70
104
  // so installing the environment guarantees the index exists. KJC-TSK-0659
71
105
  // stop-on-sudo: an index that cannot be built is a BLOCKING condition —
@@ -128,7 +162,19 @@ export async function envInstallCommand({ config = null, logger = null, flags =
128
162
  }
129
163
  }
130
164
  if (result.exitCode !== PENDING_EXIT_CODE) {
165
+ // KJC-TSK-0707 (PV-D) — privacy onboarding rides the install: kj asks,
166
+ // kj writes the denylist. Best-effort (stubbed in the SEA binary).
167
+ try {
168
+ const wizard = process.stdin.isTTY && process.stdout.isTTY ? createWizard() : null;
169
+ await ensurePrivacyList({ wizard, logger: console, isTTY: Boolean(wizard) });
170
+ wizard?.close();
171
+ } catch { /* privacy onboarding is best-effort */ }
131
172
  console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
173
+ // KJC-BUG-0133 (part 2): a mid-session install lands the playbook in
174
+ // CLAUDE.md, but the running session loaded its context BEFORE — nobody
175
+ // re-reads it. Print the method so it enters THIS conversation now.
176
+ console.log("\n— The Karajan method below is IN EFFECT from this very message. If your session started before this install, apply it from now on:\n");
177
+ console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null }));
132
178
  }
133
179
  return result;
134
180
  }
@@ -18,6 +18,7 @@ import { installConfigsForRoots } from "../harden/config-engine.js";
18
18
  import { installGuidelines } from "../harden/guidelines-engine.js";
19
19
  import { commandsForLanguage } from "../harden/hook-commands.js";
20
20
  import { installHooks } from "../harden/harden-engine.js";
21
+ import { installHarnessHooks } from "../harden/harness-hooks.js";
21
22
  import { detectStackRoots } from "../harden/stack-roots.js";
22
23
  import { installWorkflows } from "../harden/workflow-engine.js";
23
24
  import { detectTestFramework } from "../utils/project-detect.js";
@@ -140,6 +141,12 @@ export async function hardenCommand({
140
141
  let result;
141
142
  try {
142
143
  result = await installHooks({ projectDir, profile, cmds, dryRun, baseBranch });
144
+ // KJC-TSK-0710 — the TOOL gate: rules imposed at tool time (Claude Code
145
+ // PreToolUse). Standard+ only; minimal stays hooks-lite.
146
+ if (profile !== "minimal" && !dryRun) {
147
+ const hh = installHarnessHooks({ projectDir, logger });
148
+ result.harnessHooks = hh.wired ? "wired" : "script-only";
149
+ }
143
150
  } catch (err) {
144
151
  if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
145
152
  else logger.error?.(`kj harden: ${err.message}`);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `kj privacy scan` (KJC-TSK-0704) — audit an outbound boundary before it
3
+ * ships: files/dirs or the staged diff's ADDED lines. Denylist blocks
4
+ * (exit 1), generic PII warns; findings always come out masked.
5
+ */
6
+ import { runCommand } from "../utils/process.js";
7
+ import { loadPrivacyList, scanPaths, scanText, privacyConfigPath } from "../privacy/scan.js";
8
+
9
+ const addedLines = (diff) =>
10
+ diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1)).join("\n");
11
+
12
+ export async function privacyScanCommand({ paths = [], flags = {}, logger = console } = {}) {
13
+ const list = loadPrivacyList();
14
+ let findings;
15
+ if (flags.staged) {
16
+ const res = await runCommand("git", ["diff", "--cached", "--no-ext-diff", "--unified=0"]);
17
+ findings = scanText(addedLines(res.stdout || ""), { list, source: "<staged diff>" });
18
+ } else {
19
+ if (paths.length === 0) {
20
+ logger.error?.("kj privacy scan: pass one or more paths, or --staged");
21
+ process.exitCode = 2;
22
+ return { ok: false, findings: [] };
23
+ }
24
+ findings = scanPaths(paths, { list });
25
+ }
26
+
27
+ const blocks = findings.filter((f) => f.severity === "block");
28
+ const warns = findings.filter((f) => f.severity === "warn");
29
+ const ok = blocks.length === 0;
30
+ process.exitCode = ok ? 0 : 1;
31
+ // --json keeps stdout machine-clean: exactly one JSON document, no prose.
32
+ if (flags.json) {
33
+ process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, findings })}\n`);
34
+ return { ok, findings };
35
+ }
36
+ for (const f of blocks) logger.error?.(`✗ BLOCK [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
37
+ for (const f of warns) logger.warn?.(`⚠ warn [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
38
+ if (!list.present) {
39
+ logger.info?.(`hint: no personal denylist found — create ${privacyConfigPath()} (personal: [...], allow: [...]) so YOUR data blocks, not just warns`);
40
+ }
41
+ logger.info?.(ok
42
+ ? `privacy scan: clean of denylist hits (${warns.length} generic warning(s))`
43
+ : `privacy scan: ${blocks.length} personal-data hit(s) — this must not ship`);
44
+ return { ok, findings };
45
+ }
@@ -7,6 +7,7 @@ import { makeGovernedEmbedder } from "../rag/governed-embedder.js";
7
7
  import { indexProject, indexProjectDelta } from "../rag/indexer.js";
8
8
  import { query } from "../rag/retriever.js";
9
9
  import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
10
+ import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
10
11
  import { loadGoldenQueries, runEval } from "../rag/eval.js";
11
12
  import { getKarajanHome } from "../utils/paths.js";
12
13
 
@@ -47,6 +48,13 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
47
48
  });
48
49
  }
49
50
  if (totals.head) setLastIndexedCommit(db, slug, totals.head);
51
+ // KJC-TSK-0697 — the library corpus refreshes on every index run.
52
+ // Best-effort and cheap: a handful of cards, content-hash dedup.
53
+ try {
54
+ await indexLibrary({ db, embedder, logger, projectDir });
55
+ } catch (err) {
56
+ logger.warn?.(`[rag] library index failed: ${err.message}`);
57
+ }
50
58
  if (flags.json) {
51
59
  process.stdout.write(`${JSON.stringify(totals)}\n`);
52
60
  } else {
@@ -87,7 +95,10 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
87
95
  // <slug>` overrides. Pre-v2.27 chunks with NULL slug are only visible
88
96
  // when no filter is in effect.
89
97
  const detected = projectSlug(config?.projectDir || process.cwd());
90
- const project = flags.project === "all" ? null : (flags.project || detected || null);
98
+ // KJC-TSK-0697 — `--library` targets the distilled-canon collection:
99
+ // its own project namespace plus the kind filter.
100
+ const library = Boolean(flags.library);
101
+ const project = library ? LIBRARY_PROJECT : (flags.project === "all" ? null : (flags.project || detected || null));
91
102
  // KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
92
103
  // handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
93
104
  // so agents (and the `/kj-rag-query` skill from Camino B) have a
@@ -101,7 +112,12 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
101
112
  }
102
113
  const mode = flags.mode || "hybrid";
103
114
  const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
104
- const where = flags.where || null;
115
+ // Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
116
+ // AND-joined key=value pairs — an OR or parens never parses, so the
117
+ // composed clause cannot leak past the kind filter; it fails loudly.
118
+ const where = library
119
+ ? (flags.where ? `kind=library AND ${flags.where}` : "kind=library")
120
+ : (flags.where || null);
105
121
  const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
106
122
  const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
107
123
  if (flags.json) {
@@ -5,6 +5,8 @@
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";
9
+ import { isAbsolute, join } from "node:path";
8
10
  import { runCommand } from "../utils/process.js";
9
11
  import { checkVerdict } from "../review/verdict-store.js";
10
12
  import { runOneShotReview } from "../review/one-shot-review.js";
@@ -13,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
13
15
  import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
14
16
  import { checkCardFirst } from "../review/card-first.js";
15
17
  import { checkTestsWithCode } from "../review/tests-with-code.js";
18
+ import { loadPrivacyList, scanText } from "../privacy/scan.js";
16
19
 
17
20
  // KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
18
21
  // (before spending sonar/reviewer effort) AND on --check — the pre-commit
@@ -86,9 +89,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
86
89
 
87
90
  if (flags.installGate) {
88
91
  const fs = await import("node:fs/promises");
89
- const path = await import("node:path");
90
- const marker = path.join(projectDir, ".karajan", "review-gate");
91
- await fs.mkdir(path.dirname(marker), { recursive: true });
92
+ const marker = join(projectDir, ".karajan", "review-gate");
93
+ await fs.mkdir(join(projectDir, ".karajan"), { recursive: true });
92
94
  await fs.writeFile(marker, "# Cross-AI review gate enabled (ENV-C1). Commit this file so the whole team inherits the gate.\n");
93
95
  console.log("✓ review gate enabled — commits now require an approved cross-AI verdict (kj review --staged)");
94
96
  // KJC-TSK-0646: a `.karajan/` dir-exclude would silently keep the
@@ -135,6 +137,44 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
135
137
  }
136
138
  }
137
139
  }
140
+ // KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
141
+ // Denylist data rejects (KJ_ALLOW_PII=1 is the named escape); generic PII
142
+ // warns (privacy.generic: "block" hardens). Added lines only — deletions
143
+ // don't publish. In the SEA binary the privacy module is stubbed and
144
+ // throws: the gate degrades with a note instead of crashing.
145
+ try {
146
+ const added = diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1)).join("\n");
147
+ const findings = scanText(added, { list: loadPrivacyList(), source: "<staged diff>" });
148
+ const blocks = findings.filter((f) => f.severity === "block");
149
+ const warns = findings.filter((f) => f.severity === "warn");
150
+ for (const f of warns) console.log(`⚠ privacy: [${f.type}] added line ${f.line} → ${f.masked} — personal data? move it out before it ships`);
151
+ const hardened = config?.privacy?.generic === "block" && warns.length > 0;
152
+ if (blocks.length > 0 || hardened) {
153
+ if (process.env.KJ_ALLOW_PII === "1") {
154
+ console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
155
+ } else {
156
+ for (const f of blocks) console.log(`✗ privacy: [${f.type}] on added line ${f.line} → ${f.masked}`);
157
+ const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (KJ_ALLOW_PII=1 to override consciously)`;
158
+ console.log(`✗ privacy gate: ${reason}`);
159
+ process.exitCode = 1;
160
+ return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
161
+ }
162
+ }
163
+ } catch (err) {
164
+ // Known degradation: the SEA stub throws its install-from-npm message.
165
+ // Anything else fails CLOSED — a silent skip would defeat the guarantee.
166
+ if (/standalone binary/i.test(err?.message || "")) {
167
+ console.log("⚠ privacy gate unavailable in this build — skipping");
168
+ } else if (process.env.KJ_ALLOW_PII === "1") {
169
+ console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
170
+ } else {
171
+ const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked (KJ_ALLOW_PII=1 to override consciously)`;
172
+ console.log(`✗ ${reason}`);
173
+ process.exitCode = 1;
174
+ return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
175
+ }
176
+ }
177
+
138
178
  const tests = checkTestsWithCode({ config, stagedFiles: changedFiles });
139
179
  if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
140
180
  if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
@@ -145,6 +185,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
145
185
  }
146
186
 
147
187
  if (flags.check) {
188
+ // KJC-BUG-0132 (issue #1344): a PURE merge commit stages no content of
189
+ // its own — everything it brings reached the parent branches already
190
+ // reviewed, and there is no diff to bind a verdict to, so demanding one
191
+ // deadlocks the merge. A merge WITH conflict resolutions stages real
192
+ // content and still requires its verdict below.
193
+ if (!flags.range && !diff.trim()) {
194
+ const gitDirRaw = (await runCommand("git", ["rev-parse", "--git-dir"])).stdout?.trim();
195
+ const gitDir = gitDirRaw && !isAbsolute(gitDirRaw) ? join(projectDir, gitDirRaw) : gitDirRaw;
196
+ if (gitDir && existsSync(join(gitDir, "MERGE_HEAD"))) {
197
+ console.log("✓ pure merge commit — empty staged diff, parents already reviewed; gate passes with trail");
198
+ process.exitCode = 0;
199
+ return { ok: true, merge: true, reason: "pure merge commit (MERGE_HEAD, empty staged diff)" };
200
+ }
201
+ }
148
202
  const res = await checkVerdict(projectDir, diff);
149
203
  console.log(res.ok
150
204
  ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
@@ -36,6 +36,7 @@ const B = {
36
36
  "Behavior changes carry their test in the same step.",
37
37
  "Data-model or API changes never share a step with anything else.",
38
38
  "No PR beyond ~150 net lines — partition upfront, not at the gate.",
39
+ "Non-trivial task? Name at least two approaches — one as if the codebase didn't exist — and say why the winner won.",
39
40
  ],
40
41
  "numbered-free plan: steps {description, files}, risks, out-of-scope."),
41
42
  },
@@ -56,6 +57,8 @@ const B = {
56
57
  [
57
58
  "Check the ADRs before deciding — never against an accepted one.",
58
59
  "Prefer the design that deletes code over the one that adds layers.",
60
+ "One alternative answers: what would you build as if the codebase didn't exist? Picking the legacy-shaped path is fine — picking it by inertia is not.",
61
+ "Ground that alternative in the canon: `kj rag query --library \"<problem signature>\"` serves distilled pattern cards with when-NOT-to-apply and citations.",
59
62
  ],
60
63
  "chosen design, discarded alternatives with reasons, affected boundaries."),
61
64
  },
@@ -72,6 +72,11 @@ Invariants (the git gates enforce these — they are not suggestions):
72
72
  it as a proposed ADR (\`kj adr add\`) and ask — never bury it in a PR
73
73
  bullet. An oversized-diff warning is not an opinion either: partition,
74
74
  or get an explicit OK.
75
+ - A non-trivial plan names at least two approaches — one as if the
76
+ codebase didn't exist — and says why the winner won. Following the
77
+ legacy line is a choice, never a default.
78
+ - Publishing ANY artifact (a build's dist/, a docs site, a tarball)?
79
+ \`kj privacy scan <dir>\` first — nothing personal or secret-shaped ships.
75
80
  - Branch first: never commit on the base branch — every change reaches it
76
81
  through an atomic PR (~150 net lines, Conventional Commits). More than one
77
82
  task, or the base tree must stay untouched? \`kj worktree start <slug>\`
@@ -0,0 +1,80 @@
1
+ /**
2
+ * harness-hooks — the TOOL gate (KJC-TSK-0710, from proposal KJC-PRP-0013).
3
+ * Field case 2026-08-02: text rules were not enough — the environment must
4
+ * impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
5
+ * it into the project's `.claude/settings.json` (merged, never clobbered):
6
+ * Write over an existing file blocks ("use Edit", KJ_ALLOW_WRITE=1 escapes);
7
+ * Bash that reserializes whole JSON files blocks (KJ_ALLOW_REWRITE=1).
8
+ * Claude-only v1 — the abstraction arrives with the second host that
9
+ * supports tool hooks.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ const SCRIPT_REL = join(".karajan", "harness", "pretooluse.mjs");
16
+
17
+ const SCRIPT_BODY = `#!/usr/bin/env node
18
+ // kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
19
+ // tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
20
+ // so a gate bug never bricks the session.
21
+ import { existsSync } from "node:fs";
22
+ let raw = "";
23
+ process.stdin.on("data", (d) => { raw += d; });
24
+ process.stdin.on("end", () => {
25
+ try {
26
+ const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
27
+ if (tool === "Write" && process.env.KJ_ALLOW_WRITE !== "1") {
28
+ if (input.file_path && existsSync(input.file_path)) {
29
+ console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes (KJ_ALLOW_WRITE=1 to override consciously).");
30
+ process.exit(2);
31
+ }
32
+ }
33
+ if (tool === "Bash" && process.env.KJ_ALLOW_REWRITE !== "1") {
34
+ const cmd = String(input.command || "");
35
+ const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
36
+ if (/json\\.dumps?\\s*\\(/.test(cmd) && writes) {
37
+ console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
38
+ process.exit(2);
39
+ }
40
+ }
41
+ } catch { /* fail open */ }
42
+ process.exit(0);
43
+ });
44
+ `;
45
+
46
+ const hookEntry = (matcher) => ({ matcher, hooks: [{ type: "command", command: `node ${SCRIPT_REL}` }] });
47
+
48
+ /** Write the script and merge the PreToolUse entries into .claude/settings.json. */
49
+ export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
50
+ const scriptAbs = join(projectDir, SCRIPT_REL);
51
+ mkdirSync(join(projectDir, ".karajan", "harness"), { recursive: true });
52
+ writeFileSync(scriptAbs, SCRIPT_BODY, { mode: 0o755 });
53
+
54
+ const settingsPath = join(projectDir, ".claude", "settings.json");
55
+ let settings = {};
56
+ if (existsSync(settingsPath)) {
57
+ try {
58
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
59
+ } catch {
60
+ logger.warn?.(`kj harden: ${settingsPath} is not valid JSON — leaving it untouched (tool gate script written, wire it manually)`);
61
+ return { script: scriptAbs, wired: false };
62
+ }
63
+ }
64
+ settings.hooks = settings.hooks || {};
65
+ // Preserve, never clobber: an existing PreToolUse with an unexpected shape
66
+ // is the user's business — leave the file alone (same as invalid JSON).
67
+ if ("PreToolUse" in settings.hooks && !Array.isArray(settings.hooks.PreToolUse)) {
68
+ logger.warn?.(`kj harden: ${settingsPath} has a non-array hooks.PreToolUse — leaving it untouched (tool gate script written, wire it manually)`);
69
+ return { script: scriptAbs, wired: false };
70
+ }
71
+ const pre = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
72
+ for (const matcher of ["Write", "Bash"]) {
73
+ const present = pre.some((e) => e?.matcher === matcher && JSON.stringify(e).includes("pretooluse.mjs"));
74
+ if (!present) pre.push(hookEntry(matcher));
75
+ }
76
+ settings.hooks.PreToolUse = pre;
77
+ mkdirSync(join(projectDir, ".claude"), { recursive: true });
78
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
79
+ return { script: scriptAbs, wired: true };
80
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * privacy onboarding (KJC-TSK-0707, PV-D) — the user never fills YAML by
3
+ * hand: `kj env install` asks and kj writes `~/.karajan/privacy.yml`.
4
+ * Every question skips on Enter; answers are NEVER echoed back.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { stringify as stringifyYaml } from "yaml";
10
+ import { privacyConfigPath } from "./scan.js";
11
+
12
+ export async function ensurePrivacyList({ wizard = null, logger = console, isTTY = process.stdout.isTTY } = {}) {
13
+ const target = privacyConfigPath();
14
+ if (existsSync(target)) return { created: false, reason: "present" };
15
+ if (!isTTY || !wizard) {
16
+ logger.info?.(`⚠ privacy: no personal denylist yet — re-run interactively or create ${target} (personal:/allow:) so YOUR data blocks at every outbound boundary`);
17
+ return { created: false, reason: "non-interactive" };
18
+ }
19
+ logger.info?.("privacy: let's protect your personal data at every outbound boundary (Enter skips any question).");
20
+ const csv = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
21
+ const personal = [
22
+ ...csv(await wizard.input("Personal emails that must NEVER be published (comma-separated):")),
23
+ ...csv(await wizard.input("Personal phone / ID numbers to block (comma-separated, optional):")),
24
+ ...csv(await wizard.input("Full name to block (optional):")),
25
+ ];
26
+ const allow = csv(await wizard.input("Public identities that are FINE to publish (handles, comma-separated):"));
27
+ if (personal.length === 0 && allow.length === 0) {
28
+ logger.info?.("privacy: skipped — generic PII detection still warns; create the denylist later to make YOUR data block");
29
+ return { created: false, reason: "skipped" };
30
+ }
31
+ mkdirSync(dirname(target), { recursive: true });
32
+ writeFileSync(target, `# kj privacy denylist — GLOBAL, never commit this file to any repo.\n${stringifyYaml({ personal, allow })}`);
33
+ logger.info?.(`✓ privacy: denylist written to ${target} (${personal.length} entrada(s), values not echoed)`);
34
+ return { created: true, entries: personal.length };
35
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * privacy — the outbound boundary scanner (KJC-TSK-0704, epic KJC-PCS-0070).
3
+ * Born from a real incident (personal emails published on a landing):
4
+ * outputs get sanitized at boundaries like inputs do. Engine: karajan-rag's
5
+ * audited redactPII line by line — detects AND masks in one pass, so a
6
+ * finding never echoes the datum. On top, the user's denylist from
7
+ * `~/.karajan/privacy.yml` — GLOBAL, never in a repo: the list is sensitive.
8
+ */
9
+
10
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
11
+ import os from "node:os";
12
+ import { extname, join } from "node:path";
13
+ import { parse as parseYaml } from "yaml";
14
+ import { redactPII } from "karajan-rag";
15
+
16
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".kj"]);
17
+ const BINARY_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".woff", ".woff2", ".ttf", ".eot", ".mp4", ".db", ".sqlite"]);
18
+ const MAX_FILE_BYTES = 512 * 1024;
19
+
20
+ export function privacyConfigPath(home = os.homedir()) {
21
+ // KJ_PRIVACY_CONFIG: test/CI override of the global denylist location.
22
+ return process.env.KJ_PRIVACY_CONFIG || join(home, ".karajan", "privacy.yml");
23
+ }
24
+
25
+ /** Personal denylist + public-identity allowlist. Absent file → generics only. */
26
+ export function loadPrivacyList({ home = os.homedir() } = {}) {
27
+ try {
28
+ const raw = parseYaml(readFileSync(privacyConfigPath(home), "utf8")) || {};
29
+ const clean = (xs) => (Array.isArray(xs) ? xs.map(String).map((s) => s.trim()).filter(Boolean) : []);
30
+ return { personal: clean(raw.personal), allow: clean(raw.allow), present: true };
31
+ } catch {
32
+ return { personal: [], allow: [], present: false };
33
+ }
34
+ }
35
+
36
+ const maskValue = (v) => (v.length <= 4 ? "***" : `${v.slice(0, 2)}***${v.slice(-2)}`);
37
+
38
+ // KJC-TSK-0708 — hardcoded secrets. Known token shapes are never legit in
39
+ // an outbound artifact → block; heuristics (credential assignments and
40
+ // user:pass@ connection strings) warn toward .env / a secret manager.
41
+ const SECRET_SHAPES = [
42
+ { type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
43
+ { type: "openai-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
44
+ { type: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
45
+ { type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
46
+ { type: "google-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
47
+ { type: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
48
+ { type: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
49
+ ];
50
+ const SECRET_HEURISTICS = [
51
+ { type: "secret-assignment", re: /\b(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|auth)["']?\s*[:=]\s*["']([^"']{6,})["']/gi },
52
+ { type: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/gi },
53
+ ];
54
+
55
+ /**
56
+ * Scan a text. Returns findings — denylist hits as `severity:"block"`
57
+ * (masked value), generic PII as `severity:"warn"` (line already redacted).
58
+ */
59
+ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
60
+ const findings = [];
61
+ String(text).split("\n").forEach((line, i) => {
62
+ let probe = line;
63
+ for (const a of list.allow) probe = probe.split(a).join(" ");
64
+ for (const p of list.personal) {
65
+ let at = probe.toLowerCase().indexOf(p.toLowerCase());
66
+ if (at === -1) continue;
67
+ findings.push({ severity: "block", type: "denylist", source, line: i + 1, masked: maskValue(p) });
68
+ // Blank every occurrence so the generics don't double-report it.
69
+ while (at !== -1) { probe = probe.slice(0, at) + " ".repeat(p.length) + probe.slice(at + p.length); at = probe.toLowerCase().indexOf(p.toLowerCase()); }
70
+ }
71
+ for (const { type, re } of SECRET_SHAPES) {
72
+ re.lastIndex = 0;
73
+ let m;
74
+ while ((m = re.exec(probe)) !== null) {
75
+ findings.push({ severity: "block", type, source, line: i + 1, masked: maskValue(m[0]) });
76
+ probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
77
+ re.lastIndex = m.index + m[0].length;
78
+ }
79
+ }
80
+ for (const { type, re } of SECRET_HEURISTICS) {
81
+ re.lastIndex = 0;
82
+ let m;
83
+ while ((m = re.exec(probe)) !== null) {
84
+ findings.push({ severity: "warn", type, source, line: i + 1, masked: `${maskValue(m[1])} — move it to .env or a secret manager` });
85
+ probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
86
+ re.lastIndex = m.index + m[0].length;
87
+ }
88
+ }
89
+ const red = redactPII(probe);
90
+ if (red.total > 0) {
91
+ for (const [type, n] of Object.entries(red.counts)) {
92
+ if (n > 0) findings.push({ severity: "warn", type, source, line: i + 1, masked: red.text.trim().slice(0, 120) });
93
+ }
94
+ }
95
+ });
96
+ return findings;
97
+ }
98
+
99
+ /** Scan files/dirs recursively (skips node_modules/.git, binaries, big files). */
100
+ export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
101
+ const findings = [];
102
+ const visit = (p) => {
103
+ if (!existsSync(p)) return;
104
+ // Skip symlinks: an ancestor link recurses forever; an outside link ships nothing.
105
+ const st = lstatSync(p);
106
+ if (st.isSymbolicLink()) return;
107
+ if (st.isDirectory()) {
108
+ for (const name of readdirSync(p)) {
109
+ if (!SKIP_DIRS.has(name)) visit(join(p, name));
110
+ }
111
+ return;
112
+ }
113
+ if (BINARY_EXT.has(extname(p).toLowerCase()) || st.size > MAX_FILE_BYTES) return;
114
+ const content = readFileSync(p);
115
+ if (content.includes(0)) return; // binary sniff: NUL byte
116
+ findings.push(...scanText(content.toString("utf8"), { list, source: p }));
117
+ };
118
+ for (const p of paths) visit(p);
119
+ return findings;
120
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * library — the distilled engineering canon as its own RAG collection
3
+ * (KJC-TSK-0697). Markdown cards from `<pkg>/library/`, `~/.karajan/library/`
4
+ * and `<project>/.karajan/library/` index into the SAME global rag.db,
5
+ * isolated by project="library" + kind="library", so `kj rag query --library`
6
+ * reaches the canon and normal project queries never see it. The architect
7
+ * consults it to ground the greenfield alternative (KJC-TSK-0696).
8
+ */
9
+
10
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
11
+ import { createHash } from "node:crypto";
12
+ import { extname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import os from "node:os";
15
+
16
+ import { chunkMarkdown } from "./chunker.js";
17
+ import { insertChunk, deleteChunksBySource, findChunkByHash } from "./vec-store.js";
18
+
19
+ export const LIBRARY_PROJECT = "library";
20
+
21
+ const SHIPPED_LIBRARY_DIR = fileURLToPath(new URL("../../library", import.meta.url));
22
+
23
+ /** Existing library dirs, shipped canon first. */
24
+ export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.homedir(), projectDir = process.cwd() } = {}) {
25
+ const candidates = [pkgLibraryDir, join(home, ".karajan", "library"), join(projectDir, ".karajan", "library")];
26
+ return candidates.filter((d) => existsSync(d));
27
+ }
28
+
29
+ /**
30
+ * Index every markdown card found in the library dirs. Idempotent: chunks
31
+ * re-index per source and identical bodies skip the embedder (content-hash
32
+ * dedup within the library project).
33
+ */
34
+ export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir } = {}) {
35
+ let indexed = 0, failed = 0, files = 0;
36
+ for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir })) {
37
+ for (const name of readdirSync(dir)) {
38
+ if (extname(name).toLowerCase() !== ".md") continue;
39
+ const path = join(dir, name);
40
+ files += 1;
41
+ const chunks = chunkMarkdown(readFileSync(path, "utf8"), { path, kind: "library" });
42
+ const hashed = chunks.map((ch) => ({ ch, contentHash: createHash("sha256").update(ch.text).digest("hex") }));
43
+ // Unchanged card (every chunk body already known) → skip before the
44
+ // delete, so idempotent re-runs never touch the embedder.
45
+ if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash, LIBRARY_PROJECT))) continue;
46
+ deleteChunksBySource(db, path);
47
+ for (const { ch, contentHash } of hashed) {
48
+ try {
49
+ if (findChunkByHash(db, contentHash, LIBRARY_PROJECT)) continue;
50
+ const embedding = await embedder.embed(ch.text);
51
+ insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project: LIBRARY_PROJECT, contentHash });
52
+ indexed += 1;
53
+ } catch (err) {
54
+ failed += 1;
55
+ logger.warn?.(`[rag-library] embed failed for ${path}: ${err.message}`);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ if (files) logger.info?.(`[rag-library] ${files} card(s) → ${indexed} chunk(s) (${failed} failed)`);
61
+ return { files, indexed, failed };
62
+ }