karajan-code 4.70.1 → 4.70.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/checks/native-build.js +44 -1
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/harden.js +10 -3
- package/src/commands/pr-size.js +4 -3
- package/src/commands/review-gate.js +9 -7
- package/src/config/loader.js +3 -1
- package/src/git/automation.js +5 -4
- package/src/harden/harden-engine.js +5 -21
- package/src/harden/hook-templates.js +18 -30
- package/src/harden/phone-sign.js +3 -1
- package/src/harden/published-pin.js +34 -0
- package/src/harden/sentinel/posttooluse.mjs +4 -2
- package/src/harden/sentinel/pretooluse-sentinel.mjs +4 -2
- package/src/harden/sentinel/sentinel-shell.mjs +54 -4
- package/src/harden/workflow-engine.js +32 -7
- package/src/orchestrator/stages/suite-green-stage.js +3 -1
- package/src/policy/add.js +3 -1
- package/src/policy/engine.js +9 -3
- package/src/policy/supervisor-verify.js +2 -2
- package/src/rag/migrate.js +2 -1
- package/src/rag/project-store.js +13 -2
- package/src/review/pr-requirements.js +6 -6
- package/src/review/verify-pr.js +38 -7
- package/src/sonar/scanner.js +2 -1
package/package.json
CHANGED
|
@@ -9,8 +9,43 @@
|
|
|
9
9
|
* WARN in `kj doctor` with the exact remedy instead of a cryptic runtime crash.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
12
16
|
import { STRATEGY } from "./types.js";
|
|
13
17
|
|
|
18
|
+
// kj's own package root: where npm keeps its dependencies' approvals and builds.
|
|
19
|
+
const OWN_PKG_DIR = resolve(fileURLToPath(new URL("../..", import.meta.url)));
|
|
20
|
+
|
|
21
|
+
/** npm's major version, or null when npm is not there to ask. */
|
|
22
|
+
function defaultNpmMajor() {
|
|
23
|
+
try {
|
|
24
|
+
return Number.parseInt(execFileSync("npm", ["--version"], { encoding: "utf8", timeout: 10_000 }), 10) || null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* KJC-BUG-0426 (issue #2670): npm 12 runs a dependency's install script only when
|
|
32
|
+
* allowScripts covers it; reinstalling blocks it again. Approve, then rebuild, in kj's dir.
|
|
33
|
+
*/
|
|
34
|
+
export const npmAllowScriptsRemedy = (pkgDir = OWN_PKG_DIR) =>
|
|
35
|
+
`cd "${pkgDir}" && npm install-scripts approve better-sqlite3 && npm install-scripts approve node-pty && npm rebuild better-sqlite3 node-pty`;
|
|
36
|
+
|
|
37
|
+
// The addon is installed but its native build is missing (a missing package needs a reinstall, not this).
|
|
38
|
+
const UNBUILT_ADDON = /bindings file|better_sqlite3\.node/;
|
|
39
|
+
|
|
40
|
+
/** A missing better-sqlite3 addon becomes an error that says how to build it; any other error passes as it is. */
|
|
41
|
+
export function explainNativeLoadError(err, { npmMajor = defaultNpmMajor, pkgDir = OWN_PKG_DIR } = {}) {
|
|
42
|
+
if (!UNBUILT_ADDON.test(String(err?.message ?? err))) return err;
|
|
43
|
+
const remedy = (npmMajor() ?? 0) >= 12
|
|
44
|
+
? `npm 12 skipped its install script (allowScripts); build it with: ${npmAllowScriptsRemedy(pkgDir)}`
|
|
45
|
+
: "run kj doctor for the exact fix on this install";
|
|
46
|
+
return new Error(`better-sqlite3 native addon is not built, so the RAG cannot open its index — ${remedy}`, { cause: err });
|
|
47
|
+
}
|
|
48
|
+
|
|
14
49
|
/** pnpm lays packages out under a `.pnpm` virtual store and symlinks them in. */
|
|
15
50
|
function isPnpmInstall() {
|
|
16
51
|
return (
|
|
@@ -25,7 +60,7 @@ async function defaultLoadDb() {
|
|
|
25
60
|
new Database(":memory:").close();
|
|
26
61
|
}
|
|
27
62
|
|
|
28
|
-
export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpmInstall } = {}) {
|
|
63
|
+
export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpmInstall, npmMajor = defaultNpmMajor, pkgDir = OWN_PKG_DIR } = {}) {
|
|
29
64
|
return {
|
|
30
65
|
name: "native-build",
|
|
31
66
|
label: "native modules (better-sqlite3)",
|
|
@@ -48,6 +83,14 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
|
|
|
48
83
|
fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g @karajan-family/code`",
|
|
49
84
|
};
|
|
50
85
|
}
|
|
86
|
+
if ((npmMajor() ?? 0) >= 12) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
severity: "warn",
|
|
90
|
+
detail: `better-sqlite3 failed to load: ${firstLine} — npm 12 skips dependency install scripts that allowScripts does not cover, so the native build never ran`,
|
|
91
|
+
fix: npmAllowScriptsRemedy(pkgDir),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
51
94
|
return {
|
|
52
95
|
ok: false,
|
|
53
96
|
severity: "warn",
|
|
@@ -292,6 +292,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
292
292
|
const r = await verifyPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.verifyPr });
|
|
293
293
|
if (r.ok) logger.info(`✓ PR #${flags.verifyPr}: ${r.commits} commit(s), each with its cross-AI verdict`);
|
|
294
294
|
else logger.error(`✗ PR #${flags.verifyPr}:\n - ${r.problems.join("\n - ")}`);
|
|
295
|
+
if (r.sealCommits) logger.info(` seal commit(s) signed by the roster, a human act with no AI verdict: ${r.sealCommits.join(", ")}`);
|
|
295
296
|
process.exitCode = r.ok ? 0 : 1;
|
|
296
297
|
return;
|
|
297
298
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -25,7 +25,7 @@ import { installHarnessHooks } from "../harden/harness-hooks.js";
|
|
|
25
25
|
import { installSentinelHooks } from "../harden/sentinel-hooks.js";
|
|
26
26
|
import { installSandbox } from "../harden/sandbox.js";
|
|
27
27
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
28
|
-
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
28
|
+
import { installWorkflows, resolveKjPin } from "../harden/workflow-engine.js";
|
|
29
29
|
import { adviseBranchProtection } from "../harden/branch-protection.js";
|
|
30
30
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
31
31
|
import { ensureIdentity } from "../identity/bootstrap.js";
|
|
@@ -97,6 +97,7 @@ export async function hardenCommand({
|
|
|
97
97
|
// KJC-BUG-0289: the contract files already dirty before anything was generated
|
|
98
98
|
// (kj init captures them before writing its own). What is dirty NOW is the person's.
|
|
99
99
|
contractBefore = null,
|
|
100
|
+
fetchFn = fetch,
|
|
100
101
|
logger = console,
|
|
101
102
|
} = {}) {
|
|
102
103
|
const dirtyBefore = contractBefore ?? contractChanges(projectDir);
|
|
@@ -177,8 +178,11 @@ export async function hardenCommand({
|
|
|
177
178
|
const withConfig = config && profile !== "minimal";
|
|
178
179
|
const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
|
|
179
180
|
const withCi = ci && profile !== "minimal";
|
|
181
|
+
// KJC-BUG-0420: consumer CI installs kj from npm, so the workflows pin a published version.
|
|
182
|
+
const pin = withCi ? await resolveKjPin({ projectDir, fetchFn }) : null;
|
|
183
|
+
if (pin?.note) logger.warn?.(`⚠ ${pin.note}`);
|
|
180
184
|
const wf = withCi
|
|
181
|
-
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun })
|
|
185
|
+
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun, kjVersion: pin?.version ?? null })
|
|
182
186
|
: null;
|
|
183
187
|
const withGuidelines = guidelines && profile !== "minimal";
|
|
184
188
|
// KJC-BUG-0199 (issue #1773): the same detected language the workflows and
|
|
@@ -219,7 +223,10 @@ export async function hardenCommand({
|
|
|
219
223
|
const note = c.note ? ` (${c.note})` : "";
|
|
220
224
|
logger.info?.(` • ${c.file}: ${c.action}${note}`);
|
|
221
225
|
}
|
|
222
|
-
for (const w of out.workflows)
|
|
226
|
+
for (const w of out.workflows) {
|
|
227
|
+
logger.info?.(` • ${w.file}: ${w.action}`);
|
|
228
|
+
if (w.note) logger.warn?.(`⚠ ${w.note}`);
|
|
229
|
+
}
|
|
223
230
|
for (const g of out.guidelines) {
|
|
224
231
|
logger.info?.(` • ${g.file}: ${g.action}`);
|
|
225
232
|
// KJC-BUG-0206: quitar reglas no puede leerse como "updated" y nada más.
|
package/src/commands/pr-size.js
CHANGED
|
@@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process";
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
|
-
import { budgetedAdded } from "../review/loc-budget.js";
|
|
11
|
+
import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
|
|
12
12
|
|
|
13
13
|
const git = (projectDir, args) =>
|
|
14
14
|
execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -40,12 +40,13 @@ export async function branchSize({ projectDir = process.cwd(), base = "main", ex
|
|
|
40
40
|
const n = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
41
41
|
numstat += `${n}\t0\t${file}\n`;
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
// KJC-BUG-0434: `net` is what the CI gate and the policy measure; `added` stays for its readers.
|
|
44
|
+
return { ...budgetedAdded(numstat), net: budgetedNet(numstat).net, base };
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export async function prSizeCommand({ config, logger = console, flags = {} }) {
|
|
47
48
|
const s = await branchSize({ projectDir: config?.projectDir || process.cwd(), base: config?.base_branch || "main" });
|
|
48
49
|
if (flags.json) process.stdout.write(`${JSON.stringify(s)}\n`);
|
|
49
|
-
else logger.info(`pr-size: ${s.
|
|
50
|
+
else logger.info(`pr-size: ${s.net} net line(s) against ${s.base}, as the CI gate counts them (${s.added} added, ${s.testAdded} in tests, ${s.exempt} exempt)`);
|
|
50
51
|
return s;
|
|
51
52
|
}
|
|
@@ -268,26 +268,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
268
268
|
// documentation are exempt — the same rule CI applies. Counting everything
|
|
269
269
|
// here warned 234 lines on a doc-only PR that CI counted at 104, and two
|
|
270
270
|
// implementations of one rule that disagree teach people to trust neither.
|
|
271
|
-
|
|
271
|
+
// KJC-BUG-0434: measured NET, as CI and the policy's net_lines_added do — a rewrite in place is not growth.
|
|
272
|
+
const { added, testAdded } = budgetedAdded(numstat);
|
|
273
|
+
const { net, exempt } = budgetedNet(numstat);
|
|
272
274
|
// Said out loud: an exemption nobody sees is indistinguishable from a gate
|
|
273
275
|
// that is not looking.
|
|
274
276
|
if (exempt > 0) console.log(`ℓ pr-size: ${exempt} line(s) exempt from the budget (generated output and human docs; AI-rule files always count)`);
|
|
275
|
-
if (
|
|
276
|
-
console.log(`ℓ pr-size: ${
|
|
277
|
+
if (net > sizeIdeal && net <= sizeWarn) {
|
|
278
|
+
console.log(`ℓ pr-size: ${net} net lines, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
|
|
277
279
|
}
|
|
278
|
-
if (
|
|
280
|
+
if (net > sizeWarn) {
|
|
279
281
|
// KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
|
|
280
282
|
// partitioning is a decision, and it must never split code from its tests.
|
|
281
|
-
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded}
|
|
283
|
+
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} test lines added — partition by feature, never code from its tests)` : "";
|
|
282
284
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
283
285
|
// ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
|
|
284
286
|
if (sizePolicy === "block") {
|
|
285
|
-
const reason = `${
|
|
287
|
+
const reason = `${net} net lines${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
|
|
286
288
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
287
289
|
process.exitCode = 1;
|
|
288
290
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
289
291
|
} else {
|
|
290
|
-
console.log(`⚠ pr-size: ${
|
|
292
|
+
console.log(`⚠ pr-size: ${net} net lines${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
295
|
}
|
package/src/config/loader.js
CHANGED
|
@@ -119,7 +119,9 @@ export async function loadConfig(projectDir) {
|
|
|
119
119
|
// a .karajan dir from another repo, edited the wrong file, etc.) and
|
|
120
120
|
// would silently behave like a global config without the defaults
|
|
121
121
|
// people expect. Refuse with an actionable message.
|
|
122
|
-
|
|
122
|
+
// KJC-BUG-0428 (issue #2694): a CI runner never has a global config; there the
|
|
123
|
+
// team's committed project config sits on the defaults instead.
|
|
124
|
+
if (projectConfig && !globalExists && !process.env.CI) {
|
|
123
125
|
throw new Error(
|
|
124
126
|
`Local config found at ${getProjectConfigPath(projectDir)} but no global config exists.\n` +
|
|
125
127
|
"Local configs are merged ON TOP of the global one — run `kj init --global` first to create the base."
|
package/src/git/automation.js
CHANGED
|
@@ -348,10 +348,11 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
348
348
|
} catch (err) {
|
|
349
349
|
logger.warn(`pr-size: the branch could not be measured (${err.message}); the budget is not checked for this commit`);
|
|
350
350
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
351
|
+
// KJC-BUG-0434: net, as the CI gate counts it.
|
|
352
|
+
if (size && size.net > limit) {
|
|
353
|
+
logger.warn(`pr-size: ${size.net} net line(s) against ${gitCtx.baseBranch}, over the budget of ${limit}: nothing committed. Split the step (several commits, several HUs); the changes stay in the tree`);
|
|
354
|
+
await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, net: size.net, limit });
|
|
355
|
+
return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, net: size.net, limit } };
|
|
355
356
|
}
|
|
356
357
|
// ENV-F1: this path only runs after the pipeline's reviewer approved,
|
|
357
358
|
// so stamp that verdict for the staged diff — the v4 pre-commit gate
|
|
@@ -12,7 +12,7 @@ import { join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
14
14
|
import { runCommand } from "../utils/process.js";
|
|
15
|
-
import { hookBody, PROFILE_HOOKS, SHEBANG
|
|
15
|
+
import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
|
|
16
16
|
|
|
17
17
|
const BLOCK_VERSION = 1;
|
|
18
18
|
const HOOKS_DIR = join(".karajan", "hooks");
|
|
@@ -29,12 +29,12 @@ export function isGitRepo(projectDir) {
|
|
|
29
29
|
* compara por hash: un fichero con una sola coma manual ya no coincide.
|
|
30
30
|
*/
|
|
31
31
|
export function renderCanonicalHook(hook, generation = {}) {
|
|
32
|
-
const { cmds = {}, baseBranch = null
|
|
32
|
+
const { cmds = {}, baseBranch = null } = generation;
|
|
33
33
|
const { content } = upsertManagedBlock({
|
|
34
34
|
source: HOOK_PREAMBLE,
|
|
35
35
|
blockId: `hook:${hook}`,
|
|
36
36
|
version: BLOCK_VERSION,
|
|
37
|
-
body: hookBody(hook, cmds, {
|
|
37
|
+
body: hookBody(hook, cmds, { baseBranch }),
|
|
38
38
|
style: "hash",
|
|
39
39
|
});
|
|
40
40
|
return content;
|
|
@@ -57,22 +57,6 @@ export async function installHooks({
|
|
|
57
57
|
const hooks = PROFILE_HOOKS[profile];
|
|
58
58
|
if (!hooks) throw new Error(`Unknown harden profile: ${profile}`);
|
|
59
59
|
|
|
60
|
-
// KJC-TSK-0645: setting a repo-local hooksPath ECLIPSES the machine's
|
|
61
|
-
// global hooks dir — personal guards (AI-attribution commit-msg, protected
|
|
62
|
-
// branch pre-push) would silently stop applying here. Detect the previous
|
|
63
|
-
// global dir and have every generated hook chain to it. `~` is emitted as
|
|
64
|
-
// `$HOME` so the committed hook stays portable across machines (the -x
|
|
65
|
-
// guard silences it where the dir doesn't exist).
|
|
66
|
-
let globalHooksDir = null;
|
|
67
|
-
const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
|
|
68
|
-
const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
|
|
69
|
-
if (rawGlobal && rawGlobal !== HOOKS_DIR) {
|
|
70
|
-
// KJC-BUG-0169: normalize to a machine-independent form HERE (generation,
|
|
71
|
-
// on the human's machine) so the provenance stores the portable dir and CI
|
|
72
|
-
// recomputes the SAME bytes — an absolute home path is not verifiable there.
|
|
73
|
-
globalHooksDir = toPortableHooksDir(rawGlobal);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
60
|
const absHooksDir = join(projectDir, HOOKS_DIR);
|
|
77
61
|
const results = [];
|
|
78
62
|
for (const hook of hooks) {
|
|
@@ -82,7 +66,7 @@ export async function installHooks({
|
|
|
82
66
|
source,
|
|
83
67
|
blockId: `hook:${hook}`,
|
|
84
68
|
version: BLOCK_VERSION,
|
|
85
|
-
body: hookBody(hook, cmds, {
|
|
69
|
+
body: hookBody(hook, cmds, { baseBranch }),
|
|
86
70
|
style: "hash",
|
|
87
71
|
});
|
|
88
72
|
results.push({ hook, target, action });
|
|
@@ -99,5 +83,5 @@ export async function installHooks({
|
|
|
99
83
|
|
|
100
84
|
// KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
|
|
101
85
|
// provenance necesita registrar para que CI pueda recomputar y comparar.
|
|
102
|
-
return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch
|
|
86
|
+
return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch } };
|
|
103
87
|
}
|
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
* is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
|
|
13
11
|
// KJC-TSK-1076: exported so the third-party PR checks apply the same rules.
|
|
14
12
|
export const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
|
|
15
13
|
export const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
|
|
@@ -25,32 +23,21 @@ export const PROFILE_HOOKS = {
|
|
|
25
23
|
|
|
26
24
|
/** Build the managed body for a single hook. */
|
|
27
25
|
// KJC-TSK-0645: `core.hooksPath .karajan/hooks` eclipses the user's global
|
|
28
|
-
// hooks dir, silently disabling personal guards.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
// other home (CI) reproduces identical bytes. `home` is injectable for tests.
|
|
38
|
-
export function toPortableHooksDir(dir, home = homedir()) {
|
|
39
|
-
if (!dir) return dir;
|
|
40
|
-
if (dir.startsWith("$HOME")) return dir;
|
|
41
|
-
if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
|
|
42
|
-
if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
|
|
43
|
-
return dir;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function chainToGlobal(hook, globalHooksDir) {
|
|
47
|
-
if (!globalHooksDir) return [];
|
|
48
|
-
const dir = toPortableHooksDir(globalHooksDir);
|
|
26
|
+
// hooks dir, silently disabling personal guards. Every generated hook ends by
|
|
27
|
+
// chaining its namesake in the machine's global dir.
|
|
28
|
+
// KJC-BUG-0161 / 0169 (ADR 0009), HU-2762: the dir is looked up when the hook
|
|
29
|
+
// RUNS, never when kj harden ran — so the generated bytes do not depend on the
|
|
30
|
+
// machine (HOME, GIT_CONFIG_GLOBAL), the provenance verifies in CI, and no
|
|
31
|
+
// local filesystem layout leaks into a public repo. `|| true`: the preamble
|
|
32
|
+
// sets `set -e`. `--type=path` expands `~`; the pwd -P test stops a hook from
|
|
33
|
+
// calling itself when the global setting is this repo's own hooks dir.
|
|
34
|
+
function chainToGlobal(hook) {
|
|
49
35
|
// KJC-TSK-1085: pre-push already read the refs from stdin; the chained hook gets them back.
|
|
50
|
-
const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "$
|
|
36
|
+
const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "$kj_global/${hook}" "$@"; rc=$?` : `"$kj_global/${hook}" "$@"; rc=$?`;
|
|
51
37
|
return [
|
|
52
|
-
"# Chain the machine's
|
|
53
|
-
|
|
38
|
+
"# Chain the machine's global hook, looked up when the hook runs, not when kj harden ran (KJC-TSK-0645, HU-2762).",
|
|
39
|
+
"kj_global=$(git config --global --type=path --get core.hooksPath 2>/dev/null || true)",
|
|
40
|
+
`if [ -n "$kj_global" ] && [ -x "$kj_global/${hook}" ] && [ "$(cd "$kj_global" 2>/dev/null && pwd -P)" != "$(cd "$(dirname "$0")" && pwd -P)" ]; then`,
|
|
54
41
|
` ${call}`,
|
|
55
42
|
' [ "$rc" -eq 0 ] || exit "$rc"',
|
|
56
43
|
"fi",
|
|
@@ -109,8 +96,9 @@ function identityGuard(hook) {
|
|
|
109
96
|
' if [ -n "$kj_declared_email" ]; then',
|
|
110
97
|
' printf "%s\\n" "$kj_push_refs" | while read -r kj_lref kj_lsha kj_rref kj_rsha; do',
|
|
111
98
|
' case "$kj_lsha" in *[!0]*) ;; *) continue ;; esac',
|
|
112
|
-
//
|
|
113
|
-
|
|
99
|
+
// What the PUSH TARGET ($1, the remote git hands the hook) does not have yet. KJC-BUG-0436:
|
|
100
|
+
// an existing ref too, or merging main into a pushed branch judges main's own merges.
|
|
101
|
+
' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_lsha" --not "$kj_rsha" --remotes="${1:-origin}") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
|
|
114
102
|
" for kj_c in $kj_new; do",
|
|
115
103
|
' kj_who=$(git log -1 --format="%ae %ce" "$kj_c")',
|
|
116
104
|
' if [ "$kj_who" != "$kj_declared_email $kj_declared_email" ]; then',
|
|
@@ -129,8 +117,8 @@ function identityGuard(hook) {
|
|
|
129
117
|
];
|
|
130
118
|
}
|
|
131
119
|
|
|
132
|
-
export function hookBody(hook, cmds = {}, {
|
|
133
|
-
const chain = chainToGlobal(hook
|
|
120
|
+
export function hookBody(hook, cmds = {}, { baseBranch = null } = {}) {
|
|
121
|
+
const chain = chainToGlobal(hook);
|
|
134
122
|
switch (hook) {
|
|
135
123
|
case "pre-commit": {
|
|
136
124
|
const lines = [...baseBranchGuard(baseBranch), ...identityGuard("pre-commit"), "# Lint + format the working tree with the project's native tools."];
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -36,7 +36,9 @@ async function relayConfig(fetchFn) {
|
|
|
36
36
|
}
|
|
37
37
|
const SIGN_PAGE = "https://karajancode.com/sign";
|
|
38
38
|
const POLL_INTERVAL_MS = 2000;
|
|
39
|
-
|
|
39
|
+
// KJC-BUG-0432: 60 s was too short to scan, read the files and sign, and the page still said
|
|
40
|
+
// "signed" after kj had given up. 150 s leaves time and stays under the relay's 3 min window.
|
|
41
|
+
const TTL_MS = 150000;
|
|
40
42
|
|
|
41
43
|
/** Spinner en su sitio (una línea, con \r) solo en TTY; no-op fuera de TTY o en tests. */
|
|
42
44
|
function makeSpinner() {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* published-pin — the karajan-code version a seeded workflow may pin (KJC-BUG-0420).
|
|
3
|
+
*
|
|
4
|
+
* A consumer repo's CI installs kj from npm, so the pin must be a published
|
|
5
|
+
* version. A checkout ahead of npm pins the newest published version that is
|
|
6
|
+
* not newer than itself; when the registry cannot confirm anything, the
|
|
7
|
+
* running version stays and the note says so.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isNewerVersion } from "../utils/update-check.js";
|
|
11
|
+
|
|
12
|
+
const packumentUrl = (name) => `https://registry.npmjs.org/${encodeURIComponent(name)}`;
|
|
13
|
+
|
|
14
|
+
async function publishedVersions(name, fetchFn) {
|
|
15
|
+
const res = await fetchFn(packumentUrl(name), { headers: { Accept: "application/vnd.npm.install-v1+json" } });
|
|
16
|
+
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
17
|
+
return Object.keys((await res.json()).versions ?? {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @returns {Promise<{version: string, note?: string}>} */
|
|
21
|
+
export async function publishedPin({ name, version, fetchFn = fetch }) {
|
|
22
|
+
const unconfirmed = (why) => ({ version, note: `could not confirm on npm that ${name}@${version} is published (${why}); the workflow pins it as is` });
|
|
23
|
+
let published;
|
|
24
|
+
try {
|
|
25
|
+
published = await publishedVersions(name, fetchFn);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return unconfirmed(err.message);
|
|
28
|
+
}
|
|
29
|
+
if (published.includes(version)) return { version };
|
|
30
|
+
const older = published.filter((v) => !v.includes("-") && !isNewerVersion(v, version));
|
|
31
|
+
const newest = older.reduce((best, v) => (best && !isNewerVersion(v, best) ? best : v), null);
|
|
32
|
+
if (!newest) return unconfirmed("no published version at or below it");
|
|
33
|
+
return { version: newest, note: `${name}@${version} is not on npm yet; the workflow pins ${newest}, the newest published one` };
|
|
34
|
+
}
|
|
@@ -196,10 +196,12 @@ process.stdin.on("end", () => {
|
|
|
196
196
|
const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
|
|
197
197
|
let size = null;
|
|
198
198
|
try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
|
|
199
|
-
|
|
199
|
+
// KJC-BUG-0435: net lines, as the CI gate counts them; a kj older than 0434 reports only added.
|
|
200
|
+
const lines = size ? (size.net ?? size.added) : 0;
|
|
201
|
+
const crossed = size ? [200, 150].find((t) => lines > t) : undefined;
|
|
200
202
|
if (crossed && (s.size_warned || 0) < crossed) {
|
|
201
203
|
s.size_warned = crossed;
|
|
202
|
-
sizeNote = "karajan sentinel: la rama ya suma " +
|
|
204
|
+
sizeNote = "karajan sentinel: la rama ya suma " + lines + " lineas netas 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");
|
|
203
205
|
}
|
|
204
206
|
}
|
|
205
207
|
const ids = Object.keys(state.sessions);
|
|
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
15
15
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
16
|
-
import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
|
|
16
|
+
import { isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
|
|
17
17
|
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
18
18
|
import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
|
|
19
19
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
@@ -84,8 +84,10 @@ process.stdin.on("end", () => {
|
|
|
84
84
|
// substitution) cannot be resolved from command text: that vector is
|
|
85
85
|
// caught by the Stop gate's checksum verification, which blocks the turn.
|
|
86
86
|
// KJC-TSK-1114: where the host sandbox denies writing them, the sandbox guards them instead.
|
|
87
|
+
// KJC-BUG-0425 (ADR 0036): text a command only publishes or stores (a PR description) is not a write.
|
|
88
|
+
// KJC-BUG-0429: nor is a command made only of known readers (an allow-list, not a write blocklist).
|
|
87
89
|
const SUPERVISOR_PATHS = [".claude/settings.json", ".karajan/hooks", ".karajan/harness"];
|
|
88
|
-
if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
|
|
90
|
+
if (tool === "Bash" && !isReadOnlyCommand(String(input.command || "")) && PROTECTED.test(stripTextOnlyCommandValues(String(input.command || "")).replaceAll("\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
|
|
89
91
|
console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
|
|
90
92
|
process.exit(2);
|
|
91
93
|
}
|
|
@@ -174,7 +174,22 @@ const isGitCommit = (words) => {
|
|
|
174
174
|
return words[0] === "git" && words[i] === "commit";
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
// KJC-BUG-0425: gh pr/issue/release commands only publish their text to GitHub.
|
|
178
|
+
const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) || /^gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review)\b/.test(seg) || isGitCommit(seg.split(/\s+/));
|
|
179
|
+
|
|
180
|
+
// KJC-BUG-0425: a heredoc whose delimiter is quoted expands nothing; read from stdin
|
|
181
|
+
// by a text-only command, it is the text stored or published. The FIRST line equal to
|
|
182
|
+
// the delimiter must be the last one, or the shell would run what follows it.
|
|
183
|
+
const HEREDOC_HEAD = /^([^\n]*?)[ \t]*<<(-?)[ \t]*(['"])([A-Za-z_]\w*)\3[ \t]*$/;
|
|
184
|
+
const READS_STDIN = /(^|\s)(?:--body-file|--notes-file|--file|-F)(?:=|\s+)-(?=\s|$)/;
|
|
185
|
+
const quotedHeredocHead = (cmd) => {
|
|
186
|
+
const lines = cmd.replace(/\n$/, "").split("\n");
|
|
187
|
+
const m = lines.length > 1 ? HEREDOC_HEAD.exec(lines[0]) : null;
|
|
188
|
+
if (!m) return null;
|
|
189
|
+
const [, head, dash, , delim] = m;
|
|
190
|
+
const end = lines.findIndex((l, i) => i > 0 && (dash ? l.replace(/^\t+/, "") : l) === delim);
|
|
191
|
+
return end === lines.length - 1 && READS_STDIN.test(head) ? head : null;
|
|
192
|
+
};
|
|
178
193
|
|
|
179
194
|
/**
|
|
180
195
|
* KJC-BUG-0354: blank the quoted text values (stripTextOptionValues) only when the
|
|
@@ -185,9 +200,10 @@ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr|handoff)\s+add\b/.test(seg) ||
|
|
|
185
200
|
export const stripTextOnlyCommandValues = (cmd) => {
|
|
186
201
|
// Only a whole input that is ONE simple text-storage command: a chained
|
|
187
202
|
// segment could follow a shell function that turns the stored text into code.
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
return
|
|
203
|
+
const line = quotedHeredocHead(cmd) ?? cmd;
|
|
204
|
+
const bare = stripInertQuotes(line);
|
|
205
|
+
if (splitOutsideQuotes(line).length !== 1 || /[(){}<>`$\n]/.test(bare) || !isTextOnlyHead(line.trim())) return cmd;
|
|
206
|
+
return stripTextOptionValues(line);
|
|
191
207
|
};
|
|
192
208
|
|
|
193
209
|
/** Segments and the ; && || | between them, cut only outside quotes (the pieces join back to cmd). */
|
|
@@ -248,5 +264,39 @@ export const withoutRedirections = (words) => {
|
|
|
248
264
|
return rest;
|
|
249
265
|
};
|
|
250
266
|
|
|
267
|
+
// KJC-BUG-0429 (ADR 0036): the guards decide by write verb. These readers were picked
|
|
268
|
+
// because no option of theirs writes a file or runs a program; a command made only of
|
|
269
|
+
// them, with output to the session, /dev/null or another descriptor, writes nothing.
|
|
270
|
+
// KJC-BUG-0437: never git, whose reads run what its config names (filters, textconv,
|
|
271
|
+
// diff.external, fsmonitor); the Read tool reads those files instead.
|
|
272
|
+
const READERS = new Set(["cat", "head", "tail", "grep", "egrep", "fgrep", "ls", "wc", "diff", "cmp", "stat", "jq", "sha256sum", "sha1sum", "md5sum"]);
|
|
273
|
+
|
|
274
|
+
const REDIRECTION = /^(\d*)(>>|>&|>\||>|<)(.*)$/;
|
|
275
|
+
const quietRedirections = (words) => words.every((w, i) => {
|
|
276
|
+
const m = REDIRECTION.exec(w);
|
|
277
|
+
if (!m) return true;
|
|
278
|
+
const [, , op, glued] = m;
|
|
279
|
+
const target = glued || words[i + 1] || "";
|
|
280
|
+
if (op === "<") return target !== "";
|
|
281
|
+
if (op === ">&") return /^\d$/.test(target);
|
|
282
|
+
return (op === ">" || op === ">>") && target === "/dev/null";
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Whether the command only reads: every simple command is a known reader and no
|
|
287
|
+
* output goes to a file. Substitution, subshells, heredocs, expansion, newlines
|
|
288
|
+
* or assignments are not provably that.
|
|
289
|
+
* @param {string} cmd
|
|
290
|
+
*/
|
|
291
|
+
export const isReadOnlyCommand = (cmd) => {
|
|
292
|
+
const bare = stripInertQuotes(String(cmd));
|
|
293
|
+
if (/[(){}`\n]|<</.test(bare) || LIVE_EXPANSION.test(bare)) return false;
|
|
294
|
+
const segs = shellSegments(cmd);
|
|
295
|
+
return segs.length > 0 && segs.every((words) => {
|
|
296
|
+
const rest = withoutRedirections(words);
|
|
297
|
+
return quietRedirections(words) && READERS.has(rest[0]);
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
|
|
251
301
|
/** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
|
|
252
302
|
export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
|
|
@@ -14,8 +14,16 @@ import { dirname } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import { parse as parseYaml } from "yaml";
|
|
16
16
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
17
|
+
import { publishedPin } from "./published-pin.js";
|
|
17
18
|
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
18
19
|
|
|
20
|
+
// KJC-BUG-0424: on the PR that brings this change GitHub runs neither YAML (the base's
|
|
21
|
+
// listens to pull_request, the new one to pull_request_target), so policy-check never reports.
|
|
22
|
+
export const POLICY_TRIGGER_MOVE_NOTE =
|
|
23
|
+
"kj-policy.yml moves from pull_request to pull_request_target (judged from the base branch). " +
|
|
24
|
+
"The PR that brings this change gets no policy-check run: if policy-check is a required check, " +
|
|
25
|
+
"merge that one PR as an admin after the other checks pass; from then on the base runs the judge.";
|
|
26
|
+
|
|
19
27
|
// La versión del kj que corre harden — es la que se pinea en el fallback
|
|
20
28
|
// npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
|
|
21
29
|
// bundle SEA no hay package.json junto al módulo y una lectura eager a
|
|
@@ -66,6 +74,25 @@ function hasLintScript(projectDir) {
|
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
const policyDeclared = (projectDir) => existsSync(join(projectDir, ".karajan", "policy.yml"));
|
|
78
|
+
|
|
79
|
+
// STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
|
|
80
|
+
// steward.action: true in its config; nothing is imposed by default.
|
|
81
|
+
function stewardActionOn(projectDir) {
|
|
82
|
+
try {
|
|
83
|
+
return parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false; // no config — no action
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** KJC-BUG-0420: the published kj version the policy and steward workflows pin; null when neither is seeded. */
|
|
90
|
+
export async function resolveKjPin({ projectDir, fetchFn } = {}) {
|
|
91
|
+
const own = ownPkg();
|
|
92
|
+
if (!own || !(policyDeclared(projectDir) || stewardActionOn(projectDir))) return null;
|
|
93
|
+
return publishedPin({ name: own.name, version: own.version, fetchFn });
|
|
94
|
+
}
|
|
95
|
+
|
|
69
96
|
export function installWorkflows({
|
|
70
97
|
projectDir = process.cwd(),
|
|
71
98
|
language = null,
|
|
@@ -87,14 +114,10 @@ export function installWorkflows({
|
|
|
87
114
|
// pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
|
|
88
115
|
// recurso inalcanzable en los caminos reales.
|
|
89
116
|
const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
|
|
90
|
-
const policy =
|
|
117
|
+
const policy = policyDeclared(projectDir)
|
|
91
118
|
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
|
|
92
119
|
: null;
|
|
93
|
-
|
|
94
|
-
// steward.action: true in its config; nothing is imposed by default.
|
|
95
|
-
let stewardOn = false;
|
|
96
|
-
try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
|
|
97
|
-
const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
|
|
120
|
+
const steward = stewardActionOn(projectDir) ?{ file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
|
|
98
121
|
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
|
|
99
122
|
const results = [];
|
|
100
123
|
for (const wf of all) {
|
|
@@ -125,7 +148,9 @@ export function installWorkflows({
|
|
|
125
148
|
// pre-commit rechaza el fichero recién generado.
|
|
126
149
|
writeFileSync(target, content.endsWith("\n") ? content : `${content}\n`);
|
|
127
150
|
}
|
|
128
|
-
|
|
151
|
+
// KJC-BUG-0424: the PR that moves the trigger cannot report policy-check.
|
|
152
|
+
const movesTrigger = wf.blockId === "wf-policy" && action !== "unchanged" && /^\s*pull_request:\s*$/m.test(source);
|
|
153
|
+
results.push({ file: label, action, ...(movesTrigger ? { note: POLICY_TRIGGER_MOVE_NOTE } : {}) });
|
|
129
154
|
}
|
|
130
155
|
return { dryRun, workflows: results };
|
|
131
156
|
}
|
|
@@ -39,7 +39,9 @@ export async function runSuiteGreenStage({ config, logger, emitter, eventBase, s
|
|
|
39
39
|
}
|
|
40
40
|
const timeout = Number(config?.development?.test_timeout_ms) > 0 ? Number(config.development.test_timeout_ms) : DEFAULT_TIMEOUT_MS;
|
|
41
41
|
const run = runTestsImpl || (async () => {
|
|
42
|
-
|
|
42
|
+
// KJC-BUG-0427: never a login shell, which re-reads the person's profile and may rewrite
|
|
43
|
+
// PATH (nvm drops node when npm_config_prefix is set): the suite runs in kj's own environment.
|
|
44
|
+
const res = await runCommand("bash", ["-c", command], { cwd: projectDir, timeout });
|
|
43
45
|
return { exitCode: res.exitCode ?? 1, output: `${res.stdout || ""}\n${res.stderr || ""}` };
|
|
44
46
|
});
|
|
45
47
|
const res = await run(command);
|
package/src/policy/add.js
CHANGED
|
@@ -11,6 +11,7 @@ import yaml from "js-yaml";
|
|
|
11
11
|
import { parsePolicy } from "@karajan-family/governance";
|
|
12
12
|
import { createAgent } from "../agents/index.js";
|
|
13
13
|
import { resolveRole } from "../config/role-resolver.js";
|
|
14
|
+
import { agentAnswerText } from "../review/parser.js";
|
|
14
15
|
|
|
15
16
|
const PROMPT = (text) => `Traduce esta regla de proyecto hablada a un FRAGMENTO YAML del vocabulario
|
|
16
17
|
cerrado v1 de policy de Karajan. Vocabulario COMPLETO (nada fuera de esto):
|
|
@@ -27,7 +28,8 @@ async function defaultTranslate({ text, config, logger }) {
|
|
|
27
28
|
const role = resolveRole(config, "reviewer");
|
|
28
29
|
const res = await createAgent(role.provider, config, logger).reviewTask({ prompt: PROMPT(text), role: "reviewer" });
|
|
29
30
|
if (!res?.ok) throw new Error(`el agente traductor (${role.provider}) falló: ${res?.error || "sin salida"}`);
|
|
30
|
-
|
|
31
|
+
// KJC-BUG-0430 (issue #2695): claude answers with its stream-json; the answer is its final result.
|
|
32
|
+
return String(agentAnswerText(res.output) || "").replace(/^```[a-z]*\n?|```\s*$/g, "").trim();
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
// Merge conservador: las listas se CONCATENAN (añadir jamás borra), los
|
package/src/policy/engine.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
|
|
12
|
+
import { isReadOnlyCommand, stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
|
|
13
13
|
import {
|
|
14
14
|
ALLOW_VERDICT,
|
|
15
15
|
checkArtifacts,
|
|
@@ -36,7 +36,8 @@ export const SUPERVISOR_DEFAULTS = [
|
|
|
36
36
|
// it is USED (commit, CI, release), whatever wrote it: only the human's seal lifts it.
|
|
37
37
|
id: "defaults.governance.write",
|
|
38
38
|
// KJC-BUG-0400: the CI workflows too; a PR could rewrite the check that judges it.
|
|
39
|
-
|
|
39
|
+
// KJC-BUG-0429: in shell text a relative path follows a blank or a quote, not a slash.
|
|
40
|
+
pattern: /(^|[^\w.-])\.karajan\/(policy|rules)\.yml\b|(^|[^\w.-])\.github\/workflows\/[^/\s]+\.ya?ml\b/,
|
|
40
41
|
message: "es configuración de gobierno: solo entra sellada por la persona (kj harden --commit, firmado desde su móvil)",
|
|
41
42
|
},
|
|
42
43
|
];
|
|
@@ -84,7 +85,12 @@ export function evalToolCall(policy, { role = "coder", tool, input = {}, root =
|
|
|
84
85
|
// KJC-BUG-0343: the inert prose of a text option (--title, --error, -m...) is
|
|
85
86
|
// not a target: a report naming a supervisor path is not a write to it. A path
|
|
86
87
|
// anywhere else, or a value that can expand, is still judged.
|
|
87
|
-
if (tool === "Bash")
|
|
88
|
+
if (tool === "Bash") {
|
|
89
|
+
const command = stripTextOptionValues(String(input.command ?? ""));
|
|
90
|
+
// KJC-BUG-0429 (ADR 0036): a command that only reads may name the guarded files;
|
|
91
|
+
// the role's own shell rules still judge it.
|
|
92
|
+
return evalShell(isReadOnlyCommand(command) ? { ...policy, defaults: [] } : policy, role, command);
|
|
93
|
+
}
|
|
88
94
|
if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return ALLOW_VERDICT;
|
|
89
95
|
return denyVerdict(`roles.${role}.tools`, `tool "${tool}" fuera del registro de la policy — no verificable para el rol declarado ${role}`);
|
|
90
96
|
}
|
|
@@ -15,8 +15,8 @@ import { HOST_SETTINGS, isSealedConfig, PROVENANCE_FILE } from "../harden/superv
|
|
|
15
15
|
|
|
16
16
|
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
17
17
|
const HARNESS_PREFIX = ".karajan/harness/";
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// HU-2762: el render ya no interpola globalHooksDir; se conserva el guard para
|
|
19
|
+
// provenances antiguas, donde era texto libre: vocabulario cerrado o no verifica NADA.
|
|
20
20
|
const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
|
|
21
21
|
|
|
22
22
|
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
package/src/rag/migrate.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { sep } from "node:path";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
|
|
13
|
+
import { openVecStoreExplained as openVecStore } from "./project-store.js";
|
|
13
14
|
|
|
14
15
|
export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
|
|
15
16
|
|
package/src/rag/project-store.js
CHANGED
|
@@ -17,6 +17,7 @@ import { execFileSync } from "node:child_process";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
|
|
19
19
|
import { openVecStore } from "./vec-store.js";
|
|
20
|
+
import { explainNativeLoadError } from "../checks/native-build.js";
|
|
20
21
|
import { getKarajanHome } from "../utils/paths.js";
|
|
21
22
|
|
|
22
23
|
function projectRoot(dir) {
|
|
@@ -39,10 +40,20 @@ export function libraryDbPath() {
|
|
|
39
40
|
return process.env.KJ_LIBRARY_DB || path.join(getKarajanHome(), "library.db");
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// KJC-BUG-0426: an unbuilt better-sqlite3 addon says how to build it, not "bindings file".
|
|
44
|
+
// Every opening of a RAG store goes through here.
|
|
45
|
+
export function openVecStoreExplained(opts) {
|
|
46
|
+
try {
|
|
47
|
+
return openVecStore(opts);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw explainNativeLoadError(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
export function openLibraryStore({ dim = 768 } = {}) {
|
|
43
|
-
return
|
|
54
|
+
return openVecStoreExplained({ dim, path: libraryDbPath() });
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
export function openProjectStore({ projectDir = process.cwd(), dim = 768 } = {}) {
|
|
47
|
-
return
|
|
58
|
+
return openVecStoreExplained({ dim, path: projectDbPath(projectDir) });
|
|
48
59
|
}
|
|
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
|
|
10
10
|
import { loadPolicy } from "../policy/engine.js";
|
|
11
11
|
import { runCommand } from "../utils/process.js";
|
|
12
|
-
import {
|
|
12
|
+
import { budgetedNet } from "./loc-budget.js";
|
|
13
13
|
import { checkTestsWithCode } from "./tests-with-code.js";
|
|
14
14
|
|
|
15
15
|
const HEADER_MAX = 100;
|
|
@@ -37,13 +37,13 @@ export function prSizeLimit({ projectDir, config }) {
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/** Net lines that count against the budget, as the CI gate counts them (generated output and human docs exempt). */
|
|
41
41
|
export function sizeCheck({ numstat, limit }) {
|
|
42
|
-
const {
|
|
42
|
+
const { net, exempt } = budgetedNet(numstat);
|
|
43
43
|
const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
|
|
44
|
-
return
|
|
45
|
-
? { id: "size", ok: false, detail: `${
|
|
46
|
-
: { id: "size", ok: true, detail: `${
|
|
44
|
+
return net > limit
|
|
45
|
+
? { id: "size", ok: false, detail: `${net} net lines${exemptNote}, over the ${limit}-line limit: partition the PR` }
|
|
46
|
+
: { id: "size", ok: true, detail: `${net} net lines${exemptNote}, within the ${limit}-line limit` };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** Conventional Commits headers (at most 100 characters) and no AI attribution in base..head. */
|
package/src/review/verify-pr.js
CHANGED
|
@@ -9,6 +9,8 @@ import { runCommand } from "../utils/process.js";
|
|
|
9
9
|
import { diffHash } from "./verdict-store.js";
|
|
10
10
|
import { MARKER } from "./publish-pr.js";
|
|
11
11
|
import { isCleanMerge } from "./clean-merge.js";
|
|
12
|
+
import { verifiedSupervisorFiles } from "../policy/supervisor-verify.js";
|
|
13
|
+
import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
|
|
12
14
|
|
|
13
15
|
const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
14
16
|
// The marker of a kj review for any head: what MARKER writes up to the head itself.
|
|
@@ -38,10 +40,33 @@ async function hashesOf(projectDir, sha, run) {
|
|
|
38
40
|
return hashes;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// KJC-BUG-0388: kj harden --commit is the human's act, signed from the phone, and no AI
|
|
44
|
+
// reviews it. The proof is the signature, never the message: a commit that only touches
|
|
45
|
+
// files the roster-signed seal vouches for at the head is that act.
|
|
46
|
+
async function isSealCommit(projectDir, sha, run, sealed) {
|
|
47
|
+
if (sealed.signed !== true) return false;
|
|
48
|
+
const res = await run("git", ["-C", projectDir, "diff", "--name-only", `${sha}^`, sha]);
|
|
49
|
+
if (res.exitCode !== 0) throw new Error(`git diff --name-only ${sha.slice(0, 7)} failed: ${(res.stderr || "").trim()}`);
|
|
50
|
+
const files = (res.stdout || "").split("\n").filter(Boolean);
|
|
51
|
+
// KJC-BUG-0438: the seal act writes the signed provenance; a commit without it only touched files.
|
|
52
|
+
if (!files.includes(PROVENANCE_FILE)) return false;
|
|
53
|
+
if (!files.every((f) => sealed.files.has(f) || (f === PROVENANCE_FILE && sealed.complete === true))) return false;
|
|
54
|
+
// What the commit leaves is byte for byte what the signed seal vouches for at the head: a
|
|
55
|
+
// precursor whose provenance or hooks a later commit replaced is not the seal act.
|
|
56
|
+
for (const f of files) if ((await blobAt(projectDir, sha, f, run)) !== (await blobAt(projectDir, "HEAD", f, run))) return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The blob id of a file at a revision, or null when the file is absent there. */
|
|
61
|
+
async function blobAt(projectDir, rev, file, run) {
|
|
62
|
+
const res = await run("git", ["-C", projectDir, "rev-parse", "--verify", "--quiet", `${rev}:${file}`]);
|
|
63
|
+
return res.exitCode === 0 ? String(res.stdout || "").trim() : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
/**
|
|
42
|
-
* @returns {Promise<{ok: boolean, commits: number, problems: string[]}>}
|
|
67
|
+
* @returns {Promise<{ok: boolean, commits: number, problems: string[], sealCommits?: string[]}>}
|
|
43
68
|
*/
|
|
44
|
-
export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
|
|
69
|
+
export async function verifyPrReview({ projectDir, pr, run = runCommand, sealed = null }) {
|
|
45
70
|
const info = await json(run, ["pr", "view", String(pr), "--json", "headRefOid,commits"], projectDir);
|
|
46
71
|
const commits = info.commits ?? [];
|
|
47
72
|
// One review per line, every page: --slurp is missing from the gh that distros ship.
|
|
@@ -52,15 +77,21 @@ export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
|
|
|
52
77
|
// today's head: the diff hash of each commit is what proves it, and a commit pushed after
|
|
53
78
|
// the review is simply not listed. "Update branch" no longer leaves the check red.
|
|
54
79
|
const kjReviews = reviews.filter((r) => TRUSTED.has(r.author_association) && String(r.body ?? "").includes(MARKER_PREFIX));
|
|
55
|
-
if (kjReviews.length === 0) {
|
|
56
|
-
return { ok: false, commits: commits.length, problems: [`no kj review published by a member: run kj review --publish-pr ${pr}`] };
|
|
57
|
-
}
|
|
58
80
|
const listed = new Map(kjReviews.flatMap((r) => [...r.body.matchAll(LISTED)].map((m) => [m[1], m[2]])));
|
|
59
81
|
const problems = [];
|
|
82
|
+
const sealCommits = [];
|
|
83
|
+
let seal = sealed;
|
|
60
84
|
for (const c of commits) {
|
|
61
85
|
if (await isCleanMerge(projectDir, c.oid, run)) continue;
|
|
62
86
|
const hash = listed.get(c.oid.slice(0, 7));
|
|
63
|
-
if (
|
|
87
|
+
if (hash && (await hashesOf(projectDir, c.oid, run)).has(hash)) continue;
|
|
88
|
+
seal ??= verifiedSupervisorFiles({ projectDir, trackedOnly: true });
|
|
89
|
+
if (await isSealCommit(projectDir, c.oid, run, seal)) sealCommits.push(c.oid.slice(0, 7));
|
|
90
|
+
else problems.push(`${c.oid.slice(0, 7)} (${c.messageHeadline}): no approved verdict for the diff it brings`);
|
|
64
91
|
}
|
|
65
|
-
|
|
92
|
+
// A PR that only brings the seal needs no review; anything else without one (a clean merge
|
|
93
|
+
// included, KJC-BUG-0438) is told to publish it.
|
|
94
|
+
const onlySeals = sealCommits.length > 0 && sealCommits.length === commits.length;
|
|
95
|
+
if (kjReviews.length === 0 && !onlySeals) problems.splice(0, problems.length, `no kj review published by a member: run kj review --publish-pr ${pr}`);
|
|
96
|
+
return { ok: problems.length === 0, commits: commits.length, problems, ...(sealCommits.length > 0 ? { sealCommits } : {}) };
|
|
66
97
|
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -198,7 +198,8 @@ async function maybeRunCoverage(config) {
|
|
|
198
198
|
|
|
199
199
|
const command = String(coverage.command || "").trim();
|
|
200
200
|
const timeout = Number(coverage.timeout_ms) > 0 ? Number(coverage.timeout_ms) : 5 * 60 * 1000;
|
|
201
|
-
|
|
201
|
+
// KJC-BUG-0427: a plain shell in kj's environment; a login one may rewrite PATH.
|
|
202
|
+
const run = await runCommand("bash", ["-c", command], { timeout });
|
|
202
203
|
|
|
203
204
|
if (run.exitCode !== 0) {
|
|
204
205
|
if (blockOnFailure) {
|