karajan-code 4.29.1 → 4.31.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/scripts/postinstall.js +5 -1
- package/scripts/verify-pack-mcp.mjs +76 -0
- package/scripts/verify-pack.mjs +29 -0
- package/src/audit/deterministic-summary.js +24 -0
- package/src/audit/env-key-findings.js +162 -0
- package/src/checks/method.js +37 -2
- package/src/checks/rag-coverage.js +50 -0
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/check.js +16 -5
- package/src/commands/env.js +12 -0
- package/src/commands/go.js +17 -2
- package/src/commands/harden.js +4 -1
- package/src/commands/hu.js +13 -1
- package/src/commands/init.js +38 -5
- package/src/commands/review-gate.js +50 -2
- package/src/harden/harness-hooks.js +2 -0
- package/src/harden/hook-templates.js +3 -2
- package/src/harden/sentinel-hooks.js +92 -5
- package/src/harden/workflow-engine.js +6 -1
- package/src/harden/workflow-templates.js +1 -2
- package/src/identity/bootstrap.js +2 -2
- package/src/rag/coverage.js +48 -0
- package/src/review/one-shot-review.js +3 -0
- package/src/review/rag-ledger.js +41 -0
- package/src/review/rag-requirement.js +127 -0
- package/src/review/solomon-arbitration.js +6 -1
- package/src/roles/audit-role.js +12 -2
- package/src/utils/pending-user-action.js +7 -4
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -32,7 +32,11 @@ const CLAUDE_JSON_PATH = path.join(os.homedir(), ".claude.json");
|
|
|
32
32
|
// KJC-BUG-0179 (#1730): a home INSIDE the npm package is the old default
|
|
33
33
|
// this script wrote itself — wiped on every reinstall, never where `kj init`
|
|
34
34
|
// stores kj.config.yml. Such a value is stale, not a user choice.
|
|
35
|
-
|
|
35
|
+
// KJC-BUG-0181: in a linked install (npm link, source tree) the package does
|
|
36
|
+
// not live under node_modules, so THIS package's own `.karajan` is the same
|
|
37
|
+
// stale default under another path — compared as a path, not a pattern.
|
|
38
|
+
const isPackageDirHome = (home) => /node_modules[\\/]karajan-code[\\/]\.karajan[\\/]?$/.test(home)
|
|
39
|
+
|| path.resolve(home) === path.join(ROOT_DIR, ".karajan");
|
|
36
40
|
|
|
37
41
|
/** The home a previous registration carried, if the user (not this script) set it. */
|
|
38
42
|
function homeFromExistingEntry(entry) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP smoke for verify-pack (KJC-TSK-0844) — the karajan-mcp server of the
|
|
3
|
+
* INSTALLED tarball is started over stdio with the SDK client and exercised
|
|
4
|
+
* with kj_status, kj_config and kj_review, no LLM involved. The MCP surface
|
|
5
|
+
* was only proved by tests with every seam mocked and dogfooded by no
|
|
6
|
+
* development session; KJC-BUG-0175 (taskFile promised, never read) and
|
|
7
|
+
* KJC-BUG-0176 (kjHome declared, ignored) both shipped through that gap.
|
|
8
|
+
*
|
|
9
|
+
* What counts as a failure: the server not answering, a tool missing from
|
|
10
|
+
* the list, or a handler that never reached its gate — "Missing required
|
|
11
|
+
* field", "Config file not found", "taskFile read failed", "Unknown tool".
|
|
12
|
+
* A rejection the gate itself hands back (no reviewer CLI on this machine,
|
|
13
|
+
* Sonar, base branch) is the expected answer of an agentless runner and is
|
|
14
|
+
* said explicitly, never hidden.
|
|
15
|
+
*/
|
|
16
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
18
|
+
|
|
19
|
+
const FATAL = [/Missing required field/i, /Config file not found/i, /taskFile read failed/i, /Unknown tool/i];
|
|
20
|
+
const TOOLS = ["kj_status", "kj_config", "kj_review"];
|
|
21
|
+
|
|
22
|
+
/** The text of a tool answer, and whether it is a failure of the contract (not of the environment). */
|
|
23
|
+
export function classifyAnswer(tool, text) {
|
|
24
|
+
const fatal = FATAL.find((re) => re.test(text));
|
|
25
|
+
if (fatal) return { tool, ok: false, reason: `${tool} answered "${text.match(fatal)[0]}": the handler never reached its gate` };
|
|
26
|
+
let parsed = null;
|
|
27
|
+
try { parsed = JSON.parse(text); } catch { /* not JSON: plain text answer */ }
|
|
28
|
+
if (parsed && parsed.ok === false) return { tool, ok: true, gated: true, reason: String(parsed.error || "").slice(0, 200) };
|
|
29
|
+
return { tool, ok: true, gated: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} o
|
|
34
|
+
* @param {string} o.serverPath - src/mcp/server.js of the install under test
|
|
35
|
+
* @param {string} o.kjHome - a home holding kj.config.yml (passed as the tool's `kjHome` AND as KARAJAN_HOME)
|
|
36
|
+
* @param {string} o.projectDir - an initialised git repo with a task file
|
|
37
|
+
* @param {string} o.taskFile - path of the task (.md) relative to projectDir
|
|
38
|
+
* @returns {Promise<{ok: boolean, findings: string[], answers: object[]}>}
|
|
39
|
+
*/
|
|
40
|
+
export async function mcpSmoke({ serverPath, kjHome, projectDir, taskFile, env = process.env, timeoutMs = 120_000 }) {
|
|
41
|
+
const findings = [];
|
|
42
|
+
const answers = [];
|
|
43
|
+
const transport = new StdioClientTransport({
|
|
44
|
+
command: process.execPath, args: [serverPath], cwd: projectDir, stderr: "pipe",
|
|
45
|
+
env: { ...env, KARAJAN_HOME: kjHome },
|
|
46
|
+
});
|
|
47
|
+
const client = new Client({ name: "kj-verify-pack", version: "0" });
|
|
48
|
+
const call = async (name, args) => {
|
|
49
|
+
const res = await client.callTool({ name, arguments: args }, undefined, { timeout: timeoutMs });
|
|
50
|
+
const text = (res.content || []).map((c) => c.text || "").join("\n");
|
|
51
|
+
const verdict = classifyAnswer(name, text);
|
|
52
|
+
answers.push(verdict);
|
|
53
|
+
if (!verdict.ok) findings.push(verdict.reason);
|
|
54
|
+
return verdict;
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
await client.connect(transport);
|
|
58
|
+
const listed = new Set((await client.listTools()).tools.map((t) => t.name));
|
|
59
|
+
for (const t of TOOLS) if (!listed.has(t)) findings.push(`${t} is not listed by the server`);
|
|
60
|
+
if (findings.length > 0) return { ok: false, findings, answers };
|
|
61
|
+
// A project that never ran has no run log: that answer is the contract, not a refusal.
|
|
62
|
+
const status = await call("kj_status", { kjHome, projectDir, lines: 5 });
|
|
63
|
+
if (status.gated && !/no active run log/i.test(status.reason)) findings.push(`kj_status refused: ${status.reason}`);
|
|
64
|
+
const config = await call("kj_config", { kjHome, json: true });
|
|
65
|
+
if (config.gated) findings.push(`kj_config refused: ${config.reason}`);
|
|
66
|
+
// The task travels ONLY as a file: the handler must read it before any
|
|
67
|
+
// gate. The reviewer is a name no machine has, so the gate stops the call
|
|
68
|
+
// before a single LLM token, on a laptop with real agents as much as in CI.
|
|
69
|
+
await call("kj_review", { kjHome, projectDir, taskFile, reviewer: "kj-verify-stub" });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
findings.push(`MCP session failed: ${err.message}`);
|
|
72
|
+
} finally {
|
|
73
|
+
await client.close().catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
return { ok: findings.length === 0, findings, answers };
|
|
76
|
+
}
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import os from "node:os";
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
|
|
27
|
+
import { mcpSmoke } from "./verify-pack-mcp.mjs";
|
|
28
|
+
|
|
27
29
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
30
|
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
29
31
|
const expectedVersion = pkg.version;
|
|
@@ -220,6 +222,33 @@ try {
|
|
|
220
222
|
}
|
|
221
223
|
console.log("verify-pack: quickstart smoke (init + report, no-remote repo) ✓");
|
|
222
224
|
|
|
225
|
+
// 5.6 MCP smoke (KJC-TSK-0844) — the karajan-mcp server of the GLOBAL
|
|
226
|
+
// install, over stdio, against the quickstart repo and its home: kj_status,
|
|
227
|
+
// kj_config and kj_review with kjHome + taskFile, no LLM. The MCP surface
|
|
228
|
+
// shipped KJC-BUG-0175/0176 through tests that mocked every seam.
|
|
229
|
+
const gServer = path.join(
|
|
230
|
+
gTmp, ...(process.platform === "win32" ? ["node_modules"] : ["lib", "node_modules"]),
|
|
231
|
+
...pkgName.split("/"), "src", "mcp", "server.js",
|
|
232
|
+
);
|
|
233
|
+
if (!fs.existsSync(gServer)) fail(`karajan-mcp server missing in the global install: ${gServer}`);
|
|
234
|
+
// A branch with a staged source and a task file: kj_review must READ the
|
|
235
|
+
// file and reach its gate (a refusal there is this agentless runner's answer).
|
|
236
|
+
run("git", ["checkout", "-q", "-b", "smoke/mcp"], { cwd: qsTmp });
|
|
237
|
+
fs.writeFileSync(path.join(qsTmp, "task.md"), "Add a greeting helper\n");
|
|
238
|
+
fs.writeFileSync(path.join(qsTmp, "hello.js"), "export const hi = () => 'hi';\n");
|
|
239
|
+
run("git", ["add", "hello.js"], { cwd: qsTmp });
|
|
240
|
+
console.log("verify-pack: MCP smoke (kj_status, kj_config, kj_review over stdio)…");
|
|
241
|
+
const mcp = await mcpSmoke({ serverPath: gServer, kjHome: qsEnv.KARAJAN_HOME, projectDir: qsTmp, taskFile: "task.md", env: qsEnv });
|
|
242
|
+
if (!mcp.ok) fail("the tarball's karajan-mcp server did not honour its own tool contract", mcp.findings.join("\n"));
|
|
243
|
+
for (const a of mcp.answers) if (a.gated) console.log(`verify-pack: ${a.tool} reached its gate and refused (expected here): ${a.reason}`);
|
|
244
|
+
// KJC-BUG-0179: the tarball's postinstall registered the MCP in the temp
|
|
245
|
+
// HOME under the current name, never the deprecated KJ_HOME.
|
|
246
|
+
const registered = JSON.parse(fs.readFileSync(path.join(homeTmp, ".claude.json"), "utf8")).mcpServers?.["karajan-mcp"]?.env || {};
|
|
247
|
+
if (!registered.KARAJAN_HOME || registered.KJ_HOME) {
|
|
248
|
+
fail("postinstall registered karajan-mcp without KARAJAN_HOME (or with the deprecated KJ_HOME)", JSON.stringify(registered));
|
|
249
|
+
}
|
|
250
|
+
console.log("verify-pack: MCP smoke ✓");
|
|
251
|
+
|
|
223
252
|
// 6. pnpm install smoke (KJC-TSK-0580). pnpm's layout differs from npm's
|
|
224
253
|
// (a symlinked virtual store), so it can break resolution of the bundled
|
|
225
254
|
// karajan-core the way npm packaging breakage did before (KJC-BUG-0082/0086).
|
|
@@ -15,6 +15,7 @@ import { groupFindingsBySeverity as groupSemgrepBySeverity } from "./semgrep-fin
|
|
|
15
15
|
import { groupCyclesBySeverity } from "./circular-deps.js";
|
|
16
16
|
import { groupDeadExportsBySeverity } from "./dead-exports.js";
|
|
17
17
|
import { groupInjectionBySeverity } from "./injection-findings.js";
|
|
18
|
+
import { groupEnvKeyFindingsBySeverity } from "./env-key-findings.js";
|
|
18
19
|
|
|
19
20
|
const MAX_SAMPLE_DEAD_EXPORTS = 10;
|
|
20
21
|
const MAX_SAMPLE_SONAR_PER_SEVERITY = 5;
|
|
@@ -43,11 +44,33 @@ export function formatDeterministicSummary(ctx) {
|
|
|
43
44
|
if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
|
|
44
45
|
if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
|
|
45
46
|
if (ctx.aiSlop) lines.push(...formatAiSlopBlock(ctx.aiSlop));
|
|
47
|
+
if (ctx.envKeys) lines.push(...formatEnvKeysBlock(ctx.envKeys));
|
|
46
48
|
if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
|
|
47
49
|
|
|
48
50
|
return lines.join("\n");
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
const MAX_SAMPLE_ENV_KEYS = 8;
|
|
54
|
+
|
|
55
|
+
// KJC-TSK-0845: the same key resolved with two rules in two modules, and a
|
|
56
|
+
// deprecated key read where its successor never is — every site named.
|
|
57
|
+
function formatEnvKeysBlock(envKeys) {
|
|
58
|
+
if (!envKeys.available) return ["### Env/config key resolution", `- Status: not available — ${envKeys.reason || "scan failed"}`, ""];
|
|
59
|
+
const { HIGH, MEDIUM } = groupEnvKeyFindingsBySeverity(envKeys);
|
|
60
|
+
const lines = ["### Env/config key resolution", `- Files scanned: ${envKeys.scanned ?? 0}`, `- Incomplete migrations: ${HIGH.length} · keys resolved with divergent rules: ${MEDIUM.length}`];
|
|
61
|
+
for (const i of HIGH.slice(0, MAX_SAMPLE_ENV_KEYS)) {
|
|
62
|
+
lines.push(` - HIGH ${i.file}:${i.line} reads \`${i.key}\` and never \`${i.successor}\``);
|
|
63
|
+
}
|
|
64
|
+
for (const d of MEDIUM.slice(0, MAX_SAMPLE_ENV_KEYS)) {
|
|
65
|
+
lines.push(` - MEDIUM [${d.kind}] \`${d.key}\` resolved ${d.rules.length} ways:`);
|
|
66
|
+
for (const r of d.rules) lines.push(` - \`${r.rule}\` ← ${r.sites.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
const more = HIGH.length + MEDIUM.length - Math.min(HIGH.length, MAX_SAMPLE_ENV_KEYS) - Math.min(MEDIUM.length, MAX_SAMPLE_ENV_KEYS);
|
|
69
|
+
if (more > 0) lines.push(` - ... and ${more} more`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
function formatAiSlopBlock(slop) {
|
|
52
75
|
if (!slop.available) return ["### AI-slop tells (deterministic)", `- Status: not available — ${slop.reason || "scan failed"}`, ""];
|
|
53
76
|
const lines = ["### AI-slop tells (deterministic)", `- Files scanned: ${slop.filesScanned ?? 0}`, `- Score: ${slop.score}/100 (100 = clean)`, `- Findings: ${slop.total ?? 0}`];
|
|
@@ -338,5 +361,6 @@ export function deterministicContextHasFindings(ctx) {
|
|
|
338
361
|
if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
|
|
339
362
|
if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
|
|
340
363
|
if (ctx.aiSlop?.available && (ctx.aiSlop.total ?? 0) > 0) return true;
|
|
364
|
+
if (ctx.envKeys?.available && ((ctx.envKeys.divergent?.length ?? 0) + (ctx.envKeys.incomplete?.length ?? 0)) > 0) return true;
|
|
341
365
|
return false;
|
|
342
366
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env/config key resolution collector for `kj audit` (KJC-TSK-0845).
|
|
3
|
+
*
|
|
4
|
+
* The same key resolved by two modules with two rules is a migration left
|
|
5
|
+
* half-way: KJ_HOME → KARAJAN_HOME reached the runtime and not the installer
|
|
6
|
+
* (KJC-BUG-0179), and the MCP resolved the home unlike the CLI (KJC-BUG-0176).
|
|
7
|
+
* Zero tokens, lexical, best-effort — same shape as injection-findings:
|
|
8
|
+
* - divergent: one key, read with an explicit fallback chain (`||` / `??`)
|
|
9
|
+
* that differs between files. Plain reads consume a value and resolve
|
|
10
|
+
* nothing, so they never count: no false positive on a consumer.
|
|
11
|
+
* - incomplete-migration: a file reads a deprecated key and never names its
|
|
12
|
+
* successor.
|
|
13
|
+
*/
|
|
14
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
export const DEPRECATED_KEYS = { KJ_HOME: "KARAJAN_HOME" };
|
|
18
|
+
const ROOTS = ["src", "scripts", "bin", "packages"];
|
|
19
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", "tests", "test", "__tests__", "docs", "public"]);
|
|
20
|
+
const ENV_READ = /process\.env\.([A-Z][A-Z0-9_]*)|process\.env\[["']([A-Z][A-Z0-9_]*)["']\]/g;
|
|
21
|
+
const CONFIG_START = /\bconfig(?=\??\.[a-z])/g;
|
|
22
|
+
const SEGMENT = /^\??\.([a-z][a-z0-9_]*)/;
|
|
23
|
+
const STOP = new Set([" ", "\t", ";", ",", "|", "?", ":"]);
|
|
24
|
+
|
|
25
|
+
/** ` || rest` → { op, consumed } with the surrounding blanks, or null when no operator follows. */
|
|
26
|
+
function operatorAt(s) {
|
|
27
|
+
const t = s.trimStart();
|
|
28
|
+
const op = ["||", "??"].find((o) => t.startsWith(o)) ?? null;
|
|
29
|
+
if (!op) return null;
|
|
30
|
+
const afterOp = t.slice(op.length);
|
|
31
|
+
return { op, consumed: s.length - afterOp.trimStart().length };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `config.a?.b` → { key: "a.b", length } read from just after `config`, or null. */
|
|
35
|
+
function configPath(rest) {
|
|
36
|
+
const segments = [];
|
|
37
|
+
let s = rest;
|
|
38
|
+
for (let m = SEGMENT.exec(s); m; m = SEGMENT.exec(s)) {
|
|
39
|
+
segments.push(m[1]);
|
|
40
|
+
s = s.slice(m[0].length);
|
|
41
|
+
}
|
|
42
|
+
return segments.length > 0 ? { key: segments.join("."), length: rest.length - s.length } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One fallback operand: a quoted literal, or an expression up to the next operator, with balanced brackets. */
|
|
46
|
+
function readOperand(s) {
|
|
47
|
+
const quote = s[0];
|
|
48
|
+
if (quote === '"' || quote === "'" || quote === "`") {
|
|
49
|
+
const end = s.indexOf(quote, 1);
|
|
50
|
+
return end === -1 ? null : s.slice(0, end + 1);
|
|
51
|
+
}
|
|
52
|
+
const n = operandLength(s);
|
|
53
|
+
return n > 0 ? s.slice(0, n) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Length of the expression at the start of `s`: up to the next operator or unbalanced close. */
|
|
57
|
+
function operandLength(s) {
|
|
58
|
+
// Optional chaining (`a?.b`) is not an operator boundary: flatten it, same length.
|
|
59
|
+
const flat = s.replaceAll("?.", "..");
|
|
60
|
+
let depth = 0;
|
|
61
|
+
for (let i = 0; i < flat.length; i += 1) {
|
|
62
|
+
const c = flat[i];
|
|
63
|
+
if (c === "(" || c === "[") {
|
|
64
|
+
depth += 1;
|
|
65
|
+
} else if (c === ")" || c === "]") {
|
|
66
|
+
if (depth === 0) { return i; }
|
|
67
|
+
depth -= 1;
|
|
68
|
+
} else if (depth === 0 && STOP.has(c)) {
|
|
69
|
+
return i;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return flat.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function listJsFiles(dir, depth = 0) {
|
|
76
|
+
if (depth > 8) return [];
|
|
77
|
+
let entries;
|
|
78
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
const p = path.join(dir, e.name);
|
|
82
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) out.push(...(await listJsFiles(p, depth + 1))); }
|
|
83
|
+
else if (/\.(m?js|cjs)$/.test(e.name)) out.push(p);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The fallback chain written after a subject on the same line, normalized. */
|
|
89
|
+
export function fallbackChain(rest) {
|
|
90
|
+
const chain = [];
|
|
91
|
+
let s = rest;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const op = operatorAt(s);
|
|
94
|
+
if (!op) break;
|
|
95
|
+
const operand = readOperand(s.slice(op.consumed));
|
|
96
|
+
if (!operand) break;
|
|
97
|
+
// The operator is part of the rule: `|| "x"` and `?? "x"` resolve differently.
|
|
98
|
+
chain.push(`${op.op} ${operand.replaceAll(/\s+/g, "")}`);
|
|
99
|
+
s = s.slice(op.consumed + operand.length);
|
|
100
|
+
}
|
|
101
|
+
return chain;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every subject read in one file: { key, kind, line, chain }. */
|
|
105
|
+
export function readsIn(text) {
|
|
106
|
+
const reads = [];
|
|
107
|
+
text.split("\n").forEach((line, i) => {
|
|
108
|
+
if (/^\s*(\/\/|\*)/.test(line)) return;
|
|
109
|
+
for (const m of line.matchAll(ENV_READ)) {
|
|
110
|
+
reads.push({ key: m[1] || m[2], kind: "env", line: i + 1, chain: fallbackChain(line.slice(m.index + m[0].length)) });
|
|
111
|
+
}
|
|
112
|
+
for (const m of line.matchAll(CONFIG_START)) {
|
|
113
|
+
const after = m.index + m[0].length;
|
|
114
|
+
const p = configPath(line.slice(after));
|
|
115
|
+
if (p) reads.push({ key: p.key, kind: "config", line: i + 1, chain: fallbackChain(line.slice(after + p.length)) });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return reads;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function collectEnvKeyFindings(projectDir, { roots = ROOTS } = {}) {
|
|
122
|
+
if (!projectDir) return { available: false, reason: "projectDir not provided" };
|
|
123
|
+
const files = (await Promise.all(roots.map((r) => listJsFiles(path.join(projectDir, r))))).flat();
|
|
124
|
+
const sites = new Map(); // key → [{ file, line, chain }] (only reads WITH a chain)
|
|
125
|
+
const incomplete = [];
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const text = await readFile(file, "utf8");
|
|
128
|
+
const rel = path.relative(projectDir, file);
|
|
129
|
+
const reads = readsIn(text);
|
|
130
|
+
for (const r of reads) {
|
|
131
|
+
if (r.chain.length === 0) continue;
|
|
132
|
+
// One subject per namespace: process.env.PORT and config.port never compare.
|
|
133
|
+
const id = `${r.kind}:${r.key}`;
|
|
134
|
+
if (!sites.has(id)) sites.set(id, []);
|
|
135
|
+
sites.get(id).push({ file: rel, line: r.line, chain: r.chain });
|
|
136
|
+
}
|
|
137
|
+
for (const [old, successor] of Object.entries(DEPRECATED_KEYS)) {
|
|
138
|
+
// The successor must be READ in the file (a comment naming it is not a migration).
|
|
139
|
+
const hit = reads.find((r) => r.key === old);
|
|
140
|
+
if (hit && !reads.some((r) => r.key === successor)) incomplete.push({ key: old, successor, file: rel, line: hit.line });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const divergent = [];
|
|
144
|
+
for (const [id, list] of sites) {
|
|
145
|
+
const [kind, key] = [id.slice(0, id.indexOf(":")), id.slice(id.indexOf(":") + 1)];
|
|
146
|
+
const rules = new Map();
|
|
147
|
+
for (const s of list) {
|
|
148
|
+
const rule = `${key} ${s.chain.join(" ")}`;
|
|
149
|
+
if (!rules.has(rule)) rules.set(rule, []);
|
|
150
|
+
rules.get(rule).push(s);
|
|
151
|
+
}
|
|
152
|
+
if (rules.size > 1 && new Set(list.map((s) => s.file)).size > 1) {
|
|
153
|
+
divergent.push({ key, kind, rules: [...rules].map(([rule, at]) => ({ rule, sites: at.map((s) => `${s.file}:${s.line}`) })) });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
divergent.sort((a, b) => a.key.localeCompare(b.key));
|
|
157
|
+
return { available: true, scanned: files.length, divergent, incomplete };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function groupEnvKeyFindingsBySeverity({ divergent = [], incomplete = [] } = {}) {
|
|
161
|
+
return { HIGH: incomplete, MEDIUM: divergent };
|
|
162
|
+
}
|
package/src/checks/method.js
CHANGED
|
@@ -12,6 +12,7 @@ import { runCommand } from "../utils/process.js";
|
|
|
12
12
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
13
13
|
import { CARD_REF_RE } from "../review/card-first.js";
|
|
14
14
|
import { STRATEGY } from "./types.js";
|
|
15
|
+
import { createRagCoverageCheck } from "./rag-coverage.js";
|
|
15
16
|
|
|
16
17
|
function recentVerdicts(projectDir, sample) {
|
|
17
18
|
try {
|
|
@@ -40,9 +41,12 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
40
41
|
const verdicts = recentVerdicts(projectDir, sample);
|
|
41
42
|
const stamped = verdicts.filter((v) => v.workspace);
|
|
42
43
|
const sonar = { proved: 0, docsOnly: 0, granted: 0, unproved: 0 };
|
|
44
|
+
const rag = { proved: 0, docsOnly: 0, granted: 0, noHarness: 0, unproved: 0 };
|
|
43
45
|
for (const v of verdicts) {
|
|
44
46
|
const kind = sonarProof(v);
|
|
45
47
|
if (kind) sonar[kind] += 1;
|
|
48
|
+
const asked = ragProof(v);
|
|
49
|
+
if (asked) rag[asked] += 1;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
return {
|
|
@@ -50,6 +54,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
50
54
|
verdicts: { total: verdicts.length, stamped: stamped.length, root: stamped.filter((v) => v.workspace === "root").length },
|
|
51
55
|
testless: { sampled: blocks.length, offenders },
|
|
52
56
|
sonar,
|
|
57
|
+
rag,
|
|
53
58
|
};
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -74,9 +79,33 @@ export function sonarProof(v) {
|
|
|
74
79
|
return "unproved";
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
/**
|
|
83
|
+
* KJC-TSK-0849 (ADR 0010, RAG-C): what a verdict's rag block proves — the
|
|
84
|
+
* session asked the RAG about every source ("proved"), the diff was
|
|
85
|
+
* "docsOnly", a human "granted" the rule, the tree runs with "noHarness"
|
|
86
|
+
* (no session ledger can exist), or "unproved": an approved verdict for
|
|
87
|
+
* code whose block covers nothing, or was never written. A pipeline verdict
|
|
88
|
+
* carries no block (its warn is sealed at commit) and a verdict older than
|
|
89
|
+
* the requirement has none either: neither is retroactive.
|
|
90
|
+
*/
|
|
91
|
+
export function ragProof(v) {
|
|
92
|
+
const r = v?.rag;
|
|
93
|
+
if (!r || v.verdict !== "approved" || !r.mode) return null;
|
|
94
|
+
if (r.mode === "docs-only") return "docsOnly";
|
|
95
|
+
if (r.mode === "granted") return "granted";
|
|
96
|
+
if (r.mode === "no-harness") return "noHarness";
|
|
97
|
+
// A pass proves something only with real coverage: both lists present,
|
|
98
|
+
// at least one source covered, none uncovered. Anything else is a claim.
|
|
99
|
+
const covered = Array.isArray(r.covered) ? r.covered : null;
|
|
100
|
+
const uncovered = Array.isArray(r.uncovered) ? r.uncovered : null;
|
|
101
|
+
if (r.mode === "pass" && covered?.length > 0 && uncovered?.length === 0) return "proved";
|
|
102
|
+
return "unproved";
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
export function formatMethodStats(s) {
|
|
78
106
|
const sonar = s.sonar ? ` · sonar proof: ${s.sonar.proved} proved, ${s.sonar.docsOnly} docs-only, ${s.sonar.granted} granted, ${s.sonar.unproved} unproved` : "";
|
|
79
|
-
|
|
107
|
+
const rag = s.rag ? ` · rag proof: ${s.rag.proved} proved, ${s.rag.docsOnly} docs-only, ${s.rag.granted} granted, ${s.rag.noHarness} no-harness, ${s.rag.unproved} unproved` : "";
|
|
108
|
+
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}${rag}`;
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
function createMethodCheck() {
|
|
@@ -92,6 +121,10 @@ function createMethodCheck() {
|
|
|
92
121
|
if (stats.sonar.unproved > 0) {
|
|
93
122
|
return { ok: false, severity: "fail", detail: `${stats.sonar.unproved} approved verdict(s) without sonar proof — ${detail}` };
|
|
94
123
|
}
|
|
124
|
+
// KJC-TSK-0849 (ADR 0010): same for code the session never asked the RAG about.
|
|
125
|
+
if (stats.rag.unproved > 0) {
|
|
126
|
+
return { ok: false, severity: "fail", detail: `${stats.rag.unproved} approved verdict(s) without rag proof — ${detail}` };
|
|
127
|
+
}
|
|
95
128
|
const drought = stats.commits.total >= 5 && stats.commits.withCard / stats.commits.total < 0.5;
|
|
96
129
|
if (drought) {
|
|
97
130
|
return { ok: false, severity: "warn", detail: `most recent commits carry no card reference — ${detail}` };
|
|
@@ -102,5 +135,7 @@ function createMethodCheck() {
|
|
|
102
135
|
}
|
|
103
136
|
|
|
104
137
|
export function getMethodChecks() {
|
|
105
|
-
|
|
138
|
+
// KJC-TSK-0850 (ADR 0010): a source the RAG index cannot see is a defect
|
|
139
|
+
// of the method, not a preference — same family as sonar proof.
|
|
140
|
+
return [createMethodCheck(), createRagCoverageCheck()];
|
|
106
141
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0850 (ADR 0010, RAG-D) — `kj check` / `kj doctor` item: the RAG
|
|
3
|
+
* index covers every source of the project and is not older than HEAD.
|
|
4
|
+
* Red only when an index EXISTS and is incomplete or stale: a machine with
|
|
5
|
+
* no index yet (CI, a fresh clone) gets the command, not a failure.
|
|
6
|
+
*/
|
|
7
|
+
import { openVecStore } from "../rag/vec-store.js";
|
|
8
|
+
import { ragIndexCoverage } from "../rag/coverage.js";
|
|
9
|
+
import { STRATEGY } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export function createRagCoverageCheck({ openStore = (dim) => openVecStore({ dim }) } = {}) {
|
|
12
|
+
return {
|
|
13
|
+
name: "rag-coverage",
|
|
14
|
+
label: "RAG index coverage (ADR 0010)",
|
|
15
|
+
strategy: STRATEGY.NONE,
|
|
16
|
+
async detect({ config = {}, projectDir = process.cwd() } = {}) {
|
|
17
|
+
const db = openStore(config?.rag?.embedder?.dim || 768);
|
|
18
|
+
try {
|
|
19
|
+
let c;
|
|
20
|
+
try {
|
|
21
|
+
c = await ragIndexCoverage(projectDir, { db });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// Corrupt store, unreachable stamp, git failure: a failing check,
|
|
24
|
+
// never a silent pass. The only tolerated non-red state is "no index".
|
|
25
|
+
return { ok: false, severity: "fail", detail: `rag coverage could not be evaluated: ${err.message}` };
|
|
26
|
+
}
|
|
27
|
+
if (c.absent) {
|
|
28
|
+
return { ok: true, severity: "info", detail: `no RAG index for ${c.project} yet (${c.total} sources) — kj rag index --with-sources` };
|
|
29
|
+
}
|
|
30
|
+
if (!c.lastIndexedCommit) {
|
|
31
|
+
// Chunks without a commit stamp: nothing says how old they are, so
|
|
32
|
+
// freshness cannot be established — that is a defect, not a pass.
|
|
33
|
+
return { ok: false, severity: "fail", detail: `the RAG index of ${c.project} carries no commit stamp, freshness unknown (${c.indexed}/${c.total} sources) — kj rag index --with-sources` };
|
|
34
|
+
}
|
|
35
|
+
const gaps = [...c.missing, ...c.stale];
|
|
36
|
+
if (gaps.length > 0) {
|
|
37
|
+
// --since only re-embeds files CHANGED since the stamp: a source the
|
|
38
|
+
// store never saw needs the full walk.
|
|
39
|
+
const stamp = c.lastIndexedCommit.slice(0, 9);
|
|
40
|
+
const fix = c.missing.length === 0 ? `kj rag index --since ${stamp}` : "kj rag index --with-sources";
|
|
41
|
+
const sample = gaps.slice(0, 3).join(", ") + (gaps.length > 3 ? ", …" : "");
|
|
42
|
+
return { ok: false, severity: "fail", detail: `${c.missing.length} source(s) outside the RAG index, ${c.stale.length} stale (${sample}) — ${fix}` };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}` };
|
|
45
|
+
} finally {
|
|
46
|
+
db.close();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -34,6 +34,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
34
34
|
.option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
|
|
35
35
|
.option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
|
|
36
36
|
.option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
|
|
37
|
+
.option("--no-sonar", "Do not start the SonarQube container (Docker). The review gate still needs it: start it later with 'kj sonar start'")
|
|
37
38
|
.option("--json", "Emit a machine-readable summary of what init did (AB-B: for host agents)")
|
|
38
39
|
.action(async (flags) => {
|
|
39
40
|
await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
|
package/src/commands/check.js
CHANGED
|
@@ -6,14 +6,22 @@
|
|
|
6
6
|
|
|
7
7
|
import { checkHarden } from "../harden/check.js";
|
|
8
8
|
import { collectMethodStats, formatMethodStats } from "../checks/method.js";
|
|
9
|
+
import { createRagCoverageCheck } from "../checks/rag-coverage.js";
|
|
9
10
|
import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
|
|
10
11
|
import { detectObservedAgents } from "../utils/agent-detect.js";
|
|
12
|
+
import { loadConfig } from "../config.js";
|
|
11
13
|
|
|
12
14
|
export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
|
|
13
15
|
const result = await checkHarden({ projectDir, profile });
|
|
14
16
|
// KJC-TSK-0689 (MG-D): method adherence is VISIBILITY, not a gate — it
|
|
15
17
|
// rides along in check output but never affects the exit code.
|
|
16
18
|
const method = await collectMethodStats({ projectDir }).catch(() => null);
|
|
19
|
+
// KJC-TSK-0850 (ADR 0010, RAG-D): index coverage IS a gate — an index that
|
|
20
|
+
// exists but misses or lags a source turns check red; no index = the command.
|
|
21
|
+
const { config } = await loadConfig(projectDir).catch(() => ({ config: {} }));
|
|
22
|
+
// An evaluation error (corrupt store, git failure) is a failing check, never
|
|
23
|
+
// a silent pass: the only tolerated non-red state is the explicit "no index".
|
|
24
|
+
const ragCoverage = await createRagCoverageCheck().detect({ config, projectDir }).catch((err) => ({ ok: false, severity: "fail", detail: `rag coverage could not be evaluated: ${err.message}` }));
|
|
17
25
|
// KJC-TSK-0694: same deal for the MCP inventory — a nudge, never a gate.
|
|
18
26
|
// KJC-TSK-0728: observed agent CLIs ride the same snapshot as "(cli)"
|
|
19
27
|
// entries, so a newly-appeared agent binary trips the same drift question.
|
|
@@ -25,16 +33,19 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
|
|
|
25
33
|
aiSurface = checkAiSurface({ projectDir, extraSurface: clis });
|
|
26
34
|
} catch { /* inventory is best-effort */ }
|
|
27
35
|
|
|
36
|
+
const ok = result.ok && ragCoverage.ok;
|
|
28
37
|
if (json) {
|
|
29
|
-
logger.info?.(JSON.stringify({ ...result, method, aiSurface }));
|
|
30
|
-
return
|
|
38
|
+
logger.info?.(JSON.stringify({ ...result, ok, method, ragCoverage, aiSurface }));
|
|
39
|
+
return ok ? 0 : 1;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
logger.info?.(`kj check (${profile})`);
|
|
34
43
|
for (const c of result.checks) logger.info?.(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
|
|
35
44
|
if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
|
|
45
|
+
logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
|
|
36
46
|
if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
|
|
37
|
-
if (
|
|
38
|
-
else logger.info?.("
|
|
39
|
-
|
|
47
|
+
if (ok) logger.info?.("Harness OK.");
|
|
48
|
+
else if (result.ok) logger.info?.("RAG index drift detected — a gate cannot protect what it cannot see.");
|
|
49
|
+
else logger.info?.("Harness drift detected — run `kj harden` to repair.");
|
|
50
|
+
return ok ? 0 : 1;
|
|
40
51
|
}
|
package/src/commands/env.js
CHANGED
|
@@ -115,6 +115,18 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
115
115
|
try {
|
|
116
116
|
const h = await hardenCommand({ projectDir, logger: console });
|
|
117
117
|
if (h?.ok === false) throw new Error(h.error || "kj harden failed");
|
|
118
|
+
// KJC-BUG-0188: a headless harden never binds an identity by itself, and
|
|
119
|
+
// it must not. Blocking here beats the Sentinel denying every git and gh
|
|
120
|
+
// call later, with nothing on screen tying that back to this install.
|
|
121
|
+
if (h?.identityPending) {
|
|
122
|
+
result.exitCode = PENDING_EXIT_CODE;
|
|
123
|
+
result.pendingBlock = renderPendingBlock(
|
|
124
|
+
[{ tool: "identity", action: "needs-user", reason: h.identityPending, command: h.identityCommand }],
|
|
125
|
+
{ retry: "kj env install", why: "this clone has no declared identity, and kj never binds one on its own" },
|
|
126
|
+
);
|
|
127
|
+
console.log(result.pendingBlock);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
118
130
|
await reviewGateCommand({ config, flags: { installGate: true } });
|
|
119
131
|
console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
|
|
120
132
|
} catch (err) {
|
package/src/commands/go.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import { checkBinary } from "../utils/agent-detect.js";
|
|
14
14
|
import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
15
15
|
import { envInstallCommand } from "./env.js";
|
|
16
|
+
import { ensureGitRepo } from "./init.js";
|
|
16
17
|
import { boardCommand } from "./board.js";
|
|
17
18
|
// The interactive launchers a muggle can live inside (v1: the two the epic
|
|
18
19
|
// names). Auth heuristics are the same cheap file checks reviewer-fallback
|
|
@@ -44,7 +45,7 @@ export function buildGoPrompt() {
|
|
|
44
45
|
].join("\n");
|
|
45
46
|
}
|
|
46
47
|
async function defaultPrepare({ config, logger }) {
|
|
47
|
-
|
|
48
|
+
return envInstallCommand({ config, logger, flags: { yes: true } });
|
|
48
49
|
}
|
|
49
50
|
export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
|
|
50
51
|
const port = config.hu_board?.port || 4000;
|
|
@@ -95,7 +96,21 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
|
|
|
95
96
|
// Prepare ONCE: decisions already taken are never re-asked.
|
|
96
97
|
if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
|
|
97
98
|
logger.info?.("Preparando tu proyecto (solo la primera vez)…");
|
|
98
|
-
|
|
99
|
+
// KJC-BUG-0191: empezar en una carpeta vacía es lo natural, y kj env
|
|
100
|
+
// install se niega a correr sin repositorio. Lo creamos nosotros.
|
|
101
|
+
if (!ensureGitRepo({ projectDir, logger })) {
|
|
102
|
+
logger.error?.("No he podido preparar el control de versiones de tu proyecto (git). Instálalo y vuelve a escribir: kj go");
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
const prepared = await (deps.prepare ?? defaultPrepare)({ config, logger });
|
|
107
|
+
// Algo depende de tus manos (arriba tienes el detalle). Sin eso el
|
|
108
|
+
// proyecto quedaría a medias, así que paramos aquí en vez de seguir.
|
|
109
|
+
if (prepared?.exitCode) {
|
|
110
|
+
logger.error?.("Falta una cosa que solo puedes hacer tú, la tienes justo arriba. Cuando la hagas, vuelve a escribir: kj go");
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
99
114
|
}
|
|
100
115
|
const prompt = (deps.prompt ?? buildGoPrompt)();
|
|
101
116
|
// --window (MGL-E, ADR 0008): la conversación vive DENTRO del board — el
|
package/src/commands/harden.js
CHANGED
|
@@ -200,7 +200,10 @@ export async function hardenCommand({
|
|
|
200
200
|
if (!dryRun) {
|
|
201
201
|
const id = await ensureIdentity({ projectDir, logger });
|
|
202
202
|
out.identity = id.declared ? id.identity : null;
|
|
203
|
-
if (id.pending)
|
|
203
|
+
if (id.pending) {
|
|
204
|
+
out.identityPending = id.pending;
|
|
205
|
+
out.identityCommand = id.command;
|
|
206
|
+
}
|
|
204
207
|
}
|
|
205
208
|
// KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
|
|
206
209
|
// del supervisor con procedencia sellada. Acto humano — el guard vive en
|
package/src/commands/hu.js
CHANGED
|
@@ -121,6 +121,13 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
121
121
|
console.warn(`⚠ possible duplicate${plural} — check before working it:\n${candidates}`);
|
|
122
122
|
}
|
|
123
123
|
const plan = await backlogPlan(projectDir);
|
|
124
|
+
/** Lowest free HU-#### in this plan — ids are permanent, never reused. */
|
|
125
|
+
const nextHuRef = (p) => {
|
|
126
|
+
const taken = new Set((p.hus || []).map((h) => h.short_id).filter(Boolean));
|
|
127
|
+
let n = 1;
|
|
128
|
+
while (taken.has(`HU-${String(n).padStart(4, "0")}`)) n += 1;
|
|
129
|
+
return `HU-${String(n).padStart(4, "0")}`;
|
|
130
|
+
};
|
|
124
131
|
// KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
|
|
125
132
|
// recreate "fix" an agent improvises loses history and duplicates ids —
|
|
126
133
|
// refuse it with the norm spelled out.
|
|
@@ -145,7 +152,12 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
145
152
|
.flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
|
|
146
153
|
const hu = addHu(plan, {
|
|
147
154
|
title,
|
|
148
|
-
|
|
155
|
+
// KJC-BUG-0189: the card-first gate demands a branch that references a
|
|
156
|
+
// card, and it is this command the gate sends the agent to. The
|
|
157
|
+
// canonical plan id (hu_plan-<stamp>-<rand>_001) does not match
|
|
158
|
+
// CARD_REF_RE, so without an explicit --id the card kj just created
|
|
159
|
+
// could not satisfy kj's own gate. Sequential HU-#### does.
|
|
160
|
+
short_id: flags.id || nextHuRef(plan),
|
|
149
161
|
acceptance_criteria: [...splitLines(flags.ac), ...splitLines(flags.criteria)],
|
|
150
162
|
acceptance_tests: splitLines(flags.tests),
|
|
151
163
|
scope: flags.scope || null,
|