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