karajan-code 4.28.1 → 4.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/package.json +2 -2
- package/scripts/verify-pack.mjs +19 -1
- package/src/checks/config-files.js +86 -0
- package/src/checks/method.js +34 -1
- package/src/checks/sonar.js +16 -7
- package/src/cli/register-pipeline.js +4 -1
- package/src/commands/identity.js +5 -2
- package/src/commands/review-gate.js +71 -3
- package/src/git/automation.js +3 -1
- package/src/harden/harden-engine.js +5 -2
- package/src/harden/hook-templates.js +25 -10
- package/src/harden/phone-sign.js +82 -13
- package/src/harden/supervisor-commit.js +7 -0
- package/src/mcp/handlers/direct-handlers.js +34 -21
- package/src/mcp/handlers/rag-handler.js +10 -4
- package/src/mcp/server-handlers.js +11 -1
- package/src/mcp/tools.js +1 -0
- package/src/review/one-shot-review.js +3 -0
- package/src/review/sonar-pregate.js +64 -15
- package/src/review/sonar-requirement.js +47 -0
- package/src/review/tests-with-code.js +17 -6
- package/src/review/verdict-store.js +61 -1
- package/src/roles/commiter-role.js +4 -1
- package/src/sonar/scanner.js +40 -15
- package/src/utils/paths.js +1 -0
package/README.md
CHANGED
|
@@ -41,7 +41,29 @@ This repo runs under its own environment: every commit to karajan-code carries a
|
|
|
41
41
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Two ways in — pick your comfort level.
|
|
45
|
+
|
|
46
|
+
### Maggle mode — you never touch the terminal
|
|
47
|
+
|
|
48
|
+
Not a developer, or you just want it running? Paste this into your AI assistant (Claude Code, Codex, Cursor, VS Code):
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Set up Karajan in this project and start it for me: read
|
|
52
|
+
https://karajancode.com/go.md and do everything it says. I'm not a developer —
|
|
53
|
+
don't ask me technical questions, just get it running and tell me when it's ready.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It installs Karajan, prepares the project, and opens a single window with the board and your agent inside — stopping only if a step truly needs you (sudo or an account). Already installed? One command:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
kj go # or: kj go --window (the agent's terminal embedded in the board)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`kj go` detects your agent (asks which only if you have several), prepares the project silently the first time, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials.
|
|
63
|
+
|
|
64
|
+
### Developer mode — you stay in control
|
|
65
|
+
|
|
66
|
+
Want your agent to walk each step and stop at every permission? Paste:
|
|
45
67
|
|
|
46
68
|
```text
|
|
47
69
|
I want to use Karajan in this project: read https://karajancode.com/start.md
|
|
@@ -59,14 +81,6 @@ kj init && kj env install && kj harden && kj review --install-gate
|
|
|
59
81
|
git config core.hooksPath .karajan/hooks
|
|
60
82
|
```
|
|
61
83
|
|
|
62
|
-
Already installed and just want to work — even with no computing background? One command:
|
|
63
|
-
|
|
64
|
-
```sh
|
|
65
|
-
kj go
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials. Prefer everything in ONE browser window? `kj go --window` embeds the agent's real terminal inside the board (loopback-only, single-session token).
|
|
69
|
-
|
|
70
84
|
Requires git and at least one AI agent CLI — two enables cross-AI review; three enables arbitration. The npm route is `npm install -g @karajan-family/code` (published as `karajan-code` before joining the scope; the legacy name still installs the same versions). All install routes (npm, binaries, brew, Python wrapper) in the [install docs](https://karajancode.com/docs/v4/install/).
|
|
71
85
|
|
|
72
86
|
## The daily loop
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.29.0",
|
|
4
4
|
"description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"express-rate-limit": "^8.5.0",
|
|
113
113
|
"helmet": "^8.1.0",
|
|
114
114
|
"js-yaml": "^4.2.0",
|
|
115
|
-
"karajan-core": "^1.
|
|
115
|
+
"karajan-core": "^1.5.0",
|
|
116
116
|
"karajan-rag": "^1.2.0",
|
|
117
117
|
"knip": "^6.15.0",
|
|
118
118
|
"madge": "^8.0.0",
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -33,8 +33,25 @@ const pkgName = pkg.name;
|
|
|
33
33
|
|
|
34
34
|
// Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
|
|
35
35
|
// runs otherwise) and force a non-interactive, quiet npm.
|
|
36
|
+
// KJC-BUG-0171: the tarball's postinstall registers karajan-mcp in
|
|
37
|
+
// ~/.claude.json / ~/.codex / ~/.karajan pointing at THIS install's path. Every
|
|
38
|
+
// verify install lives in a throwaway prefix, so those paths go stale the moment
|
|
39
|
+
// the temp dir is cleaned — a CONNECTION_CLOSED MCP left in the real user config.
|
|
40
|
+
// Redirect HOME so every global-config write lands in an isolated dir removed
|
|
41
|
+
// with the rest; seed a git identity so any commit still works without ~/.gitconfig.
|
|
42
|
+
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), "kj-verify-home-"));
|
|
36
43
|
const { CLAUDECODE: _omit, ...cleanEnv } = process.env;
|
|
37
|
-
const childEnv = {
|
|
44
|
+
const childEnv = {
|
|
45
|
+
...cleanEnv,
|
|
46
|
+
npm_config_yes: "true",
|
|
47
|
+
CI: "1",
|
|
48
|
+
HOME: homeTmp,
|
|
49
|
+
USERPROFILE: homeTmp,
|
|
50
|
+
GIT_AUTHOR_NAME: "kj verify",
|
|
51
|
+
GIT_AUTHOR_EMAIL: "verify@kj.local",
|
|
52
|
+
GIT_COMMITTER_NAME: "kj verify",
|
|
53
|
+
GIT_COMMITTER_EMAIL: "verify@kj.local",
|
|
54
|
+
};
|
|
38
55
|
|
|
39
56
|
function run(cmd, args, opts = {}) {
|
|
40
57
|
return execFileSync(cmd, args, {
|
|
@@ -271,6 +288,7 @@ try {
|
|
|
271
288
|
console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
|
|
272
289
|
} finally {
|
|
273
290
|
if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
|
|
291
|
+
if (homeTmp && fs.existsSync(homeTmp)) fs.rmSync(homeTmp, { recursive: true, force: true });
|
|
274
292
|
if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
275
293
|
if (gTmp && fs.existsSync(gTmp)) fs.rmSync(gTmp, { recursive: true, force: true });
|
|
276
294
|
if (pnpmTmp && fs.existsSync(pnpmTmp)) fs.rmSync(pnpmTmp, { recursive: true, force: true });
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
* ~/.karajan/kj.config.yml (strategy: manual — structural fixes needed)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
11
12
|
import fs from "node:fs/promises";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
14
16
|
import { exists } from "../utils/fs.js";
|
|
15
17
|
import { getConfigPath } from "../config.js";
|
|
16
18
|
import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
|
|
@@ -132,6 +134,89 @@ function createClaudeConfigCheck() {
|
|
|
132
134
|
};
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* KJC-BUG-0171: the karajan-mcp server path registered in ~/.claude.json.
|
|
139
|
+
* Returns the args entry that points at the MCP server (…/mcp/server.js), or
|
|
140
|
+
* null when karajan-mcp is not registered.
|
|
141
|
+
*/
|
|
142
|
+
export function karajanMcpServerPath(config) {
|
|
143
|
+
const server = config?.mcpServers?.["karajan-mcp"];
|
|
144
|
+
const args = Array.isArray(server?.args) ? server.args : [];
|
|
145
|
+
return args.find((a) => typeof a === "string" && /[\\/]mcp[\\/]server\.js$/.test(a)) ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Repair a stale karajan-mcp registration in place: re-point it to this
|
|
150
|
+
* install's server when that server exists, or drop the dead entry when this
|
|
151
|
+
* install has no bundled server (the standalone binary). `serverExists` is
|
|
152
|
+
* injectable for tests. Mutates `config`; returns "repointed" | "removed" | null.
|
|
153
|
+
*/
|
|
154
|
+
export function repairKarajanMcpPath(config, correctServerPath, serverExists) {
|
|
155
|
+
const server = config?.mcpServers?.["karajan-mcp"];
|
|
156
|
+
if (!server) return null;
|
|
157
|
+
if (correctServerPath && serverExists(correctServerPath)) {
|
|
158
|
+
server.args = [correctServerPath];
|
|
159
|
+
server.cwd = path.resolve(path.dirname(correctServerPath), "..", "..");
|
|
160
|
+
return "repointed";
|
|
161
|
+
}
|
|
162
|
+
delete config.mcpServers["karajan-mcp"];
|
|
163
|
+
return "removed";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* karajan-mcp registration path validity (~/.claude.json) — KJC-BUG-0171.
|
|
168
|
+
* A verify/temp install used to register the MCP at a throwaway prefix; once
|
|
169
|
+
* cleaned, the path 404s and every session's MCP fails with CONNECTION_CLOSED,
|
|
170
|
+
* and the health check never caught it (it spawns its OWN bundled server).
|
|
171
|
+
*/
|
|
172
|
+
function createStaleMcpPathCheck() {
|
|
173
|
+
return {
|
|
174
|
+
name: "agent-config:karajan-mcp-path",
|
|
175
|
+
label: "MCP registration path (~/.claude.json)",
|
|
176
|
+
// KJC-BUG-0172: AUTO, not PROMPT — the runner never remediates a WARN
|
|
177
|
+
// check whose strategy is PROMPT, so the repair was inert under `kj doctor
|
|
178
|
+
// -y`. Re-pointing a dead entry to the current server (or dropping it) is
|
|
179
|
+
// deterministic and reversible on an already-broken entry; `--check-only`
|
|
180
|
+
// stays available to detect without touching the config.
|
|
181
|
+
strategy: STRATEGY.AUTO,
|
|
182
|
+
describe: "Re-point karajan-mcp in ~/.claude.json to this install's server (or drop a dead entry)",
|
|
183
|
+
async detect() {
|
|
184
|
+
const claudeJsonPath = path.join(os.homedir(), ".claude.json");
|
|
185
|
+
let config;
|
|
186
|
+
try {
|
|
187
|
+
config = JSON.parse(await fs.readFile(claudeJsonPath, "utf8"));
|
|
188
|
+
} catch {
|
|
189
|
+
return { ok: true, severity: "info", detail: "Not present or unparseable (skipped)" };
|
|
190
|
+
}
|
|
191
|
+
const registered = karajanMcpServerPath(config);
|
|
192
|
+
if (!registered) return { ok: true, severity: "info", detail: "karajan-mcp not registered (skipped)" };
|
|
193
|
+
if (await exists(registered)) return { ok: true, severity: "info", detail: "karajan-mcp path valid" };
|
|
194
|
+
const correct = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "mcp", "server.js");
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
severity: "warn",
|
|
198
|
+
detail: `karajan-mcp points at a path that no longer exists: ${registered} — this is the CONNECTION_CLOSED cause`,
|
|
199
|
+
fix: `Re-point karajan-mcp to ${correct} (or remove the entry) in ~/.claude.json`,
|
|
200
|
+
extra: { claudeJsonPath, correct },
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
async remediate({ extra }) {
|
|
204
|
+
try {
|
|
205
|
+
const config = JSON.parse(await fs.readFile(extra.claudeJsonPath, "utf8"));
|
|
206
|
+
const action = repairKarajanMcpPath(config, extra.correct, existsSync);
|
|
207
|
+
if (!action) return { fixed: false, detail: "karajan-mcp entry vanished before repair" };
|
|
208
|
+
await fs.writeFile(extra.claudeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
209
|
+
return {
|
|
210
|
+
fixed: true,
|
|
211
|
+
detail: action === "repointed" ? `Re-pointed karajan-mcp to ${extra.correct}` : "Removed the dead karajan-mcp entry",
|
|
212
|
+
};
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { fixed: false, detail: `Failed to repair ~/.claude.json: ${err.message}` };
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
135
220
|
/**
|
|
136
221
|
* Codex config (~/.codex/config.toml) validity.
|
|
137
222
|
*/
|
|
@@ -211,6 +296,7 @@ export function getConfigFileChecks() {
|
|
|
211
296
|
createReviewRulesCheck(),
|
|
212
297
|
createCoderRulesCheck(),
|
|
213
298
|
createClaudeConfigCheck(),
|
|
299
|
+
createStaleMcpPathCheck(),
|
|
214
300
|
createCodexConfigCheck(),
|
|
215
301
|
createKjConfigYamlCheck(),
|
|
216
302
|
];
|
package/src/checks/method.js
CHANGED
|
@@ -39,16 +39,44 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
39
39
|
|
|
40
40
|
const verdicts = recentVerdicts(projectDir, sample);
|
|
41
41
|
const stamped = verdicts.filter((v) => v.workspace);
|
|
42
|
+
const sonar = { proved: 0, docsOnly: 0, granted: 0, unproved: 0 };
|
|
43
|
+
for (const v of verdicts) {
|
|
44
|
+
const kind = sonarProof(v);
|
|
45
|
+
if (kind) sonar[kind] += 1;
|
|
46
|
+
}
|
|
42
47
|
|
|
43
48
|
return {
|
|
44
49
|
commits: { total: subjects.length, withCard: subjects.filter((s) => CARD_REF_RE.test(s)).length },
|
|
45
50
|
verdicts: { total: verdicts.length, stamped: stamped.length, root: stamped.filter((v) => v.workspace === "root").length },
|
|
46
51
|
testless: { sampled: blocks.length, offenders },
|
|
52
|
+
sonar,
|
|
47
53
|
};
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/**
|
|
57
|
+
* KJC-TSK-0838 (ADR 2026-09-13): what a verdict's sonar block proves —
|
|
58
|
+
* "proved" (ran and covered every source), "docsOnly", "granted" (a human
|
|
59
|
+
* lifted the rule) or "unproved" (an approved verdict for code with no
|
|
60
|
+
* analysis, a skipped pipeline stage, or a source the scan never saw).
|
|
61
|
+
* A verdict without a block predates the ADR and counts as nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function sonarProof(v) {
|
|
64
|
+
const s = v?.sonar;
|
|
65
|
+
if (!s || v.verdict !== "approved") return null;
|
|
66
|
+
// A pipeline stamp has no mode: the stage ran, or it did not.
|
|
67
|
+
if (s.source === "pipeline") return s.ran ? "proved" : "unproved";
|
|
68
|
+
// A block without a mode was written before the requirement existed
|
|
69
|
+
// (the block landed one PR before fail-closed): not retroactive either.
|
|
70
|
+
if (!s.mode) return null;
|
|
71
|
+
if (s.mode === "docs-only") return "docsOnly";
|
|
72
|
+
if (s.mode === "granted") return "granted";
|
|
73
|
+
if (s.ran && (s.uncovered || []).length === 0) return "proved";
|
|
74
|
+
return "unproved";
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export function formatMethodStats(s) {
|
|
51
|
-
|
|
78
|
+
const sonar = s.sonar ? ` · sonar proof: ${s.sonar.proved} proved, ${s.sonar.docsOnly} docs-only, ${s.sonar.granted} granted, ${s.sonar.unproved} unproved` : "";
|
|
79
|
+
return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}${sonar}`;
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
function createMethodCheck() {
|
|
@@ -59,6 +87,11 @@ function createMethodCheck() {
|
|
|
59
87
|
async detect({ config = {}, projectDir = process.cwd(), run = runCommand } = {}) {
|
|
60
88
|
const stats = await collectMethodStats({ projectDir, run, sample: config.method_gates?.report_sample || 20 });
|
|
61
89
|
const detail = formatMethodStats(stats);
|
|
90
|
+
// KJC-TSK-0838: an approved verdict for code without sonar proof is the
|
|
91
|
+
// method in red, not a trend — the gate should have refused it.
|
|
92
|
+
if (stats.sonar.unproved > 0) {
|
|
93
|
+
return { ok: false, severity: "fail", detail: `${stats.sonar.unproved} approved verdict(s) without sonar proof — ${detail}` };
|
|
94
|
+
}
|
|
62
95
|
const drought = stats.commits.total >= 5 && stats.commits.withCard / stats.commits.total < 0.5;
|
|
63
96
|
if (drought) {
|
|
64
97
|
return { ok: false, severity: "warn", detail: `most recent commits carry no card reference — ${detail}` };
|
package/src/checks/sonar.js
CHANGED
|
@@ -11,11 +11,11 @@ import { isSonarReachable } from "../sonar/manager.js";
|
|
|
11
11
|
import { STRATEGY } from "./types.js";
|
|
12
12
|
import { withDocLink } from "../utils/doc-links.js";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
14
|
+
/** Which config switch turns Sonar off, or null when none does. */
|
|
15
|
+
function disabledSwitch(config) {
|
|
16
|
+
if (config.sonarqube?.enabled === false) return "sonarqube.enabled: false";
|
|
17
|
+
if (config.review_gate?.sonar === false) return "review_gate.sonar: false";
|
|
18
|
+
return null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function sonarHost(config) {
|
|
@@ -33,8 +33,17 @@ function createSonarStatusCheck() {
|
|
|
33
33
|
strategy: STRATEGY.NONE,
|
|
34
34
|
async detect({ config }) {
|
|
35
35
|
const host = sonarHost(config);
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// KJC-TSK-0838 (ADR 2026-09-13): Sonar switched off is a DEFECT, not a
|
|
37
|
+
// preference — the commit gate rejects code diffs without a sonar
|
|
38
|
+
// proof, so this config only hurts. Only docs-only diffs are exempt.
|
|
39
|
+
const off = disabledSwitch(config);
|
|
40
|
+
if (off) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
severity: "fail",
|
|
44
|
+
detail: `Disabled in config (${off}) — Sonar is mandatory for code; the commit gate rejects code diffs without a sonar proof`,
|
|
45
|
+
fix: withDocLink(`Set sonarqube.enabled: true and drop review_gate.sonar: false; a laptop without Docker runs 'kj sonar start'. Only a human grant (kj policy grant --rule method.sonar.code) lifts the gate.`, "sonar_docker"),
|
|
46
|
+
};
|
|
38
47
|
}
|
|
39
48
|
let reachable;
|
|
40
49
|
try {
|
|
@@ -234,12 +234,15 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
234
234
|
.option("--check", "Verify the recorded verdict matches the staged diff (exit 0/1, hook-friendly)")
|
|
235
235
|
.option("--range <range>", "Review a git range (e.g. main..HEAD) instead of the staged diff")
|
|
236
236
|
.option("--install-gate", "Enable the pre-commit review gate for this project (creates .karajan/review-gate)")
|
|
237
|
+
.option("--prune", "Garbage-collect verdicts in .karajan/reviews older than the TTL (they age out as diffs change)")
|
|
238
|
+
.option("--prune-days <n>", "TTL in days for --prune (default: 14)")
|
|
239
|
+
.option("--dry-run", "With --prune: report what would be removed without deleting")
|
|
237
240
|
.option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
|
|
238
241
|
.action(async (task, flags) => {
|
|
239
242
|
await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
|
|
240
243
|
// ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
|
|
241
244
|
// the exact diff so the pre-commit hook can enforce cross-AI review.
|
|
242
|
-
if (flags.staged || flags.check || flags.range || flags.installGate) {
|
|
245
|
+
if (flags.staged || flags.check || flags.range || flags.installGate || flags.prune) {
|
|
243
246
|
await reviewGateCommand({ config, logger, flags: { ...flags, task } });
|
|
244
247
|
return;
|
|
245
248
|
}
|
package/src/commands/identity.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* no session and no flags → exit 1.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { enrollPhone } from "../harden/phone-sign.js";
|
|
11
|
+
import { addSigner, enrollPhone } from "../harden/phone-sign.js";
|
|
12
12
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
13
13
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
14
14
|
import { compareIdentity } from "../identity/compare.js";
|
|
@@ -27,12 +27,15 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
27
27
|
// KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
|
|
28
28
|
if (action === "enroll-phone") {
|
|
29
29
|
try {
|
|
30
|
+
// KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
|
|
31
|
+
// base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
|
|
32
|
+
addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
|
|
30
33
|
enrollPhone(flags.publicKeyBase64, { home: deps.home });
|
|
31
34
|
} catch (err) {
|
|
32
35
|
log(`kj identity enroll-phone: ${err.message}`);
|
|
33
36
|
return 1;
|
|
34
37
|
}
|
|
35
|
-
log("Móvil enrolado: la clave pública
|
|
38
|
+
log("Móvil enrolado: la clave pública se añadió al padrón .karajan/supervisor-signers.json (commítealo) y a ~/.karajan/supervisor-phone.json. Sellar el supervisor pedirá la firma de un móvil del padrón.");
|
|
36
39
|
return 0;
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
|
-
import { checkVerdict, diffHash } from "../review/verdict-store.js";
|
|
11
|
+
import { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
|
|
12
12
|
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
|
+
import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
|
|
16
17
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
17
18
|
import { checkCardFirst } from "../review/card-first.js";
|
|
18
19
|
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
@@ -53,6 +54,14 @@ async function rawDiff(range, extraArgs = []) {
|
|
|
53
54
|
return res.stdout;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// KJC-TSK-0838: a grant is the ONLY way past the sonar requirement, so it is
|
|
58
|
+
// said with who gave it, its scope and until when.
|
|
59
|
+
const formatSonarGrant = (g) => {
|
|
60
|
+
const scope = g.origin === "global" ? " GLOBAL" : "";
|
|
61
|
+
const who = g.who?.git ?? "?";
|
|
62
|
+
return `⚠ sonar requirement lifted by a HUMAN grant [${SONAR_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
|
|
63
|
+
};
|
|
64
|
+
|
|
56
65
|
// KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
|
|
57
66
|
// (concedido para toda la máquina) no pasa por uno del proyecto. El texto
|
|
58
67
|
// de las de proyecto queda EXACTAMENTE como estaba.
|
|
@@ -144,6 +153,21 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
144
153
|
return { installed: true };
|
|
145
154
|
}
|
|
146
155
|
|
|
156
|
+
// KJC-BUG-0173: manual GC of the verdict store. Opportunistic pruning runs
|
|
157
|
+
// on every saved verdict; this lets a human sweep on demand, with --dry-run
|
|
158
|
+
// to see what would go without deleting.
|
|
159
|
+
if (flags.prune) {
|
|
160
|
+
const dryRun = Boolean(flags.dryRun || flags.checkOnly);
|
|
161
|
+
const days = Number(flags.pruneDays);
|
|
162
|
+
const res = await pruneVerdicts({ projectDir, dryRun, ...(Number.isFinite(days) && days > 0 ? { maxAgeDays: days } : {}) });
|
|
163
|
+
console.log(
|
|
164
|
+
dryRun
|
|
165
|
+
? `kj review --prune (dry run): ${res.expired.length} of ${res.scanned} verdict(s) older than ${res.maxAgeDays}d would be removed`
|
|
166
|
+
: `kj review --prune: removed ${res.removed} verdict(s) older than ${res.maxAgeDays}d (${res.scanned - res.removed} kept)`,
|
|
167
|
+
);
|
|
168
|
+
return { pruned: res.removed, dryRun };
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
const diff = await rawDiff(flags.range);
|
|
148
172
|
|
|
149
173
|
const card = await enforceCardFirst({ config, projectDir });
|
|
@@ -312,7 +336,25 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
312
336
|
return { ok: true, merge: true, reason: "pure merge commit (MERGE_HEAD, empty staged diff)" };
|
|
313
337
|
}
|
|
314
338
|
}
|
|
315
|
-
|
|
339
|
+
let res = await checkVerdict(projectDir, diff);
|
|
340
|
+
// KJC-TSK-0838: a reviewed diff with code enters only if the verdict's
|
|
341
|
+
// sonar block proves the analysis ran and covered every staged source.
|
|
342
|
+
// The headless pipeline stamps its verdict after its own sonar stage and
|
|
343
|
+
// carries no block yet (step 3 of the card) — said, never assumed silent.
|
|
344
|
+
if (res.ok && res.verdict.host === "kj-pipeline") {
|
|
345
|
+
// The pipeline scans the whole project (no per-file proof yet), so its
|
|
346
|
+
// stamp must at least show the stage RAN; no block or a skipped stage
|
|
347
|
+
// is refused like any other unanalysed code.
|
|
348
|
+
const stage = res.verdict.sonar;
|
|
349
|
+
if (!stage?.ran) {
|
|
350
|
+
const why = stage ? `the pipeline's sonar stage did not run: ${stage.reason || "unknown reason"}` : "the pipeline verdict carries no sonar block";
|
|
351
|
+
res = { ok: false, verdict: res.verdict, reason: `Sonar is mandatory for code — ${why}` };
|
|
352
|
+
}
|
|
353
|
+
} else if (res.ok) {
|
|
354
|
+
const req = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
|
|
355
|
+
if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
|
|
356
|
+
else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
|
|
357
|
+
}
|
|
316
358
|
console.log(res.ok
|
|
317
359
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
318
360
|
: `✗ ${res.reason}`);
|
|
@@ -329,6 +371,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
329
371
|
// without spending reviewer tokens; the rest travel with the task so
|
|
330
372
|
// the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
|
|
331
373
|
let task = flags.task;
|
|
374
|
+
// KJC-TSK-0838: what sonar saw travels INSIDE the verdict, bound to the
|
|
375
|
+
// diff hash — a reviewed diff and a reviewed AND analysed diff must be
|
|
376
|
+
// distinguishable by --check and by the method report.
|
|
377
|
+
let sonarRecord = { ran: false, reason: "--no-sonar: the pre-gate was skipped by flag" };
|
|
332
378
|
if (flags.sonar !== false) {
|
|
333
379
|
// KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
|
|
334
380
|
const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
|
|
@@ -341,7 +387,15 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
341
387
|
console.log(chosen
|
|
342
388
|
? `⚠ sonar pre-gate skipped: ${pre.reason}`
|
|
343
389
|
: `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
|
|
390
|
+
sonarRecord = { ran: false, reason: pre.reason };
|
|
344
391
|
} else {
|
|
392
|
+
sonarRecord = {
|
|
393
|
+
ran: true, projectKey: pre.projectKey, covered: pre.covered ?? [], uncovered: pre.uncovered ?? [],
|
|
394
|
+
blocking: pre.blocking.length, advisory: pre.advisory.length,
|
|
395
|
+
};
|
|
396
|
+
if (sonarRecord.uncovered.length > 0) {
|
|
397
|
+
console.log(`⚠ sonar coverage: ${sonarRecord.uncovered.length} staged source(s) were NOT in the analysis (${sonarRecord.uncovered.slice(0, 5).join(", ")}${sonarRecord.uncovered.length > 5 ? "…" : ""}) — the scan did not see them`);
|
|
398
|
+
}
|
|
345
399
|
const found = [...pre.blocking, ...pre.advisory];
|
|
346
400
|
if (found.length > 0) {
|
|
347
401
|
console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
|
|
@@ -366,6 +420,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
366
420
|
}
|
|
367
421
|
}
|
|
368
422
|
|
|
423
|
+
// KJC-TSK-0838: fail-CLOSED for code. Sonar disabled, down, skipped by
|
|
424
|
+
// flag, or blind to a staged source — the diff does not reach the reviewer.
|
|
425
|
+
// Docs-only diffs pass; a live human grant on the rule is the only escape.
|
|
426
|
+
const sonarReq = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: sonarRecord, standingExceptions: std.standing });
|
|
427
|
+
if (!sonarReq.ok) {
|
|
428
|
+
console.log(`✗ ${sonarReq.reason}`);
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
return { verdict: "rejected", reviewer: "sonar", issues: [{ severity: "high", file: undefined, description: sonarReq.reason }] };
|
|
431
|
+
}
|
|
432
|
+
if (sonarReq.mode === "granted") console.log(formatSonarGrant(sonarReq.grant));
|
|
433
|
+
// The verdict says on what grounds the requirement was met (proof, docs-only,
|
|
434
|
+
// human grant) — the method report reads it back.
|
|
435
|
+
sonarRecord.mode = sonarReq.mode;
|
|
436
|
+
|
|
369
437
|
// MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
|
|
370
438
|
// SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
|
|
371
439
|
// el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
|
|
@@ -388,7 +456,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
388
456
|
}
|
|
389
457
|
}
|
|
390
458
|
|
|
391
|
-
const record = await runOneShotReview({ diff, task, config, logger, projectDir });
|
|
459
|
+
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord });
|
|
392
460
|
printVerdict(record);
|
|
393
461
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
394
462
|
return record;
|
package/src/git/automation.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { addCheckpoint } from "../session/store.js";
|
|
7
|
-
import { stampStagedVerdict } from "../review/verdict-store.js";
|
|
7
|
+
import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
|
|
8
8
|
import {
|
|
9
9
|
ensureGitRepo,
|
|
10
10
|
currentBranch,
|
|
@@ -277,6 +277,8 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
277
277
|
projectDir: config?.projectDir || process.cwd(),
|
|
278
278
|
reviewer: config?.reviewer || "pipeline-reviewer",
|
|
279
279
|
summary: `kj run session ${session?.id || ""}: reviewer approved`.trim(),
|
|
280
|
+
// KJC-TSK-0838: the sonar stage result travels with the stamp.
|
|
281
|
+
sonar: pipelineSonarBlock(stageResults?.sonar),
|
|
280
282
|
}),
|
|
281
283
|
});
|
|
282
284
|
committed = commitResult.committed;
|
|
@@ -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 } from "./hook-templates.js";
|
|
15
|
+
import { hookBody, PROFILE_HOOKS, SHEBANG, toPortableHooksDir } from "./hook-templates.js";
|
|
16
16
|
|
|
17
17
|
const BLOCK_VERSION = 1;
|
|
18
18
|
const HOOKS_DIR = join(".karajan", "hooks");
|
|
@@ -67,7 +67,10 @@ export async function installHooks({
|
|
|
67
67
|
const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
|
|
68
68
|
const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
|
|
69
69
|
if (rawGlobal && rawGlobal !== HOOKS_DIR) {
|
|
70
|
-
|
|
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);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
const absHooksDir = join(projectDir, HOOKS_DIR);
|
|
@@ -27,16 +27,24 @@ export const PROFILE_HOOKS = {
|
|
|
27
27
|
// hooks dir, silently disabling personal guards. When a previous global dir
|
|
28
28
|
// is known, every generated hook ends by chaining its namesake there —
|
|
29
29
|
// guarded, so machines without it are unaffected.
|
|
30
|
+
// KJC-BUG-0161 / 0169 (ADR 0009): a committed hook must not bake a machine's
|
|
31
|
+
// absolute home path — a dir under the home resolves through $HOME so the same
|
|
32
|
+
// generated content is valid on every machine and its provenance stays
|
|
33
|
+
// verifiable IN CI (a different home), with no local filesystem layout leaking
|
|
34
|
+
// into a public repo. Normalization happens ONCE, at generation, on the human's
|
|
35
|
+
// machine; the stored/rendered value is already portable, so a render on any
|
|
36
|
+
// other home (CI) reproduces identical bytes. `home` is injectable for tests.
|
|
37
|
+
export function toPortableHooksDir(dir, home = homedir()) {
|
|
38
|
+
if (!dir) return dir;
|
|
39
|
+
if (dir.startsWith("$HOME")) return dir;
|
|
40
|
+
if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
|
|
41
|
+
if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
function chainToGlobal(hook, globalHooksDir) {
|
|
31
46
|
if (!globalHooksDir) return [];
|
|
32
|
-
|
|
33
|
-
// absolute home path — resolve through $HOME at runtime so the same
|
|
34
|
-
// generated content is valid on every machine and its provenance stays
|
|
35
|
-
// verifiable (and no local filesystem layout leaks into a public repo).
|
|
36
|
-
const home = homedir();
|
|
37
|
-
const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
|
|
38
|
-
? `$HOME${globalHooksDir.slice(home.length)}`
|
|
39
|
-
: globalHooksDir;
|
|
47
|
+
const dir = toPortableHooksDir(globalHooksDir);
|
|
40
48
|
return [
|
|
41
49
|
"# Chain the machine's previous global hook (kj harden keeps it active).",
|
|
42
50
|
`if [ -x "${dir}/${hook}" ]; then`,
|
|
@@ -114,12 +122,19 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
114
122
|
"# the same precise pattern (tool MENTIONS stay legal; attribution not).",
|
|
115
123
|
"# The guard's own definition files are excluded by literal path — they",
|
|
116
124
|
"# CONTAIN the pattern; excluding a path that does not exist is harmless.",
|
|
117
|
-
|
|
125
|
+
"# KJC-BUG-0170: the generated supervisor hooks (.karajan/hooks/pre-commit,",
|
|
126
|
+
"# commit-msg) also CONTAIN the pattern by design — the bootstrap commit",
|
|
127
|
+
"# that versions them must not self-detect in the consumer repo.",
|
|
128
|
+
`if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
|
|
118
129
|
" echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
|
|
119
130
|
"fi",
|
|
120
131
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
|
121
132
|
"# a staged diff only enters with a recorded cross-AI approved verdict.",
|
|
122
|
-
"
|
|
133
|
+
"# KJC-BUG-0165: the gate is active once the marker is COMMITTED (HEAD),",
|
|
134
|
+
"# not merely present — so the bootstrap commit that INTRODUCES it is",
|
|
135
|
+
"# exempt (no gate can exist before it does), and it cannot be bypassed",
|
|
136
|
+
"# by deleting the working-tree file.",
|
|
137
|
+
"if git cat-file -e HEAD:.karajan/review-gate 2>/dev/null; then",
|
|
123
138
|
" if ! command -v kj >/dev/null 2>&1; then",
|
|
124
139
|
" echo 'kj: review gate is enabled but kj is not installed — see karajancode.com/docs/getting-started/installation'; exit 1",
|
|
125
140
|
" fi",
|