@karajan-family/code 4.31.0 → 4.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karajan-family/code",
3
- "version": "4.31.0",
3
+ "version": "4.31.1",
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",
@@ -10,6 +10,7 @@ import { isAbsolute, join } from "node:path";
10
10
  import { runCommand } from "../utils/process.js";
11
11
  import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
12
12
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
13
+ import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
13
14
 
14
15
  // KJC-TSK-0769 — the effect boundary: what ships is re-evaluated against the
15
16
  // policy IN FORCE now, not the one each PR was merged under. Artifact rules
@@ -28,7 +29,13 @@ async function policyRangeCheck(projectDir) {
28
29
  : ["-C", projectDir, "ls-tree", "-r", "--name-only", "HEAD"]);
29
30
  if (listed.exitCode !== 0) throw new Error((listed.stderr || "git failed").trim());
30
31
  const files = listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
31
- const violations = checkStagedDiff(policy, { role: "coder", files, netLinesAdded: null }).filter((v) => !prScoped.has(v.rule_id));
32
+ const raw = checkStagedDiff(policy, { role: "coder", files, netLinesAdded: null }).filter((v) => !prScoped.has(v.rule_id));
33
+ // KJC-BUG-0178 (ADR 0009): the supervisor seal trips the very rule that
34
+ // protects the supervisor. `kj review` and `kj policy check` already lift
35
+ // what the sealed provenance backs; without this the release check blocked
36
+ // every version cut after a human `kj harden --commit`, by its own seal.
37
+ const sup = liftSealedSupervisorViolations({ projectDir, violations: raw });
38
+ const violations = sup.violations;
32
39
  const hard = violations.filter((v) => v.enforcement === "deny");
33
40
  const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
34
41
  if (hard.length > 0) {
@@ -9,6 +9,9 @@
9
9
  export const CORE_COMMANDS = [
10
10
  "go",
11
11
  "start",
12
+ // BOOT-A (KJC-TSK-0857): starting a project from nothing is as core as it
13
+ // gets — it is the first command a new project ever runs.
14
+ "bootstrap",
12
15
  "init",
13
16
  "run",
14
17
  "plan",
@@ -5,6 +5,8 @@ import { architectCommand } from "../commands/architect.js";
5
5
  import { onboardCommand } from "../commands/onboard.js";
6
6
  import { startCommand } from "../commands/start.js";
7
7
  import { goCommand } from "../commands/go.js";
8
+ import { bootstrapCommand } from "../commands/bootstrap.js";
9
+ import { PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
8
10
  import { identityCommand } from "../commands/identity.js";
9
11
  import { policyCommand } from "../commands/policy.js";
10
12
  import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
@@ -158,6 +160,18 @@ export function registerMeta(program, { pkgVersion }) {
158
160
  });
159
161
  });
160
162
 
163
+ // BOOT-A (KJC-TSK-0857, cold-start ADR): the pieces existed, the ORDER did
164
+ // not. One command from an empty directory to a project under the method.
165
+ program
166
+ .command("bootstrap")
167
+ .description("Del directorio vacío al método listo: repositorio, configuración, harness, gate y RAG, en el orden que funciona")
168
+ .action(async (flags) => {
169
+ await withConfig(pkgVersion, "bootstrap", flags, async ({ config, logger }) => {
170
+ const res = await bootstrapCommand({ config, logger, flags });
171
+ if (!res.ok) process.exitCode = PENDING_EXIT_CODE;
172
+ });
173
+ });
174
+
161
175
  // MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
162
176
  // question, and the person lands inside a governed conversation.
163
177
  program
