karajan-code 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/ai-trash/src/install.js +22 -1
- package/scripts/verify-pack.mjs +28 -6
- package/src/agents/agy-agent.js +1 -0
- package/src/agents/base-agent.js +1 -1
- package/src/agents/claude-agent.js +3 -2
- package/src/agents/codex-agent.js +3 -1
- package/src/agents/kimi-agent.js +1 -0
- package/src/checks/release-check.js +10 -17
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-pipeline.js +26 -4
- package/src/commands/env.js +1 -1
- package/src/commands/harden.js +4 -1
- package/src/commands/rag.js +4 -4
- package/src/commands/review-gate.js +3 -0
- package/src/commands/tournament.js +85 -0
- package/src/config/defaults.js +3 -1
- package/src/environment/board-select.js +2 -1
- package/src/harden/config-engine.js +27 -0
- package/src/harden/config-templates.js +8 -3
- package/src/harden/hook-templates.js +2 -2
- package/src/harden/workflow-engine.js +11 -1
- package/src/harden/workflow-templates.js +41 -22
- package/src/orchestrator/direct-actions.js +12 -1
- package/src/prompts/diff-clip.js +27 -0
- package/src/prompts/reviewer.js +8 -2
- package/src/prompts/split-signal.js +82 -0
- package/src/review/card-first.js +19 -5
- package/src/review/card-verify.js +83 -0
- package/src/roles/reviewer-role.js +9 -8
- package/src/sonar/scanner.js +11 -7
- package/src/tournament/crown.js +88 -0
- package/src/tournament/judge.js +150 -0
- package/src/tournament/run.js +142 -0
- package/src/tournament/scoreboard.js +124 -0
- package/src/utils/update-check.js +2 -1
package/package.json
CHANGED
|
@@ -8,7 +8,14 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
11
|
-
|
|
11
|
+
// KJC-BUG-0095: the bare command fails SILENTLY when the bin drops off PATH
|
|
12
|
+
// (nvm switch, global reinstall) — the anti-delete net goes down without
|
|
13
|
+
// notice. The installed line fails CLOSED: a missing bin blocks the tool
|
|
14
|
+
// call (exit 2) naming the net and the remedy, never a quiet pass-through.
|
|
15
|
+
export const LEGACY_HOOK_COMMAND = "kj-trash hook";
|
|
16
|
+
export const HOOK_COMMAND =
|
|
17
|
+
"if command -v kj-trash >/dev/null 2>&1; then exec kj-trash hook; fi; " +
|
|
18
|
+
"echo 'kj-trash: bin not on PATH — the anti-delete net is DOWN. Fix: npm i -g karajan-code (or remove this hook from ~/.claude/settings.json).' >&2; exit 2";
|
|
12
19
|
const MATCHER = "Bash";
|
|
13
20
|
|
|
14
21
|
async function readSettings(path) {
|
|
@@ -34,6 +41,20 @@ export function patchSettings(settings, command = HOOK_COMMAND, matcher = MATCHE
|
|
|
34
41
|
entry = { matcher, hooks: [{ type: "command", command }] };
|
|
35
42
|
preToolUse.push(entry);
|
|
36
43
|
mutated = true;
|
|
44
|
+
} else if (hasHookCommand(entry, LEGACY_HOOK_COMMAND) && command !== LEGACY_HOOK_COMMAND) {
|
|
45
|
+
// KJC-BUG-0095 migration: upgrade the silent legacy line in place —
|
|
46
|
+
// deduped (reviewer catch): an entry already carrying the new line
|
|
47
|
+
// must end up with a single copy, not legacy-turned-duplicate.
|
|
48
|
+
let seen = false;
|
|
49
|
+
entry.hooks = entry.hooks
|
|
50
|
+
.map((h) => (h.type === "command" && h.command === LEGACY_HOOK_COMMAND ? { ...h, command } : h))
|
|
51
|
+
.filter((h) => {
|
|
52
|
+
if (!(h.type === "command" && h.command === command)) return true;
|
|
53
|
+
if (seen) return false;
|
|
54
|
+
seen = true;
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
mutated = true;
|
|
37
58
|
} else if (!hasHookCommand(entry, command)) {
|
|
38
59
|
entry.hooks = [...(entry.hooks ?? []), { type: "command", command }];
|
|
39
60
|
mutated = true;
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -208,7 +208,17 @@ try {
|
|
|
208
208
|
// better-sqlite3's native build by default, so DB-backed commands need
|
|
209
209
|
// `pnpm approve-builds`; that's surfaced as a doctor warning, KJC-BUG-0092 —
|
|
210
210
|
// not a gate failure, since it's inherent to pnpm.) Auto-skips without pnpm.
|
|
211
|
-
|
|
211
|
+
// KJC-BUG-0135: `npm run` leaks npm_config_* into the child env, and pnpm
|
|
212
|
+
// maps those variables onto its own config — which can re-anchor it to THIS
|
|
213
|
+
// repo (7-aug incident: the repo's node_modules converted to pnpm layout,
|
|
214
|
+
// native bindings wiped, and simple-git-hooks' hook overwrote
|
|
215
|
+
// .karajan/hooks). The smoke runs with a scrubbed env and a pinned --dir,
|
|
216
|
+
// and the tripwire below turns any host contamination into a loud gate
|
|
217
|
+
// failure instead of a silent green.
|
|
218
|
+
const pnpmEnv = Object.fromEntries(Object.entries(childEnv).filter(([k]) => !/^npm_config_/i.test(k)));
|
|
219
|
+
const HOST_MARKERS = ["pnpm-lock.yaml", "pnpm-workspace.yaml", path.join("node_modules", ".pnpm")];
|
|
220
|
+
const hostMarkersBefore = new Set(HOST_MARKERS.filter((m) => fs.existsSync(path.join(repoRoot, m))));
|
|
221
|
+
const hasPnpm = spawnSync("pnpm", ["--version"], { encoding: "utf8", env: pnpmEnv }).status === 0;
|
|
212
222
|
if (!hasPnpm) {
|
|
213
223
|
console.log("verify-pack: pnpm not installed — skipping pnpm smoke (npm path covered above) ⚠");
|
|
214
224
|
} else {
|
|
@@ -224,11 +234,11 @@ try {
|
|
|
224
234
|
// versions (supply-chain protection), so right after publishing
|
|
225
235
|
// karajan-core it silently resolves an OLD one and this smoke fails on
|
|
226
236
|
// missing subpaths. The gate verifies packaging, not release-age policy.
|
|
227
|
-
spawnSync(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
cwd: pnpmTmp,
|
|
231
|
-
|
|
237
|
+
spawnSync(
|
|
238
|
+
"pnpm",
|
|
239
|
+
["add", tgzPath, "--dir", pnpmTmp, "--ignore-workspace", "--store-dir", path.join(pnpmTmp, ".store"), "--config.minimum-release-age=0"],
|
|
240
|
+
{ encoding: "utf8", env: pnpmEnv, cwd: pnpmTmp },
|
|
241
|
+
);
|
|
232
242
|
const pnpmBin = path.join(pnpmTmp, "node_modules", ".bin", "kj");
|
|
233
243
|
if (!fs.existsSync(pnpmBin)) fail(`kj binary missing after pnpm install: ${pnpmBin}`);
|
|
234
244
|
let pnpmVersion;
|
|
@@ -242,6 +252,18 @@ try {
|
|
|
242
252
|
}
|
|
243
253
|
console.log(`verify-pack: pnpm install + kj --version → ${pnpmVersion} ✓ (DB build needs \`pnpm approve-builds\`)`);
|
|
244
254
|
}
|
|
255
|
+
// Tripwire (KJC-BUG-0135): the smoke must NEVER touch the host repo. If a
|
|
256
|
+
// pnpm artifact appeared at the repo root during this run, the gate fails
|
|
257
|
+
// naming it — never a green over a contaminated tree.
|
|
258
|
+
const contaminated = HOST_MARKERS.filter(
|
|
259
|
+
(m) => !hostMarkersBefore.has(m) && fs.existsSync(path.join(repoRoot, m)),
|
|
260
|
+
);
|
|
261
|
+
if (contaminated.length > 0) {
|
|
262
|
+
fail(
|
|
263
|
+
`pnpm smoke contaminated the host repo: ${contaminated.join(", ")} appeared at the repo root`,
|
|
264
|
+
"remove the listed files and run `npm ci` to restore the npm layout; then verify .karajan/hooks with `git status`",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
245
267
|
|
|
246
268
|
console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
|
|
247
269
|
} finally {
|
package/src/agents/agy-agent.js
CHANGED
|
@@ -37,6 +37,7 @@ export class AgyAgent extends BaseAgent {
|
|
|
37
37
|
onOutput: task.onOutput,
|
|
38
38
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
39
39
|
timeout: task.timeoutMs,
|
|
40
|
+
cwd: task.cwd,
|
|
40
41
|
});
|
|
41
42
|
const parsed = extractAgyOutput(res.stdout);
|
|
42
43
|
const ok = res.exitCode === 0 && parsed?.status === "SUCCESS";
|
package/src/agents/base-agent.js
CHANGED
|
@@ -60,7 +60,7 @@ export class BaseAgent {
|
|
|
60
60
|
// a PATH is a partial overlay and merges over the inherited env first,
|
|
61
61
|
// so an overlay caller can never cost the child PATH/HOME.
|
|
62
62
|
const complete = options.env && ("PATH" in options.env || "Path" in options.env);
|
|
63
|
-
const base = complete ? options.env : { ...process.env, ...
|
|
63
|
+
const base = complete ? options.env : { ...process.env, ...options.env };
|
|
64
64
|
options = {
|
|
65
65
|
...options,
|
|
66
66
|
env: buildAgentEnv(base, {
|
|
@@ -374,7 +374,8 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
374
374
|
onOutput: streamFilter,
|
|
375
375
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
376
376
|
timeout: task.timeoutMs,
|
|
377
|
-
env: task.env
|
|
377
|
+
env: task.env,
|
|
378
|
+
cwd: task.cwd
|
|
378
379
|
}));
|
|
379
380
|
const raw = pickOutput(res);
|
|
380
381
|
const output = extractTextFromStreamJson(raw);
|
|
@@ -384,7 +385,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
384
385
|
|
|
385
386
|
// Without streaming, use json output to get structured response via stderr
|
|
386
387
|
args.push("--output-format", "json");
|
|
387
|
-
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: task.env }));
|
|
388
|
+
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: task.env, cwd: task.cwd }));
|
|
388
389
|
const raw = pickOutput(res);
|
|
389
390
|
const output = extractTextFromStreamJson(raw);
|
|
390
391
|
const usage = extractUsageFromStreamJson(raw);
|
|
@@ -85,7 +85,9 @@ export class CodexAgent extends BaseAgent {
|
|
|
85
85
|
onOutput: task.onOutput,
|
|
86
86
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
87
87
|
timeout: task.timeoutMs,
|
|
88
|
-
input: task.prompt
|
|
88
|
+
input: task.prompt,
|
|
89
|
+
// TOR-A (KJC-TSK-0723): tournament lanes run the coder INSIDE the lane.
|
|
90
|
+
cwd: task.cwd
|
|
89
91
|
});
|
|
90
92
|
const usage = extractCodexTokens(res.stdout);
|
|
91
93
|
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
package/src/agents/kimi-agent.js
CHANGED
|
@@ -38,6 +38,7 @@ export class KimiAgent extends BaseAgent {
|
|
|
38
38
|
onOutput: task.onOutput,
|
|
39
39
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
40
40
|
timeout: task.timeoutMs,
|
|
41
|
+
cwd: task.cwd,
|
|
41
42
|
});
|
|
42
43
|
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
|
|
43
44
|
}
|
|
@@ -28,9 +28,7 @@ async function genericChecks(projectDir) {
|
|
|
28
28
|
checks.push({ name: "manifest", ok: Boolean(version), detail: version ? `version ${version}` : "package.json has no version" });
|
|
29
29
|
|
|
30
30
|
const clPath = join(projectDir, "CHANGELOG.md");
|
|
31
|
-
if (
|
|
32
|
-
checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
|
|
33
|
-
} else {
|
|
31
|
+
if (existsSync(clPath)) {
|
|
34
32
|
const cl = readFileSync(clPath, "utf8");
|
|
35
33
|
const topVersioned = (cl.match(/^## \[(\d+\.\d+\.\d+)\]/m) || [])[1] || null;
|
|
36
34
|
// Content still sitting under [Unreleased] means unpromoted entries —
|
|
@@ -39,25 +37,20 @@ async function genericChecks(projectDir) {
|
|
|
39
37
|
const unreleasedBody = afterUnreleased.split(/^## \[/m)[0] ?? "";
|
|
40
38
|
const pending = /\S/.test(unreleasedBody);
|
|
41
39
|
const ok = topVersioned === version && !pending;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
? `[Unreleased] still carries content — promote it into [${version}] before releasing`
|
|
49
|
-
: `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`,
|
|
50
|
-
});
|
|
40
|
+
let detail = `top section is [${version}], Unreleased empty`;
|
|
41
|
+
if (pending) detail = `[Unreleased] still carries content — promote it into [${version}] before releasing`;
|
|
42
|
+
else if (!ok) detail = `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`;
|
|
43
|
+
checks.push({ name: "changelog-current", ok, detail });
|
|
44
|
+
} else {
|
|
45
|
+
checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
|
|
51
46
|
}
|
|
52
47
|
|
|
53
48
|
const tagsOut = await runCommand("git", ["-C", projectDir, "tag", "--list", "v[0-9]*"]);
|
|
54
49
|
const tags = (tagsOut.stdout || "").split("\n").map((t) => t.trim().replace(/^v/, "")).filter((t) => /^\d+\.\d+\.\d+$/.test(t));
|
|
55
50
|
const ahead = version ? tags.filter((t) => semverCmp(t, version) > 0) : [];
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
detail: ahead.length ? `tag(s) ahead of the manifest exist: v${ahead.join(", v")}` : (tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`),
|
|
60
|
-
});
|
|
51
|
+
let tagDetail = tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`;
|
|
52
|
+
if (ahead.length > 0) tagDetail = `tag(s) ahead of the manifest exist: v${ahead.join(", v")}`;
|
|
53
|
+
checks.push({ name: "tags", ok: ahead.length === 0, detail: tagDetail });
|
|
61
54
|
return { checks, version, pkg };
|
|
62
55
|
}
|
|
63
56
|
|
|
@@ -27,7 +27,7 @@ export const META_COMMANDS = ["advanced", "help"];
|
|
|
27
27
|
* so a newly-registered command can never silently vanish from `kj advanced`.
|
|
28
28
|
*/
|
|
29
29
|
export const ADVANCED_GROUPS = [
|
|
30
|
-
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan"] },
|
|
30
|
+
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
33
|
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release"] },
|
|
@@ -3,6 +3,8 @@ import { ollamaStartCommand, ollamaStopCommand, ollamaStatusCommand, ollamaPullC
|
|
|
3
3
|
import { configCommand } from "../commands/config.js";
|
|
4
4
|
import { codeCommand } from "../commands/code.js";
|
|
5
5
|
import { reviewCommand } from "../commands/review.js";
|
|
6
|
+
import { tournamentCommand } from "../commands/tournament.js";
|
|
7
|
+
import { resolveTaskInput } from "../utils/task-file.js";
|
|
6
8
|
import { reviewGateCommand, solomonCommand } from "../commands/review-gate.js";
|
|
7
9
|
import { scanCommand } from "../commands/scan.js";
|
|
8
10
|
import { installToolsCommand } from "../commands/install-tools.js";
|
|
@@ -144,7 +146,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
144
146
|
effectiveTask = plan.task;
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
148
149
|
const resolvedTask = await resolveTaskInput({ task: effectiveTask, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
149
150
|
const res = await runCommandHandler({ task: resolvedTask, config, logger, flags });
|
|
150
151
|
// KJC-TSK-0674 (issue #1289): an aborted run (spec-review cancel,
|
|
@@ -163,7 +164,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
163
164
|
.option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
|
|
164
165
|
.action(async (task, flags) => {
|
|
165
166
|
const code = await withConfig(pkgVersion, "autorun", flags, async ({ config, logger }) => {
|
|
166
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
167
167
|
const { autorunCommand } = await import("../commands/autorun.js");
|
|
168
168
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
169
169
|
const res = await autorunCommand({ task: resolvedTask, config, logger, flags });
|
|
@@ -181,7 +181,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
181
181
|
.option("--coder-model <name>")
|
|
182
182
|
.action(async (task, flags) => {
|
|
183
183
|
await withConfig(pkgVersion, "code", flags, async ({ config, logger }) => {
|
|
184
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
185
184
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
186
185
|
await codeCommand({ task: resolvedTask, config, logger });
|
|
187
186
|
});
|
|
@@ -199,6 +198,30 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
199
198
|
});
|
|
200
199
|
});
|
|
201
200
|
|
|
201
|
+
// KJC-TSK-0723 (TOR-A) — the governed tournament: same task, N coders,
|
|
202
|
+
// isolated lanes; the method picks the winner (TOR-B/C), not vibes.
|
|
203
|
+
program
|
|
204
|
+
.command("tournament")
|
|
205
|
+
.description("Fan the SAME task out to N coders in isolated worktree lanes and collect per-lane evidence (diff, suite, log)")
|
|
206
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
207
|
+
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
208
|
+
.option("--coders <csv>", "Comma-separated coder agents (minimum 2), e.g. claude,codex,agy")
|
|
209
|
+
.option("--score <id>", "Score an EXISTING tournament from its artifacts (deterministic, zero LLM)")
|
|
210
|
+
.option("--judge <id>", "Cross-AI judge ranks the finalists (scoreboard in sight; solomon on tie/dispute)")
|
|
211
|
+
.option("--crown <id>", "Commit the judged winner in its lane THROUGH the real review gate")
|
|
212
|
+
.option("--card <ref>", "With --crown: card reference for the commit message (card-first gates)")
|
|
213
|
+
.option("--json", "With --score/--judge/--crown: emit stable JSON")
|
|
214
|
+
.action(async (task, flags) => {
|
|
215
|
+
await withConfig(pkgVersion, "tournament", flags, async ({ config, logger }) => {
|
|
216
|
+
if (flags.score || flags.judge || flags.crown) {
|
|
217
|
+
await tournamentCommand({ task: "", config, logger, flags });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
221
|
+
await tournamentCommand({ task: resolvedTask, config, logger, flags });
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
202
225
|
program
|
|
203
226
|
.command("review")
|
|
204
227
|
.description("Run only reviewer (--staged/--check: v4 cross-AI gate with recorded verdict)")
|
|
@@ -220,7 +243,6 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
220
243
|
await reviewGateCommand({ config, logger, flags: { ...flags, task } });
|
|
221
244
|
return;
|
|
222
245
|
}
|
|
223
|
-
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
224
246
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
225
247
|
await reviewCommand({ task: resolvedTask, config, logger, baseRef: flags.baseRef });
|
|
226
248
|
});
|
package/src/commands/env.js
CHANGED
|
@@ -62,7 +62,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
62
62
|
// The effective backend feeds the render on EVERY non-error path —
|
|
63
63
|
// chosen, declared-in-file or default — so the playbook never falls
|
|
64
64
|
// back to hu-board while the selection said otherwise.
|
|
65
|
-
config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...
|
|
65
|
+
config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...config?.board, name: sel.boardName } : config?.board };
|
|
66
66
|
} catch (err) {
|
|
67
67
|
console.log(`⚠ board selection failed (${err.message}) — continuing with the configured default`);
|
|
68
68
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -184,7 +184,10 @@ export async function hardenCommand({
|
|
|
184
184
|
const verb = dryRun ? "would install" : "installed";
|
|
185
185
|
logger.info?.(`kj harden (${profile}) — ${verb} ${result.hooks.length} hook(s) → ${result.hooksPath}`);
|
|
186
186
|
for (const h of result.hooks) logger.info?.(` • ${h.hook}: ${h.action}`);
|
|
187
|
-
for (const c of out.configs)
|
|
187
|
+
for (const c of out.configs) {
|
|
188
|
+
const note = c.note ? ` (${c.note})` : "";
|
|
189
|
+
logger.info?.(` • ${c.file}: ${c.action}${note}`);
|
|
190
|
+
}
|
|
188
191
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
189
192
|
for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
|
|
190
193
|
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
package/src/commands/rag.js
CHANGED
|
@@ -98,7 +98,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
98
98
|
// KJC-TSK-0697 — `--library` targets the distilled-canon collection:
|
|
99
99
|
// its own project namespace plus the kind filter.
|
|
100
100
|
const library = Boolean(flags.library);
|
|
101
|
-
|
|
101
|
+
let project = flags.project === "all" ? null : (flags.project || detected || null);
|
|
102
|
+
if (library) project = LIBRARY_PROJECT;
|
|
102
103
|
// KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
|
|
103
104
|
// handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
|
|
104
105
|
// so agents (and the `/kj-rag-query` skill from Camino B) have a
|
|
@@ -115,9 +116,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
115
116
|
// Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
|
|
116
117
|
// AND-joined key=value pairs — an OR or parens never parses, so the
|
|
117
118
|
// composed clause cannot leak past the kind filter; it fails loudly.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
: (flags.where || null);
|
|
119
|
+
let where = flags.where || null;
|
|
120
|
+
if (library) where = flags.where ? `kind=library AND ${flags.where}` : "kind=library";
|
|
121
121
|
const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
|
|
122
122
|
const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
|
|
123
123
|
if (flags.json) {
|
|
@@ -26,6 +26,9 @@ async function enforceCardFirst({ config, projectDir }) {
|
|
|
26
26
|
const branch = branchRes.stdout?.trim() || "HEAD";
|
|
27
27
|
const card = await checkCardFirst({ config, projectDir, branch });
|
|
28
28
|
if (card.mode === "warn") console.log(`⚠ card-first: ${card.reason}`);
|
|
29
|
+
// KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
|
|
30
|
+
// branch-ref alone must never read as a tracker-verified pass.
|
|
31
|
+
if (card.note) console.log(`⚠ card-first: ${card.note}`);
|
|
29
32
|
if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
|
|
30
33
|
if (!card.ok) {
|
|
31
34
|
console.log(`✗ card-first gate: ${card.reason}`);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj tournament` (KJC-TSK-0723, TOR-A) — fan the SAME task out to N coders
|
|
3
|
+
* in N isolated worktree lanes and collect the evidence. The deterministic
|
|
4
|
+
* scoreboard (TOR-B) and the governed judgement/merge (TOR-C) build on the
|
|
5
|
+
* artifacts this command leaves under .kj/tournaments/<id>/.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { runTournament } from "../tournament/run.js";
|
|
9
|
+
import { scoreTournament } from "../tournament/scoreboard.js";
|
|
10
|
+
import { judgeTournament } from "../tournament/judge.js";
|
|
11
|
+
import { crownWinner } from "../tournament/crown.js";
|
|
12
|
+
|
|
13
|
+
// The id feeds a path — same traversal class codex caught in TOR-A's coder
|
|
14
|
+
// names: strict slug, no separators, before any filesystem use.
|
|
15
|
+
function tournamentDirFor(config, rawId) {
|
|
16
|
+
const id = String(rawId);
|
|
17
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(id)) {
|
|
18
|
+
throw new Error(`tournament: id inválido "${id}" — usa el id que imprimió el torneo (p. ej. tor-abc123)`);
|
|
19
|
+
}
|
|
20
|
+
return path.join(config?.projectDir || process.cwd(), ".kj", "tournaments", id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function tournamentCommand({
|
|
24
|
+
task, config, logger = console, flags = {},
|
|
25
|
+
runTournamentFn = runTournament, scoreTournamentFn = scoreTournament,
|
|
26
|
+
judgeTournamentFn = judgeTournament, crownWinnerFn = crownWinner,
|
|
27
|
+
}) {
|
|
28
|
+
// TOR-C: `--judge <id>` ranks the finalists; `--crown <id>` commits the
|
|
29
|
+
// winner through the real gate in its lane.
|
|
30
|
+
if (flags.judge) {
|
|
31
|
+
const judgement = await judgeTournamentFn({ tournamentDir: tournamentDirFor(config, flags.judge), config, logger });
|
|
32
|
+
if (flags.json) logger?.info?.(JSON.stringify(judgement));
|
|
33
|
+
else logger?.info?.(` siguiente: kj tournament --crown ${flags.judge}${judgement.escalated ? " (ganador vía solomon)" : ""}`);
|
|
34
|
+
return judgement;
|
|
35
|
+
}
|
|
36
|
+
if (flags.crown) {
|
|
37
|
+
const crown = await crownWinnerFn({
|
|
38
|
+
tournamentDir: tournamentDirFor(config, flags.crown), config, logger, cardRef: flags.card || null,
|
|
39
|
+
});
|
|
40
|
+
if (flags.json) logger?.info?.(JSON.stringify(crown));
|
|
41
|
+
return crown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// TOR-B: `--score <id>` scores an EXISTING tournament from its artifacts.
|
|
45
|
+
if (flags.score) {
|
|
46
|
+
const tournamentDir = tournamentDirFor(config, flags.score);
|
|
47
|
+
const board = await scoreTournamentFn({ tournamentDir });
|
|
48
|
+
if (flags.json) {
|
|
49
|
+
logger?.info?.(JSON.stringify(board));
|
|
50
|
+
return board;
|
|
51
|
+
}
|
|
52
|
+
logger?.info?.(`scoreboard ${board.id} — regla: ${board.rule}`);
|
|
53
|
+
for (const r of board.rows) {
|
|
54
|
+
if (!r.finalist) {
|
|
55
|
+
logger?.info?.(` ${r.coder.padEnd(10)} eliminado (${r.eliminated_by})`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const mut = typeof r.mutation?.score === "number" ? `${r.mutation.score}%` : "n/a";
|
|
59
|
+
logger?.info?.(` ${r.coder.padEnd(10)} FINALISTA loc:${String(r.loc.net).padStart(5)} tests:${r.loc.testsTouched} mutation:${mut}`);
|
|
60
|
+
}
|
|
61
|
+
logger?.info?.(` ranking: ${board.ranking.join(" > ") || "(sin finalistas)"}`);
|
|
62
|
+
return board;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!task || !String(task).trim()) {
|
|
66
|
+
throw new Error("tournament: falta la tarea — kj tournament \"<tarea>\" --coders claude,codex");
|
|
67
|
+
}
|
|
68
|
+
const coders = String(flags.coders || "")
|
|
69
|
+
.split(",")
|
|
70
|
+
.map((c) => c.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
if (coders.length < 2) {
|
|
73
|
+
throw new Error("tournament: hacen falta al menos 2 en --coders — p. ej. --coders claude,codex,agy (con uno no hay torneo)");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const res = await runTournamentFn({ task, coders, config, logger });
|
|
77
|
+
|
|
78
|
+
const completed = res.lanes.filter((l) => l.status === "completed");
|
|
79
|
+
const green = completed.filter((l) => l.suite?.ok);
|
|
80
|
+
logger?.info?.("");
|
|
81
|
+
logger?.info?.(`torneo ${res.id}: ${completed.length}/${res.lanes.length} carriles completados, ${green.length} con suite verde`);
|
|
82
|
+
logger?.info?.(` evidencia: ${res.dir}`);
|
|
83
|
+
logger?.info?.(" siguiente: el scoreboard determinista (TOR-B) puntuará los carriles; de momento compara los diff.patch a mano.");
|
|
84
|
+
return res;
|
|
85
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -66,7 +66,9 @@ const DEFAULTS = {
|
|
|
66
66
|
// through the host agent's MCP/tools, never mirrored by kj. There is NO
|
|
67
67
|
// "none": Karajan does not run without a board.
|
|
68
68
|
state_backend: "hu-board",
|
|
69
|
-
|
|
69
|
+
// verify_cmd (KJC-TSK-0732): user adapter that checks a card ref against
|
|
70
|
+
// the real tracker — prints {exists, live, status} JSON; {ref} substituted.
|
|
71
|
+
board: { name: null, verify_cmd: null },
|
|
70
72
|
// Method gates (KJC-PCS-0068): rules climb from playbook text to
|
|
71
73
|
// deterministic enforcement. card_first: "auto" = block with hu-board
|
|
72
74
|
// (fully verifiable), warn with planning-game/external (presence only);
|
|
@@ -53,6 +53,7 @@ export async function pickStateBackend({ projectDir = process.cwd(), home = os.h
|
|
|
53
53
|
const backend = external ? "external" : value;
|
|
54
54
|
const boardName = external ? value.slice("external:".length) : null;
|
|
55
55
|
await persistStateBackend(projectDir, backend, boardName);
|
|
56
|
-
|
|
56
|
+
const suffix = boardName ? ` (${boardName})` : "";
|
|
57
|
+
logger.info?.(`✓ board: ${backend}${suffix} — persisted in .karajan/kj.config.yml`);
|
|
57
58
|
return { backend, boardName, source: "chosen" };
|
|
58
59
|
}
|
|
@@ -15,6 +15,24 @@ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
|
|
|
15
15
|
|
|
16
16
|
const BLOCK_VERSION = 1;
|
|
17
17
|
|
|
18
|
+
// KJC-BUG-0137 (issue #1357): is the tool a config demands actually installed?
|
|
19
|
+
// Declared dependency or a resolvable bin both count; an unreadable
|
|
20
|
+
// package.json is no evidence of the tool.
|
|
21
|
+
function hasTool(dirs, tool) {
|
|
22
|
+
for (const d of dirs) {
|
|
23
|
+
if (existsSync(join(d, "node_modules", ".bin", tool))) return true;
|
|
24
|
+
const pkgPath = join(d, "package.json");
|
|
25
|
+
if (!existsSync(pkgPath)) continue;
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
28
|
+
if (pkg.devDependencies?.[tool] || pkg.dependencies?.[tool]) return true;
|
|
29
|
+
} catch {
|
|
30
|
+
/* unreadable ⇒ keep looking */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
function writeFile(target, content) {
|
|
19
37
|
mkdirSync(dirname(target), { recursive: true });
|
|
20
38
|
writeFileSync(target, content);
|
|
@@ -47,6 +65,15 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
|
|
|
47
65
|
results.push({ file, action: "covered", by: variant });
|
|
48
66
|
continue;
|
|
49
67
|
}
|
|
68
|
+
// KJC-BUG-0137: no tool ⇒ no config — report the actionable gap instead.
|
|
69
|
+
if (cfg.requires && !hasTool(altDirs, cfg.requires)) {
|
|
70
|
+
results.push({
|
|
71
|
+
file,
|
|
72
|
+
action: "omitted",
|
|
73
|
+
note: `${cfg.requires} is not installed — npm i -D ${cfg.requires}, then re-run kj harden`,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
if (cfg.json) {
|
|
@@ -95,10 +95,15 @@ export const UNIVERSAL_CONFIGS = [
|
|
|
95
95
|
{ file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
|
|
96
96
|
];
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* `json:true` ⇒ seed-only (no comment syntax for a marker). `requires` names
|
|
100
|
+
* the tool the config is for: absent from the project ⇒ the config is omitted
|
|
101
|
+
* with the install command in the report (KJC-BUG-0137) — a config without its
|
|
102
|
+
* tool is decorative, and kj never generates a demand it didn't satisfy.
|
|
103
|
+
*/
|
|
99
104
|
export const JS_CONFIGS = [
|
|
100
|
-
{ file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY },
|
|
101
|
-
{ file: ".prettierrc.json", json: true, body: PRETTIER_BODY },
|
|
105
|
+
{ file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY, requires: "eslint" },
|
|
106
|
+
{ file: ".prettierrc.json", json: true, body: PRETTIER_BODY, requires: "prettier" },
|
|
102
107
|
];
|
|
103
108
|
export const PY_CONFIGS = [{ file: "ruff.toml", blockId: "ruff", style: "hash", body: RUFF_BODY }];
|
|
104
109
|
export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style: "hash", body: GOLANGCI_BODY }];
|
|
@@ -109,7 +109,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
109
109
|
"# POSIX-safe extraction (no grep -o): the ref is the leading",
|
|
110
110
|
"# card-shaped token of the branch's last segment (feat/REF-desc).",
|
|
111
111
|
"seg=$(printf '%s' \"$branch\" | tr '[:upper:]' '[:lower:]'); seg=\"${seg##*/}\"",
|
|
112
|
-
|
|
112
|
+
String.raw`ref=$(printf '%s' "$seg" | sed -nE 's/^([a-z][a-z0-9]{1,9}(-[a-z][a-z0-9]{1,9})*-[0-9]{1,6})(-.*)?$/\1/p')`,
|
|
113
113
|
'header=$(head -n1 "$msg_file")',
|
|
114
114
|
'if [ -n "$ref" ] && [ -n "$header" ]; then',
|
|
115
115
|
' case "$header" in',
|
|
@@ -122,7 +122,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
122
122
|
" stamp=$(printf '%s' \"$ref\" | tr '[:lower:]' '[:upper:]')",
|
|
123
123
|
' new_header="$header ($stamp)"',
|
|
124
124
|
' if [ "${#new_header}" -le 100 ]; then',
|
|
125
|
-
|
|
125
|
+
String.raw` { printf '%s\n' "$new_header"; tail -n +2 "$msg_file"; } > "$msg_file.kjtmp" && mv "$msg_file.kjtmp" "$msg_file"`,
|
|
126
126
|
" fi",
|
|
127
127
|
" fi",
|
|
128
128
|
" ;;",
|
|
@@ -34,6 +34,16 @@ export function detectPackageManager(projectDir) {
|
|
|
34
34
|
return "npm";
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// KJC-BUG-0137: the JS quality workflow only carries a lint step when the
|
|
38
|
+
// project HAS a lint script — and then it enforces (no --if-present).
|
|
39
|
+
function hasLintScript(projectDir) {
|
|
40
|
+
try {
|
|
41
|
+
return Boolean(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).scripts?.lint);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
export function installWorkflows({
|
|
38
48
|
projectDir = process.cwd(),
|
|
39
49
|
language = null,
|
|
@@ -43,7 +53,7 @@ export function installWorkflows({
|
|
|
43
53
|
} = {}) {
|
|
44
54
|
const dir = join(projectDir, WORKFLOWS_DIR);
|
|
45
55
|
const pm = detectPackageManager(projectDir);
|
|
46
|
-
const quality = qualityWorkflowFor(language, pm);
|
|
56
|
+
const quality = qualityWorkflowFor(language, pm, hasLintScript(projectDir));
|
|
47
57
|
const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir), pm });
|
|
48
58
|
const mut = mutation ? mutationWorkflowFor(language, pm) : null;
|
|
49
59
|
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : [])];
|