karajan-code 4.31.0 → 4.32.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 +1 -1
- package/src/checks/project-checks.js +5 -0
- package/src/checks/release-check.js +8 -1
- package/src/checks/repo-state.js +89 -0
- package/src/cli/advanced-commands.js +3 -0
- package/src/cli/register-meta.js +14 -0
- package/src/cli/register-pipeline.js +4 -0
- package/src/commands/bootstrap.js +131 -0
- package/src/commands/go.js +9 -10
- package/src/commands/review-gate.js +16 -1
- package/src/orchestrator/fs-leak-detector.js +18 -0
- package/src/orchestrator/stages/coder-stage.js +8 -3
- package/src/privacy/scan.js +14 -1
- package/src/review/one-shot-review.js +5 -0
- package/src/review/ui-evidence.js +65 -0
- package/src/start/project-script.js +84 -0
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ import { runCommand } from "../utils/process.js";
|
|
|
23
23
|
import { checkBinary } from "../utils/agent-detect.js";
|
|
24
24
|
import { getInstallCommand } from "../utils/os-detect.js";
|
|
25
25
|
import { STRATEGY } from "./types.js";
|
|
26
|
+
import { createRepoStateCheck } from "./repo-state.js";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Detect signals del proyecto. Devuelve un set de tags con qué hay en el
|
|
@@ -241,5 +242,9 @@ export function getProjectChecks({ projectDir }) {
|
|
|
241
242
|
createToolCheck({ signal: "terraform", tool: "terraform", label: "Terraform (project signal)", signals }),
|
|
242
243
|
createEnvConsistencyCheck({ projectDir, signals }),
|
|
243
244
|
createGhRemoteCheck({ projectDir }),
|
|
245
|
+
// BOOT-B (KJC-TSK-0858): the state of the repo itself, which no other
|
|
246
|
+
// check looks at — the order it was set up in, whether the contract
|
|
247
|
+
// travels, whether this clone declares who works it.
|
|
248
|
+
createRepoStateCheck({ projectDir }),
|
|
244
249
|
];
|
|
245
250
|
}
|
|
@@ -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
|
|
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) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOOT-B (KJC-TSK-0858, epic KJC-PCS-0088) — the state of the REPOSITORY.
|
|
3
|
+
*
|
|
4
|
+
* The cold-start map of 20-sep found projects raised outside the method, and
|
|
5
|
+
* nobody noticed until a gate blocked with no explanation: the harness
|
|
6
|
+
* installed before git, the gate marker never committed (so a clone inherits
|
|
7
|
+
* nothing), no identity declared (so the Sentinel later denies every git and
|
|
8
|
+
* gh call). None of the other checks looks at this: they check tools, ports,
|
|
9
|
+
* index coverage, the method's own report — never whether the repo itself was
|
|
10
|
+
* set up in the right order.
|
|
11
|
+
*
|
|
12
|
+
* Two rules, both learned the hard way in this codebase:
|
|
13
|
+
* - The bootstrap phase is NOT a defect. A repo with no commit yet is
|
|
14
|
+
* starting, and saying otherwise is the false alarm that teaches people to
|
|
15
|
+
* ignore the output.
|
|
16
|
+
* - Every symptom carries the literal command that repairs it. A diagnosis
|
|
17
|
+
* nobody can act on is a complaint.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
const STRATEGY_MANUAL = "manual";
|
|
25
|
+
|
|
26
|
+
const git = (projectDir, args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
27
|
+
const gitOk = (projectDir, args) => { try { git(projectDir, args); return true; } catch { return false; } };
|
|
28
|
+
|
|
29
|
+
/** @internal Exported for dynamic import from tests. */
|
|
30
|
+
export function createRepoStateCheck({ projectDir }) {
|
|
31
|
+
return {
|
|
32
|
+
name: "repo-state",
|
|
33
|
+
label: "estado del repositorio",
|
|
34
|
+
strategy: STRATEGY_MANUAL,
|
|
35
|
+
describe: "Detect a project raised or carried outside the method (order, contract, identity)",
|
|
36
|
+
async detect() {
|
|
37
|
+
const dir = projectDir;
|
|
38
|
+
const has = (rel) => existsSync(join(dir, rel));
|
|
39
|
+
const hardened = has(".karajan/hooks/pre-commit") || has(".karajan/review-gate");
|
|
40
|
+
|
|
41
|
+
// 1. No repository. The gates live in git hooks, so there is nothing to
|
|
42
|
+
// govern yet — and if the harness is already there, it governs nothing.
|
|
43
|
+
if (!has(".git")) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
severity: "warn",
|
|
47
|
+
detail: hardened
|
|
48
|
+
? "el proyecto está sin repositorio y el harness ya está escrito: no gobierna nada"
|
|
49
|
+
: "el proyecto está sin repositorio, así que ningún gate del método puede actuar",
|
|
50
|
+
fix: "kj bootstrap (crea el repositorio y deja el método en orden), o git init si prefieres hacerlo a mano",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. The harness written before git existed: nothing tracks the contract,
|
|
55
|
+
// so cloning inherits nothing even though this clone looks hardened.
|
|
56
|
+
const hasCommits = gitOk(dir, ["rev-parse", "--verify", "HEAD"]);
|
|
57
|
+
if (!hasCommits) {
|
|
58
|
+
return hardened
|
|
59
|
+
? { ok: true, severity: "info", detail: "fase de arranque: el método está escrito y falta el primer commit" }
|
|
60
|
+
: { ok: true, severity: "info", detail: "fase de arranque: repositorio recién creado, sin método todavía" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const symptoms = [];
|
|
64
|
+
// Against HEAD, not the index (catch de codex): a file merely STAGED is
|
|
65
|
+
// not inherited by a clone, which is the whole point of this symptom.
|
|
66
|
+
const committed = (rel) => gitOk(dir, ["cat-file", "-e", `HEAD:${rel}`]);
|
|
67
|
+
if (hardened && !committed(".karajan/review-gate") && !committed(".karajan/hooks/pre-commit")) {
|
|
68
|
+
symptoms.push({
|
|
69
|
+
detail: "el harness está instalado pero su contrato no está en git: quien clone no hereda ningún gate",
|
|
70
|
+
fix: "git add .karajan/review-gate .karajan/hooks && git commit -m \"chore: el contrato del método\"",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (hardened && !has(".karajan/identity.local.yml")) {
|
|
74
|
+
symptoms.push({
|
|
75
|
+
detail: "este clon no declara identidad, y el Sentinel denegará cada git y cada gh hasta que la declares",
|
|
76
|
+
fix: "kj identity set --gh <usuario> --email <correo>",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (symptoms.length === 0) return { ok: true, severity: "info", detail: "el repositorio está en orden" };
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
severity: "warn",
|
|
84
|
+
detail: symptoms.map((s) => s.detail).join(" · "),
|
|
85
|
+
fix: symptoms.map((s) => s.fix).join(" · "),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
|
@@ -239,6 +239,10 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
239
239
|
.option("--prune-days <n>", "TTL in days for --prune (default: 14)")
|
|
240
240
|
.option("--dry-run", "With --prune: report what would be removed without deleting")
|
|
241
241
|
.option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
|
|
242
|
+
// BOOT-D (KJC-TSK-0863): how the walkthrough gets INTO the verdict. Without
|
|
243
|
+
// this the rule could only complain, which is the gate with no exit.
|
|
244
|
+
.option("--walked <route>", "Recorrido comprobado como lo haría una persona (repetible) — queda en el veredicto", (v, prev) => [...(prev || []), v], [])
|
|
245
|
+
.option("--walked-with <tool>", "Con qué se comprobó el recorrido (por ejemplo chrome-devtools)")
|
|
242
246
|
.action(async (task, flags) => {
|
|
243
247
|
await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
|
|
244
248
|
// ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
|
|
@@ -0,0 +1,131 @@
|
|
|
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 { execFileSync } from "node:child_process";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { ensureGitRepo } from "./init.js";
|
|
22
|
+
import { initCommand } from "./init.js";
|
|
23
|
+
import { envInstallCommand } from "./env.js";
|
|
24
|
+
import { runStartScript, START_SCRIPT_CONTRACT } from "../start/project-script.js";
|
|
25
|
+
|
|
26
|
+
const STEP_LABEL = {
|
|
27
|
+
git: "repositorio",
|
|
28
|
+
config: "configuración del proyecto",
|
|
29
|
+
method: "método activo (harness, gate y RAG)",
|
|
30
|
+
contract: "commit del contrato",
|
|
31
|
+
start: "arranque del proyecto",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** What kj generates and the whole team must inherit by cloning. */
|
|
35
|
+
const CONTRACT_PATHS = [
|
|
36
|
+
".gitignore",
|
|
37
|
+
".karajan/hooks",
|
|
38
|
+
".karajan/review-gate",
|
|
39
|
+
".karajan/adrs",
|
|
40
|
+
".karajan/policy.yml",
|
|
41
|
+
".claude",
|
|
42
|
+
"CLAUDE.md",
|
|
43
|
+
"AGENTS.md",
|
|
44
|
+
"GEMINI.md",
|
|
45
|
+
];
|
|
46
|
+
const CONTRACT_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
|
|
47
|
+
|
|
48
|
+
const hasCommits = (projectDir, git) => {
|
|
49
|
+
try { git(["rev-parse", "--verify", "HEAD"]); return true; } catch { return false; }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {Promise<{ok: boolean, pending: string|null, steps: Array<{name: string, status: "done"|"already"|"pending"}>}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function bootstrapCommand({ config = {}, logger = console, flags = {}, deps = {} } = {}) {
|
|
56
|
+
const projectDir = config.projectDir || process.cwd();
|
|
57
|
+
const steps = [];
|
|
58
|
+
const say = (name, status, detail) => {
|
|
59
|
+
steps.push({ name, status });
|
|
60
|
+
const mark = status === "already" ? "·" : "✓";
|
|
61
|
+
logger.info?.(`${mark} ${STEP_LABEL[name]}${detail ? ` — ${detail}` : ""}`);
|
|
62
|
+
};
|
|
63
|
+
const stop = (name, why) => {
|
|
64
|
+
steps.push({ name, status: "pending" });
|
|
65
|
+
logger.error?.(`✗ ${STEP_LABEL[name]} — ${why}`);
|
|
66
|
+
return { ok: false, pending: name, steps };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// 1. The repository. Without it there are no hooks, and without hooks there
|
|
70
|
+
// are no guarantees: this is the one step that cannot be skipped.
|
|
71
|
+
const hadRepo = existsSync(join(projectDir, ".git"));
|
|
72
|
+
if (!ensureGitRepo({ projectDir, logger: { info() {}, warn() {} }, gitFn: deps.gitFn })) {
|
|
73
|
+
return stop("git", "git no está disponible y las garantías de Karajan viven en sus hooks");
|
|
74
|
+
}
|
|
75
|
+
say("git", hadRepo ? "already" : "done", hadRepo ? "ya existía" : "creado, sin commit todavía");
|
|
76
|
+
|
|
77
|
+
// 2. The project's own configuration.
|
|
78
|
+
const hasConfig = existsSync(join(projectDir, ".karajan", "kj.config.yml"));
|
|
79
|
+
if (hasConfig) say("config", "already", "ya estaba");
|
|
80
|
+
else {
|
|
81
|
+
const init = await (deps.init ?? initCommand)({ logger, flags: { ...flags, noInteractive: true } });
|
|
82
|
+
if (init?.exitCode) return stop("config", "queda algo que solo puedes hacer tú, lo tienes arriba");
|
|
83
|
+
say("config", "done");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. The method itself: harness, verdict gate, playbook and RAG index. This
|
|
87
|
+
// is the step that turns an installed kj into an enforced one.
|
|
88
|
+
const hasGate = existsSync(join(projectDir, ".karajan", "review-gate"));
|
|
89
|
+
if (hasGate) say("method", "already", "ya estaba activo");
|
|
90
|
+
else {
|
|
91
|
+
const env = await (deps.env ?? envInstallCommand)({ config, logger, flags: { yes: true } });
|
|
92
|
+
if (env?.exitCode) return stop("method", "queda algo que solo puedes hacer tú, lo tienes arriba");
|
|
93
|
+
say("method", "done");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4. The contract commit. project-new.md asked the USER for it, and it was
|
|
97
|
+
// the commit their own freshly installed gates rejected: the review gate
|
|
98
|
+
// (KJC-BUG-0165) and the branch guard (KJC-BUG-0186) both exempt it now,
|
|
99
|
+
// so kj can make it itself instead of leaving the person to fight it.
|
|
100
|
+
// Only what kj generated, only while the repo has no commit, never the
|
|
101
|
+
// person's own code. This is NOT the supervisor seal, which stays a human
|
|
102
|
+
// act with its own four layers (ADR 0009).
|
|
103
|
+
const git = deps.gitRun ?? ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
104
|
+
if (hasCommits(projectDir, git)) say("contract", "already", "el repositorio ya tiene historia");
|
|
105
|
+
else {
|
|
106
|
+
const present = CONTRACT_PATHS.filter((p) => existsSync(join(projectDir, p)));
|
|
107
|
+
if (present.length === 0) say("contract", "already", "no hay contrato que commitear");
|
|
108
|
+
else {
|
|
109
|
+
try {
|
|
110
|
+
git(["add", "--", ...present]);
|
|
111
|
+
git(["commit", "-m", CONTRACT_MESSAGE]);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Nunca explotar aquí: lo más probable es que falte la identidad del
|
|
114
|
+
// clon, y ese cauce ya lo pide el paso anterior (KJC-BUG-0188).
|
|
115
|
+
return stop("contract", `git no pudo commitear el contrato: ${String(err.message).split("\n")[0]}`);
|
|
116
|
+
}
|
|
117
|
+
say("contract", "done", `${present.length} ruta(s) del contrato`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 5. Does the project actually run? kj verified the method; nobody verified
|
|
122
|
+
// the application (BOOT-C, KJC-TSK-0862). It REPORTS, never blocks: a
|
|
123
|
+
// start script written badly must not stop work over something unrelated,
|
|
124
|
+
// and a tree that was already broken has to be said BEFORE implementing.
|
|
125
|
+
const start = await (deps.start ?? runStartScript)({ projectDir, config });
|
|
126
|
+
if (start.status === "absent") say("start", "already", `sin guion de arranque — ${START_SCRIPT_CONTRACT}`);
|
|
127
|
+
else if (start.status === "ok") say("start", "done", "el proyecto arranca y su humo pasa");
|
|
128
|
+
else say("start", "already", `el árbol YA venía roto (${start.status}) — no lo ha causado este trabajo`);
|
|
129
|
+
|
|
130
|
+
return { ok: true, pending: null, steps };
|
|
131
|
+
}
|
package/src/commands/go.js
CHANGED
|
@@ -12,8 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { checkBinary } from "../utils/agent-detect.js";
|
|
14
14
|
import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
15
|
-
import {
|
|
16
|
-
import { ensureGitRepo } from "./init.js";
|
|
15
|
+
import { bootstrapCommand } from "./bootstrap.js";
|
|
17
16
|
import { boardCommand } from "./board.js";
|
|
18
17
|
// The interactive launchers a muggle can live inside (v1: the two the epic
|
|
19
18
|
// names). Auth heuristics are the same cheap file checks reviewer-fallback
|
|
@@ -44,9 +43,6 @@ export function buildGoPrompt() {
|
|
|
44
43
|
"Empieza presentándote en dos frases y preguntando qué quiere construir o cambiar hoy.",
|
|
45
44
|
].join("\n");
|
|
46
45
|
}
|
|
47
|
-
async function defaultPrepare({ config, logger }) {
|
|
48
|
-
return envInstallCommand({ config, logger, flags: { yes: true } });
|
|
49
|
-
}
|
|
50
46
|
export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
|
|
51
47
|
const port = config.hu_board?.port || 4000;
|
|
52
48
|
await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
|
|
@@ -96,14 +92,17 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
|
|
|
96
92
|
// Prepare ONCE: decisions already taken are never re-asked.
|
|
97
93
|
if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
|
|
98
94
|
logger.info?.("Preparando tu proyecto (solo la primera vez)…");
|
|
99
|
-
// KJC-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
95
|
+
// BOOT-A paso 3 (KJC-TSK-0857): el orden vive en UN sitio, kj bootstrap.
|
|
96
|
+
// Antes esta puerta cableaba su propia secuencia (git init y env install),
|
|
97
|
+
// así que la puerta del maggle y la técnica podían separarse sin que nadie
|
|
98
|
+
// se diera cuenta. Ahora las dos hacen lo mismo.
|
|
99
|
+
const booted = await (deps.bootstrap ?? bootstrapCommand)({ config, logger, flags: {}, deps: {} });
|
|
100
|
+
if (!booted.ok) {
|
|
101
|
+
logger.error?.(`Falta una cosa que solo puedes hacer tú, la tienes justo arriba (${booted.pending}). Cuando la hagas, vuelve a escribir: kj go`);
|
|
103
102
|
process.exitCode = 1;
|
|
104
103
|
return 1;
|
|
105
104
|
}
|
|
106
|
-
const prepared = await
|
|
105
|
+
const prepared = deps.prepare ? await deps.prepare({ config, logger }) : null;
|
|
107
106
|
// Algo depende de tus manos (arriba tienes el detalle). Sin eso el
|
|
108
107
|
// proyecto quedaría a medias, así que paramos aquí en vez de seguir.
|
|
109
108
|
if (prepared?.exitCode) {
|
|
@@ -15,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
16
|
import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
|
|
17
17
|
import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
|
|
18
|
+
import { checkUiEvidence, uiBlock } from "../review/ui-evidence.js";
|
|
18
19
|
import { readRagLedger } from "../review/rag-ledger.js";
|
|
19
20
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
20
21
|
import { checkCardFirst } from "../review/card-first.js";
|
|
@@ -482,6 +483,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
482
483
|
+ twins.map((t) => `- ${t}`).join("\n");
|
|
483
484
|
}
|
|
484
485
|
|
|
486
|
+
// BOOT-D (KJC-TSK-0863): green is not proof for what a person SEES. kj does
|
|
487
|
+
// not drive the browser (the host has it); it demands the walkthrough and
|
|
488
|
+
// records it in the verdict, bound to the diff like sonar and rag. It WARNS:
|
|
489
|
+
// the last proof, never the only one, and a gate that fires often teaches
|
|
490
|
+
// people to skip gates.
|
|
491
|
+
const walked = Array.isArray(flags.walked) ? flags.walked : [];
|
|
492
|
+
const uiReq = checkUiEvidence({
|
|
493
|
+
stagedFiles: changedFiles,
|
|
494
|
+
evidence: walked.length > 0 ? { walked, tool: flags.walkedWith ?? null } : null,
|
|
495
|
+
standingExceptions: std.standing,
|
|
496
|
+
});
|
|
497
|
+
if (uiReq.warn) console.log(`⚠ ${uiReq.reason}`);
|
|
498
|
+
const uiRecord = uiBlock(uiReq);
|
|
499
|
+
|
|
485
500
|
// MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
|
|
486
501
|
// SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
|
|
487
502
|
// el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
|
|
@@ -504,7 +519,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
504
519
|
}
|
|
505
520
|
}
|
|
506
521
|
|
|
507
|
-
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord });
|
|
522
|
+
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord, ui: uiRecord });
|
|
508
523
|
printVerdict(record);
|
|
509
524
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
510
525
|
return record;
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
package/src/privacy/scan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -47,6 +47,8 @@ export async function runOneShotReview({
|
|
|
47
47
|
sonar = null,
|
|
48
48
|
// KJC-TSK-0849 (ADR 0010): what the session's RAG ledger proved, same place.
|
|
49
49
|
rag = null,
|
|
50
|
+
// BOOT-D (KJC-TSK-0863): the walkthrough of what a person sees, same place.
|
|
51
|
+
ui = null,
|
|
50
52
|
hostAgent = detectHostAgent(),
|
|
51
53
|
createAgentFn = createAgent,
|
|
52
54
|
detectAgents = detectAvailableAgents,
|
|
@@ -129,6 +131,9 @@ export async function runOneShotReview({
|
|
|
129
131
|
summary: parsed.summary || parsed.raw_summary || "",
|
|
130
132
|
...(sonar ? { sonar } : {}),
|
|
131
133
|
...(rag ? { rag } : {}),
|
|
134
|
+
// BOOT-D (KJC-TSK-0863): the walkthrough of what a person sees travels with
|
|
135
|
+
// the verdict, bound to this diff, like the other two proofs.
|
|
136
|
+
...(ui ? { ui } : {}),
|
|
132
137
|
confidence: parsed.confidence ?? null,
|
|
133
138
|
});
|
|
134
139
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOOT-D (KJC-TSK-0863) — the done-statement of something a person SEES is not
|
|
3
|
+
* proved by a green suite.
|
|
4
|
+
*
|
|
5
|
+
* The Anthropic write-up on long-running agents reports it as an observed
|
|
6
|
+
* failure: the agent marked features complete without checking them. This
|
|
7
|
+
* codebase has the same hole. `impeccable` gives an AI OPINION about the diff,
|
|
8
|
+
* which is precisely what KJC-TSK-0726 rejects; what is missing is the proof
|
|
9
|
+
* that the route works when a person walks it.
|
|
10
|
+
*
|
|
11
|
+
* kj does not drive the browser: the host already has Chrome DevTools, and
|
|
12
|
+
* duplicating that would tie kj to one runner. kj DEMANDS the evidence and
|
|
13
|
+
* records it in the verdict, bound to the diff, exactly as it does with sonar
|
|
14
|
+
* and rag.
|
|
15
|
+
*
|
|
16
|
+
* It WARNS, it does not block. The article itself admits the browser misses
|
|
17
|
+
* native modals, so this is the last proof, never the only one — and a gate
|
|
18
|
+
* that fails often teaches people to skip gates, which is the doctrine behind
|
|
19
|
+
* every other rule here.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const UI_RULE_ID = "method.ui.evidence";
|
|
23
|
+
|
|
24
|
+
// What a person can actually see. Deliberately narrow: a false demand costs
|
|
25
|
+
// more than a missed one while this only warns.
|
|
26
|
+
const VISIBLE = /\.(jsx|tsx|vue|svelte|astro|css|scss|sass|less|html)$/i;
|
|
27
|
+
|
|
28
|
+
const liveGrant = (standingExceptions, now) => (standingExceptions || []).find((e) => {
|
|
29
|
+
if (e?.rule_id !== UI_RULE_ID) return false;
|
|
30
|
+
const until = Date.parse(e?.expiresAt ?? "");
|
|
31
|
+
return Number.isFinite(until) && until > now.getTime();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @returns {{ok: boolean, mode: "not-visible"|"walked"|"granted"|"missing", visible: string[], warn?: boolean, reason?: string, evidence?: object, grant?: object}}
|
|
36
|
+
*/
|
|
37
|
+
export function checkUiEvidence({ stagedFiles = [], evidence = null, standingExceptions = [], now = new Date() } = {}) {
|
|
38
|
+
const visible = stagedFiles.filter((f) => VISIBLE.test(String(f)));
|
|
39
|
+
if (visible.length === 0) return { ok: true, mode: "not-visible", visible };
|
|
40
|
+
|
|
41
|
+
const walked = Array.isArray(evidence?.walked) ? evidence.walked.filter(Boolean) : [];
|
|
42
|
+
if (walked.length > 0) return { ok: true, mode: "walked", visible, evidence: { walked, tool: evidence.tool ?? null } };
|
|
43
|
+
|
|
44
|
+
const grant = liveGrant(standingExceptions, now);
|
|
45
|
+
if (grant) return { ok: true, mode: "granted", visible, grant };
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
warn: true,
|
|
50
|
+
mode: "missing",
|
|
51
|
+
visible,
|
|
52
|
+
reason: `verde no es prueba: ${visible.length} fichero(s) que una persona VE (${visible.join(", ")}) sin recorrido comprobado — recórrelo como lo haría ella y deja constancia`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The block that travels inside the verdict, bound to the diff like sonar's. */
|
|
57
|
+
export function uiBlock(req) {
|
|
58
|
+
if (!req || req.mode === "not-visible") return null;
|
|
59
|
+
return {
|
|
60
|
+
mode: req.mode,
|
|
61
|
+
walked: req.evidence?.walked ?? [],
|
|
62
|
+
tool: req.evidence?.tool ?? null,
|
|
63
|
+
visible: req.visible ?? [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOOT-C (KJC-TSK-0862) — the project's start script.
|
|
3
|
+
*
|
|
4
|
+
* From the Anthropic write-up on harnesses for long-running agents, and from
|
|
5
|
+
* the cold-start map: a session burns tokens working out how the project is
|
|
6
|
+
* launched, and worse, starts building on a tree that was already broken
|
|
7
|
+
* without knowing it. kj verifies the method; nobody verified that the
|
|
8
|
+
* application runs.
|
|
9
|
+
*
|
|
10
|
+
* kj declares the CONTRACT and checks it. The project writes the script,
|
|
11
|
+
* because kj cannot know how a project it has never seen is launched, and
|
|
12
|
+
* generating one from stack detection would produce the decorative artefact
|
|
13
|
+
* this codebase already rejects elsewhere (a config without its tool).
|
|
14
|
+
*
|
|
15
|
+
* It reports, it does not block: a badly written script must not stop work
|
|
16
|
+
* over something unrelated to the card. It earns the right to block when it
|
|
17
|
+
* proves reliable, the same path Sonar and the RAG took.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_START_SCRIPT = ".karajan/start.sh";
|
|
25
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
26
|
+
export const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export const START_SCRIPT_CONTRACT = [
|
|
29
|
+
"El guion de arranque levanta lo que haya que levantar y corre un humo de referencia.",
|
|
30
|
+
"Sale con 0 si el proyecto arranca y el humo pasa; con otro código si no.",
|
|
31
|
+
"No modifica el árbol ni depende de credenciales: solo responde si esto funciona ahora mismo.",
|
|
32
|
+
].join(" ");
|
|
33
|
+
|
|
34
|
+
/** Where the start script lives: the project's config decides, with a default. */
|
|
35
|
+
export function startScriptPath(projectDir, config = {}) {
|
|
36
|
+
return join(projectDir, config.start_script || DEFAULT_START_SCRIPT);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @returns {Promise<{status: "absent"|"ok"|"broken"|"timeout", exitCode?: number, output?: string, contract?: string, path: string}>}
|
|
41
|
+
*/
|
|
42
|
+
export function runStartScript({ projectDir, config = {}, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
43
|
+
const script = startScriptPath(projectDir, config);
|
|
44
|
+
if (!existsSync(script)) {
|
|
45
|
+
return Promise.resolve({ status: "absent", path: script, contract: START_SCRIPT_CONTRACT });
|
|
46
|
+
}
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const ownGroup = process.platform !== "win32";
|
|
49
|
+
const child = spawn("sh", [script], { cwd: projectDir, detached: ownGroup });
|
|
50
|
+
let output = "";
|
|
51
|
+
let truncated = false;
|
|
52
|
+
let timedOut = false;
|
|
53
|
+
// Bounded on purpose (catch de codex): a verbose or runaway script would
|
|
54
|
+
// otherwise eat the CLI's memory for the whole timeout. The LAST bytes are
|
|
55
|
+
// the ones worth keeping — that is where a failure explains itself.
|
|
56
|
+
const collect = (buf) => {
|
|
57
|
+
output += String(buf);
|
|
58
|
+
if (output.length > MAX_OUTPUT_BYTES) {
|
|
59
|
+
output = output.slice(-MAX_OUTPUT_BYTES);
|
|
60
|
+
truncated = true;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
child.stdout?.on("data", collect);
|
|
64
|
+
child.stderr?.on("data", collect);
|
|
65
|
+
// Kill the GROUP, not just the shell (catch de codex): this script's whole
|
|
66
|
+
// job is to START things, so the dev server it launched would keep its port
|
|
67
|
+
// long after we reported a timeout.
|
|
68
|
+
const killTree = () => {
|
|
69
|
+
try {
|
|
70
|
+
if (ownGroup && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
71
|
+
else child.kill("SIGKILL");
|
|
72
|
+
} catch { /* ya no está */ }
|
|
73
|
+
};
|
|
74
|
+
// A script that hangs is not a verdict: it is a script that hangs.
|
|
75
|
+
const timer = setTimeout(() => { timedOut = true; killTree(); }, timeoutMs);
|
|
76
|
+
child.on("error", (err) => { clearTimeout(timer); resolve({ status: "broken", path: script, exitCode: null, output: `${output}${err.message}`, truncated }); });
|
|
77
|
+
// Resolve on CLOSE, never before: the answer comes once the tree is down.
|
|
78
|
+
child.on("close", (code) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
if (timedOut) resolve({ status: "timeout", path: script, output, truncated });
|
|
81
|
+
else resolve({ status: code === 0 ? "ok" : "broken", exitCode: code, path: script, output, truncated });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|