karajan-code 3.7.1 → 3.8.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.
@@ -27,7 +27,8 @@ const DEFAULTS = {
27
27
  triage: { provider: null, model: null },
28
28
  discover: { provider: null, model: null },
29
29
  architect: { provider: null, model: null },
30
- hu_reviewer: { provider: null, model: null }
30
+ hu_reviewer: { provider: null, model: null },
31
+ start: { provider: null, model: "haiku" }
31
32
  },
32
33
  pipeline: {
33
34
  planner: { enabled: false },
@@ -90,6 +90,7 @@ const RolesMap = v.optional(v.looseObject({
90
90
  discover: v.optional(RoleEntry),
91
91
  architect: v.optional(RoleEntry),
92
92
  hu_reviewer: v.optional(RoleEntry),
93
+ start: v.optional(RoleEntry),
93
94
  }));
94
95
 
95
96
  const PipelineEntry = v.looseObject({
@@ -0,0 +1,71 @@
1
+ // KJC-TSK-0583 — Inbound secret redaction for the review boundary.
2
+ //
3
+ // The outbound boundary (output-guard.js) already blocks committing secrets.
4
+ // This closes the *inbound* gap: a hardcoded credential in the working tree
5
+ // would otherwise reach the (possibly cloud) reviewer model verbatim inside the
6
+ // diff. `redactSecrets` masks each credential before the model sees it, reusing
7
+ // the SAME catalog as output-guard — no new patterns, no AI cost, deterministic.
8
+ import { CREDENTIAL_PATTERNS } from "./output-guard.js";
9
+
10
+ // Families whose match begins with a fixed, identifiable literal prefix. The
11
+ // placeholder keeps that prefix so the reviewer can still tell WHAT kind of
12
+ // secret was redacted (and say "move it to .env") without seeing the value.
13
+ const STABLE_PREFIX_BY_ID = {
14
+ "aws-key": "AKIA",
15
+ "npm-token": "npm_",
16
+ "openai-key": "sk-",
17
+ "anthropic-key": "sk-ant-",
18
+ "stripe-key": "sk_live_",
19
+ "stripe-test": "sk_test_",
20
+ "google-api-key": "AIza",
21
+ };
22
+
23
+ // Families with a dynamic prefix: keep the real match up to and including the
24
+ // first separator (ghp_/gho_/…, xoxb-/xoxp-/…), then mask the rest.
25
+ const DYNAMIC_SEPARATOR_BY_ID = {
26
+ "github-token": "_",
27
+ "slack-token": "-",
28
+ };
29
+
30
+ const GENERIC = "***REDACTED-SECRET***";
31
+ const SUFFIX = "***REDACTED***";
32
+
33
+ function placeholderFor(id, match) {
34
+ const stable = STABLE_PREFIX_BY_ID[id];
35
+ if (stable) return `${stable}${SUFFIX}`;
36
+ const sep = DYNAMIC_SEPARATOR_BY_ID[id];
37
+ if (sep) {
38
+ const idx = match.indexOf(sep);
39
+ return idx >= 0 ? `${match.slice(0, idx + 1)}${SUFFIX}` : GENERIC;
40
+ }
41
+ return GENERIC;
42
+ }
43
+
44
+ // Apply the most specific prefixes first so `sk-ant-` wins over `sk-`; families
45
+ // without a stable prefix keep their catalog order (stable sort) and run last.
46
+ function orderedPatterns() {
47
+ return [...CREDENTIAL_PATTERNS].sort(
48
+ (a, b) => (STABLE_PREFIX_BY_ID[b.id]?.length ?? 0) - (STABLE_PREFIX_BY_ID[a.id]?.length ?? 0)
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Mask every credential in `text`, preserving line structure. Pure and
54
+ * deterministic: clean text is returned untouched (no-op, no cost).
55
+ * @param {string} text
56
+ * @returns {string}
57
+ */
58
+ export function redactSecrets(text) {
59
+ if (typeof text !== "string" || text.length === 0) return text;
60
+ let out = text;
61
+ for (const { id, pattern } of orderedPatterns()) {
62
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
63
+ out = out.replace(new RegExp(pattern.source, flags), (match) =>
64
+ // Idempotent: a broad pattern (generic-secret, hardcoded-key-assignment)
65
+ // can span an already-masked value; leave the more specific placeholder
66
+ // in place instead of clobbering it with the generic one.
67
+ match.includes("REDACTED") ? match : placeholderFor(id, match)
68
+ );
69
+ }
70
+ return out;
71
+ }
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
12
  import { upsertManagedBlock } from "../utils/managed-markers.js";
13
- import { extraWorkflowsFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
13
+ import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
14
14
 
15
15
  const BLOCK_VERSION = 1;
16
16
  const WORKFLOWS_DIR = join(".github", "workflows");
@@ -31,12 +31,14 @@ export function installWorkflows({
31
31
  projectDir = process.cwd(),
32
32
  language = null,
33
33
  profile = "standard",
34
+ mutation = false,
34
35
  dryRun = false,
35
36
  } = {}) {
36
37
  const dir = join(projectDir, WORKFLOWS_DIR);
37
38
  const quality = qualityWorkflowFor(language);
38
39
  const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir) });
39
- const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras];
40
+ const mut = mutation ? mutationWorkflowFor(language) : null;
41
+ const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : [])];
40
42
  const results = [];
41
43
  for (const wf of all) {
42
44
  const target = join(dir, wf.file);
@@ -147,6 +147,58 @@ export const PACK_SMOKE_WORKFLOW = [
147
147
  ' echo "Tarball installs clean: $tgz"',
148
148
  ].join("\n");
149
149
 
150
+ // Opt-in nightly/manual mutation audit (KJC-TSK-0589). NEVER a PR gate: it runs
151
+ // on a weekly schedule + manual dispatch, and the mutate step is non-blocking
152
+ // (continue-on-error) so a red run never nags. Only stacks whose `kj mutate`
153
+ // yields a JSON report (stryker/mutmut/infection) get a workflow — no silent
154
+ // scaffold for tools that can't report yet.
155
+ const MUTATION_TOOLCHAIN = {
156
+ javascript: [" - run: npm ci"],
157
+ python: [
158
+ " - uses: actions/setup-python@v5",
159
+ " with:",
160
+ " python-version: '3.12'",
161
+ " - run: pip install mutmut pytest",
162
+ ],
163
+ php: [
164
+ " - uses: shivammathur/setup-php@v2",
165
+ " with:",
166
+ " php-version: '8.3'",
167
+ " - run: composer install --no-interaction --no-progress",
168
+ ],
169
+ };
170
+ MUTATION_TOOLCHAIN.typescript = MUTATION_TOOLCHAIN.javascript;
171
+
172
+ /** Opt-in mutation workflow entry, or null for a stack without a JSON report. */
173
+ export function mutationWorkflowFor(language) {
174
+ const toolchain = MUTATION_TOOLCHAIN[language];
175
+ if (!toolchain) return null;
176
+ const body = [
177
+ "name: Mutation (nightly)",
178
+ "on:",
179
+ " schedule:",
180
+ " - cron: '0 4 * * 1'",
181
+ " workflow_dispatch:",
182
+ "permissions:",
183
+ " contents: read",
184
+ "jobs:",
185
+ " mutation:",
186
+ " runs-on: ubuntu-latest",
187
+ " steps:",
188
+ " - uses: actions/checkout@v4",
189
+ " with:",
190
+ " fetch-depth: 0",
191
+ " - uses: actions/setup-node@v4",
192
+ " with:",
193
+ " node-version: 22",
194
+ ...toolchain,
195
+ " - name: Mutation testing (advisory, never blocks a PR)",
196
+ " continue-on-error: true",
197
+ " run: npx --yes --package karajan-code kj mutate --since HEAD~1",
198
+ ].join("\n");
199
+ return { file: "kj-mutation.yml", blockId: "wf-mutation", body };
200
+ }
201
+
150
202
  /** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
151
203
  export function extraWorkflowsFor({ profile = "standard", publishable = false } = {}) {
152
204
  const extras = [];
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Diff-scoping for mutation testing. Translates a git range (`--since <ref>`)
3
+ * into changed source files + line ranges, then into the scope flags of the
4
+ * tool resolved by the registry (`src/mutate/tool-registry.js`). Mutating only
5
+ * what the coder just touched is what makes mutation testing cheap and useful.
6
+ * The git call is injectable so the pure logic is testable without a repo.
7
+ */
8
+
9
+ import { runCommand } from "../utils/process.js";
10
+ import { getMutationTool } from "./tool-registry.js";
11
+
12
+ const SOURCE_EXTENSIONS = {
13
+ javascript: [".js", ".mjs", ".cjs", ".jsx"],
14
+ typescript: [".ts", ".tsx"],
15
+ python: [".py"],
16
+ php: [".php"],
17
+ go: [".go"],
18
+ java: [".java"],
19
+ };
20
+
21
+ const HUNK = /^@@ -\S+ \+(\d+)(?:,(\d+))?/;
22
+
23
+ /**
24
+ * Parse `git diff --unified=0` into new-side line ranges per file. Deleted
25
+ * files (`+++ /dev/null`) are omitted — there is nothing to mutate.
26
+ * @returns {Record<string, Array<[number, number]>>}
27
+ */
28
+ export function parseUnifiedDiff(diffText) {
29
+ const ranges = {};
30
+ let current = null;
31
+ for (const line of String(diffText || "").split("\n")) {
32
+ if (line.startsWith("+++ ")) {
33
+ const target = line.slice(4).trim();
34
+ current = target === "/dev/null" ? null : target.replace(/^b\//, "");
35
+ if (current) ranges[current] ??= [];
36
+ continue;
37
+ }
38
+ if (!current) continue;
39
+ const match = HUNK.exec(line);
40
+ if (!match) continue;
41
+ const start = Number(match[1]);
42
+ const count = match[2] === undefined ? 1 : Number(match[2]);
43
+ if (count > 0) ranges[current].push([start, start + count - 1]);
44
+ }
45
+ return ranges;
46
+ }
47
+
48
+ /** @returns {string[]} source extensions for a language ([] if unsupported) */
49
+ export function sourceExtensions(language) {
50
+ return SOURCE_EXTENSIONS[language] ?? [];
51
+ }
52
+
53
+ /** Keep only files whose extension belongs to the language's source set. */
54
+ export function filterSourceFiles(files, language) {
55
+ const exts = sourceExtensions(language);
56
+ if (exts.length === 0) return [];
57
+ return files.filter((file) => exts.some((ext) => file.endsWith(ext)));
58
+ }
59
+
60
+ /**
61
+ * Build the tool's scope arguments for the changed source files. `ranges`
62
+ * (per-file changed line ranges) is only consumed by Stryker (`path:start-end`).
63
+ * @param {{language: string, files: string[], ranges?: Record<string, Array<[number, number]>>}} params
64
+ */
65
+ export function buildScope({ language, files, ranges }) {
66
+ const tool = getMutationTool(language);
67
+ if (!tool.supported) {
68
+ return { supported: false, reason: tool.reason, empty: true, args: [] };
69
+ }
70
+ const source = filterSourceFiles(files ?? [], language);
71
+ if (source.length === 0) {
72
+ return { supported: true, tool: tool.id, empty: true, targets: [], args: [] };
73
+ }
74
+ const targets =
75
+ tool.id === "stryker" && ranges
76
+ ? source.flatMap((file) =>
77
+ (ranges[file] ?? [[undefined, undefined]]).map(([start, end]) =>
78
+ start === undefined ? file : `${file}:${start}-${end}`,
79
+ ),
80
+ )
81
+ : source;
82
+ const value = targets.join(tool.scope.separator);
83
+ const args = tool.scope.flag ? [tool.scope.flag, value] : [value];
84
+ return { supported: true, tool: tool.id, empty: false, targets, args };
85
+ }
86
+
87
+ /**
88
+ * Fetch the diff for `since..toRef` and compute the mutation scope. `run` is
89
+ * the injectable git runner (defaults to `runCommand`).
90
+ * @param {{since: string, language: string, toRef?: string, run?: Function}} params
91
+ */
92
+ export async function getDiffScope({ since, language, toRef = "HEAD", run = runCommand }) {
93
+ const result = await run("git", ["diff", "--unified=0", `${since}..${toRef}`]).catch(
94
+ () => null,
95
+ );
96
+ if (!result || result.exitCode !== 0) {
97
+ return { supported: true, empty: true, targets: [], args: [] };
98
+ }
99
+ const ranges = parseUnifiedDiff(result.stdout);
100
+ return buildScope({ language, files: Object.keys(ranges), ranges });
101
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Reviewer opt-in mutation signal (KJC-TSK-0588). OFF by default: only when
3
+ * `KJ_REVIEW_MUTATION` is set does the reviewer run `kj mutate` scoped to the
4
+ * diff and fold surviving mutants into its verdict as a weak-test signal.
5
+ * A null return means "add nothing" → the default-off prompt stays identical.
6
+ */
7
+
8
+ import { mutateCommand } from "../commands/mutate.js";
9
+
10
+ const DEFAULT_SINCE = "HEAD~1";
11
+ const ENABLED = /^(1|true|yes|on)$/i;
12
+
13
+ /** True when the reviewer mutation opt-in is switched on via env. */
14
+ export function isMutationReviewEnabled(env = process.env) {
15
+ return ENABLED.test(String(env?.KJ_REVIEW_MUTATION ?? "").trim());
16
+ }
17
+
18
+ // Drives `kj mutate --json` over the diff, capturing its output instead of
19
+ // printing it, and returns {code, result} where result is the normalized JSON.
20
+ async function defaultRun({ since, projectDir }) {
21
+ const lines = [];
22
+ const code = await mutateCommand({
23
+ since,
24
+ projectDir,
25
+ json: true,
26
+ maxSurvivors: Number.POSITIVE_INFINITY,
27
+ logger: { info: (m) => lines.push(String(m)) },
28
+ });
29
+ const jsonLine = [...lines].reverse().find((l) => l.trim().startsWith("{"));
30
+ return { code, result: jsonLine ? JSON.parse(jsonLine) : null };
31
+ }
32
+
33
+ function unavailableNote() {
34
+ return [
35
+ "## Mutation testing (advisory)",
36
+ "Mutation testing was requested but could not run on this diff (unsupported",
37
+ "stack or tool unavailable). Treat it as unavailable — do not infer test",
38
+ "quality from its absence.",
39
+ ].join("\n");
40
+ }
41
+
42
+ function survivorSection(survivors) {
43
+ const lines = survivors.map((s) => `- ${s.file}:${s.line ?? "?"} (${s.mutator ?? s.status})`);
44
+ return [
45
+ "## Mutation testing (advisory signal)",
46
+ "The following mutants survived over the changed lines — the tests did not",
47
+ "catch these deliberate defects, so the covering tests are weak. Weigh this",
48
+ "as a signal about test quality, not as a hard blocker on its own:",
49
+ ...lines,
50
+ ].join("\n");
51
+ }
52
+
53
+ /**
54
+ * Build the reviewer's mutation-signal prompt section, or null to add nothing.
55
+ * `run` ({since, projectDir}) → {code, result} is injectable for tests.
56
+ * @returns {Promise<string|null>}
57
+ */
58
+ export async function buildReviewerMutationSignal({
59
+ enabled,
60
+ since = DEFAULT_SINCE,
61
+ projectDir = process.cwd(),
62
+ run = defaultRun,
63
+ } = {}) {
64
+ if (!enabled) return null;
65
+ let outcome;
66
+ try {
67
+ outcome = await run({ since, projectDir });
68
+ } catch {
69
+ return unavailableNote();
70
+ }
71
+ const { code, result } = outcome ?? {};
72
+ if (code === 1 || code === 2) return unavailableNote();
73
+ const survivors = result?.survived ?? [];
74
+ if (survivors.length === 0) return null;
75
+ return survivorSection(survivors);
76
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Mutation runner + report normalizer. Executes a mutation tool and parses its
3
+ * report (the shared mutation-testing-report-schema, `files[].mutants[]`) into a
4
+ * tool-agnostic result the CLI, reviewer and CI can all consume the same way.
5
+ * `exec` and `readReport` are injectable so the logic is testable without a
6
+ * real mutation tool installed.
7
+ */
8
+
9
+ import { runCommand } from "../utils/process.js";
10
+
11
+ // A mutant the test suite detected (killed or forced to time out).
12
+ const KILLED = new Set(["Killed", "Timeout"]);
13
+ // A real survivor: the mutation went undetected (or no test covered it).
14
+ const SURVIVED = new Set(["Survived", "NoCoverage"]);
15
+ // Not a real result: equivalent, config-ignored, or uncompilable mutants.
16
+ const EXCLUDED = new Set(["Ignored", "CompileError", "RuntimeError"]);
17
+
18
+ /**
19
+ * Normalize a standard-schema report into `{score, killed, survived, total, excluded}`.
20
+ * `score` is the covered mutation score `killed / (killed + survived) * 100`, or
21
+ * null when there is nothing to mutate. Excluded mutants never count as survivors.
22
+ * @param {{files?: Record<string, {mutants?: Array<object>}>}} report
23
+ */
24
+ export function parseStandardReport(report) {
25
+ const files = report?.files ?? {};
26
+ let killed = 0;
27
+ let excluded = 0;
28
+ const survived = [];
29
+ for (const [file, entry] of Object.entries(files)) {
30
+ for (const mutant of entry?.mutants ?? []) {
31
+ const { status } = mutant;
32
+ if (EXCLUDED.has(status)) {
33
+ excluded += 1;
34
+ } else if (KILLED.has(status)) {
35
+ killed += 1;
36
+ } else if (SURVIVED.has(status)) {
37
+ survived.push({
38
+ file,
39
+ line: mutant.location?.start?.line ?? null,
40
+ mutator: mutant.mutatorName ?? null,
41
+ status,
42
+ });
43
+ }
44
+ }
45
+ }
46
+ const total = killed + survived.length;
47
+ const score = total === 0 ? null : Number(((killed / total) * 100).toFixed(2));
48
+ return { score, killed, survived, total, excluded };
49
+ }
50
+
51
+ /**
52
+ * Run a mutation tool and normalize the report it produced.
53
+ * @param {object} params
54
+ * @param {string} params.binary
55
+ * @param {string[]} [params.args]
56
+ * @param {string} [params.cwd]
57
+ * @param {Function} [params.exec] - injectable command runner (default runCommand)
58
+ * @param {() => Promise<object>} [params.readReport] - reads/parses the tool's report file
59
+ */
60
+ export async function runMutation({ binary, args = [], cwd, exec = runCommand, readReport }) {
61
+ const run = await exec(binary, args, { cwd }).catch((err) => ({
62
+ exitCode: 1,
63
+ stdout: "",
64
+ stderr: String(err?.message ?? err),
65
+ }));
66
+ let result = null;
67
+ if (readReport) {
68
+ const report = await readReport().catch(() => null);
69
+ if (report) result = parseStandardReport(report);
70
+ }
71
+ return { exitCode: run.exitCode, result, stdout: run.stdout, stderr: run.stderr };
72
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Mutation-tool registry.
3
+ *
4
+ * Maps a detected stack language (see `src/harden/stack-roots.js`) to the
5
+ * mutation-testing runner Karajan drives for it. Unknown or native-mobile
6
+ * languages resolve to an explicit `{ supported: false, reason }` object —
7
+ * never a silent fallback to another tool.
8
+ *
9
+ * `scope` describes how the runner limits mutation to a subset of files, so
10
+ * diff-scoped runs (`kj mutate --since <ref>`) can pass only the changed paths:
11
+ * - flag: the CLI flag that accepts the file list
12
+ * - separator: how multiple paths are joined for that flag
13
+ */
14
+
15
+ const TOOLS_BY_LANGUAGE = {
16
+ javascript: {
17
+ id: "stryker",
18
+ binary: "stryker",
19
+ installHint: "npm install --save-dev @stryker-mutator/core",
20
+ scope: { flag: "--mutate", separator: "," },
21
+ reportFormat: "json",
22
+ },
23
+ typescript: {
24
+ id: "stryker",
25
+ binary: "stryker",
26
+ installHint: "npm install --save-dev @stryker-mutator/core",
27
+ scope: { flag: "--mutate", separator: "," },
28
+ reportFormat: "json",
29
+ },
30
+ python: {
31
+ id: "mutmut",
32
+ binary: "mutmut",
33
+ installHint: "pip install mutmut",
34
+ scope: { flag: "--paths-to-mutate", separator: "," },
35
+ reportFormat: "json",
36
+ },
37
+ php: {
38
+ id: "infection",
39
+ binary: "infection",
40
+ installHint: "composer require --dev infection/infection",
41
+ scope: { flag: "--filter", separator: "," },
42
+ reportFormat: "json",
43
+ },
44
+ go: {
45
+ id: "go-mutesting",
46
+ binary: "go-mutesting",
47
+ installHint: "go install github.com/avito-tech/go-mutesting/cmd/go-mutesting@latest",
48
+ scope: { flag: "", separator: " " },
49
+ reportFormat: "json",
50
+ },
51
+ java: {
52
+ id: "pitest",
53
+ binary: "pitest",
54
+ installHint: "add org.pitest:pitest-maven (Maven) or gradle-pitest-plugin",
55
+ scope: { flag: "targetClasses", separator: "," },
56
+ reportFormat: "json",
57
+ },
58
+ };
59
+
60
+ const UNSUPPORTED_REASONS = {
61
+ swift: "Swift (iOS) has no mutation runner wired into Karajan",
62
+ kotlin: "Kotlin (Android) has no mutation runner wired into Karajan",
63
+ };
64
+
65
+ function normalize(language) {
66
+ if (typeof language !== "string") return "";
67
+ return language.trim().toLowerCase();
68
+ }
69
+
70
+ /**
71
+ * @param {string} language - language id from `detectStackRoots`
72
+ * @returns {{supported: true, id, binary, language, installHint, scope, reportFormat}
73
+ * | {supported: false, language, reason: string}}
74
+ */
75
+ export function getMutationTool(language) {
76
+ const key = normalize(language);
77
+ const tool = TOOLS_BY_LANGUAGE[key];
78
+ if (tool) {
79
+ return { supported: true, language: key, ...tool };
80
+ }
81
+ const reason =
82
+ UNSUPPORTED_REASONS[key] ??
83
+ `no mutation-testing tool is registered for language "${key || "(none)"}"`;
84
+ return { supported: false, language: key, reason };
85
+ }
86
+
87
+ /** @returns {string[]} languages with a registered mutation tool */
88
+ export function listSupportedLanguages() {
89
+ return Object.keys(TOOLS_BY_LANGUAGE);
90
+ }
@@ -347,8 +347,8 @@ export async function tryAutoStartBoard(config, logger, emitter, eventBase) {
347
347
  try {
348
348
  const { startBoard, renderBoardBanner } = await import("../../commands/board.js");
349
349
  const boardPort = config.hu_board.port || 4000;
350
- // Scope the board URL to the current run's project (`/p/<slug>`) so
351
- // the user lands on a filtered view, not the global dashboard.
350
+ // Scope the board URL to the current run's project (`#board/<slug>`)
351
+ // so the user lands on a filtered view, not the global dashboard.
352
352
  const slug = config.projectDir ? slugFor(config.projectDir) : null;
353
353
  const boardResult = await startBoard(boardPort, { projectSlug: slug });
354
354
  const status = boardResult.alreadyRunning ? "already running" : "started";
@@ -6,6 +6,7 @@
6
6
  import { addCheckpoint, markSessionStatus, saveSession } from "../../session/store.js";
7
7
  import { setReviewerFeedback, setDeferredIssues } from "../../session/mutators.js";
8
8
  import { generateDiff } from "../../review/diff-generator.js";
9
+ import { redactSecrets } from "../../guards/secret-redactor.js";
9
10
  import { validateReviewResult } from "../../review/schema.js";
10
11
  import { filterReviewScope } from "../../review/scope-filter.js";
11
12
  import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
@@ -194,13 +195,18 @@ async function handleReviewerRejection({ review, repeatDetector, config, logger,
194
195
  }
195
196
 
196
197
  export async function fetchReviewDiff(session, logger) {
198
+ let diff;
197
199
  if (session.ci_pr_number) {
198
200
  const { getPrDiff } = await import("../../ci/pr-diff.js");
199
- const diff = await getPrDiff(session.ci_pr_number);
201
+ diff = await getPrDiff(session.ci_pr_number);
200
202
  logger.info(`Reviewer reading PR diff #${session.ci_pr_number}`);
201
- return diff;
203
+ } else {
204
+ diff = await generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
202
205
  }
203
- return generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
206
+ // Inbound boundary: mask hardcoded secrets before the diff reaches the
207
+ // (possibly cloud) reviewer model. Runs BEFORE the injection guard below so
208
+ // that guard still sees the real prompt-injection phrasing (KJC-TSK-0583).
209
+ return redactSecrets(diff);
204
210
  }
205
211
 
206
212
  export async function runReviewerStage({ reviewerRole, config, logger, emitter, eventBase, session, trackBudget, iteration, reviewRules, task, repeatDetector, budgetSummary, askQuestion, brainCtx }) {
@@ -0,0 +1,52 @@
1
+ // KJC-TSK-0569 (Onboard B) — prompt + intent schema for the StartDecidor.
2
+ //
3
+ // The decider only DECIDES (one intent from a closed set); the safe HOW is
4
+ // deterministic (0570 maps the intent to a saved command). Keep this prompt
5
+ // tight: it routes, it never executes.
6
+ const PREAMBLE = [
7
+ "IMPORTANT: You are running as a Karajan sub-agent (the `kj start` decider).",
8
+ "Do NOT mention Karajan, do NOT use MCP tools, do NOT execute anything.",
9
+ "Your only job: read the project assessment and pick ONE next-step intent.",
10
+ ].join(" ");
11
+
12
+ /** Closed set of intents. Anything else degrades to ASK_USER. */
13
+ export const INTENTS = new Set([
14
+ "ASSESS_ONLY",
15
+ "RECOMMEND_HARDEN",
16
+ "RECOMMEND_INDEX",
17
+ "START_TASK",
18
+ "PROPOSE_PLAN",
19
+ "ASK_USER",
20
+ ]);
21
+
22
+ const INTENT_GUIDE = [
23
+ "- ASSESS_ONLY: the project is healthy and the user gave no goal — just report.",
24
+ "- RECOMMEND_HARDEN: quality gaps exist (no tests/CI, drifting checks, missing config).",
25
+ "- RECOMMEND_INDEX: the codebase has no knowledge index yet.",
26
+ "- START_TASK: the user stated a concrete thing to build or fix.",
27
+ "- PROPOSE_PLAN: legacy/large project, or a goal that needs a modernization/feature plan.",
28
+ "- ASK_USER: intent is unclear — ask ONE open question to disambiguate.",
29
+ ].join("\n");
30
+
31
+ /**
32
+ * @param {{userMessage?: string, assessment?: string, instructions?: string}} input
33
+ * @returns {string}
34
+ */
35
+ export function buildStartDecisionPrompt({ userMessage = "", assessment = "", instructions } = {}) {
36
+ const sections = [PREAMBLE];
37
+ if (instructions) sections.push(instructions);
38
+ sections.push(
39
+ "You route the next step for a project the user just opened with `kj start`.",
40
+ "## Project assessment (read-only signals)",
41
+ assessment || "(no assessment available)",
42
+ "## User goal",
43
+ userMessage.trim() || "(none given)",
44
+ "## Intents",
45
+ INTENT_GUIDE,
46
+ "Pick the single best intent. Prefer a concrete next step over ASK_USER when the assessment is clear.",
47
+ "If you pick ASK_USER, put the open question in questionToAsk.",
48
+ "Return a single valid JSON object and nothing else.",
49
+ 'JSON schema: {"intent":"ASSESS_ONLY|RECOMMEND_HARDEN|RECOMMEND_INDEX|START_TASK|PROPOSE_PLAN|ASK_USER","rationale":string,"questionToAsk":string}',
50
+ );
51
+ return sections.join("\n\n");
52
+ }
@@ -2,6 +2,7 @@ import { AgentRole } from "./agent-role.js";
2
2
  import { buildRtkInstructions } from "../prompts/rtk-snippet.js";
3
3
  import { extractFirstJson } from "../utils/json-extract.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "../prompts/prompt-layout.js";
5
+ import { isMutationReviewEnabled, buildReviewerMutationSignal } from "../mutate/reviewer-signal.js";
5
6
 
6
7
  const MAX_DIFF_LENGTH = 12000;
7
8
 
@@ -38,6 +39,11 @@ export class ReviewerRole extends AgentRole {
38
39
  // on every review — last. The buckets ride along so ClaudeAgent can
39
40
  // ship the stable block via --append-system-prompt (Φ1-D).
40
41
  async buildPrompt({ task, diff, reviewRules }) {
42
+ // Opt-in (KJ_REVIEW_MUTATION): null when off/clean → prompt stays identical.
43
+ const mutationSignal = await buildReviewerMutationSignal({
44
+ enabled: isMutationReviewEnabled(),
45
+ projectDir: this.config?.projectDir,
46
+ });
41
47
  const layout = buildPromptLayout([
42
48
  section(SUBAGENT_PREAMBLE, STABLE),
43
49
  section(this.instructions, STABLE),
@@ -51,6 +57,7 @@ export class ReviewerRole extends AgentRole {
51
57
  section(reviewRules ? `Review rules:\n${reviewRules}` : null, STABLE),
52
58
  section(`Task context:\n${task}`, VOLATILE),
53
59
  section(`Git diff:\n${truncateDiff(diff)}`, VOLATILE),
60
+ section(mutationSignal, VOLATILE),
54
61
  ]);
55
62
  return { prompt: joinLayout(layout), stablePrompt: layout.stable, volatilePrompt: layout.volatile };
56
63
  }