karajan-code 4.33.0 → 4.35.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/agents/base-agent.js +22 -0
- package/src/agents/kimi-agent.js +12 -3
- package/src/brain/one-shot-policy.js +26 -0
- package/src/brain/with-brain-recovery.js +4 -1
- package/src/checks/release-check.js +51 -5
- package/src/cli/register-meta.js +53 -3
- package/src/commands/code.js +18 -16
- package/src/commands/harden.js +9 -2
- package/src/commands/policy.js +21 -14
- package/src/commands/rag.js +21 -0
- package/src/commands/report.js +28 -0
- package/src/commands/review-gate.js +13 -11
- package/src/config/defaults.js +13 -1
- package/src/environment/panel.js +33 -0
- package/src/guards/output-guard.js +56 -4
- package/src/harden/guidelines-engine.js +7 -1
- package/src/harden/guidelines-templates.js +10 -1
- package/src/harden/harness-hooks.js +6 -0
- package/src/harden/sentinel-hooks.js +243 -26
- package/src/harden/supervisor-commit.js +41 -1
- package/src/harden/workflow-templates.js +9 -3
- package/src/orchestrator/config-init.js +16 -102
- package/src/orchestrator/drivers/iteration-phases/guards.js +8 -1
- package/src/orchestrator/drivers/pre-loop.js +1 -2
- package/src/policy/supervisor-verify.js +45 -11
- package/src/rag/coverage.js +32 -1
- package/src/review/board-pending.js +68 -0
- package/src/review/card-first.js +12 -0
- package/src/review/gate-gitignore.js +33 -0
- package/src/review/loc-budget.js +97 -0
- package/src/roles/audit-role.js +28 -2
package/src/environment/panel.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* code, and then it says so instead of staying quiet.
|
|
12
12
|
*/
|
|
13
13
|
import { resolveRole } from "../config/role-resolver.js";
|
|
14
|
+
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
15
|
+
import { readIdentity } from "../identity/store.js";
|
|
14
16
|
|
|
15
17
|
/** Roles worth announcing: who writes, who reviews, who arbitrates. */
|
|
16
18
|
export const PANEL_ROLES = ["coder", "reviewer", "solomon"];
|
|
@@ -42,6 +44,37 @@ export function panelLine(config) {
|
|
|
42
44
|
].join("\n");
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* KJC-TSK-0868: the host writing the code while another coder is declared is
|
|
49
|
+
* not an offence to block, it is a fact to record. Announcing at session start
|
|
50
|
+
* is not enough: the user chose who writes and finds out it did not happen by
|
|
51
|
+
* accident, one turn at a time.
|
|
52
|
+
*
|
|
53
|
+
* @returns {{coder: string, host: string}|null} null when there is nothing to
|
|
54
|
+
* record: no coder declared, no host detected, or the host IS the coder.
|
|
55
|
+
*/
|
|
56
|
+
export function panelDeviation(config, host) {
|
|
57
|
+
const { coder } = resolvePanel(config);
|
|
58
|
+
if (!coder || !host || coder === host) return null;
|
|
59
|
+
return { coder, host };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* KJC-TSK-0873: the log holds both sides. A deviation alone cannot say whether
|
|
64
|
+
* the panel is honoured almost always or almost never, and those are different
|
|
65
|
+
* situations. The compliance seat is written where the declared coder is
|
|
66
|
+
* actually invoked (`kj code`); the deviation one when the turn ends.
|
|
67
|
+
*/
|
|
68
|
+
export function sealPanel({ projectDir, coder, host = null, honoured = false }) {
|
|
69
|
+
const who = readIdentity(projectDir);
|
|
70
|
+
return recordGateDecision(projectDir, {
|
|
71
|
+
decision: honoured ? "ok" : "exempt", chokepoint: "panel", escape: null, tool: null,
|
|
72
|
+
coder, host: honoured ? coder : host,
|
|
73
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
74
|
+
policy_hash: policyFileHash(projectDir),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
/** Human-facing one-liner for `kj check` and friends. */
|
|
46
79
|
export function panelSummary(config) {
|
|
47
80
|
const { coder, reviewer, solomon } = resolvePanel(config);
|
|
@@ -12,7 +12,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
12
12
|
const CREDENTIAL_PATTERNS = [
|
|
13
13
|
{ id: "aws-key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical", message: "AWS access key exposed" },
|
|
14
14
|
{ id: "private-key", pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/, severity: "critical", message: "Private key exposed" },
|
|
15
|
-
{ id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "Hardcoded secret detected.
|
|
15
|
+
{ id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "Hardcoded secret detected. Move it to the environment or a secrets manager" },
|
|
16
16
|
{ id: "github-token", pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, severity: "critical", message: "GitHub token exposed" },
|
|
17
17
|
{ id: "npm-token", pattern: /npm_[A-Za-z0-9]{36,}/, severity: "critical", message: "npm token exposed" },
|
|
18
18
|
{ id: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/, severity: "critical", message: "OpenAI API key exposed" },
|
|
@@ -24,7 +24,7 @@ const CREDENTIAL_PATTERNS = [
|
|
|
24
24
|
{ id: "slack-token", pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/, severity: "critical", message: "Slack token exposed" },
|
|
25
25
|
{ id: "jwt-secret", pattern: /jwt[_-]?secret\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "JWT secret hardcoded. Use .env file" },
|
|
26
26
|
{ id: "database-url", pattern: /(mongodb|postgres|mysql|redis):\/\/[^"'\s]+:[^"'\s]+@/i, severity: "critical", message: "Database URL with credentials exposed. Use .env file" },
|
|
27
|
-
{ id: "hardcoded-key-assignment", pattern: /(?:const|let|var)\s+\w*(?:key|secret|token|password)\w*\s*=\s*["'][A-Za-z0-9_-]{16,}["']/i, severity: "critical", message: "Hardcoded key in
|
|
27
|
+
{ id: "hardcoded-key-assignment", pattern: /(?:const|let|var)\s+\w*(?:key|secret|token|password)\w*\s*=\s*["'][A-Za-z0-9_-]{16,}["']/i, severity: "critical", message: "Hardcoded key in an assignment. Move it to the environment or a secrets manager" },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
// Default protected files (block if these appear in added/modified lines)
|
|
@@ -115,6 +115,49 @@ export function checkProtectedFiles(diff, protectedFiles) {
|
|
|
115
115
|
return violations;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* KJC-BUG-0215 (issue #1733): estas dos reglas acusan por el NOMBRE del
|
|
120
|
+
* identificador, no por la forma del valor, asi que un prefijo de cache llamado
|
|
121
|
+
* EXPERIMENTS_HANDOFF_TOKENS_NAMESPACE bloqueo una ejecucion entera con
|
|
122
|
+
* severidad critica y sin salida. Las demas reglas miran el valor (AKIA…,
|
|
123
|
+
* sk_live_…) y esas aciertan.
|
|
124
|
+
*
|
|
125
|
+
* El arreglo NO es adivinar: la review tumbo tres heuristicas seguidas y con
|
|
126
|
+
* razon, porque `const dbPassword = "db-password"` tambien tiene pinta inocente
|
|
127
|
+
* y es una credencial. Lo que faltaba era CAUCE: que el proyecto pueda declarar
|
|
128
|
+
* la excepcion en su configuracion versionada, donde se revisa en la PR como
|
|
129
|
+
* cualquier otra linea, y que el guard diga como hacerlo cuando bloquea.
|
|
130
|
+
*/
|
|
131
|
+
const NAME_BASED_RULES = new Set(["hardcoded-key-assignment", "generic-secret"]);
|
|
132
|
+
|
|
133
|
+
const HOW_TO_DECLARE = "If it is not a secret, declare it in guards.output.non_secret_assignments as NAME or path/to/file:NAME";
|
|
134
|
+
|
|
135
|
+
/** El identificador al que se asigna, cuando la linea deja leerlo. */
|
|
136
|
+
const assignedName = (content) => /([A-Za-z_$][\w$]*)\s*[:=]\s*["']/.exec(content)?.[1] ?? null;
|
|
137
|
+
|
|
138
|
+
/** Una entrada declarada se lee como `NAME` o `path/to/file:NAME`. */
|
|
139
|
+
const splitEntry = (entry) => {
|
|
140
|
+
const cut = entry.lastIndexOf(":");
|
|
141
|
+
return cut === -1 ? { path: null, name: entry } : { path: entry.slice(0, cut), name: entry.slice(cut + 1) };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Excepcion DECLARADA, no inferida: el proyecto afirma por escrito que esta
|
|
146
|
+
* asignacion no guarda un secreto. Sin `file` la declaracion vale para todo el
|
|
147
|
+
* repositorio; con el, solo para ese fichero, que es lo estrecho y preferible.
|
|
148
|
+
*/
|
|
149
|
+
export function declaredNonSecret(configGuards, file, content) {
|
|
150
|
+
const declared = configGuards?.output?.non_secret_assignments;
|
|
151
|
+
if (!Array.isArray(declared) || declared.length === 0) return false;
|
|
152
|
+
const name = assignedName(content);
|
|
153
|
+
if (!name) return false;
|
|
154
|
+
return declared.some(entry => {
|
|
155
|
+
if (typeof entry !== "string") return false;
|
|
156
|
+
const target = splitEntry(entry.trim());
|
|
157
|
+
return target.name === name && (target.path === null || target.path === file);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
118
161
|
/**
|
|
119
162
|
* Scan a diff for pattern violations.
|
|
120
163
|
* Returns { pass: boolean, violations: Array<{id, severity, file, line, message, matchedContent}> }
|
|
@@ -133,8 +176,17 @@ export function scanDiff(diff, config = {}) {
|
|
|
133
176
|
// Check patterns against added lines
|
|
134
177
|
for (const { file, line, content } of addedLines) {
|
|
135
178
|
for (const { id, pattern, severity, message } of patterns) {
|
|
136
|
-
if (pattern.test(content))
|
|
137
|
-
|
|
179
|
+
if (!pattern.test(content)) continue;
|
|
180
|
+
const matchedContent = content.trim().slice(0, 200);
|
|
181
|
+
// KJC-BUG-0215: una regla que acusa por el nombre necesita un cauce
|
|
182
|
+
// declarado para el falso positivo, y decirlo cuando bloquea.
|
|
183
|
+
if (!NAME_BASED_RULES.has(id)) {
|
|
184
|
+
violations.push({ id, severity, file, line, message, matchedContent });
|
|
185
|
+
} else if (declaredNonSecret(configGuards, file, content)) {
|
|
186
|
+
violations.push({ id, severity: "warning", file, line, matchedContent,
|
|
187
|
+
message: `${message}. Declared as non-secret in guards.output.non_secret_assignments` });
|
|
188
|
+
} else {
|
|
189
|
+
violations.push({ id, severity, file, line, matchedContent, message: `${message}. ${HOW_TO_DECLARE}` });
|
|
138
190
|
}
|
|
139
191
|
}
|
|
140
192
|
}
|
|
@@ -28,6 +28,8 @@ export function stripDevHooksBlock(source) {
|
|
|
28
28
|
return { content: `${before}${joiner}${after}`, migrated: true };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
const countLines = (text) => String(text || "").split("\n").filter((l) => l.trim()).length;
|
|
32
|
+
|
|
31
33
|
export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
|
|
32
34
|
// KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
|
|
33
35
|
// be the rules of THIS project's language. kj harden already detects it for
|
|
@@ -45,8 +47,12 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
|
|
|
45
47
|
body,
|
|
46
48
|
style: "html",
|
|
47
49
|
});
|
|
50
|
+
// KJC-BUG-0206: harden removing rules is a fact worth saying. It silently
|
|
51
|
+
// dropped six lines from this repo's own files because the detected language
|
|
52
|
+
// had no block, and "updated" read like an improvement.
|
|
53
|
+
const shrank = countLines(content) < countLines(raw);
|
|
48
54
|
if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
|
|
49
|
-
results.push({ file, action: migrated ? "migrated" : action });
|
|
55
|
+
results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true } : {}) });
|
|
50
56
|
}
|
|
51
57
|
return { dryRun, guidelines: results };
|
|
52
58
|
}
|
|
@@ -76,9 +76,18 @@ export const LANGUAGE_GUIDELINES = {
|
|
|
76
76
|
],
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Languages that share another's block (KJC-BUG-0206). TypeScript is the same
|
|
81
|
+
* ecosystem as JavaScript, and the block already says so in its heading; but the
|
|
82
|
+
* stack detector reports `typescript` for a repo with a tsconfig, so the lookup
|
|
83
|
+
* missed and harden ERASED the rules a project already had.
|
|
84
|
+
*/
|
|
85
|
+
const ALIASES = { typescript: "javascript" };
|
|
86
|
+
|
|
79
87
|
/** The guidelines body for a language (unknown or absent ⇒ the core alone). */
|
|
80
88
|
export function guidelinesBody(language = null) {
|
|
81
|
-
const
|
|
89
|
+
const key = String(language || "").toLowerCase();
|
|
90
|
+
const extra = LANGUAGE_GUIDELINES[ALIASES[key] ?? key] ?? [];
|
|
82
91
|
return [...CORE, ...extra].join("\n");
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -44,6 +44,12 @@ process.stdin.on("end", () => {
|
|
|
44
44
|
`;
|
|
45
45
|
|
|
46
46
|
/** Write a managed hook script under .karajan/harness/ and return its path. */
|
|
47
|
+
/**
|
|
48
|
+
* KJC-BUG-0211: el cuerpo canonico de los guardias que instala ESTE modulo,
|
|
49
|
+
* para que quien verifica un sello pueda RECOMPUTAR en vez de fiarse del hash.
|
|
50
|
+
*/
|
|
51
|
+
export const HARNESS_BODIES = { "pretooluse.mjs": SCRIPT_BODY };
|
|
52
|
+
|
|
47
53
|
export function writeHarnessScript(projectDir, name, body) {
|
|
48
54
|
const dir = join(projectDir, ".karajan", "harness");
|
|
49
55
|
mkdirSync(dir, { recursive: true });
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { CARD_REF_RE } from "../review/card-first.js";
|
|
15
|
-
import { mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
|
|
16
|
+
import { HARNESS_BODIES, mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
|
|
16
17
|
|
|
17
18
|
/** The harness lives at the PROJECT root — resolve it even from a subdir. */
|
|
18
19
|
export function resolveSentinelRoot(dir = process.cwd()) {
|
|
@@ -134,7 +135,7 @@ import process from "node:process";
|
|
|
134
135
|
import { relative } from "node:path";
|
|
135
136
|
import { spawnSync } from "node:child_process";
|
|
136
137
|
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
137
|
-
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG"];
|
|
138
|
+
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG", "KJ_ALLOW_NO_VERIFY"];
|
|
138
139
|
let raw = "";
|
|
139
140
|
process.stdin.on("data", (d) => { raw += d; });
|
|
140
141
|
process.stdin.on("end", () => {
|
|
@@ -364,6 +365,18 @@ process.stdin.on("end", () => {
|
|
|
364
365
|
if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
|
|
365
366
|
process.exit(0);
|
|
366
367
|
}
|
|
368
|
+
// KJC-TSK-0868: si el anfitrion ha escrito codigo teniendo otro coder
|
|
369
|
+
// declarado, eso no se bloquea, se REGISTRA: el usuario eligio quien
|
|
370
|
+
// escribe y merece enterarse por el decision log, no por casualidad. Una
|
|
371
|
+
// vez por sesion, y si kj no responde se reintenta en el turno siguiente.
|
|
372
|
+
if (!s.panel_sealed && (s.edited_sources || []).length > 0) {
|
|
373
|
+
const p = spawnSync("kj", ["sentinel", "panel-check", "--session", sid], { cwd: ROOT, encoding: "utf8" });
|
|
374
|
+
if (!p.error && p.status === 0) {
|
|
375
|
+
s.panel_sealed = true;
|
|
376
|
+
save(state);
|
|
377
|
+
if ((p.stdout || "").trim()) console.error(p.stdout.trim());
|
|
378
|
+
}
|
|
379
|
+
}
|
|
367
380
|
const v = violations(s, branchOf());
|
|
368
381
|
if (claimsBlock) v.push(claimsBlock);
|
|
369
382
|
if (!v.length) {
|
|
@@ -406,9 +419,19 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
406
419
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
407
420
|
import { spawnSync } from "node:child_process";
|
|
408
421
|
import { homedir } from "node:os";
|
|
422
|
+
import { fileURLToPath } from "node:url";
|
|
409
423
|
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
410
424
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
411
|
-
|
|
425
|
+
// KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
|
|
426
|
+
// (firebase --account a@b --project p deploy --only hosting:main), asi que la
|
|
427
|
+
// adyacencia dejaba pasar sin mirar justo el despliegue que el gate vigila.
|
|
428
|
+
// Las palabras del verbo tienen que aparecer en orden, no pegadas.
|
|
429
|
+
const PUBLISH_VERBS = [["npm", "publish"], ["firebase", "deploy"], ["gh", "release", "create"]];
|
|
430
|
+
// Cada palabra se lee por su basename: /usr/bin/npm publish es la misma
|
|
431
|
+
// publicacion que npm publish (catch de la review).
|
|
432
|
+
const wordsOf = (text) => String(text).split(/[^A-Za-z0-9_.@:/-]+/).filter(Boolean).map((w) => w.slice(w.lastIndexOf("/") + 1)).filter(Boolean);
|
|
433
|
+
const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[i] && ++i === want.length) return true; return false; };
|
|
434
|
+
const isPublish = (cmd) => { const got = wordsOf(cmd); return PUBLISH_VERBS.some((verb) => inOrder(verb, got)); };
|
|
412
435
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
413
436
|
const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
|
|
414
437
|
let raw = "";
|
|
@@ -849,7 +872,30 @@ process.stdin.on("end", () => {
|
|
|
849
872
|
process.exit(2);
|
|
850
873
|
}
|
|
851
874
|
} else if (pres.status !== 0) {
|
|
852
|
-
|
|
875
|
+
// KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
|
|
876
|
+
// kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
|
|
877
|
+
// la sesion sin Edit, sin Write y sin Bash, o sea sin las herramientas
|
|
878
|
+
// para arreglar el fichero, y el remedio que sugeria (kj policy check)
|
|
879
|
+
// tampoco arrancaba. Se avisa fuerte y se deja pasar: el resto de este
|
|
880
|
+
// guard sigue vigente, que es lo que protege el metodo.
|
|
881
|
+
const broken = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/.exec(String(pres.stderr || ""));
|
|
882
|
+
// Abrir el gate entero seria un bypass a la carta (romper un modulo y
|
|
883
|
+
// seguir sin policy), catch de la review. Se permite EXACTAMENTE una
|
|
884
|
+
// cosa: editar el fichero que rompe kj. Lo demas sigue denegado.
|
|
885
|
+
const at = broken ? (String(pres.stderr || "").split("\\n").map((l) => l.trim()).find((l) => /^[^ ]+\\.(m?js|cjs|ts):\\d+$/.test(l)) || "") : "";
|
|
886
|
+
// Node localiza los errores de un modulo ESM como file:///ruta.js:124,
|
|
887
|
+
// y esa forma no es una ruta de fichero (catch de la review).
|
|
888
|
+
const asPath = (p) => (p.startsWith("file:") ? fileURLToPath(p) : p);
|
|
889
|
+
const brokenFile = at ? asPath(at.slice(0, at.lastIndexOf(":"))) : "";
|
|
890
|
+
if (broken) {
|
|
891
|
+
const target = input.file_path || input.notebook_path || "";
|
|
892
|
+
const repairing = EDIT_TOOLS.includes(tool) && brokenFile && target && resolve(String(target)) === resolve(brokenFile);
|
|
893
|
+
if (repairing) console.error("karajan sentinel: kj no arranca (" + broken[0] + " en " + at + ") — la policy no se puede evaluar, asi que solo se permite arreglar ESE fichero. Arreglalo y vuelve." + doc("policy"));
|
|
894
|
+
else {
|
|
895
|
+
console.error("karajan sentinel: kj no arranca (" + broken[0] + (at ? " en " + at : "") + "), asi que la policy no se puede evaluar y NADA MAS pasa: edita " + (brokenFile || "el fichero que rompe kj") + " para arreglarlo. No es un deny de policy, es kj roto." + doc("policy"));
|
|
896
|
+
process.exit(2);
|
|
897
|
+
}
|
|
898
|
+
} else if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
853
899
|
else {
|
|
854
900
|
console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada)." + doc("policy"));
|
|
855
901
|
process.exit(2);
|
|
@@ -907,19 +953,40 @@ process.stdin.on("end", () => {
|
|
|
907
953
|
const rs = load().sessions?.[sid] || {};
|
|
908
954
|
const hits = rs.rag_hits || [];
|
|
909
955
|
const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
|
910
|
-
|
|
956
|
+
// KJC-BUG-0210: nuevo no es "no existe en disco". En cuanto la sesion
|
|
957
|
+
// lo crea, la siguiente edicion exigia una respuesta del RAG sobre un
|
|
958
|
+
// fichero que el indice NO puede tener. El hecho comprobable: un
|
|
959
|
+
// fichero que git no conoce no ha podido indexarse.
|
|
960
|
+
// Solo se pregunta a git en el camino lento, cuando el gate iba a
|
|
961
|
+
// denegar: exit 1 = git responde que no lo conoce; cualquier otro
|
|
962
|
+
// resultado se comporta como hasta ahora y bloquea.
|
|
963
|
+
const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", ROOT, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
|
|
911
964
|
// A path outside this tree is another lane's business (the lane guard
|
|
912
965
|
// above rules on it) and the ledger only holds in-repo paths.
|
|
913
966
|
// KJC-BUG-0190: with nothing indexed, no query can cover anything —
|
|
914
967
|
// the gate asks for kj rag index instead of denying the impossible.
|
|
915
|
-
const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (
|
|
968
|
+
const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
|
|
916
969
|
if (!covered) {
|
|
917
970
|
if (escOn("KJ_ALLOW_NO_RAG")) {
|
|
918
971
|
// Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
|
|
919
972
|
if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
|
|
920
973
|
} else {
|
|
921
|
-
|
|
922
|
-
|
|
974
|
+
// KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
|
|
975
|
+
// sin adapter NO puede estar en el indice, asi que exigir una
|
|
976
|
+
// respuesta del RAG sobre el es pedir lo imposible, y empujaba a
|
|
977
|
+
// dejar KJ_ALLOW_NO_RAG puesta para siempre. La exencion es SOLO
|
|
978
|
+
// esa: un indice vacio o por detras si tiene arreglo (kj rag index)
|
|
979
|
+
// y sigue bloqueando, porque si no el gate desapareceria en
|
|
980
|
+
// cualquier proyecto sin indexar. Nada legible = se bloquea.
|
|
981
|
+
const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
982
|
+
let blind = null;
|
|
983
|
+
try { const c = JSON.parse(String(cov.stdout || "")); if (c.state === "not-indexable") blind = c.reason; } catch { blind = null; }
|
|
984
|
+
if (blind) {
|
|
985
|
+
console.error("karajan sentinel: rag-first — " + blind + " — no se te exige consultar lo que el indice no puede tener; el gate lo anota y sigue" + doc("rag-first"));
|
|
986
|
+
} else {
|
|
987
|
+
console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
|
|
988
|
+
process.exit(2);
|
|
989
|
+
}
|
|
923
990
|
}
|
|
924
991
|
}
|
|
925
992
|
}
|
|
@@ -930,15 +997,54 @@ process.stdin.on("end", () => {
|
|
|
930
997
|
// and nothing advances (commit, new PR, another merge; push and the end of
|
|
931
998
|
// the turn go through violations()) until the card is moved. The board is
|
|
932
999
|
// true at all times or the method stops — never a rule in the agent's memory.
|
|
1000
|
+
// ENF-B (KJC-TSK-0854): el review es obligatorio, no una sugerencia. Una
|
|
1001
|
+
// bandera no puede apagar el gate de commit, y mover core.hooksPath lo
|
|
1002
|
+
// apaga entero. El --no-verify que kj harden --commit usa por dentro no
|
|
1003
|
+
// pasa por aqui: no es una tool call. Escape humano, sellado como el resto.
|
|
1004
|
+
const words = wordsOf(cmd);
|
|
1005
|
+
// git acepta abreviaturas no ambiguas de las opciones largas, asi que
|
|
1006
|
+
// --no-ver salta el hook igual que --no-verify (catch de la review).
|
|
1007
|
+
const shortWithN = (w) => w.length > 1 && w[0] === "-" && w[1] !== "-" && w.includes("n");
|
|
1008
|
+
const skipsVerify = (w) => shortWithN(w) || (w.length >= 5 && "--no-verify".startsWith(w));
|
|
1009
|
+
// Mover core.hooksPath apaga TODOS los hooks. git tiene varias puertas
|
|
1010
|
+
// para lo mismo (config, -c, --config-env, GIT_CONFIG_KEY_n) y no
|
|
1011
|
+
// distingue mayusculas en la llave, asi que lo que se vigila es la LLAVE
|
|
1012
|
+
// en cualquier posicion (dos catches de la review). Leerla no hace nada.
|
|
1013
|
+
// Nombrar la llave basta para denegar: exceptuar las lecturas invitaba a
|
|
1014
|
+
// colar una detras del cambio (git config core.hooksPath /x; cat algo),
|
|
1015
|
+
// catch de la review. Leerla tiene remedio barato, perderla no.
|
|
1016
|
+
// Se busca la llave DENTRO del token y no solo al principio: las formas
|
|
1017
|
+
// de inyeccion la empotran (GIT_CONFIG_KEY_0=core.hooksPath,
|
|
1018
|
+
// --config-env=core.hooksPath=VAR) y una propiedad de seguridad no puede
|
|
1019
|
+
// depender de como parta las palabras el separador (catch de la review).
|
|
1020
|
+
const movesHooks = cmd.toLowerCase().includes("core.hookspath");
|
|
1021
|
+
if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
|
|
1022
|
+
if (escOn("KJ_ALLOW_NO_VERIFY")) { recordEscape(sid, "KJ_ALLOW_NO_VERIFY", tool); }
|
|
1023
|
+
else {
|
|
1024
|
+
console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta, es una decision de tu usuario. (KJ_ALLOW_NO_VERIFY=1 = excepcion consciente, queda registrada)" + doc("escapes"));
|
|
1025
|
+
process.exit(2);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
933
1028
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
934
1029
|
const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
|
|
935
1030
|
const mergeM = cmd.match(MERGE);
|
|
936
1031
|
const pend = pendingMoves(load().sessions?.[sid]);
|
|
937
1032
|
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1033
|
+
// KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
|
|
1034
|
+
// por su primera mitad. kj decide con un hecho comprobable (otra PR
|
|
1035
|
+
// abierta de la card); si kj no responde, se bloquea como siempre.
|
|
1036
|
+
const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
1037
|
+
let blocking = pend, carried = [];
|
|
1038
|
+
if (!bg.error && (bg.status === 0 || bg.status === 2)) {
|
|
1039
|
+
try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
|
|
1040
|
+
}
|
|
1041
|
+
for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
|
|
1042
|
+
if (blocking.length > 0) {
|
|
1043
|
+
if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
|
|
1044
|
+
else {
|
|
1045
|
+
console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
|
|
1046
|
+
process.exit(2);
|
|
1047
|
+
}
|
|
942
1048
|
}
|
|
943
1049
|
}
|
|
944
1050
|
// (the pending entry itself is recorded by the PostToolUse hook, only once
|
|
@@ -956,13 +1062,24 @@ process.stdin.on("end", () => {
|
|
|
956
1062
|
process.exit(2);
|
|
957
1063
|
}
|
|
958
1064
|
}
|
|
959
|
-
if (
|
|
1065
|
+
if (isPublish(cmd)) {
|
|
960
1066
|
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
961
|
-
|
|
1067
|
+
// KJC-BUG-0204: el check se evalua PARA este comando. Un item que
|
|
1068
|
+
// declara remedied_by no bloquea el comando que lo repara (desplegar
|
|
1069
|
+
// la landing era justo lo que el check pedia); publicar no se exime.
|
|
1070
|
+
const res = spawnSync("kj", ["release", "check", "--json", "--for-command", cmd], { cwd: ROOT, encoding: "utf8" });
|
|
962
1071
|
if (res.error || res.status === null) process.exit(0);
|
|
1072
|
+
let parsed = null;
|
|
1073
|
+
try { parsed = JSON.parse(res.stdout); } catch { /* raw output */ }
|
|
1074
|
+
const lifted = (parsed && parsed.lifted) || [];
|
|
1075
|
+
if (res.status === 0 && lifted.length > 0) {
|
|
1076
|
+
// En voz alta: una exencion que nadie ve no se distingue de un gate que no mira.
|
|
1077
|
+
console.error("karajan sentinel: release check en rojo SOLO por lo que este comando repara (" + lifted.join(", ") + ") — pasa, y el resto sigue en verde" + doc("release"));
|
|
1078
|
+
process.exit(0);
|
|
1079
|
+
}
|
|
963
1080
|
if (res.status !== 0) {
|
|
964
1081
|
let items = "";
|
|
965
|
-
try { items = (
|
|
1082
|
+
try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
|
|
966
1083
|
console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)" + doc("release"));
|
|
967
1084
|
process.exit(2);
|
|
968
1085
|
}
|
|
@@ -1000,24 +1117,124 @@ const SCRIPT_BODIES = {
|
|
|
1000
1117
|
* package — outside the project tree, beyond the session's tool reach —
|
|
1001
1118
|
* because nothing under .karajan can vouch for itself.
|
|
1002
1119
|
*/
|
|
1003
|
-
|
|
1004
|
-
|
|
1120
|
+
const PROVENANCE_PATH = ".karajan/supervisor-provenance.json";
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* KJC-BUG-0211: el cuerpo que kj escribiria para ese guardia, venga de las
|
|
1124
|
+
* plantillas del Sentinel o de las del harness. Quien verifica un sello
|
|
1125
|
+
* recomputa con esto; sin el, un sello que cubre los guardias (KJC-BUG-0197)
|
|
1126
|
+
* no se puede ni commitear, porque nadie sabe comprobarlos.
|
|
1127
|
+
*/
|
|
1128
|
+
export const canonicalHarnessBody = (name) => SCRIPT_BODIES[name] ?? HARNESS_BODIES[name] ?? null;
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* KJC-BUG-0197: what did a human actually seal, per guard?
|
|
1132
|
+
*
|
|
1133
|
+
* Read from **git**, never from the working tree. The provenance is tracked and
|
|
1134
|
+
* written only by `kj harden --commit` (a human act with four layers, ADR 0009),
|
|
1135
|
+
* so the committed copy is the external reference; the copy on disk is as
|
|
1136
|
+
* forgeable as the guard it would vouch for, and the review caught exactly that.
|
|
1137
|
+
* No git, no commit, no seal: then nothing is forgiven and the check fails
|
|
1138
|
+
* closed, which is the safe direction.
|
|
1139
|
+
*
|
|
1140
|
+
* The map is keyed BY PATH: a hash sealed for one file never vouches for
|
|
1141
|
+
* another (also the review's catch).
|
|
1142
|
+
*
|
|
1143
|
+
* @returns {Map<string, string>} relative path → sealed sha256
|
|
1144
|
+
*/
|
|
1145
|
+
function sealedByPath(projectDir, gitShowFn) {
|
|
1146
|
+
try {
|
|
1147
|
+
const raw = gitShowFn
|
|
1148
|
+
? gitShowFn(projectDir, PROVENANCE_PATH)
|
|
1149
|
+
: execFileSync("git", ["-C", projectDir, "show", `HEAD:${PROVENANCE_PATH}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1150
|
+
const files = JSON.parse(raw)?.files;
|
|
1151
|
+
const out = new Map();
|
|
1152
|
+
for (const entry of Array.isArray(files) ? files : []) {
|
|
1153
|
+
if (typeof entry?.file === "string" && typeof entry?.sha256 === "string") out.set(entry.file, entry.sha256);
|
|
1154
|
+
}
|
|
1155
|
+
return out;
|
|
1156
|
+
} catch {
|
|
1157
|
+
return new Map();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* @returns {{ok: boolean, mismatched: string[], drift?: string[], reason?: string}}
|
|
1163
|
+
* `mismatched` is TAMPERING: a guard holds content nobody sealed, and that is
|
|
1164
|
+
* what the hooks block on. `drift` is a sealed guard whose templates have
|
|
1165
|
+
* moved on, which `kj harden` applies and which is not a defect.
|
|
1166
|
+
*/
|
|
1167
|
+
export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, gitShowFn = null } = {}) {
|
|
1168
|
+
const root = projectDir || resolveSentinelRoot();
|
|
1169
|
+
const dir = join(root, ".karajan", "harness");
|
|
1170
|
+
const installed = new Map();
|
|
1005
1171
|
const mismatched = [];
|
|
1006
1172
|
for (const [name, body] of Object.entries(SCRIPT_BODIES)) {
|
|
1007
1173
|
try {
|
|
1008
|
-
|
|
1174
|
+
const text = readFileFn(join(dir, name), "utf8");
|
|
1175
|
+
installed.set(name, text);
|
|
1176
|
+
if (text !== body) mismatched.push(name);
|
|
1009
1177
|
} catch {
|
|
1010
|
-
mismatched.push(name);
|
|
1178
|
+
mismatched.push(name); // missing: there is no content for a seal to cover
|
|
1011
1179
|
}
|
|
1012
1180
|
}
|
|
1013
|
-
|
|
1181
|
+
if (mismatched.length === 0) return { ok: true, mismatched: [] };
|
|
1182
|
+
|
|
1183
|
+
// A mismatch is only forgiven when what sits there is something a human
|
|
1184
|
+
// sealed. Editing a guard produces content no seal covers, so the forgiveness
|
|
1185
|
+
// cannot be borrowed by also editing a template: that was the hole in the
|
|
1186
|
+
// first attempt at this fix, and the review was right to reject it.
|
|
1187
|
+
const sealed = sealedByPath(root, gitShowFn);
|
|
1188
|
+
const drift = [];
|
|
1189
|
+
const tampered = [];
|
|
1190
|
+
for (const name of mismatched) {
|
|
1191
|
+
const text = installed.get(name);
|
|
1192
|
+
const sealedHash = sealed.get(`.karajan/harness/${name}`);
|
|
1193
|
+
if (text !== undefined && sealedHash && sealedHash === createHash("sha256").update(text).digest("hex")) drift.push(name);
|
|
1194
|
+
else tampered.push(name);
|
|
1195
|
+
}
|
|
1196
|
+
if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}) };
|
|
1197
|
+
return {
|
|
1198
|
+
ok: true,
|
|
1199
|
+
mismatched: [],
|
|
1200
|
+
drift,
|
|
1201
|
+
reason: "el harness instalado está sellado y las plantillas de este árbol van por delante (kj harden lo pone al día) — no es manipulación",
|
|
1202
|
+
};
|
|
1014
1203
|
}
|
|
1015
1204
|
|
|
1016
|
-
/**
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1205
|
+
/**
|
|
1206
|
+
* Write the sentinel scripts (shared lib + state writer + gates) and wire them.
|
|
1207
|
+
*
|
|
1208
|
+
* KJC-BUG-0193: a guard holding exactly what a human sealed is LEFT ALONE. This
|
|
1209
|
+
* is the command a session reaches for when it has edited the templates, and
|
|
1210
|
+
* rewriting the guards here un-sealed the ones actually running, so the very act
|
|
1211
|
+
* of unblocking destroyed the seal that made unblocking unnecessary. Advancing
|
|
1212
|
+
* them is a human act: `kj harden --commit` moves guards and seal together.
|
|
1213
|
+
* Anything nobody sealed is rewritten as before, because restoring a tampered
|
|
1214
|
+
* guard is still the right move.
|
|
1215
|
+
*/
|
|
1216
|
+
export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
|
|
1217
|
+
const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
|
|
1218
|
+
const deferred = [];
|
|
1219
|
+
const keepsItsSeal = (name) => {
|
|
1220
|
+
const sealedHash = sealed.get(`.karajan/harness/${name}`);
|
|
1221
|
+
if (!sealedHash) return false;
|
|
1222
|
+
try {
|
|
1223
|
+
return sealedHash === createHash("sha256").update(readFileSync(join(projectDir, ".karajan", "harness", name), "utf8")).digest("hex");
|
|
1224
|
+
} catch {
|
|
1225
|
+
return false; // no hay contenido que un sello pueda cubrir
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) => {
|
|
1229
|
+
if (keepsItsSeal(name)) {
|
|
1230
|
+
deferred.push(name);
|
|
1231
|
+
return join(projectDir, ".karajan", "harness", name);
|
|
1232
|
+
}
|
|
1233
|
+
return writeHarnessScript(projectDir, name, body);
|
|
1234
|
+
});
|
|
1235
|
+
if (deferred.length > 0) {
|
|
1236
|
+
logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
|
|
1237
|
+
}
|
|
1021
1238
|
const { wired } = mergeClaudeHooks({
|
|
1022
1239
|
projectDir,
|
|
1023
1240
|
logger,
|
|
@@ -1033,5 +1250,5 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
1033
1250
|
{ event: "Stop", script: "stop.mjs" },
|
|
1034
1251
|
],
|
|
1035
1252
|
});
|
|
1036
|
-
return { scripts: [lib, post, stop, pre], wired };
|
|
1253
|
+
return { scripts: [lib, post, stop, pre], wired, deferred };
|
|
1037
1254
|
}
|
|
@@ -16,6 +16,13 @@ import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
|
16
16
|
|
|
17
17
|
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
18
18
|
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
19
|
+
// KJC-BUG-0197: la procedencia del supervisor no cubría los guardias del
|
|
20
|
+
// supervisor. Sellaba los hooks de git y ningún .mjs del harness, que son
|
|
21
|
+
// justo los que vigilan la sesión, así que nada externo podía decir si un
|
|
22
|
+
// guardia instalado era auténtico. Los ficheros siguen fuera de git (los
|
|
23
|
+
// veredictos son locales por diseño); lo que viaja y se firma es su HUELLA,
|
|
24
|
+
// dentro de una procedencia que sí está trackeada.
|
|
25
|
+
const HARNESS_PREFIX = ".karajan/harness/";
|
|
19
26
|
|
|
20
27
|
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
21
28
|
|
|
@@ -58,6 +65,23 @@ function defaultConfirm(nonce) {
|
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
|
|
68
|
+
/**
|
|
69
|
+
* KJC-BUG-0197: los guardias del Sentinel que el sello debe cubrir. No salen de
|
|
70
|
+
* `git status` (están gitignorados), así que se leen del disco. Un proyecto sin
|
|
71
|
+
* harness (perfil minimal) sella los hooks y nada más.
|
|
72
|
+
* @returns {string[]} rutas relativas, ordenadas
|
|
73
|
+
*/
|
|
74
|
+
export function harnessGuards(projectDir) {
|
|
75
|
+
try {
|
|
76
|
+
return readdirSync(join(projectDir, HARNESS_PREFIX))
|
|
77
|
+
.filter((f) => f.endsWith(".mjs"))
|
|
78
|
+
.map((f) => HARNESS_PREFIX + f)
|
|
79
|
+
.sort();
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
export function supervisorDrift({ projectDir, gitFn }) {
|
|
62
86
|
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
63
87
|
// Un rename sale como "R old -> new" (catch de codex): ambas rutas son
|
|
@@ -107,10 +131,26 @@ export async function commitSupervisorRegeneration({
|
|
|
107
131
|
// La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
|
|
108
132
|
// en el primer estreno real: un sello parcial pisaba al anterior y dejaba
|
|
109
133
|
// ficheros sin respaldo). Borrados del drift ⇒ deleted (catch de codex).
|
|
110
|
-
const current =
|
|
134
|
+
const current = [
|
|
135
|
+
...readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f),
|
|
136
|
+
...harnessGuards(projectDir),
|
|
137
|
+
].sort();
|
|
138
|
+
// KJC-BUG-0197: un guardia BORRADO se anota como borrado, igual que un hook.
|
|
139
|
+
// No hacía falta para desencadenar el re-sello (la lista encoge, así que la
|
|
140
|
+
// cobertura ya falla), pero registrar la ausencia dice más que omitirla:
|
|
141
|
+
// quien lea la procedencia ve que ese guardia se fue, no que nunca estuvo.
|
|
142
|
+
const previousGuards = (() => {
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8"))?.files ?? [];
|
|
145
|
+
} catch { return []; }
|
|
146
|
+
})();
|
|
147
|
+
const goneGuards = previousGuards
|
|
148
|
+
.map((f) => f?.file)
|
|
149
|
+
.filter((f) => typeof f === "string" && f.startsWith(HARNESS_PREFIX) && !existsSync(join(projectDir, f)));
|
|
111
150
|
const hashed = [
|
|
112
151
|
...current.map((file) => ({ file, sha256: sha256(join(projectDir, file)) })),
|
|
113
152
|
...drift.filter((f) => !existsSync(join(projectDir, f))).map((file) => ({ file, deleted: true })),
|
|
153
|
+
...goneGuards.map((file) => ({ file, deleted: true })),
|
|
114
154
|
];
|
|
115
155
|
let previous = null;
|
|
116
156
|
try { previous = JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8")); } catch { /* primer sello */ }
|