karajan-code 4.32.0 → 4.33.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/src/agents/aider-agent.js +2 -12
- package/src/agents/base-agent.js +9 -20
- package/src/agents/claude-agent.js +2 -12
- package/src/agents/codex-agent.js +2 -12
- package/src/agents/dead-models.js +74 -0
- package/src/agents/gemini-agent.js +2 -12
- package/src/agents/model-errors.js +35 -0
- package/src/agents/opencode-agent.js +2 -12
- package/src/brain/agent-error-classifier.js +19 -1
- package/src/brain/role-fallback-chain.js +100 -0
- package/src/brain/with-brain-recovery.js +59 -3
- package/src/checks/action-pins.js +131 -0
- package/src/checks/project-checks.js +6 -0
- package/src/cli/register-pipeline.js +11 -1
- package/src/commands/check.js +5 -0
- package/src/commands/code.js +57 -4
- package/src/commands/env.js +2 -1
- package/src/commands/harden.js +3 -1
- package/src/commands/review-gate.js +12 -4
- package/src/environment/panel.js +52 -0
- package/src/environment/playbook.js +7 -6
- package/src/harden/config-templates.js +7 -1
- package/src/harden/guidelines-engine.js +7 -3
- package/src/harden/guidelines-templates.js +60 -10
- package/src/harden/hook-templates.js +4 -1
- package/src/privacy/diff-scope.js +58 -0
- package/src/privacy/scan.js +18 -0
- package/src/prompts/card-context.js +57 -0
- package/src/prompts/session-context.js +68 -0
- package/src/roles/agent-role.js +11 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0870 — the maintenance that pinning moved onto kj.
|
|
3
|
+
*
|
|
4
|
+
* Issue #1374 was right: `kj harden` generated workflows with moving tags
|
|
5
|
+
* (`actions/checkout@v4`), and kj's own `kj audit --security` flagged them, so
|
|
6
|
+
* five of the project's seven warnings came from files kj had just written.
|
|
7
|
+
* They are pinned to commit SHAs now.
|
|
8
|
+
*
|
|
9
|
+
* Pinning trades one risk for another: nobody can move the tag under you, and
|
|
10
|
+
* nobody delivers the upstream patch either. A pin that nobody revisits is a
|
|
11
|
+
* vulnerability with a long shelf life, and since kj writes those lines, kj is
|
|
12
|
+
* the one that has to notice. That is this check.
|
|
13
|
+
*
|
|
14
|
+
* It lives in `kj doctor` (a person asking about their environment) and NOT in
|
|
15
|
+
* `kj check` (a gate that CI runs): a gate that needs the network is a gate
|
|
16
|
+
* that fails on a plane, and turning red over an unreachable API is how a gate
|
|
17
|
+
* loses its credibility.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
|
|
23
|
+
import { PINNED_ACTIONS } from "../harden/workflow-templates.js";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
// Nobody waits for a diagnostic. Both clients are capped, and the pins are
|
|
27
|
+
// asked in parallel, so an unreachable GitHub costs one timeout and not one
|
|
28
|
+
// per action.
|
|
29
|
+
const CALL_TIMEOUT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
const STRATEGY_MANUAL = "manual";
|
|
32
|
+
const API = "https://api.github.com/repos";
|
|
33
|
+
|
|
34
|
+
/** `owner/repo@sha # tag` as written in the templates. */
|
|
35
|
+
export function parsePin(pin) {
|
|
36
|
+
const m = /^([^@\s]+)@([0-9a-f]{40})\s*#\s*(\S+)$/i.exec(String(pin || "").trim());
|
|
37
|
+
return m ? { action: m[1], sha: m[2].toLowerCase(), tag: m[3] } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
41
|
+
const clean = (s) => String(s || "").trim().toLowerCase();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The SHA a tag points at today, asked through `gh` when it is there.
|
|
45
|
+
*
|
|
46
|
+
* Measured, not assumed: plain unauthenticated `fetch` answered 403 on the
|
|
47
|
+
* first real run, because GitHub allows 60 calls an hour PER IP and any shared
|
|
48
|
+
* or NAT-ed address burns that between everyone behind it. A check that
|
|
49
|
+
* answers "could not check" almost always is decorative. `gh` carries the
|
|
50
|
+
* user's own credentials (5000/hour) and kj already relies on it elsewhere;
|
|
51
|
+
* `fetch` stays as the fallback for whoever has no gh.
|
|
52
|
+
*
|
|
53
|
+
* @returns {Promise<string|null>} null when the answer cannot be trusted.
|
|
54
|
+
*/
|
|
55
|
+
export async function currentSha(action, tag, { fetchFn = fetch, ghFn = ghSha } = {}) {
|
|
56
|
+
const viaGh = await ghFn(action, tag);
|
|
57
|
+
if (viaGh) return viaGh;
|
|
58
|
+
const res = await fetchFn(`${API}/${action}/commits/${encodeURIComponent(tag)}`, {
|
|
59
|
+
headers: { Accept: "application/vnd.github.sha", "User-Agent": "karajan-code" },
|
|
60
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
61
|
+
});
|
|
62
|
+
if (!res?.ok) return null;
|
|
63
|
+
const body = clean(await res.text());
|
|
64
|
+
return SHA_RE.test(body) ? body : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {Promise<string|null>} null when gh is absent, unauthenticated or unhappy. */
|
|
68
|
+
async function ghSha(action, tag) {
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await execFileAsync("gh", ["api", `repos/${action}/commits/${tag}`, "--jq", ".sha"], {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
timeout: CALL_TIMEOUT_MS,
|
|
73
|
+
});
|
|
74
|
+
const sha = clean(stdout);
|
|
75
|
+
return SHA_RE.test(sha) ? sha : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @internal Exported for dynamic import from tests. */
|
|
82
|
+
export function createActionPinsCheck({ pins = PINNED_ACTIONS, deps = {} } = {}) {
|
|
83
|
+
return {
|
|
84
|
+
name: "action-pins",
|
|
85
|
+
label: "actions fijadas por SHA",
|
|
86
|
+
strategy: STRATEGY_MANUAL,
|
|
87
|
+
describe: "Detect a pinned GitHub Action whose tag has moved on without it",
|
|
88
|
+
async detect() {
|
|
89
|
+
const entries = Object.entries(pins).map(([key, pin]) => ({ key, ...(parsePin(pin) || {}) }));
|
|
90
|
+
const malformed = entries.filter((e) => !e.sha);
|
|
91
|
+
const asked = await Promise.all(
|
|
92
|
+
entries.filter((x) => x.sha).map(async (e) => {
|
|
93
|
+
// offline, rate limited, DNS down, gh missing: not an answer
|
|
94
|
+
const latest = await currentSha(e.action, e.tag, deps).catch(() => null);
|
|
95
|
+
return { ...e, latest };
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const stale = asked.filter((e) => e.latest && e.latest !== e.sha);
|
|
99
|
+
const unchecked = asked.filter((e) => !e.latest).length;
|
|
100
|
+
|
|
101
|
+
if (malformed.length > 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
severity: "warn",
|
|
105
|
+
detail: `pin mal formado: ${malformed.map((m) => m.key).join(", ")} — un pin que no se puede leer no se puede comprobar`,
|
|
106
|
+
fix: "revisa PINNED_ACTIONS en src/harden/workflow-templates.js: owner/repo@<sha de 40> # <tag>",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (stale.length > 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
severity: "warn",
|
|
113
|
+
detail: stale.map((s) => `${s.action} ${s.tag} apunta hoy a ${s.latest.slice(0, 12)} y kj fija ${s.sha.slice(0, 12)}`).join(" · "),
|
|
114
|
+
fix: stale.map((s) => `${s.action}@${s.latest} # ${s.tag}`).join(" · "),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Never claim what was not looked at (the card's own acceptance
|
|
118
|
+
// criterion): an unreachable API is said, not passed off as green.
|
|
119
|
+
if (unchecked > 0) {
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
severity: "info",
|
|
123
|
+
detail: unchecked === entries.length
|
|
124
|
+
? "no se pudo comprobar ninguna action (¿sin red o con la API de GitHub limitando?)"
|
|
125
|
+
: `${unchecked} action(s) sin comprobar (¿sin red o con la API de GitHub limitando?)`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, severity: "info", detail: `${entries.length} action(s) fijadas y al día` };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -24,6 +24,7 @@ import { checkBinary } from "../utils/agent-detect.js";
|
|
|
24
24
|
import { getInstallCommand } from "../utils/os-detect.js";
|
|
25
25
|
import { STRATEGY } from "./types.js";
|
|
26
26
|
import { createRepoStateCheck } from "./repo-state.js";
|
|
27
|
+
import { createActionPinsCheck } from "./action-pins.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Detect signals del proyecto. Devuelve un set de tags con qué hay en el
|
|
@@ -246,5 +247,10 @@ export function getProjectChecks({ projectDir }) {
|
|
|
246
247
|
// check looks at — the order it was set up in, whether the contract
|
|
247
248
|
// travels, whether this clone declares who works it.
|
|
248
249
|
createRepoStateCheck({ projectDir }),
|
|
250
|
+
// KJC-TSK-0870: pinning the actions by SHA (issue #1374) moved their
|
|
251
|
+
// maintenance onto kj. Doctor asks upstream whether a tag has moved on
|
|
252
|
+
// without its pin; `kj check` deliberately does not, because a gate that
|
|
253
|
+
// needs the network is a gate that fails on a plane.
|
|
254
|
+
createActionPinsCheck(),
|
|
249
255
|
];
|
|
250
256
|
}
|
|
@@ -179,11 +179,21 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
179
179
|
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
180
180
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
181
181
|
.option("--coder <name>")
|
|
182
|
+
// KJC-TSK-0864: --coder is the pipeline's word for it, --agent is the
|
|
183
|
+
// session's. Same knob, two vocabularies; naming both and disagreeing is
|
|
184
|
+
// an error, never a silent precedence.
|
|
185
|
+
.option("--agent <name>", "The agent that writes (alias of --coder)")
|
|
186
|
+
.option("--card <ref>", "Card/HU the work belongs to — its statement and criteria reach the coder")
|
|
182
187
|
.option("--coder-model <name>")
|
|
183
188
|
.action(async (task, flags) => {
|
|
189
|
+
if (flags.agent && flags.coder && flags.agent !== flags.coder) {
|
|
190
|
+
console.error(`kj code: --agent ${flags.agent} and --coder ${flags.coder} name different agents — pick one`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
if (flags.agent) flags.coder = flags.agent;
|
|
184
194
|
await withConfig(pkgVersion, "code", flags, async ({ config, logger }) => {
|
|
185
195
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
186
|
-
await codeCommand({ task: resolvedTask, config, logger });
|
|
196
|
+
await codeCommand({ task: resolvedTask, config, logger, flags });
|
|
187
197
|
});
|
|
188
198
|
});
|
|
189
199
|
|
package/src/commands/check.js
CHANGED
|
@@ -9,6 +9,7 @@ import { collectMethodStats, formatMethodStats } from "../checks/method.js";
|
|
|
9
9
|
import { createRagCoverageCheck } from "../checks/rag-coverage.js";
|
|
10
10
|
import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
|
|
11
11
|
import { detectObservedAgents } from "../utils/agent-detect.js";
|
|
12
|
+
import { panelSummary } from "../environment/panel.js";
|
|
12
13
|
import { loadConfig } from "../config.js";
|
|
13
14
|
|
|
14
15
|
export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
|
|
@@ -44,6 +45,10 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
|
|
|
44
45
|
if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
|
|
45
46
|
logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
|
|
46
47
|
if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
|
|
48
|
+
// KJC-TSK-0865: who writes and who reviews is the user's choice, so it is
|
|
49
|
+
// said out loud next to everything else that describes the project. Never
|
|
50
|
+
// a check: it has no ok/fail, it informs.
|
|
51
|
+
logger.info?.(` ${panelSummary(config)}`);
|
|
47
52
|
if (ok) logger.info?.("Harness OK.");
|
|
48
53
|
else if (result.ok) logger.info?.("RAG index drift detected — a gate cannot protect what it cannot see.");
|
|
49
54
|
else logger.info?.("Harness drift detected — run `kj harden` to repair.");
|
package/src/commands/code.js
CHANGED
|
@@ -2,13 +2,35 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import { createAgent } from "../agents/index.js";
|
|
3
3
|
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
4
4
|
import { buildCoderPrompt } from "../prompts/coder.js";
|
|
5
|
+
import { resolveCardContext } from "../prompts/card-context.js";
|
|
6
|
+
import { composeTask, resolveRagContext } from "../prompts/session-context.js";
|
|
5
7
|
import { resolveRole } from "../config.js";
|
|
8
|
+
import { withBrainRecovery, DEFAULT_RECOVERY_POLICY } from "../brain/with-brain-recovery.js";
|
|
9
|
+
import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
|
|
10
|
+
import { ERROR_CLASS } from "../brain/agent-error-classifier.js";
|
|
11
|
+
|
|
12
|
+
// KJC-TSK-0859: the agents no longer substitute a dead model behind your back,
|
|
13
|
+
// so the command needs the declared chain. A one-shot CLI must not inherit the
|
|
14
|
+
// pipeline's standby either: sleeping five hours inside `kj code` would be
|
|
15
|
+
// worse than the failure. A quota wall takes the next candidate at once, and
|
|
16
|
+
// with none left it stops and says what it tried.
|
|
17
|
+
const ONE_SHOT_POLICY = Object.freeze({
|
|
18
|
+
...DEFAULT_RECOVERY_POLICY,
|
|
19
|
+
classes: {
|
|
20
|
+
...DEFAULT_RECOVERY_POLICY.classes,
|
|
21
|
+
[ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
|
|
22
|
+
[ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
6
25
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
7
26
|
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
8
27
|
|
|
9
|
-
export async function codeCommand({ task, config, logger }) {
|
|
28
|
+
export async function codeCommand({ task, config, logger, flags = {} }) {
|
|
10
29
|
return withCliRunLog("code", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
11
30
|
const coderRole = resolveRole(config, "coder");
|
|
31
|
+
// KJC-TSK-0864: no quota, not authenticated or simply absent? This THROWS
|
|
32
|
+
// with the agent named. The one thing it never does is quietly fall back
|
|
33
|
+
// to whoever happens to be reachable.
|
|
12
34
|
await assertAgentsAvailable([coderRole.provider]);
|
|
13
35
|
logger.info(`Coder (${coderRole.provider}) starting...`);
|
|
14
36
|
runLog.logText(`[coder] provider=${coderRole.provider}`);
|
|
@@ -21,11 +43,39 @@ export async function codeCommand({ task, config, logger }) {
|
|
|
21
43
|
try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
|
|
22
44
|
}
|
|
23
45
|
}
|
|
24
|
-
const
|
|
46
|
+
const card = await resolveCardContext({ projectDir: config.projectDir, ref: flags.card || null });
|
|
47
|
+
if (card?.external) logger.warn(`card ${card.huId} lives on a board kj cannot read — passing the reference, not its text`);
|
|
48
|
+
else if (card) logger.info(`Card ${card.huId}: ${card.title}`);
|
|
49
|
+
// The method's first invariant is that the RAG answers before you assume.
|
|
50
|
+
// The session asks on the coder's behalf: a subprocess coder cannot run
|
|
51
|
+
// `kj rag query` itself, so it used to guess the codebase instead.
|
|
52
|
+
const rag = await resolveRagContext({ task, config, logger });
|
|
53
|
+
// The pipeline always passed projectDir (it arms the directory-boundary
|
|
54
|
+
// rule and the skills section) and the provider. `kj code` did not, so the
|
|
55
|
+
// declared coder got a weaker prompt from the session than from `kj run`.
|
|
56
|
+
const prompt = await buildCoderPrompt({
|
|
57
|
+
task: composeTask(task, [card?.section, rag?.section]),
|
|
58
|
+
coderRules,
|
|
59
|
+
methodology: config.development?.methodology || "tdd",
|
|
60
|
+
projectDir: config.projectDir,
|
|
61
|
+
provider: coderRole.provider,
|
|
62
|
+
huId: card?.huId ?? null,
|
|
63
|
+
acceptanceTests: card?.acceptanceTests ?? null,
|
|
64
|
+
serenaEnabled: Boolean(config.serena?.enabled),
|
|
65
|
+
rtkAvailable: Boolean(config.rtk?.available),
|
|
66
|
+
});
|
|
25
67
|
const progress = createCliProgressReporter({ role: "coder" });
|
|
26
68
|
let result;
|
|
27
69
|
try {
|
|
28
|
-
result = await
|
|
70
|
+
result = await withBrainRecovery({
|
|
71
|
+
agent: { runTask: (args) => coder.runTask(args), provider: coderRole.provider, model: coderRole.model },
|
|
72
|
+
taskArgs: { prompt, onOutput: progress.onOutput, role: "coder" },
|
|
73
|
+
role: "coder",
|
|
74
|
+
provider: coderRole.provider,
|
|
75
|
+
logger,
|
|
76
|
+
policy: ONE_SHOT_POLICY,
|
|
77
|
+
fallback: buildRoleFallbackChain({ config, role: "coder", logger }),
|
|
78
|
+
});
|
|
29
79
|
progress.finish(result.ok ? "done" : "failed");
|
|
30
80
|
} catch (err) { progress.finish("failed"); throw err; }
|
|
31
81
|
if (!result.ok) {
|
|
@@ -39,7 +89,10 @@ export async function codeCommand({ task, config, logger }) {
|
|
|
39
89
|
logger.warn(result.error);
|
|
40
90
|
}
|
|
41
91
|
logger.info(`Coder completed (exit ${result.exitCode})`);
|
|
92
|
+
// The work is in the tree, not committed: the gate is the next step and a
|
|
93
|
+
// DIFFERENT AI runs it. Saying so here is what keeps the panel honest.
|
|
94
|
+
logger.info(`The work is in the tree. Review it before committing: kj review --staged (a different AI than ${coderRole.provider}).`);
|
|
42
95
|
runLog.logText(`[coder] finished (exit=${result.exitCode})`);
|
|
43
|
-
return { ok: true };
|
|
96
|
+
return { ok: true, provider: coderRole.provider, card: card?.huId ?? null, ragSources: rag?.sources ?? [] };
|
|
44
97
|
});
|
|
45
98
|
}
|
package/src/commands/env.js
CHANGED
|
@@ -71,6 +71,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
71
71
|
projectDir, target: flags.target || "all",
|
|
72
72
|
stateBackend: config?.state_backend || "hu-board",
|
|
73
73
|
boardName: config?.board?.name || null,
|
|
74
|
+
config, // KJC-TSK-0865: the panel travels with the method
|
|
74
75
|
});
|
|
75
76
|
console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
|
|
76
77
|
|
|
@@ -213,7 +214,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
213
214
|
// CLAUDE.md, but the running session loaded its context BEFORE — nobody
|
|
214
215
|
// re-reads it. Print the method so it enters THIS conversation now.
|
|
215
216
|
console.log("\n— The Karajan method below is IN EFFECT from this very message. If your session started before this install, apply it from now on:\n");
|
|
216
|
-
console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null }));
|
|
217
|
+
console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null, config }));
|
|
217
218
|
}
|
|
218
219
|
return result;
|
|
219
220
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -171,7 +171,9 @@ export async function hardenCommand({
|
|
|
171
171
|
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, mutation, dryRun })
|
|
172
172
|
: null;
|
|
173
173
|
const withGuidelines = guidelines && profile !== "minimal";
|
|
174
|
-
|
|
174
|
+
// KJC-BUG-0199 (issue #1773): the same detected language the workflows and
|
|
175
|
+
// the configs already use — a Python project must not be told to use `const`.
|
|
176
|
+
const gl = withGuidelines ? installGuidelines({ projectDir, language: roots[0]?.language ?? null, dryRun }) : null;
|
|
175
177
|
const out = {
|
|
176
178
|
ok: true,
|
|
177
179
|
...result,
|
|
@@ -22,6 +22,7 @@ import { checkCardFirst } from "../review/card-first.js";
|
|
|
22
22
|
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
23
23
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
24
24
|
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
25
|
+
import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
|
|
25
26
|
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
26
27
|
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
27
28
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
@@ -230,17 +231,24 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
230
231
|
// don't publish. In the SEA binary the privacy module is stubbed and
|
|
231
232
|
// throws: the gate degrades with a note instead of crashing.
|
|
232
233
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
234
|
+
// KJC-BUG-0203: per file, so a finding can name where it lives and so
|
|
235
|
+
// build output is judged as what it is. Generic heuristics are silenced
|
|
236
|
+
// there (nobody typed a minified bundle); a denylist hit is not, because
|
|
237
|
+
// the incident behind this scanner was personal data inside a build.
|
|
238
|
+
const list = loadPrivacyList();
|
|
239
|
+
const findings = splitAddedByFile(diff).flatMap(({ file, added }) => {
|
|
240
|
+
const found = scanText(added, { list, source: file });
|
|
241
|
+
return isGeneratedPath(file) ? found.filter((f) => f.severity === "block") : found;
|
|
242
|
+
});
|
|
235
243
|
const blocks = findings.filter((f) => f.severity === "block");
|
|
236
244
|
const warns = findings.filter((f) => f.severity === "warn");
|
|
237
|
-
for (const f of warns) console.log(`⚠ privacy: [${f.type}]
|
|
245
|
+
for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
|
|
238
246
|
const hardened = config?.privacy?.generic === "block" && warns.length > 0;
|
|
239
247
|
if (blocks.length > 0 || hardened) {
|
|
240
248
|
if (process.env.KJ_ALLOW_PII === "1") {
|
|
241
249
|
console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
|
|
242
250
|
} else {
|
|
243
|
-
for (const f of blocks) console.log(`✗ privacy: [${f.type}]
|
|
251
|
+
for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
244
252
|
const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (KJ_ALLOW_PII=1 to override consciously)`;
|
|
245
253
|
console.log(`✗ privacy gate: ${reason}`);
|
|
246
254
|
process.exitCode = 1;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's panel, said out loud (KJC-TSK-0865).
|
|
3
|
+
*
|
|
4
|
+
* The user chooses who writes and who reviews. `kj run` honoured that choice
|
|
5
|
+
* because it launched the agents itself; since v4 the HOST agent orchestrates,
|
|
6
|
+
* and nothing carried the choice into the session, so the host wrote the code
|
|
7
|
+
* and the user found out by accident.
|
|
8
|
+
*
|
|
9
|
+
* The panel line travels with the playbook, which is what every host reads at
|
|
10
|
+
* session start. It ANNOUNCES, it never blocks: the host may still write the
|
|
11
|
+
* code, and then it says so instead of staying quiet.
|
|
12
|
+
*/
|
|
13
|
+
import { resolveRole } from "../config/role-resolver.js";
|
|
14
|
+
|
|
15
|
+
/** Roles worth announcing: who writes, who reviews, who arbitrates. */
|
|
16
|
+
export const PANEL_ROLES = ["coder", "reviewer", "solomon"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @returns {{coder: string|null, reviewer: string|null, solomon: string|null}}
|
|
20
|
+
* the provider declared (or inherited) for each panel role.
|
|
21
|
+
*/
|
|
22
|
+
export function resolvePanel(config) {
|
|
23
|
+
const panel = {};
|
|
24
|
+
for (const role of PANEL_ROLES) panel[role] = resolveRole(config, role).provider || null;
|
|
25
|
+
return panel;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One agent-facing line naming the panel, or null when the project declares
|
|
30
|
+
* no coder and no reviewer. A project without a panel gets no line at all:
|
|
31
|
+
* announcing "coder: nobody" would spend attention to say nothing.
|
|
32
|
+
*/
|
|
33
|
+
export function panelLine(config) {
|
|
34
|
+
const { coder, reviewer, solomon } = resolvePanel(config);
|
|
35
|
+
if (!coder && !reviewer) return null;
|
|
36
|
+
const parts = [`coder=${coder || "unset"}`, `reviewer=${reviewer || "unset"}`];
|
|
37
|
+
if (solomon && solomon !== coder) parts.push(`solomon=${solomon}`);
|
|
38
|
+
return [
|
|
39
|
+
`- The panel is your user's, not yours: ${parts.join(", ")}.`,
|
|
40
|
+
" When the coder is not you, the writing is ITS job; write it yourself",
|
|
41
|
+
" anyway and say so in your closing message, never in silence.",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Human-facing one-liner for `kj check` and friends. */
|
|
46
|
+
export function panelSummary(config) {
|
|
47
|
+
const { coder, reviewer, solomon } = resolvePanel(config);
|
|
48
|
+
if (!coder && !reviewer) return "panel: not declared (no coder, no reviewer)";
|
|
49
|
+
const parts = [`coder ${coder || "unset"}`, `reviewer ${reviewer || "unset"}`];
|
|
50
|
+
if (solomon && solomon !== coder) parts.push(`solomon ${solomon}`);
|
|
51
|
+
return `panel: ${parts.join(" · ")}`;
|
|
52
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
15
|
+
import { panelLine } from "./panel.js";
|
|
15
16
|
|
|
16
17
|
// AB-A (KJC-TSK-0650): any agent can be the brain. AGENTS.md is the
|
|
17
18
|
// emerging standard (Codex, Cursor and most new CLIs read it); GEMINI.md
|
|
@@ -48,7 +49,7 @@ function trackingLine(stateBackend, boardName) {
|
|
|
48
49
|
// AB-C2 (KJC-TSK-0653): outcome-first — invariants over step scripts.
|
|
49
50
|
// Frontier models choose their own path best; what they need explicit are
|
|
50
51
|
// the limits. The git gates enforce these regardless of what any brain does.
|
|
51
|
-
const playbookBody = (stateBackend, boardName) => `# Karajan method (v4)
|
|
52
|
+
const playbookBody = (stateBackend, boardName, panel) => `# Karajan method (v4)
|
|
52
53
|
|
|
53
54
|
You are the orchestrator; Karajan governs. A task is DONE when its
|
|
54
55
|
done-statement is literally true, the full suite is green, and every commit
|
|
@@ -68,7 +69,7 @@ Invariants (the git gates enforce these — they are not suggestions):
|
|
|
68
69
|
- Every diff is reviewed by a DIFFERENT AI before it is committed
|
|
69
70
|
(\`kj review --staged\`): verdicts bind to the exact diff — change the code
|
|
70
71
|
and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
|
|
71
|
-
- Security findings are never overridable — not even by arbitration. You
|
|
72
|
+
${panel ? `${panel}\n` : ""}- Security findings are never overridable — not even by arbitration. You
|
|
72
73
|
absorb the security role: \`kj brief security\` states what must be true.
|
|
73
74
|
Task touches auth, user input, secrets, network or deps? Run
|
|
74
75
|
\`kj audit --security\` (zero tokens) and remediate BEFORE the review.
|
|
@@ -100,15 +101,15 @@ kj announces a newer version? Tell your user what it brings and ask —
|
|
|
100
101
|
never run \`kj update\` on your own.
|
|
101
102
|
`;
|
|
102
103
|
|
|
103
|
-
export function renderPlaybook({ stateBackend = "hu-board", boardName = null } = {}) {
|
|
104
|
-
return playbookBody(stateBackend, boardName);
|
|
104
|
+
export function renderPlaybook({ stateBackend = "hu-board", boardName = null, config = null } = {}) {
|
|
105
|
+
return playbookBody(stateBackend, boardName, panelLine(config));
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
109
|
* Install/refresh the playbook block in the target agent files.
|
|
109
110
|
* User content outside the managed block is never touched.
|
|
110
111
|
*/
|
|
111
|
-
export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board", boardName = null }) {
|
|
112
|
+
export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board", boardName = null, config = null }) {
|
|
112
113
|
if (!PLAYBOOK_TARGETS.includes(target)) {
|
|
113
114
|
throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
|
|
114
115
|
}
|
|
@@ -118,7 +119,7 @@ export async function installPlaybook({ projectDir, target = "all", version = "1
|
|
|
118
119
|
let source = "";
|
|
119
120
|
try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
|
|
120
121
|
const { content, action } = upsertManagedBlock({
|
|
121
|
-
source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName }), style: "html",
|
|
122
|
+
source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName, config }), style: "html",
|
|
122
123
|
note: "do not edit: regenerated by kj env install",
|
|
123
124
|
});
|
|
124
125
|
if (action !== "unchanged") await fs.writeFile(fullPath, content);
|
|
@@ -90,9 +90,14 @@ export const GOLANGCI_BODY = [
|
|
|
90
90
|
].join("\n");
|
|
91
91
|
|
|
92
92
|
/** Language-agnostic configs (every stack gets these). */
|
|
93
|
+
// KJC-BUG-0201 (issue #1774, reported from the field): commitlint used to live
|
|
94
|
+
// here, so a Python repo needed a Node runtime just to validate commit
|
|
95
|
+
// messages. The generated commit-msg hook ALREADY enforces that contract in
|
|
96
|
+
// pure sh (Conventional Commits, the 100-char cap, the AI-attribution ban), so
|
|
97
|
+
// nothing is lost by scoping the tool to the stack that already has Node. The
|
|
98
|
+
// GUARANTEE is language-agnostic; the tool does not have to be.
|
|
93
99
|
export const UNIVERSAL_CONFIGS = [
|
|
94
100
|
{ file: ".editorconfig", blockId: "editorconfig", style: "hash", body: EDITORCONFIG_BODY },
|
|
95
|
-
{ file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
|
|
96
101
|
];
|
|
97
102
|
|
|
98
103
|
/**
|
|
@@ -102,6 +107,7 @@ export const UNIVERSAL_CONFIGS = [
|
|
|
102
107
|
* tool is decorative, and kj never generates a demand it didn't satisfy.
|
|
103
108
|
*/
|
|
104
109
|
export const JS_CONFIGS = [
|
|
110
|
+
{ file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
|
|
105
111
|
{ file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY, requires: "eslint" },
|
|
106
112
|
{ file: ".prettierrc.json", json: true, body: PRETTIER_BODY, requires: "prettier" },
|
|
107
113
|
];
|
|
@@ -9,7 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
11
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
12
|
-
import {
|
|
12
|
+
import { guidelinesBody } from "./guidelines-templates.js";
|
|
13
13
|
|
|
14
14
|
const BLOCK_VERSION = 1;
|
|
15
15
|
const TARGETS = ["AGENTS.md", "CLAUDE.md"];
|
|
@@ -28,7 +28,11 @@ export function stripDevHooksBlock(source) {
|
|
|
28
28
|
return { content: `${before}${joiner}${after}`, migrated: true };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function installGuidelines({ projectDir = process.cwd(), dryRun = false } = {}) {
|
|
31
|
+
export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
|
|
32
|
+
// KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
|
|
33
|
+
// be the rules of THIS project's language. kj harden already detects it for
|
|
34
|
+
// the configs; the guidelines just never asked.
|
|
35
|
+
const body = guidelinesBody(language);
|
|
32
36
|
const results = [];
|
|
33
37
|
for (const file of TARGETS) {
|
|
34
38
|
const target = join(projectDir, file);
|
|
@@ -38,7 +42,7 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false }
|
|
|
38
42
|
source,
|
|
39
43
|
blockId: "guidelines",
|
|
40
44
|
version: BLOCK_VERSION,
|
|
41
|
-
body
|
|
45
|
+
body,
|
|
42
46
|
style: "html",
|
|
43
47
|
});
|
|
44
48
|
if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
|
|
@@ -7,14 +7,21 @@
|
|
|
7
7
|
* bloat dilutes the signal. Distilled from the dev-toolkit guidelines.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// KJC-BUG-0199 (issue #1773, reported from the field): this used to be ONE
|
|
11
|
+
// fixed text, so a Python project was told to use `const`, target ES2025 and
|
|
12
|
+
// prefer ES modules over require. These rules enter the agent's context on
|
|
13
|
+
// EVERY run, so the noise is not harmless: it dilutes the signal and states
|
|
14
|
+
// things that do not apply. The configs were already language-aware (ruff for
|
|
15
|
+
// Python, golangci for Go); the guidelines had not caught up.
|
|
16
|
+
//
|
|
17
|
+
// Shape: a CORE that holds in any language, plus one block per language. A
|
|
18
|
+
// language kj does not know gets the core ALONE, never another language's
|
|
19
|
+
// rules, which is the failure this fixes.
|
|
20
|
+
const CORE = [
|
|
11
21
|
"# Project guidelines (kj harden)",
|
|
12
22
|
"",
|
|
13
23
|
"## Code",
|
|
14
|
-
"- SOLID, DRY, KISS, YAGNI.
|
|
15
|
-
"- ES2025 target: never `var`, `document.write`, `alert`/`confirm`/`prompt`, `escape`/`unescape`, `substr`.",
|
|
16
|
-
" Prefer modern APIs (`structuredClone`, `Object.groupBy`, `Array.at`/`findLast`/`toSorted`, optional chaining, `??`).",
|
|
17
|
-
"- ES modules (`import`/`export`), never `require` in new code. Names in English, descriptive.",
|
|
24
|
+
"- SOLID, DRY, KISS, YAGNI. Names in English, descriptive.",
|
|
18
25
|
"- No silent fallbacks: the system works or fails loudly. Validate and sanitize all input.",
|
|
19
26
|
"",
|
|
20
27
|
"## Commits & PRs",
|
|
@@ -26,11 +33,54 @@ export const GUIDELINES_BODY = [
|
|
|
26
33
|
"- Test-first. Run the tests after each meaningful change. Never skip tests.",
|
|
27
34
|
"",
|
|
28
35
|
"## Security",
|
|
29
|
-
"- Never commit secrets, keys or tokens. Parameterized queries; sanitize output against
|
|
30
|
-
"",
|
|
31
|
-
"## UI/UX",
|
|
32
|
-
"- No native `alert`/`confirm`/`prompt` — use the app's modal system. Loading states; accessible; mobile-first.",
|
|
36
|
+
"- Never commit secrets, keys or tokens. Parameterized queries; sanitize output against injection.",
|
|
33
37
|
"",
|
|
34
38
|
"## Files",
|
|
35
39
|
"- Edit existing files in place; never overwrite a whole file to make a small change.",
|
|
36
|
-
]
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Per-language rules. A language absent from here gets the core alone. */
|
|
43
|
+
export const LANGUAGE_GUIDELINES = {
|
|
44
|
+
javascript: [
|
|
45
|
+
"",
|
|
46
|
+
"## JavaScript / TypeScript",
|
|
47
|
+
"- `const` by default; arrow callbacks; template literals.",
|
|
48
|
+
"- ES2025 target: never `var`, `document.write`, `alert`/`confirm`/`prompt`, `escape`/`unescape`, `substr`.",
|
|
49
|
+
" Prefer modern APIs (`structuredClone`, `Object.groupBy`, `Array.at`/`findLast`/`toSorted`, optional chaining, `??`).",
|
|
50
|
+
"- ES modules (`import`/`export`), never `require` in new code. Avoid `any` in TypeScript.",
|
|
51
|
+
"",
|
|
52
|
+
"## UI/UX",
|
|
53
|
+
"- No native `alert`/`confirm`/`prompt` — use the app's modal system. Loading states; accessible; mobile-first.",
|
|
54
|
+
],
|
|
55
|
+
python: [
|
|
56
|
+
"",
|
|
57
|
+
"## Python",
|
|
58
|
+
"- PEP 8, and type hints on every public signature. Prefer `pathlib` over string paths.",
|
|
59
|
+
"- Never a bare `except:` — catch what you can handle and let the rest fail loudly.",
|
|
60
|
+
"- f-strings over concatenation or `%`. Comprehensions when they read better than a loop, not by default.",
|
|
61
|
+
"- Tooling and dependencies declared in `pyproject.toml`; no other language's runtime imposed on the project.",
|
|
62
|
+
],
|
|
63
|
+
go: [
|
|
64
|
+
"",
|
|
65
|
+
"## Go",
|
|
66
|
+
"- `gofmt` is not negotiable. Errors are values: wrap with `%w` and handle them where the caller can decide.",
|
|
67
|
+
"- No naked returns in long functions; accept interfaces, return structs.",
|
|
68
|
+
"- Concurrency with a purpose: a goroutine with no way to stop it is a leak.",
|
|
69
|
+
],
|
|
70
|
+
rust: [
|
|
71
|
+
"",
|
|
72
|
+
"## Rust",
|
|
73
|
+
"- `cargo fmt` and `cargo clippy` clean before a commit.",
|
|
74
|
+
"- No `unwrap()` outside tests: propagate with `?` and let the type say what can fail.",
|
|
75
|
+
"- `unsafe` needs a comment naming the invariant that makes it sound.",
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The guidelines body for a language (unknown or absent ⇒ the core alone). */
|
|
80
|
+
export function guidelinesBody(language = null) {
|
|
81
|
+
const extra = LANGUAGE_GUIDELINES[String(language || "").toLowerCase()] ?? [];
|
|
82
|
+
return [...CORE, ...extra].join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Back-compat for callers that predate the language split. */
|
|
86
|
+
export const GUIDELINES_BODY = guidelinesBody("javascript");
|
|
@@ -126,7 +126,10 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
126
126
|
"# KJC-BUG-0170: the generated supervisor hooks (.karajan/hooks/pre-commit,",
|
|
127
127
|
"# commit-msg) also CONTAIN the pattern by design — the bootstrap commit",
|
|
128
128
|
"# that versions them must not self-detect in the consumer repo.",
|
|
129
|
-
|
|
129
|
+
"# KJC-BUG-0200 (issue #1772): the same is true of .karajan/harness — the",
|
|
130
|
+
"# whole DIRECTORY is excluded, not one file, so a supervisor script added",
|
|
131
|
+
"# later cannot bring the bug back.",
|
|
132
|
+
`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' ':(exclude).karajan/harness/' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
|
|
130
133
|
" echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
|
|
131
134
|
"fi",
|
|
132
135
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|