karajan-code 4.70.2 → 4.70.4
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/ai-surface.js +9 -7
- package/src/checks/config-files.js +8 -7
- package/src/cli/register-pipeline.js +4 -0
- package/src/commands/env.js +3 -1
- package/src/commands/go.js +5 -2
- package/src/commands/harden.js +20 -6
- package/src/commands/pr-size.js +4 -3
- package/src/commands/review-gate.js +18 -13
- package/src/config/overrides.js +2 -1
- package/src/environment/board-access.js +11 -9
- package/src/git/automation.js +5 -4
- package/src/harden/branch-protection.js +36 -3
- 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 +22 -3
- package/src/harden/sentinel/sentinel-lib.mjs +23 -0
- package/src/harden/sentinel/sentinel-shell.mjs +109 -0
- package/src/harden/workflow-engine.js +34 -7
- package/src/mcp/handlers/hu-handlers.js +43 -20
- package/src/mcp/tools.js +4 -4
- package/src/orchestrator/stages/suite-green-stage.js +3 -1
- package/src/policy/engine.js +9 -3
- package/src/policy/supervisor-verify.js +2 -2
- package/src/review/dual-review.js +5 -4
- package/src/review/gate-hook.js +39 -0
- package/src/review/one-shot-review.js +7 -3
- package/src/review/pr-requirements.js +6 -6
- package/src/review/publish-pr.js +4 -1
- package/src/review/reviewer-fallback.js +18 -2
- package/src/review/verify-pr.js +39 -9
- package/src/sonar/scanner.js +2 -1
- package/src/utils/agent-home.js +33 -0
package/package.json
CHANGED
package/src/checks/ai-surface.js
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
14
|
+
import { claudeConfigFile, codexHome } from "../utils/agent-home.js";
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// KJC-TSK-1344: claude and codex configs of the account CLAUDE_CONFIG_DIR / CODEX_HOME select.
|
|
17
|
+
const sources = (projectDir, home, env = process.env) => [
|
|
16
18
|
{ file: path.join(projectDir, ".mcp.json"), format: "json" },
|
|
17
|
-
{ file:
|
|
18
|
-
{ file: path.join(home,
|
|
19
|
+
{ file: claudeConfigFile({ home, env }), format: "json" },
|
|
20
|
+
{ file: path.join(codexHome({ home, env }), "config.toml"), format: "toml" },
|
|
19
21
|
{ file: path.join(home, ".gemini", "settings.json"), format: "json" },
|
|
20
22
|
];
|
|
21
23
|
|
|
@@ -34,9 +36,9 @@ const tomlServerNames = (text) =>
|
|
|
34
36
|
new Set([...text.matchAll(/^\s*\[mcp_servers\.["']?([^\]"']+)["']?\]/gm)].map((m) => m[1]));
|
|
35
37
|
|
|
36
38
|
/** Sorted `name (config-file)` entries for every MCP reachable in this project. */
|
|
37
|
-
export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir() } = {}) {
|
|
39
|
+
export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir(), env = process.env } = {}) {
|
|
38
40
|
const surface = new Set();
|
|
39
|
-
for (const { file, format } of sources(projectDir, home)) {
|
|
41
|
+
for (const { file, format } of sources(projectDir, home, env)) {
|
|
40
42
|
if (!existsSync(file)) continue;
|
|
41
43
|
let text;
|
|
42
44
|
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
@@ -50,11 +52,11 @@ export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir
|
|
|
50
52
|
* Diff the current surface against the last-seen snapshot and persist the
|
|
51
53
|
* new state. First run records the baseline silently.
|
|
52
54
|
*/
|
|
53
|
-
export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), statePath = path.join(os.homedir(), ".karajan", "ai-surface.json"), extraSurface = [] } = {}) {
|
|
55
|
+
export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), env = process.env, statePath = path.join(os.homedir(), ".karajan", "ai-surface.json"), extraSurface = [] } = {}) {
|
|
54
56
|
// KJC-TSK-0728: extraSurface carries entries the (async) caller collected
|
|
55
57
|
// outside config files — e.g. observed agent CLIs as "grok (cli)" — so
|
|
56
58
|
// they ride the same snapshot and the same "NEW since last check" drift.
|
|
57
|
-
const surface = [...new Set([...collectAiSurface({ projectDir, home }), ...extraSurface])].sort();
|
|
59
|
+
const surface = [...new Set([...collectAiSurface({ projectDir, home, env }), ...extraSurface])].sort();
|
|
58
60
|
let state = {};
|
|
59
61
|
try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* first run or corrupt → baseline */ }
|
|
60
62
|
const prev = Array.isArray(state[projectDir]) ? state[projectDir] : null;
|
|
@@ -18,6 +18,7 @@ import { getConfigPath } from "../config.js";
|
|
|
18
18
|
import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
|
|
19
19
|
import { withDocLink } from "../utils/doc-links.js";
|
|
20
20
|
import { STRATEGY } from "./types.js";
|
|
21
|
+
import { codexHome } from "../utils/agent-home.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* kj.config.yml presence check.
|
|
@@ -218,16 +219,16 @@ function createStaleMcpPathCheck() {
|
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
/**
|
|
221
|
-
* Codex config (
|
|
222
|
+
* Codex config ($CODEX_HOME/config.toml, ~/.codex by default) validity.
|
|
223
|
+
* KJC-TSK-1344: the account CODEX_HOME selects is the one whose config counts.
|
|
222
224
|
*/
|
|
223
|
-
function createCodexConfigCheck() {
|
|
225
|
+
export function createCodexConfigCheck({ env = process.env, home = os.homedir() } = {}) {
|
|
226
|
+
const codexTomlPath = path.join(codexHome({ home, env }), "config.toml");
|
|
224
227
|
return {
|
|
225
228
|
name: "agent-config:codex",
|
|
226
|
-
label:
|
|
229
|
+
label: `Agent config: codex (${codexTomlPath})`,
|
|
227
230
|
strategy: STRATEGY.MANUAL,
|
|
228
231
|
async detect() {
|
|
229
|
-
const home = os.homedir();
|
|
230
|
-
const codexTomlPath = path.join(home, ".codex", "config.toml");
|
|
231
232
|
try {
|
|
232
233
|
const raw = await fs.readFile(codexTomlPath, "utf8");
|
|
233
234
|
const duplicates = findDuplicateTomlKeys(raw);
|
|
@@ -236,7 +237,7 @@ function createCodexConfigCheck() {
|
|
|
236
237
|
ok: false,
|
|
237
238
|
severity: "warn",
|
|
238
239
|
detail: `Duplicate TOML keys: ${duplicates.join(", ")}`,
|
|
239
|
-
fix: `Remove duplicate entries in
|
|
240
|
+
fix: `Remove duplicate entries in ${codexTomlPath}: ${duplicates.join(", ")}`,
|
|
240
241
|
};
|
|
241
242
|
}
|
|
242
243
|
return { ok: true, severity: "info", detail: "Valid TOML (no duplicate keys)" };
|
|
@@ -248,7 +249,7 @@ function createCodexConfigCheck() {
|
|
|
248
249
|
ok: false,
|
|
249
250
|
severity: "warn",
|
|
250
251
|
detail: `Cannot read: ${err.message.split("\n")[0]}`,
|
|
251
|
-
fix:
|
|
252
|
+
fix: `Check file permissions on ${codexTomlPath}`,
|
|
252
253
|
};
|
|
253
254
|
}
|
|
254
255
|
},
|
|
@@ -136,6 +136,9 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
136
136
|
.option("-q, --quiet", "Show only stage status lines, suppress raw agent output (default)")
|
|
137
137
|
.option("-v, --verbose", "Show full agent output (stream-json, raw lines)")
|
|
138
138
|
.action(async (task, flags) => {
|
|
139
|
+
// KJC-BUG-0448: run's --profile is the model profile; harden and check use
|
|
140
|
+
// --profile for the harness, so the config loader reads modelProfile.
|
|
141
|
+
if (flags.profile) flags.modelProfile = flags.profile;
|
|
139
142
|
await withConfig(pkgVersion, "run", flags, async ({ config, logger }) => {
|
|
140
143
|
// If `--plan <id>` was passed and the user didn't provide a task
|
|
141
144
|
// argument or --task-file, fall back to the task embedded in the
|
|
@@ -292,6 +295,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
292
295
|
const r = await verifyPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.verifyPr });
|
|
293
296
|
if (r.ok) logger.info(`✓ PR #${flags.verifyPr}: ${r.commits} commit(s), each with its cross-AI verdict`);
|
|
294
297
|
else logger.error(`✗ PR #${flags.verifyPr}:\n - ${r.problems.join("\n - ")}`);
|
|
298
|
+
if (r.sealCommits) logger.info(` seal commit(s) signed by the roster, a human act with no AI verdict: ${r.sealCommits.join(", ")}`);
|
|
295
299
|
process.exitCode = r.ok ? 0 : 1;
|
|
296
300
|
return;
|
|
297
301
|
}
|
package/src/commands/env.js
CHANGED
|
@@ -135,7 +135,9 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
135
135
|
console.log(result.pendingBlock);
|
|
136
136
|
return result;
|
|
137
137
|
}
|
|
138
|
-
await reviewGateCommand({ config, flags: { installGate: true } });
|
|
138
|
+
const gate = await reviewGateCommand({ config, flags: { installGate: true } });
|
|
139
|
+
// KJC-BUG-0447: a gate no hook runs is decorative; never call it active.
|
|
140
|
+
if (!gate?.enforced) throw new Error("the review gate is declared but no pre-commit hook runs it");
|
|
139
141
|
console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
|
|
140
142
|
} catch (err) {
|
|
141
143
|
result.exitCode = PENDING_EXIT_CODE;
|
package/src/commands/go.js
CHANGED
|
@@ -11,6 +11,7 @@ import os from "node:os";
|
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { checkBinary } from "../utils/agent-detect.js";
|
|
14
|
+
import { agentAuthFiles } from "../utils/agent-home.js";
|
|
14
15
|
import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
15
16
|
import { bootstrapCommand } from "./bootstrap.js";
|
|
16
17
|
import { boardCommand } from "./board.js";
|
|
@@ -21,12 +22,14 @@ export const MAGGLE_AGENTS = [
|
|
|
21
22
|
{ name: "claude", label: "Claude Code (Anthropic)", authPaths: [".claude.json", ".claude"], install: "npm install -g @anthropic-ai/claude-code", login: "claude (sigue el enlace de inicio de sesión que te muestre)" },
|
|
22
23
|
{ name: "codex", label: "Codex (OpenAI)", authPaths: [".codex/auth.json"], install: "npm install -g @openai/codex", login: "codex login" },
|
|
23
24
|
];
|
|
24
|
-
export async function detectMaggleAgents({ home = os.homedir(), checkBin = checkBinary } = {}) {
|
|
25
|
+
export async function detectMaggleAgents({ home = os.homedir(), env = process.env, checkBin = checkBinary } = {}) {
|
|
25
26
|
return Promise.all(
|
|
26
27
|
MAGGLE_AGENTS.map(async (a) => {
|
|
27
28
|
let installed = false;
|
|
28
29
|
try { installed = (await checkBin(a.name)).ok; } catch { /* not installed */ }
|
|
29
|
-
|
|
30
|
+
// KJC-TSK-1344: the session lives where CLAUDE_CONFIG_DIR / CODEX_HOME point.
|
|
31
|
+
const files = agentAuthFiles(a.name, { home, env }) ?? a.authPaths.map((p) => path.join(home, p));
|
|
32
|
+
const authenticated = installed && files.some((f) => existsSync(f));
|
|
30
33
|
return { ...a, installed, authenticated };
|
|
31
34
|
}),
|
|
32
35
|
);
|
package/src/commands/harden.js
CHANGED
|
@@ -25,12 +25,20 @@ 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 { detectPackageManager, 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";
|
|
32
32
|
import { commitContract, contractChanges } from "../environment/contract-commit.js";
|
|
33
33
|
|
|
34
|
+
// How each package manager runs a package.json script in a hook (`bun test` is bun's own runner, not the script).
|
|
35
|
+
const HOOK_RUNNERS = {
|
|
36
|
+
npm: { run: (s) => `npm run -s ${s}`, test: "npm test" },
|
|
37
|
+
pnpm: { run: (s) => `pnpm run -s ${s}`, test: "pnpm test" },
|
|
38
|
+
yarn: { run: (s) => `yarn run ${s}`, test: "yarn test" },
|
|
39
|
+
bun: { run: (s) => `bun run ${s}`, test: "bun run test" },
|
|
40
|
+
};
|
|
41
|
+
|
|
34
42
|
const JS_TEST_CMD = {
|
|
35
43
|
vitest: "npx vitest run",
|
|
36
44
|
jest: "npx jest",
|
|
@@ -51,10 +59,12 @@ export async function resolveCmds(projectDir, language) {
|
|
|
51
59
|
if (existsSync(pkgPath)) {
|
|
52
60
|
try {
|
|
53
61
|
const scripts = JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (scripts
|
|
62
|
+
// KJC-BUG-0450 (#2789): the project's own package manager, as the workflows use it.
|
|
63
|
+
const pm = HOOK_RUNNERS[detectPackageManager(projectDir)] ?? HOOK_RUNNERS.npm;
|
|
64
|
+
if (scripts.lint) cmds.lint = pm.run("lint");
|
|
65
|
+
if (scripts["format:check"]) cmds.format = pm.run("format:check");
|
|
66
|
+
else if (scripts.format) cmds.format = pm.run("format");
|
|
67
|
+
if (scripts.test) cmds.test = pm.test;
|
|
58
68
|
} catch {
|
|
59
69
|
/* unreadable package.json → fall back to framework detection */
|
|
60
70
|
}
|
|
@@ -97,6 +107,7 @@ export async function hardenCommand({
|
|
|
97
107
|
// KJC-BUG-0289: the contract files already dirty before anything was generated
|
|
98
108
|
// (kj init captures them before writing its own). What is dirty NOW is the person's.
|
|
99
109
|
contractBefore = null,
|
|
110
|
+
fetchFn = fetch,
|
|
100
111
|
logger = console,
|
|
101
112
|
} = {}) {
|
|
102
113
|
const dirtyBefore = contractBefore ?? contractChanges(projectDir);
|
|
@@ -177,8 +188,11 @@ export async function hardenCommand({
|
|
|
177
188
|
const withConfig = config && profile !== "minimal";
|
|
178
189
|
const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
|
|
179
190
|
const withCi = ci && profile !== "minimal";
|
|
191
|
+
// KJC-BUG-0420: consumer CI installs kj from npm, so the workflows pin a published version.
|
|
192
|
+
const pin = withCi ? await resolveKjPin({ projectDir, fetchFn }) : null;
|
|
193
|
+
if (pin?.note) logger.warn?.(`⚠ ${pin.note}`);
|
|
180
194
|
const wf = withCi
|
|
181
|
-
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun })
|
|
195
|
+
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun, kjVersion: pin?.version ?? null })
|
|
182
196
|
: null;
|
|
183
197
|
const withGuidelines = guidelines && profile !== "minimal";
|
|
184
198
|
// KJC-BUG-0199 (issue #1773): the same detected language the workflows and
|
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
|
}
|
|
@@ -34,6 +34,7 @@ import { evaluatePolicyGate } from "../review/policy-gate.js";
|
|
|
34
34
|
import { correctionNote, reviewCorrectionRounds } from "../review/correction-rounds.js";
|
|
35
35
|
import { cooldownAdvisories, cooldownGate, DEFAULT_COOLDOWN_HOURS, formatCooldown } from "../review/dep-cooldown.js";
|
|
36
36
|
import { branchOverlaps, formatOverlaps } from "../review/branch-overlap.js";
|
|
37
|
+
import { gateHookStatus } from "../review/gate-hook.js";
|
|
37
38
|
|
|
38
39
|
// KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
|
|
39
40
|
// (before spending sonar/reviewer effort) AND on --check — the pre-commit
|
|
@@ -195,18 +196,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
195
196
|
const marker = join(projectDir, ".karajan", "review-gate");
|
|
196
197
|
await fs.mkdir(join(projectDir, ".karajan"), { recursive: true });
|
|
197
198
|
await fs.writeFile(marker, "# Cross-AI review gate enabled (ENV-C1). Commit this file so the whole team inherits the gate.\n");
|
|
198
|
-
|
|
199
|
+
// KJC-BUG-0447 (#2655): the marker declares the gate; only a pre-commit that runs it enforces it.
|
|
200
|
+
const hook = gateHookStatus(projectDir);
|
|
201
|
+
if (hook.enforced) console.log("✓ review gate enabled — commits now require an approved cross-AI verdict (kj review --staged)");
|
|
202
|
+
else {
|
|
203
|
+
console.log(`✗ review gate declared (.karajan/review-gate) but NOT enforced: ${hook.reason} — run \`kj harden\` so the pre-commit hook runs it`);
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
}
|
|
199
206
|
// KJC-TSK-0646: a `.karajan/` dir-exclude would silently keep the
|
|
200
207
|
// contract out of git — rewrite it so the team actually inherits it.
|
|
201
208
|
const gi = await ensureGateTrackable(projectDir);
|
|
202
209
|
if (gi.changed) {
|
|
203
210
|
console.log("✓ .gitignore adjusted: the gate contract (.karajan/review-gate, hooks) is now trackable — commit it");
|
|
204
211
|
}
|
|
205
|
-
|
|
206
|
-
if (!hookProbe.stdout?.trim()) {
|
|
207
|
-
console.log("⚠ no core.hooksPath configured — run `kj harden` so the pre-commit hook enforces the gate");
|
|
208
|
-
}
|
|
209
|
-
return { installed: true };
|
|
212
|
+
return { installed: true, enforced: hook.enforced };
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
// KJC-BUG-0173: manual GC of the verdict store. Opportunistic pruning runs
|
|
@@ -268,26 +271,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
268
271
|
// documentation are exempt — the same rule CI applies. Counting everything
|
|
269
272
|
// here warned 234 lines on a doc-only PR that CI counted at 104, and two
|
|
270
273
|
// implementations of one rule that disagree teach people to trust neither.
|
|
271
|
-
|
|
274
|
+
// KJC-BUG-0434: measured NET, as CI and the policy's net_lines_added do — a rewrite in place is not growth.
|
|
275
|
+
const { added, testAdded } = budgetedAdded(numstat);
|
|
276
|
+
const { net, exempt } = budgetedNet(numstat);
|
|
272
277
|
// Said out loud: an exemption nobody sees is indistinguishable from a gate
|
|
273
278
|
// that is not looking.
|
|
274
279
|
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: ${
|
|
280
|
+
if (net > sizeIdeal && net <= sizeWarn) {
|
|
281
|
+
console.log(`ℓ pr-size: ${net} net lines, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
|
|
277
282
|
}
|
|
278
|
-
if (
|
|
283
|
+
if (net > sizeWarn) {
|
|
279
284
|
// KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
|
|
280
285
|
// partitioning is a decision, and it must never split code from its tests.
|
|
281
|
-
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded}
|
|
286
|
+
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} test lines added — partition by feature, never code from its tests)` : "";
|
|
282
287
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
283
288
|
// ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
|
|
284
289
|
if (sizePolicy === "block") {
|
|
285
|
-
const reason = `${
|
|
290
|
+
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
291
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
287
292
|
process.exitCode = 1;
|
|
288
293
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
289
294
|
} else {
|
|
290
|
-
console.log(`⚠ pr-size: ${
|
|
295
|
+
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
296
|
}
|
|
292
297
|
}
|
|
293
298
|
}
|
package/src/config/overrides.js
CHANGED
|
@@ -102,7 +102,8 @@ function applyRoleOverrides(out, flags) {
|
|
|
102
102
|
if (flags.reviewerModel) out.reviewer_options.model = String(flags.reviewerModel);
|
|
103
103
|
|
|
104
104
|
// KJC-TSK-0699: a profile fills only the models nothing above has set.
|
|
105
|
-
|
|
105
|
+
// KJC-BUG-0448: its own key — `--profile` belongs to harden/check (the harness profile).
|
|
106
|
+
const profile = flags.modelProfile ?? out.model_profile;
|
|
106
107
|
if (profile) {
|
|
107
108
|
applyModelProfile(out, String(profile));
|
|
108
109
|
out.model_profile = String(profile);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
|
+
import { claudeConfigFile, codexHome } from "../utils/agent-home.js";
|
|
17
18
|
|
|
18
19
|
const TOKEN_CONVENTIONS = {
|
|
19
20
|
linear: ["LINEAR_API_KEY"],
|
|
@@ -23,18 +24,19 @@ const TOKEN_CONVENTIONS = {
|
|
|
23
24
|
asana: ["ASANA_ACCESS_TOKEN"],
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
// KJC-TSK-1344: claude and codex configs of the account CLAUDE_CONFIG_DIR / CODEX_HOME select.
|
|
28
|
+
function mcpConfigPaths(projectDir, home, env = process.env) {
|
|
27
29
|
return [
|
|
28
30
|
path.join(projectDir, ".mcp.json"),
|
|
29
|
-
|
|
30
|
-
path.join(home,
|
|
31
|
+
claudeConfigFile({ home, env }),
|
|
32
|
+
path.join(codexHome({ home, env }), "config.toml"),
|
|
31
33
|
path.join(home, ".gemini", "settings.json"),
|
|
32
34
|
];
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function mcpConfigsMention(slug, projectDir, home) {
|
|
37
|
+
function mcpConfigsMention(slug, projectDir, home, env) {
|
|
36
38
|
const needle = slug.toLowerCase();
|
|
37
|
-
for (const p of mcpConfigPaths(projectDir, home)) {
|
|
39
|
+
for (const p of mcpConfigPaths(projectDir, home, env)) {
|
|
38
40
|
try {
|
|
39
41
|
if (existsSync(p) && readFileSync(p, "utf8").toLowerCase().includes(needle)) return p;
|
|
40
42
|
} catch { /* unreadable config — keep looking */ }
|
|
@@ -50,13 +52,13 @@ function mcpConfigsMention(slug, projectDir, home) {
|
|
|
50
52
|
*/
|
|
51
53
|
export function detectAvailableBoards({ projectDir = process.cwd(), env = process.env, home = os.homedir() } = {}) {
|
|
52
54
|
const boards = [{ value: "hu-board", label: "HU Board (bundled, zero setup)" }];
|
|
53
|
-
if (mcpConfigsMention("planning-game", projectDir, home)) {
|
|
55
|
+
if (mcpConfigsMention("planning-game", projectDir, home, env)) {
|
|
54
56
|
boards.push({ value: "planning-game", label: "Planning Game (MCP detected)" });
|
|
55
57
|
}
|
|
56
58
|
for (const [slug, envs] of Object.entries(TOKEN_CONVENTIONS)) {
|
|
57
59
|
const token = envs.find((k) => env[k]);
|
|
58
60
|
if (token) boards.push({ value: `external:${slug}`, label: `${slug} (token ${token})` });
|
|
59
|
-
else if (mcpConfigsMention(slug, projectDir, home)) boards.push({ value: `external:${slug}`, label: `${slug} (MCP detected)` });
|
|
61
|
+
else if (mcpConfigsMention(slug, projectDir, home, env)) boards.push({ value: `external:${slug}`, label: `${slug} (MCP detected)` });
|
|
60
62
|
}
|
|
61
63
|
return boards;
|
|
62
64
|
}
|
|
@@ -65,7 +67,7 @@ export function verifyBoardAccess({ config = {}, projectDir = process.cwd(), env
|
|
|
65
67
|
const backend = config.state_backend || "hu-board";
|
|
66
68
|
|
|
67
69
|
if (backend === "planning-game") {
|
|
68
|
-
const hit = mcpConfigsMention("planning-game", projectDir, home);
|
|
70
|
+
const hit = mcpConfigsMention("planning-game", projectDir, home, env);
|
|
69
71
|
if (hit) return { ok: true, backend, via: `planning-game MCP (${hit})` };
|
|
70
72
|
return {
|
|
71
73
|
ok: false, backend,
|
|
@@ -77,7 +79,7 @@ export function verifyBoardAccess({ config = {}, projectDir = process.cwd(), env
|
|
|
77
79
|
const name = String(config.board?.name || "").trim();
|
|
78
80
|
if (!name) return { ok: false, backend, needed: ["declare board.name in kj.config.yml (e.g. Linear, Trello, Jira, GitHub Issues)"] };
|
|
79
81
|
const slug = name.toLowerCase().split(/\s+/)[0];
|
|
80
|
-
const hit = mcpConfigsMention(slug, projectDir, home);
|
|
82
|
+
const hit = mcpConfigsMention(slug, projectDir, home, env);
|
|
81
83
|
if (hit) return { ok: true, backend, via: `${name} MCP detected (${hit})` };
|
|
82
84
|
const tokenEnvs = config.board?.token_env ? [config.board.token_env] : (TOKEN_CONVENTIONS[slug] || []);
|
|
83
85
|
const found = tokenEnvs.find((k) => env[k]);
|
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
|
|
@@ -43,6 +43,34 @@ const addCommand = ({ missing, ...target }) =>
|
|
|
43
43
|
`gh api -X POST ${protectionPath(target)}/required_status_checks/contexts ${missing.map((c) => `-f ${shellQuote(`contexts[]=${c}`)}`).join(" ")}`;
|
|
44
44
|
|
|
45
45
|
const settingsUrl = ({ owner, repo }) => `https://github.com/${owner}/${repo}/settings/branches`;
|
|
46
|
+
const rulesUrl = ({ owner, repo }) => `https://github.com/${owner}/${repo}/settings/rules`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* KJC-BUG-0449 (#2787): every rule that applies to the branch, rulesets included.
|
|
50
|
+
* [] when none applies (a 404 included); null when it cannot be read.
|
|
51
|
+
*/
|
|
52
|
+
async function branchRules({ owner, repo, branch }, run) {
|
|
53
|
+
let res;
|
|
54
|
+
try { res = await run("gh", ["api", `repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}`]); } catch { return null; }
|
|
55
|
+
if (res.exitCode !== 0) return /404/.test(`${res.stderr}${res.stdout}`) ? [] : null;
|
|
56
|
+
try {
|
|
57
|
+
const rules = JSON.parse(res.stdout || "[]");
|
|
58
|
+
return Array.isArray(rules) ? rules : null;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rulesetChecks = (rules) => rules
|
|
63
|
+
.filter((r) => r?.type === "required_status_checks")
|
|
64
|
+
.flatMap((r) => (r.parameters?.required_status_checks ?? []).map((c) => c.context));
|
|
65
|
+
|
|
66
|
+
/** No classic protection: the rulesets decide, and they are fixed in their own page, never with a PUT. */
|
|
67
|
+
function rulesetAdvice({ rules, checks, target }) {
|
|
68
|
+
if (rules === null) return { status: "unverified", reason: "the branch rules could not be read", settings: rulesUrl(target), missing: checks };
|
|
69
|
+
if (rules.length === 0) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
|
|
70
|
+
const required = new Set(rulesetChecks(rules));
|
|
71
|
+
const missing = checks.filter((c) => !required.has(c));
|
|
72
|
+
return missing.length > 0 ? { status: "ruleset-missing", missing, settings: rulesUrl(target) } : { status: "ok", via: "ruleset" };
|
|
73
|
+
}
|
|
46
74
|
|
|
47
75
|
/**
|
|
48
76
|
* What the base branch needs for the checks to be required, and how. A command
|
|
@@ -61,14 +89,18 @@ export async function protectionAdvice({ owner, repo, branch, checks, run = runC
|
|
|
61
89
|
return { status: "unverified", reason: err.message, settings: settingsUrl(target), missing: checks };
|
|
62
90
|
}
|
|
63
91
|
if (res.exitCode !== 0) {
|
|
64
|
-
if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return {
|
|
92
|
+
if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return rulesetAdvice({ rules: await branchRules(target, run), checks, target });
|
|
65
93
|
return { status: "unverified", reason: (res.stderr || res.stdout || "gh failed").trim(), settings: settingsUrl(target), missing: checks };
|
|
66
94
|
}
|
|
67
95
|
const protection = JSON.parse(res.stdout || "{}");
|
|
96
|
+
const rules = await branchRules(target, run);
|
|
97
|
+
const fromRules = rulesetChecks(rules ?? []);
|
|
68
98
|
const statusChecks = protection.required_status_checks;
|
|
69
|
-
|
|
70
|
-
const existing = new Set(statusChecks.contexts ?? []);
|
|
99
|
+
const existing = new Set([...(statusChecks?.contexts ?? []), ...fromRules]);
|
|
71
100
|
const missing = checks.filter((c) => !existing.has(c));
|
|
101
|
+
// Unreadable rules: a ruleset may require what the classic layer lacks, so nothing is "missing" for sure.
|
|
102
|
+
if (rules === null && missing.length > 0) return { status: "unverified", reason: "the branch rules could not be read", settings: rulesUrl(target), missing };
|
|
103
|
+
if (!statusChecks && fromRules.length === 0) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
|
|
72
104
|
// KJC-BUG-0401: required checks an admin skips are not required for an agent holding an admin's token.
|
|
73
105
|
const bindAdmins = protection.enforce_admins?.enabled === false ? `gh api -X POST ${protectionPath(target)}/enforce_admins` : null;
|
|
74
106
|
if (missing.length > 0) return { status: "missing", missing, command: [addCommand({ ...target, missing }), bindAdmins].filter(Boolean).join(" && ") };
|
|
@@ -105,6 +137,7 @@ export async function adviseBranchProtection({ projectDir, branch, logger, run =
|
|
|
105
137
|
missing: `${branch} does not require ${advice.missing?.length} kj check(s) (${list}). An admin runs (kj never does):`,
|
|
106
138
|
"no-checks": `${branch} is protected but requires no status checks. An admin enables "Require status checks" and adds ${list} in:`,
|
|
107
139
|
unverified: `could not read the protection of ${branch} (${advice.reason}). An admin checks that ${list} are required in:`,
|
|
140
|
+
"ruleset-missing": `${branch} is protected by a ruleset that does not require ${advice.missing?.length} kj check(s) (${list}). An admin adds them to the ruleset (no second protection layer) in:`,
|
|
108
141
|
"admins-exempt": `${branch} requires the kj checks, but admins can merge without them, and an agent working with an admin's token would. An admin runs (kj never does):`,
|
|
109
142
|
}[advice.status];
|
|
110
143
|
logger.warn?.(` ⚠ branch protection: ${why}`);
|
|
@@ -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."];
|