@karajan-family/code 4.70.3 → 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 +3 -0
- package/src/commands/env.js +3 -1
- package/src/commands/go.js +5 -2
- package/src/commands/harden.js +15 -5
- package/src/commands/review-gate.js +9 -6
- package/src/config/overrides.js +2 -1
- package/src/environment/board-access.js +11 -9
- package/src/harden/branch-protection.js +36 -3
- package/src/harden/sentinel/pretooluse-sentinel.mjs +20 -2
- package/src/harden/sentinel/sentinel-lib.mjs +23 -0
- package/src/harden/sentinel/sentinel-shell.mjs +75 -0
- package/src/harden/workflow-engine.js +12 -1
- package/src/mcp/handlers/hu-handlers.js +43 -20
- package/src/mcp/tools.js +4 -4
- 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/publish-pr.js +4 -1
- package/src/review/reviewer-fallback.js +18 -2
- package/src/review/verify-pr.js +1 -2
- 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
|
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, resolveKjPin } 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
|
}
|
|
@@ -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
|
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]);
|
|
@@ -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}`);
|
|
@@ -11,9 +11,9 @@ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from
|
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
14
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText, stagedVerdict } 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, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
|
|
16
|
+
import { commitStaging, 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"];
|
|
@@ -635,6 +635,24 @@ process.stdin.on("end", () => {
|
|
|
635
635
|
console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
|
|
636
636
|
process.exit(2);
|
|
637
637
|
}
|
|
638
|
+
// KJC-BUG-0455 (#2933): the verdict of the staged diff, checked HERE as well as in
|
|
639
|
+
// the pre-commit hook: a repo whose hook does not run the gate, or a review piped into
|
|
640
|
+
// tail that hid its failure, let a commit through with no verdict at all.
|
|
641
|
+
const plan = commitStaging(cmd);
|
|
642
|
+
if (plan.commits) {
|
|
643
|
+
// 4.x: the hook payload carries no cwd; the session works in the project root.
|
|
644
|
+
const verdict = stagedVerdict(plan.dir ? resolve(ROOT, plan.dir) : ROOT);
|
|
645
|
+
if (verdict.gate && plan.stages) {
|
|
646
|
+
console.error("karajan sentinel: stagea y commitea en comandos separados — el veredicto se comprueba sobre lo que ya esta stageado, y este comando stagea a la vez que commitea (git add/rm/mv, -a o rutas en git commit). Stagea por nombre, pasa kj review --staged y luego git commit." + doc("commit-verdict"));
|
|
647
|
+
process.exit(2);
|
|
648
|
+
}
|
|
649
|
+
// Only an approved verdict passes, or nothing staged (a merge: git itself decides).
|
|
650
|
+
if (verdict.gate && verdict.state !== "approved" && verdict.state !== "empty") {
|
|
651
|
+
const why = { rejected: "el review de este diff fue RECHAZADO por " + (verdict.reviewer || "el reviewer") + " — corrige y vuelve a pasar kj review --staged", unknown: "git no pudo leer el diff stageado, asi que el veredicto no se puede comprobar" }[verdict.state] || "no hay veredicto para el diff stageado — pasa kj review --staged (solo, sin tuberias que oculten si falla)";
|
|
652
|
+
console.error("karajan sentinel: commit sin veredicto aprobado: " + why + "." + doc("commit-verdict"));
|
|
653
|
+
process.exit(2);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
638
656
|
// KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
|
|
639
657
|
// git add -A / git add . / git add -u put a Claude scheduler lock into
|
|
640
658
|
// main; and the agent's runtime files (the .claude locks, .kj/, the local
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { execSync, spawnSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
7
8
|
import { basename, dirname, join } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -65,6 +66,28 @@ export const bootstrapPhase = () => {
|
|
|
65
66
|
try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
|
|
66
67
|
catch { return true; }
|
|
67
68
|
};
|
|
69
|
+
// KJC-BUG-0455 (#2933): the cross-AI verdict of the diff staged in `dir`, read the way
|
|
70
|
+
// `kj review --check` reads it (sha256 of `git diff --cached`, trailing blanks trimmed,
|
|
71
|
+
// in <top>/.karajan/reviews). gate:false when the repo has not committed the gate marker.
|
|
72
|
+
// state: approved | rejected | missing | empty (nothing staged) | unknown (git failed).
|
|
73
|
+
export const stagedVerdict = (dir) => {
|
|
74
|
+
const git = (args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
75
|
+
// No commit yet (or no repo): no committed marker can exist, as the hook reads it.
|
|
76
|
+
if (git(["rev-parse", "--verify", "-q", "HEAD"]).status !== 0) return { gate: false };
|
|
77
|
+
// A git error is not a missing marker: it fails closed.
|
|
78
|
+
const marker = git(["ls-tree", "--name-only", "HEAD", ".karajan/review-gate"]);
|
|
79
|
+
if (marker.status !== 0) return { gate: true, state: "unknown" };
|
|
80
|
+
if (!marker.stdout.trim()) return { gate: false };
|
|
81
|
+
const top = git(["rev-parse", "--show-toplevel"]).stdout?.trim();
|
|
82
|
+
const diff = git(["diff", "--cached"]);
|
|
83
|
+
if (!top || diff.status !== 0) return { gate: true, state: "unknown" };
|
|
84
|
+
if (!diff.stdout.trim()) return { gate: true, state: "empty" };
|
|
85
|
+
const hash = createHash("sha256").update(diff.stdout.trimEnd(), "utf8").digest("hex");
|
|
86
|
+
try {
|
|
87
|
+
const v = JSON.parse(readFileSync(join(top, ".karajan", "reviews", hash + ".json"), "utf8"));
|
|
88
|
+
return { gate: true, state: v.verdict === "approved" ? "approved" : "rejected", reviewer: v.reviewer || null };
|
|
89
|
+
} catch { return { gate: true, state: "missing" }; }
|
|
90
|
+
};
|
|
68
91
|
// KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
|
|
69
92
|
// open. kj decides with that checkable fact, the same answer for commit and push
|
|
70
93
|
// (the Stop gate still holds the turn: the board owes the move); when kj does not
|
|
@@ -300,3 +300,78 @@ export const isReadOnlyCommand = (cmd) => {
|
|
|
300
300
|
|
|
301
301
|
/** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
|
|
302
302
|
export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
|
|
303
|
+
|
|
304
|
+
// KJC-BUG-0455 (#2933): git commit's options that take a value, and the git verbs that
|
|
305
|
+
// change the index. A commit that stages in the same command commits a diff nobody
|
|
306
|
+
// could have reviewed yet: the verdict is checked against what is staged BEFORE it runs.
|
|
307
|
+
const COMMIT_VALUE_OPTS = new Set(["-m", "-F", "-C", "-c", "-t", "--message", "--file", "--reuse-message", "--reedit-message", "--template", "--author", "--date", "--cleanup", "--fixup", "--squash", "--trailer", "--pathspec-from-file"]);
|
|
308
|
+
const COMMIT_VALUE_SHORT = new Set(["m", "F", "C", "c", "t"]);
|
|
309
|
+
const INDEX_VERBS = new Set(["add", "rm", "mv", "stage", "restore", "reset", "apply", "update-index"]);
|
|
310
|
+
|
|
311
|
+
/** Where `git` is in a segment and its verb, skipping git's global options; null when not git. */
|
|
312
|
+
const gitVerbOf = (words) => {
|
|
313
|
+
if (words[0]?.slice(words[0].lastIndexOf("/") + 1) !== "git") return null;
|
|
314
|
+
let i = 1;
|
|
315
|
+
let dir = null;
|
|
316
|
+
while (i < words.length) {
|
|
317
|
+
const w = words[i];
|
|
318
|
+
if (GIT_GLOBAL_WITH_VALUE.has(w)) {
|
|
319
|
+
if (w === "-C") {
|
|
320
|
+
dir = words[i + 1] ?? null;
|
|
321
|
+
}
|
|
322
|
+
i += 2; // the option and its value
|
|
323
|
+
} else if (GIT_GLOBAL_FLAG.test(w)) {
|
|
324
|
+
i += 1;
|
|
325
|
+
} else {
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { verb: words[i], at: i, dir };
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** How many words a git commit argument takes, and whether it makes the commit stage on its own. */
|
|
333
|
+
const commitArg = (a) => {
|
|
334
|
+
if (a === "--all") return { stages: true, words: 1 };
|
|
335
|
+
if (a.startsWith("--")) {
|
|
336
|
+
// --message=x carries its value; --message x takes the next word.
|
|
337
|
+
return { stages: false, words: COMMIT_VALUE_OPTS.has(a) ? 2 : 1 };
|
|
338
|
+
}
|
|
339
|
+
if (/^-[a-zA-Z]/.test(a)) {
|
|
340
|
+
const cluster = a.slice(1);
|
|
341
|
+
const valueAt = [...cluster].findIndex((c) => COMMIT_VALUE_SHORT.has(c));
|
|
342
|
+
const flags = valueAt < 0 ? cluster : cluster.slice(0, valueAt);
|
|
343
|
+
if (flags.includes("a")) return { stages: true, words: 1 };
|
|
344
|
+
// -m x takes the next word; -mx carries its value.
|
|
345
|
+
return { stages: false, words: valueAt === cluster.length - 1 ? 2 : 1 };
|
|
346
|
+
}
|
|
347
|
+
return { stages: true, words: 1 }; // a pathspec
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** -a/--all, or a path (with or without --), makes git commit stage on its own. */
|
|
351
|
+
const commitStagesItself = (args) => {
|
|
352
|
+
let i = 0;
|
|
353
|
+
while (i < args.length) {
|
|
354
|
+
if (args[i] === "--") {
|
|
355
|
+
return i + 1 < args.length;
|
|
356
|
+
}
|
|
357
|
+
const { stages, words } = commitArg(args[i]);
|
|
358
|
+
if (stages) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
i += words;
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Does the command run git commit, and does it stage in the same command?
|
|
368
|
+
* @param {string} cmd
|
|
369
|
+
* @returns {{commits: boolean, stages: boolean, dir: string|null}}
|
|
370
|
+
*/
|
|
371
|
+
export const commitStaging = (cmd) => {
|
|
372
|
+
const gits = shellSegments(cmd).map((words) => ({ words, g: gitVerbOf(words) })).filter((s) => s.g);
|
|
373
|
+
const commits = gits.filter((s) => s.g.verb === "commit");
|
|
374
|
+
if (commits.length === 0) return { commits: false, stages: false, dir: null };
|
|
375
|
+
const stages = gits.some((s) => INDEX_VERBS.has(s.g.verb)) || commits.some((s) => commitStagesItself(s.words.slice(s.g.at + 1)));
|
|
376
|
+
return { commits: true, stages, dir: commits[0].g.dir };
|
|
377
|
+
};
|
|
@@ -57,9 +57,20 @@ export function isPublishableNpm(projectDir) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
const KNOWN_PMS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The project's package manager (KJC-BUG-0131: npm ci kills pnpm/yarn CI).
|
|
64
|
+
* KJC-BUG-0450 (#2789): the same answer for hooks and workflows; the
|
|
65
|
+
* packageManager field of package.json first, then the lockfile.
|
|
66
|
+
*/
|
|
61
67
|
export function detectPackageManager(projectDir) {
|
|
68
|
+
try {
|
|
69
|
+
const declared = String(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).packageManager || "").split("@")[0];
|
|
70
|
+
if (KNOWN_PMS.has(declared)) return declared;
|
|
71
|
+
} catch { /* no or unreadable package.json: the lockfile decides */ }
|
|
62
72
|
if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
73
|
+
if (existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))) return "bun";
|
|
63
74
|
if (existsSync(join(projectDir, "yarn.lock"))) return "yarn";
|
|
64
75
|
return "npm";
|
|
65
76
|
}
|
|
@@ -4,44 +4,67 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { handleSuggestion } from "../suggest-handler.js";
|
|
7
|
+
import { runKjCommand } from "../run-kj.js";
|
|
7
8
|
import {
|
|
8
9
|
resolveProjectDir,
|
|
9
10
|
failPayload,
|
|
10
11
|
} from "../shared-helpers.js";
|
|
11
12
|
|
|
13
|
+
export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* KJC-BUG-0452 (#2815): kj_hu used the old kj run store, so an agent saw an empty
|
|
17
|
+
* board while `kj hu list` showed every card. It runs the CLI's own `kj hu` in a
|
|
18
|
+
* subprocess: the same board (local, shared or a lane's), and stdout, which is
|
|
19
|
+
* this server's protocol channel, stays clean.
|
|
20
|
+
*/
|
|
21
|
+
async function kjHu(projectDir, args) {
|
|
22
|
+
const res = await runKjCommand({ command: "hu", commandArgs: [...args, "--json"], options: { projectDir } });
|
|
23
|
+
if (!res.ok) return { error: `kj hu ${args[0]} failed: ${res.errorSummary || res.stderr || "no output"}` };
|
|
24
|
+
const line = String(res.stdout || "").trim().split("\n").reverse().find((l) => /^[[{]/.test(l.trim()));
|
|
25
|
+
try { return { data: JSON.parse(line) }; } catch { return { error: `kj hu ${args[0]} answered no JSON: ${String(res.stdout || "").slice(0, 200)}` }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const notOnBoard = (id) => `HU ${id} is not on the board — kj_hu list shows its cards`;
|
|
29
|
+
|
|
30
|
+
async function createHu(projectDir, a) {
|
|
31
|
+
if (!a.title) return failPayload("Missing required field: title (required for create)");
|
|
32
|
+
const args = ["add", a.title, ...(a.acceptanceCriteria ? ["--ac", a.acceptanceCriteria] : []), ...(a.ref ? ["--ref", a.ref] : [])];
|
|
33
|
+
const added = await kjHu(projectDir, args);
|
|
34
|
+
if (added.error) return failPayload(added.error);
|
|
35
|
+
if (!a.status || a.status === "pending") return { ok: true, hu: added.data };
|
|
36
|
+
return moveHu(projectDir, added.data.short_id || added.data.id, a.status);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function moveHu(projectDir, huId, status) {
|
|
40
|
+
if (!HU_STATUSES.includes(status)) return failPayload(`Unknown status "${status}": use ${HU_STATUSES.join(", ")}`);
|
|
41
|
+
const moved = await kjHu(projectDir, ["move", huId, status]);
|
|
42
|
+
return moved.error ? failPayload(moved.error) : { ok: true, hu: moved.data };
|
|
43
|
+
}
|
|
44
|
+
|
|
12
45
|
export async function handleHu(a, server) {
|
|
13
46
|
const action = a.action;
|
|
14
47
|
if (!action) return failPayload("Missing required field: action");
|
|
15
|
-
|
|
16
48
|
const projectDir = await resolveProjectDir(server, a.projectDir);
|
|
17
|
-
const { createManualHu, listHus, getHu, updateHuStatus } = await import("../../hu/store.js");
|
|
18
49
|
|
|
19
50
|
switch (action) {
|
|
20
|
-
case "create":
|
|
21
|
-
|
|
22
|
-
const hu = await createManualHu(projectDir, {
|
|
23
|
-
title: a.title,
|
|
24
|
-
description: a.description,
|
|
25
|
-
status: a.status,
|
|
26
|
-
acceptanceCriteria: a.acceptanceCriteria
|
|
27
|
-
});
|
|
28
|
-
return { ok: true, hu };
|
|
29
|
-
}
|
|
51
|
+
case "create":
|
|
52
|
+
return createHu(projectDir, a);
|
|
30
53
|
case "list": {
|
|
31
|
-
const
|
|
32
|
-
return { ok: true, hus };
|
|
54
|
+
const listed = await kjHu(projectDir, ["list"]);
|
|
55
|
+
return listed.error ? failPayload(listed.error) : { ok: true, hus: listed.data };
|
|
33
56
|
}
|
|
34
57
|
case "get": {
|
|
35
58
|
if (!a.huId) return failPayload("Missing required field: huId (required for get)");
|
|
36
|
-
const
|
|
37
|
-
|
|
59
|
+
const listed = await kjHu(projectDir, ["list"]);
|
|
60
|
+
if (listed.error) return failPayload(listed.error);
|
|
61
|
+
const hu = listed.data.find((h) => h.id === a.huId || h.short_id === a.huId);
|
|
62
|
+
return hu ? { ok: true, hu } : failPayload(notOnBoard(a.huId));
|
|
38
63
|
}
|
|
39
|
-
case "update":
|
|
64
|
+
case "update":
|
|
40
65
|
if (!a.huId) return failPayload("Missing required field: huId (required for update)");
|
|
41
66
|
if (!a.status) return failPayload("Missing required field: status (required for update)");
|
|
42
|
-
|
|
43
|
-
return { ok: true, hu };
|
|
44
|
-
}
|
|
67
|
+
return moveHu(projectDir, a.huId, a.status);
|
|
45
68
|
default:
|
|
46
69
|
return failPayload(`Unknown hu action: ${action}`);
|
|
47
70
|
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -335,17 +335,17 @@ export const tools = [
|
|
|
335
335
|
},
|
|
336
336
|
{
|
|
337
337
|
name: "kj_hu",
|
|
338
|
-
description: "Manage user stories (HUs) in the local
|
|
338
|
+
description: "Manage user stories (HUs) in the HU Board, the same board `kj hu` uses (local or shared)",
|
|
339
339
|
inputSchema: {
|
|
340
340
|
type: "object",
|
|
341
341
|
required: ["action"],
|
|
342
342
|
properties: {
|
|
343
343
|
action: { type: "string", enum: ["create", "update", "list", "get"], description: "Action to perform" },
|
|
344
344
|
title: { type: "string", description: "HU title (required for create)" },
|
|
345
|
-
|
|
346
|
-
|
|
345
|
+
acceptanceCriteria: { type: "string", description: "Acceptance criteria, one per line" },
|
|
346
|
+
ref: { type: "string", description: "Id of this card's replica in the external tracker (Planning Game, Jira, Linear...)" },
|
|
347
347
|
huId: { type: "string", description: "HU ID (required for update/get)" },
|
|
348
|
-
status: { type: "string", description: "HU status (for create/update)", enum: ["pending", "
|
|
348
|
+
status: { type: "string", description: "HU status (for create/update)", enum: ["pending", "running", "done", "failed", "skipped"] },
|
|
349
349
|
projectDir: { type: "string", description: "Project directory (defaults to cwd)" }
|
|
350
350
|
}
|
|
351
351
|
}
|
|
@@ -47,10 +47,11 @@ function verdictOf(sides, findings, coverage) {
|
|
|
47
47
|
if (blockers.length) return { status: "rejected", approved: false, reason: `confirmed blocker(s): ${blockers.map(where).join(", ")}`, summary };
|
|
48
48
|
const rejecting = sides.filter((s) => !s.verdict?.approved).map((s) => s.reviewer);
|
|
49
49
|
if (rejecting.length === 0 && !coverage.truncated) return { status: "approved", approved: true, summary };
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return { status: "partial", approved: false, reason, summary };
|
|
50
|
+
const dispute = rejecting.length ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family` : null;
|
|
51
|
+
// KJC-BUG-0453 (#2921): "partial" only when files were left out (they are in coverage.unreviewed);
|
|
52
|
+
// everything reviewed and the reviewers disagreeing is "disputed".
|
|
53
|
+
if (coverage.truncated) return { status: "partial", approved: false, reason: ["not every file was reviewed", dispute].filter(Boolean).join("; "), summary };
|
|
54
|
+
return { status: "disputed", approved: false, reason: dispute, summary };
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/** The two sides (reviewer, model, verdict, coverage, findings, outcomes, verification) as one result. */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0447 (issue #2655): is the review gate actually ENFORCED? The marker
|
|
3
|
+
* only declares it; what refuses a commit is a pre-commit hook that runs
|
|
4
|
+
* `kj review --check`. Checked where git will look (core.hooksPath, local or
|
|
5
|
+
* global, or .git/hooks), never assumed from a hooks path being configured.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
// The contract: a line that RUNS the check and stops the commit when it fails, as the
|
|
12
|
+
// pre-commit kj harden writes (`kj review --check || { ...; exit 1; }`) or `|| exit 1`.
|
|
13
|
+
// A comment, the phrase inside an echo, `|| true`, `|| exit 0` or a group that does
|
|
14
|
+
// not END in `exit N` (N > 0) enforce nothing. Right after `||`, a bare `exit` keeps
|
|
15
|
+
// the failing check's status; inside a group it would take the previous command's.
|
|
16
|
+
const GATE_LINE = /^\s*kj review --check\s*\|\|\s*(?:exit(?:\s+[1-9]\d*)?\s*(?:;|#|$)|\{(?:[^}]*;)?\s*exit\s+[1-9]\d*\s*;?\s*\})/;
|
|
17
|
+
const runsGate = (body) => body.split("\n").some((line) => GATE_LINE.test(line));
|
|
18
|
+
|
|
19
|
+
/** @returns {{enforced: boolean, hooksDir: string|null, reason: string|null}} */
|
|
20
|
+
export function gateHookStatus(projectDir) {
|
|
21
|
+
let hooksDir;
|
|
22
|
+
try {
|
|
23
|
+
// --git-path hooks honours core.hooksPath (repo or global) and worktrees.
|
|
24
|
+
const rel = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
25
|
+
hooksDir = path.resolve(projectDir, rel);
|
|
26
|
+
} catch {
|
|
27
|
+
return { enforced: false, hooksDir: null, reason: "not a git repository: no hook can enforce the gate" };
|
|
28
|
+
}
|
|
29
|
+
const hook = path.join(hooksDir, "pre-commit");
|
|
30
|
+
let body;
|
|
31
|
+
try { body = fs.readFileSync(hook, "utf8"); } catch {
|
|
32
|
+
return { enforced: false, hooksDir, reason: `no pre-commit hook in ${hooksDir}, where git looks for hooks` };
|
|
33
|
+
}
|
|
34
|
+
if (!runsGate(body)) return { enforced: false, hooksDir, reason: `${hook} does not run kj review --check` };
|
|
35
|
+
try { fs.accessSync(hook, fs.constants.X_OK); } catch {
|
|
36
|
+
return { enforced: false, hooksDir, reason: `${hook} is not executable, so git skips it` };
|
|
37
|
+
}
|
|
38
|
+
return { enforced: true, hooksDir, reason: null };
|
|
39
|
+
}
|
|
@@ -14,12 +14,13 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
|
|
|
14
14
|
import { resolveReviewProfile } from "./profiles.js";
|
|
15
15
|
import { parseReviewVerdict } from "./parser.js";
|
|
16
16
|
import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
|
|
17
|
+
import { accountLabel } from "../utils/agent-home.js";
|
|
17
18
|
import { saveVerdict, diffHash } from "./verdict-store.js";
|
|
18
19
|
import { summarizeGeneratedFiles } from "./prompt-diff.js";
|
|
19
20
|
import { reportUnparseableVerdict } from "./unparseable-verdict.js";
|
|
20
21
|
import { detectWorkspace } from "./workspace.js";
|
|
21
22
|
import { branchOf } from "./correction-rounds.js";
|
|
22
|
-
import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
|
|
23
|
+
import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu, reviewerFailureMessage } from "./reviewer-fallback.js";
|
|
23
24
|
|
|
24
25
|
// Cross-AI preference when the configured reviewer IS the host.
|
|
25
26
|
const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
|
|
@@ -60,6 +61,7 @@ export async function runOneShotReview({
|
|
|
60
61
|
// review can never unlock a commit of one's own.
|
|
61
62
|
reviewerOverride = null,
|
|
62
63
|
persist = true,
|
|
64
|
+
env = process.env,
|
|
63
65
|
}) {
|
|
64
66
|
if (!diff || !diff.trim()) {
|
|
65
67
|
throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
|
|
@@ -79,7 +81,9 @@ export async function runOneShotReview({
|
|
|
79
81
|
// KJC-BUG-0347: generated files summarized; the verdict still binds to the full diff.
|
|
80
82
|
diff: summarizeGeneratedFiles(diff), reviewRules: rules, mode: "standard", provider: who, projectDir,
|
|
81
83
|
});
|
|
82
|
-
|
|
84
|
+
// KJC-TSK-1344: with two subscriptions per CLI, say which account reviews.
|
|
85
|
+
const account = accountLabel(who, { env });
|
|
86
|
+
logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)${account ? ` [${account}]` : ""}`);
|
|
83
87
|
return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
|
|
84
88
|
};
|
|
85
89
|
|
|
@@ -115,7 +119,7 @@ export async function runOneShotReview({
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
if (!result?.ok) {
|
|
118
|
-
throw new Error(
|
|
122
|
+
throw new Error(reviewerFailureMessage(activeReviewer, result?.error));
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
// KJC-BUG-0146, second half: parseMaybeJsonString only PARSES — it returns whatever JSON came
|
package/src/review/publish-pr.js
CHANGED
|
@@ -9,6 +9,8 @@ import { diffHash, loadVerdict } from "./verdict-store.js";
|
|
|
9
9
|
import { isCleanMerge } from "./clean-merge.js";
|
|
10
10
|
|
|
11
11
|
export const MARKER = (head) => `<!-- kj-review head=${head} -->`;
|
|
12
|
+
/** Who may publish a kj review that counts: verify-pr ignores anyone else. */
|
|
13
|
+
export const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
12
14
|
|
|
13
15
|
// gh runs in the project's own directory, so it reads and writes the same repo the verdicts belong to.
|
|
14
16
|
async function json(run, cmd, args, cwd) {
|
|
@@ -39,7 +41,8 @@ export async function publishPrReview({ projectDir, pr, run = runCommand }) {
|
|
|
39
41
|
}
|
|
40
42
|
if (uncovered.length > 0) throw new Error(`PR #${pr}: ${uncovered.length} commit(s) without an approved verdict of their exact diff, nothing published: ${uncovered.join(", ")}`);
|
|
41
43
|
const reviews = await json(run, "gh", ["api", `repos/{owner}/{repo}/pulls/${pr}/reviews`], projectDir);
|
|
42
|
-
|
|
44
|
+
// KJC-BUG-0456: one a non-member posted (gh switched to another account) does not count in CI.
|
|
45
|
+
if ((reviews ?? []).some((r) => TRUSTED.has(r.author_association) && String(r.body ?? "").includes(MARKER(info.headRefOid)))) {
|
|
43
46
|
return { published: false, reason: `a kj review for head ${info.headRefOid.slice(0, 7)} is already published` };
|
|
44
47
|
}
|
|
45
48
|
const body = [
|
|
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs";
|
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { checkBinary } from "../utils/agent-detect.js";
|
|
17
|
+
import { agentAuthFiles } from "../utils/agent-home.js";
|
|
17
18
|
|
|
18
19
|
export const REVIEWER_CANDIDATES = [
|
|
19
20
|
{
|
|
@@ -70,9 +71,22 @@ export const REVIEWER_CANDIDATES = [
|
|
|
70
71
|
|
|
71
72
|
const QUOTA_RE = /usage limit|quota|rate.?limit|credits? (?:exhausted|left: ?0)|hit your .{0,20}limit/i;
|
|
72
73
|
|
|
74
|
+
// KJC-BUG-0451 (#2799): codex puts a `rate_limits` telemetry object in every JSON event;
|
|
75
|
+
// it says how much quota is LEFT, so it is no evidence that the quota ran out.
|
|
76
|
+
const RATE_LIMITS_TELEMETRY = /"rate_limits"\s*:\s*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}|null)/g;
|
|
77
|
+
|
|
73
78
|
/** True when the agent error smells like exhausted quota, not a real failure. */
|
|
74
79
|
export function isQuotaExhausted(text) {
|
|
75
|
-
return typeof text === "string" && QUOTA_RE.test(text);
|
|
80
|
+
return typeof text === "string" && QUOTA_RE.test(text.replace(RATE_LIMITS_TELEMETRY, ""));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The CLI could not reach its service at all: a sandboxed shell or the network, not the reviewer.
|
|
84
|
+
const UNREACHABLE_RE = /routing discovery failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|getaddrinfo|stream disconnected|network (?:error|is unreachable)/i;
|
|
85
|
+
|
|
86
|
+
/** The real error, plus a hint when the reviewer CLI never reached its service. */
|
|
87
|
+
export function reviewerFailureMessage(reviewer, error) {
|
|
88
|
+
const base = `reviewer ${reviewer} failed: ${error || "no output"}`;
|
|
89
|
+
return UNREACHABLE_RE.test(String(error || "")) ? `${base} — ${reviewer} could not reach its service (a sandboxed shell or the network?); run kj review from an unsandboxed shell` : base;
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
/** installed × authenticated for every candidate, from cheap local checks. */
|
|
@@ -86,8 +100,10 @@ export async function candidateStatus({ home = os.homedir(), checkBin = checkBin
|
|
|
86
100
|
// Session-file heuristics, or env keys for CLIs (aider) that carry
|
|
87
101
|
// no session file. Key present ≠ credit left — it is menu signal;
|
|
88
102
|
// the failover only sticks if the candidate actually answers.
|
|
103
|
+
// KJC-TSK-1344: codex and claude keep the account where CODEX_HOME / CLAUDE_CONFIG_DIR say.
|
|
104
|
+
const files = agentAuthFiles(c.name, { home, env }) ?? c.authPaths.map((p) => path.join(home, p));
|
|
89
105
|
const authenticated =
|
|
90
|
-
|
|
106
|
+
files.some((f) => existsSync(f)) ||
|
|
91
107
|
(c.authEnv || []).some((name) => Boolean(env[name]));
|
|
92
108
|
return { ...c, installed, authenticated };
|
|
93
109
|
}),
|
package/src/review/verify-pr.js
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { runCommand } from "../utils/process.js";
|
|
9
9
|
import { diffHash } from "./verdict-store.js";
|
|
10
|
-
import { MARKER } from "./publish-pr.js";
|
|
10
|
+
import { MARKER, TRUSTED } from "./publish-pr.js";
|
|
11
11
|
import { isCleanMerge } from "./clean-merge.js";
|
|
12
12
|
import { verifiedSupervisorFiles } from "../policy/supervisor-verify.js";
|
|
13
13
|
import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
|
|
14
14
|
|
|
15
|
-
const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
16
15
|
// The marker of a kj review for any head: what MARKER writes up to the head itself.
|
|
17
16
|
const MARKER_PREFIX = MARKER("\0").split("\0")[0];
|
|
18
17
|
// One line per commit, as publish-pr writes it: - `<sha7>` headline: approved by X (diff <hash12>)...
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1344 — where each CLI keeps its account. One person may hold two
|
|
3
|
+
* subscriptions per CLI (personal and work): claude switches account with
|
|
4
|
+
* CLAUDE_CONFIG_DIR and codex with CODEX_HOME. kj passes both to the agents
|
|
5
|
+
* (role-env allows CLAUDE* and CODEX*), so it must also LOOK there for the
|
|
6
|
+
* session and the config, never at a fixed ~/.codex or ~/.claude.json.
|
|
7
|
+
*/
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const ACCOUNT_VAR = { codex: "CODEX_HOME", claude: "CLAUDE_CONFIG_DIR" };
|
|
12
|
+
|
|
13
|
+
export const codexHome = ({ home = os.homedir(), env = process.env } = {}) =>
|
|
14
|
+
env.CODEX_HOME || path.join(home, ".codex");
|
|
15
|
+
|
|
16
|
+
export const claudeConfigFile = ({ home = os.homedir(), env = process.env } = {}) =>
|
|
17
|
+
path.join(env.CLAUDE_CONFIG_DIR || home, ".claude.json");
|
|
18
|
+
|
|
19
|
+
/** Absolute files that mark a logged-in session, or null for agents without an account variable. */
|
|
20
|
+
export function agentAuthFiles(agent, { home = os.homedir(), env = process.env } = {}) {
|
|
21
|
+
if (agent === "codex") return [path.join(codexHome({ home, env }), "auth.json")];
|
|
22
|
+
if (agent === "claude") {
|
|
23
|
+
const dir = env.CLAUDE_CONFIG_DIR;
|
|
24
|
+
return dir ? [path.join(dir, ".claude.json"), path.join(dir, ".credentials.json")] : [path.join(home, ".claude.json"), path.join(home, ".claude")];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** "CODEX_HOME=/path" when the variable moves the account, null on the default. */
|
|
30
|
+
export function accountLabel(agent, { env = process.env } = {}) {
|
|
31
|
+
const name = ACCOUNT_VAR[agent];
|
|
32
|
+
return name && env[name] ? `${name}=${env[name]}` : null;
|
|
33
|
+
}
|