@@ -0,0 +1,75 @@
1
+ /**
2
+ * BOOT-A (KJC-TSK-0857, epic KJC-PCS-0088) — `kj bootstrap`: from an empty
3
+ * directory to a project under the method, in one command.
4
+ *
5
+ * It invents nothing. The pieces already existed; what was missing was the
6
+ * ORDER and somebody imposing it. The field report that opened the cold-start
7
+ * ADR found nine walls on that path, and several of them fired precisely
8
+ * because the repository had been raised by hand, in the wrong sequence.
9
+ *
10
+ * Rules this command obeys:
11
+ * - Idempotent. What is already there is reported as `already`, never redone.
12
+ * - Fail loud, never half. A step that needs the person's hands STOPS the
13
+ * sequence and says which one; nothing downstream pretends it ran.
14
+ * - No guarantees without git: the gates live in git hooks, so the repo comes
15
+ * first and a git that cannot run is the end of the line, not a warning.
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { ensureGitRepo } from "./init.js";
21
+ import { initCommand } from "./init.js";
22
+ import { envInstallCommand } from "./env.js";
23
+
24
+ const STEP_LABEL = {
25
+ git: "repositorio",
26
+ config: "configuración del proyecto",
27
+ method: "método activo (harness, gate y RAG)",
28
+ };
29
+
30
+ /**
31
+ * @returns {Promise<{ok: boolean, pending: string|null, steps: Array<{name: string, status: "done"|"already"|"pending"}>}>}
32
+ */
33
+ export async function bootstrapCommand({ config = {}, logger = console, flags = {}, deps = {} } = {}) {
34
+ const projectDir = config.projectDir || process.cwd();
35
+ const steps = [];
36
+ const say = (name, status, detail) => {
37
+ steps.push({ name, status });
38
+ const mark = status === "already" ? "·" : "✓";
39
+ logger.info?.(`${mark} ${STEP_LABEL[name]}${detail ? ` — ${detail}` : ""}`);
40
+ };
41
+ const stop = (name, why) => {
42
+ steps.push({ name, status: "pending" });
43
+ logger.error?.(`✗ ${STEP_LABEL[name]} — ${why}`);
44
+ return { ok: false, pending: name, steps };
45
+ };
46
+
47
+ // 1. The repository. Without it there are no hooks, and without hooks there
48
+ // are no guarantees: this is the one step that cannot be skipped.
49
+ const hadRepo = existsSync(join(projectDir, ".git"));
50
+ if (!ensureGitRepo({ projectDir, logger: { info() {}, warn() {} }, gitFn: deps.gitFn })) {
51
+ return stop("git", "git no está disponible y las garantías de Karajan viven en sus hooks");
52
+ }
53
+ say("git", hadRepo ? "already" : "done", hadRepo ? "ya existía" : "creado, sin commit todavía");
54
+
55
+ // 2. The project's own configuration.
56
+ const hasConfig = existsSync(join(projectDir, ".karajan", "kj.config.yml"));
57
+ if (hasConfig) say("config", "already", "ya estaba");
58
+ else {
59
+ const init = await (deps.init ?? initCommand)({ logger, flags: { ...flags, noInteractive: true } });
60
+ if (init?.exitCode) return stop("config", "queda algo que solo puedes hacer tú, lo tienes arriba");
61
+ say("config", "done");
62
+ }
63
+
64
+ // 3. The method itself: harness, verdict gate, playbook and RAG index. This
65
+ // is the step that turns an installed kj into an enforced one.
66
+ const hasGate = existsSync(join(projectDir, ".karajan", "review-gate"));
67
+ if (hasGate) say("method", "already", "ya estaba activo");
68
+ else {
69
+ const env = await (deps.env ?? envInstallCommand)({ config, logger, flags: { yes: true } });
70
+ if (env?.exitCode) return stop("method", "queda algo que solo puedes hacer tú, lo tienes arriba");
71
+ say("method", "done");
72
+ }
73
+
74
+ return { ok: true, pending: null, steps };
75
+ }
@@ -112,6 +112,24 @@ export function detectNewHomeEntries(before, projectDir) {
112
112
  * @param {string|null|undefined} transcript — coder's full output text
113
113
  * @returns {string[]} filtered leaks (the subset attributable to coder)
114
114
  */
115
+ /**
116
+ * KJC-BUG-0180 — attribution is not severity. `verifyLeaksAgainstTranscript`
117
+ * stays strict on purpose: with no transcript it returns everything. But
118
+ * "the snapshot changed and there is NO evidence the coder did it" is a
119
+ * different fact from "the coder wrote there", and only the second one
120
+ * should kill the run. The flaky CI failure was exactly this: other test
121
+ * files writing under $HOME during the iteration window, an empty
122
+ * transcript from a mocked coder, and the whole flow failing and retrying.
123
+ *
124
+ * @returns {{attributed: string[], unattributed: string[]}} — `attributed`
125
+ * fails the run; `unattributed` is reported and the work goes on.
126
+ */
127
+ export function splitLeakEvidence(leaks, transcript) {
128
+ const hasTranscript = typeof transcript === "string" && transcript.length > 0;
129
+ const found = verifyLeaksAgainstTranscript(leaks, transcript);
130
+ return hasTranscript ? { attributed: found, unattributed: [] } : { attributed: [], unattributed: found };
131
+ }
132
+
115
133
  export function verifyLeaksAgainstTranscript(leaks, transcript) {
116
134
  if (!Array.isArray(leaks) || leaks.length === 0) return [];
117
135
  if (!transcript || typeof transcript !== "string" || transcript.length === 0) {
@@ -20,7 +20,7 @@ import { detectRateLimit } from "../../utils/rate-limit-detector.js";
20
20
  import { createStallDetector } from "../../utils/stall-detector.js";
21
21
  import { buildStandbyState } from "../../brain/standby-store.js";
22
22
  import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
23
- import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
23
+ import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, splitLeakEvidence, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
24
24
 
25
25
  export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
26
26
  logger.setContext({ iteration, stage: "coder" });
@@ -186,9 +186,14 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
186
186
  // detected entries against the coder's transcript: only flag those
187
187
  // the coder demonstrably referenced.
188
188
  const coderTranscript = coderExecResult.result?.output || "";
189
- const layer1Leaks = verifyLeaksAgainstTranscript(candidateLeaks, coderTranscript);
189
+ // KJC-BUG-0180: only what the transcript ATTRIBUTES to the coder fails the
190
+ // run. Without a transcript there is no evidence it wrote anything, so the
191
+ // finding is reported and the work goes on — a concurrent host-side write
192
+ // under $HOME used to kill the whole flow.
193
+ const { attributed: layer1Leaks, unattributed } = splitLeakEvidence(candidateLeaks, coderTranscript);
190
194
  if (candidateLeaks.length > 0 && layer1Leaks.length === 0) {
191
- logger.warn(`fs-leak-detector: ${candidateLeaks.length} new $HOME entr(y/ies) detected but none referenced in the coder transcript — likely a concurrent host-side write, not flagging (#546).`);
195
+ const why = unattributed.length > 0 ? "no coder transcript to attribute them" : "none referenced in the coder transcript";
196
+ logger.warn(`fs-leak-detector: ${candidateLeaks.length} new $HOME entr(y/ies) detected but ${why} — likely a concurrent host-side write, not flagging (#546).`);
192
197
  }
193
198
  // Layer 2 (BUG-0032): scan the transcript for `cd <abs-out-of-project> && <write-cmd>`
194
199
  // patterns the snapshot-diff misses (e.g. when target dir pre-existed).
@@ -59,6 +59,17 @@ const SECRET_HEURISTICS = [
59
59
  // are blanked before redactPII and COUNTED, so a clean result can say
60
60
  // "nothing found" or "found and discarded by context" — never the same thing.
61
61
  // The personal denylist runs before this: the user's own datum always blocks.
62
+ // KJC-BUG-0195 — a Google key inside a Firebase web config is public by
63
+ // design: it names the project, and what protects the data are the Firestore
64
+ // rules. It still BLOCKS (the shape alone cannot prove which key it is, and a
65
+ // server key next to it would ride along), but the finding stops being a dead
66
+ // end: it names the declared way out, the allowlist, and what actually
67
+ // protects that key. A gate with no exit teaches people to skip the gate.
68
+ // Markers kept unambiguous on purpose (codex caught projectId being generic
69
+ // enough to appear next to a server key): only Firebase-specific ones count.
70
+ const FIREBASE_WEB_MARKERS = /firebaseConfig|authDomain|firebasestorage|firestore\.googleapis\.com|firebaseapp\.com/;
71
+ const FIREBASE_KEY_HINT = "si es la clave web de Firebase (publica por diseno): declarala en el allow de ~/.karajan/privacy.yml y restringela por dominio en la consola de Google";
72
+
62
73
  const CONTEXT_DISCARDS = [
63
74
  { type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
64
75
  { type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
@@ -71,6 +82,7 @@ const CONTEXT_DISCARDS = [
71
82
  export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
72
83
  const findings = [];
73
84
  let discarded = 0;
85
+ const firebaseWeb = FIREBASE_WEB_MARKERS.test(String(text));
74
86
  String(text).split("\n").forEach((line, i) => {
75
87
  let probe = line;
76
88
  for (const a of list.allow) probe = probe.split(a).join(" ");
@@ -85,7 +97,8 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
85
97
  re.lastIndex = 0;
86
98
  let m;
87
99
  while ((m = re.exec(probe)) !== null) {
88
- findings.push({ severity: "block", type, source, line: i + 1, masked: maskValue(m[0]) });
100
+ const hint = type === "google-key" && firebaseWeb ? ` — ${FIREBASE_KEY_HINT}` : "";
101
+ findings.push({ severity: "block", type, source, line: i + 1, masked: `${maskValue(m[0])}${hint}` });
89
102
  probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
90
103
  re.lastIndex = m.index + m[0].length;
91
104
  }