karajan-code 3.7.2 → 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.
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/cli/advanced-commands.js +61 -0
- package/src/cli/register-meta.js +49 -0
- package/src/cli.js +17 -4
- package/src/commands/advanced.js +31 -0
- package/src/commands/harden.js +2 -1
- package/src/commands/mutate.js +94 -0
- package/src/commands/start.js +117 -0
- package/src/config/defaults.js +2 -1
- package/src/config/schema.js +1 -0
- package/src/guards/secret-redactor.js +71 -0
- package/src/harden/workflow-engine.js +4 -2
- package/src/harden/workflow-templates.js +52 -0
- package/src/mutate/diff-scope.js +101 -0
- package/src/mutate/reviewer-signal.js +76 -0
- package/src/mutate/runner.js +72 -0
- package/src/mutate/tool-registry.js +90 -0
- package/src/orchestrator/stages/reviewer-stage.js +9 -3
- package/src/prompts/start-decision.js +52 -0
- package/src/roles/reviewer-role.js +7 -0
- package/src/start/assessment.js +74 -0
- package/src/start/maturity.js +73 -0
- package/src/start/start-decider-role.js +79 -0
- package/src/start/sweep.js +112 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
201
|
+
diff = await getPrDiff(session.ci_pr_number);
|
|
200
202
|
logger.info(`Reviewer reading PR diff #${session.ci_pr_number}`);
|
|
201
|
-
|
|
203
|
+
} else {
|
|
204
|
+
diff = await generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
|
|
202
205
|
}
|
|
203
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// KJC-TSK-0569 (Onboard B) — deterministic synthesis of the read-only sweep.
|
|
2
|
+
//
|
|
3
|
+
// Turns the sweep bundle (Onboard A, 0568) into one coherent digest: a
|
|
4
|
+
// human-readable report plus a machine summary the decider (StartDecidorRole)
|
|
5
|
+
// consumes to pick an intent. No LLM here — the costly call is the haiku
|
|
6
|
+
// decider, not a redundant strong-model narration. Reuses the harden advisory
|
|
7
|
+
// formatter so the improvements section reads exactly like `kj harden --report`.
|
|
8
|
+
import { formatAdvisoryReport } from "../harden/advisory.js";
|
|
9
|
+
|
|
10
|
+
const EMPTY_TALLY = { install: 0, update: 0, review: 0, keep: 0 };
|
|
11
|
+
|
|
12
|
+
/** Tally harden advisory artifacts by recommendation. */
|
|
13
|
+
function tallyImprovements(improvements) {
|
|
14
|
+
const tally = { ...EMPTY_TALLY };
|
|
15
|
+
for (const a of improvements?.artifacts ?? []) {
|
|
16
|
+
if (a?.recommendation in tally) tally[a.recommendation] += 1;
|
|
17
|
+
}
|
|
18
|
+
return tally;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Quality/maintenance gaps as short phrases, derived from signals + drift. */
|
|
22
|
+
function collectGaps(signals = {}, drift) {
|
|
23
|
+
const gaps = [];
|
|
24
|
+
if (!signals.hasTests) gaps.push("no tests");
|
|
25
|
+
if (!signals.hasCI) gaps.push("no CI");
|
|
26
|
+
if (typeof signals.staleDays === "number" && signals.staleDays > 365) {
|
|
27
|
+
gaps.push(`last commit ${signals.staleDays}d old`);
|
|
28
|
+
}
|
|
29
|
+
if (drift && drift.ok === false) {
|
|
30
|
+
const failed = (drift.checks ?? []).filter((c) => c?.ok === false).length;
|
|
31
|
+
if (failed > 0) gaps.push(`${failed} harden check(s) drifting`);
|
|
32
|
+
}
|
|
33
|
+
return gaps;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} bundle output of runReadOnlySweep (0568)
|
|
38
|
+
* @returns {{text: string, summary: object}}
|
|
39
|
+
*/
|
|
40
|
+
export function buildAssessment(bundle) {
|
|
41
|
+
if (!bundle || typeof bundle !== "object") {
|
|
42
|
+
throw new TypeError("buildAssessment: bundle must be an object");
|
|
43
|
+
}
|
|
44
|
+
const { maturity = {}, signals = {}, drift, improvements, rag, qmd } = bundle;
|
|
45
|
+
const gaps = collectGaps(signals, drift);
|
|
46
|
+
const improvementCounts = tallyImprovements(improvements);
|
|
47
|
+
|
|
48
|
+
const summary = {
|
|
49
|
+
maturity: maturity.maturity ?? "unknown",
|
|
50
|
+
declared: Boolean(maturity.declared),
|
|
51
|
+
deepHealthRecommended: Boolean(maturity.deepHealthRecommended),
|
|
52
|
+
gaps,
|
|
53
|
+
driftOk: drift ? drift.ok !== false : null,
|
|
54
|
+
improvementCounts,
|
|
55
|
+
indexed: rag ? Boolean(rag.indexed) : null,
|
|
56
|
+
qmdAvailable: qmd ? Boolean(qmd.available) : null,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const lines = [
|
|
60
|
+
`Project maturity: ${summary.maturity}${summary.declared ? " (declared)" : " (inferred)"}`,
|
|
61
|
+
...(maturity.reasons ?? []).map((r) => ` - ${r}`),
|
|
62
|
+
"",
|
|
63
|
+
`Tests: ${signals.hasTests ? "yes" : "no"} · CI: ${signals.hasCI ? "yes" : "no"} · commits: ${signals.commitCount ?? 0}`,
|
|
64
|
+
`Quality gaps: ${gaps.length ? gaps.join(", ") : "none detected"}`,
|
|
65
|
+
`Knowledge index: ${summary.indexed === null ? "n/a" : summary.indexed ? "present" : "missing"}` +
|
|
66
|
+
` · qmd: ${summary.qmdAvailable === null ? "n/a" : summary.qmdAvailable ? "available" : "absent"}`,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
if (improvements) {
|
|
70
|
+
lines.push("", ...formatAdvisoryReport(improvements));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { text: lines.join("\n"), summary };
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// KJC-TSK-0568 (Onboard A) — maturity classifier for the Brain (`kj start`).
|
|
2
|
+
//
|
|
3
|
+
// Pure and deterministic: given a bundle of read-only signals it labels the
|
|
4
|
+
// project new | existing | legacy. No LLM, no I/O — the sweep (sweep.js) does
|
|
5
|
+
// the reading; this only decides. The label drives which read-only subset the
|
|
6
|
+
// orchestrator runs and, downstream (0570), whether a deep health pass is worth
|
|
7
|
+
// the cost. A user-declared maturity always wins over inference.
|
|
8
|
+
//
|
|
9
|
+
// Acceptance criteria (KJC-TSK-0568):
|
|
10
|
+
// - empty repo / scaffolding only -> new
|
|
11
|
+
// - code + config + tests/CI -> existing
|
|
12
|
+
// - code but missing tests/CI, or very stale -> legacy
|
|
13
|
+
|
|
14
|
+
/** Last-commit age (days) beyond which a repo counts as stalled/neglected. */
|
|
15
|
+
export const STALE_DAYS_THRESHOLD = 365;
|
|
16
|
+
|
|
17
|
+
const VALID = new Set(["new", "existing", "legacy"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} signals
|
|
21
|
+
* @param {("new"|"existing"|"legacy"|null)} [signals.declared] user override
|
|
22
|
+
* @param {boolean} signals.hasSourceCode real source files present
|
|
23
|
+
* @param {boolean} signals.scaffoldingOnly only generator boilerplate, no real code
|
|
24
|
+
* @param {boolean} signals.hasTests a test suite is present
|
|
25
|
+
* @param {boolean} signals.hasCI CI workflows are present
|
|
26
|
+
* @param {number} [signals.commitCount] number of commits (capped upstream)
|
|
27
|
+
* @param {number|null} [signals.staleDays] age of the last commit in days
|
|
28
|
+
* @returns {{maturity: "new"|"existing"|"legacy", reasons: string[], deepHealthRecommended: boolean, declared: boolean}}
|
|
29
|
+
*/
|
|
30
|
+
export function classifyMaturity(signals) {
|
|
31
|
+
if (!signals || typeof signals !== "object") {
|
|
32
|
+
throw new TypeError("classifyMaturity: signals must be an object");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (VALID.has(signals.declared)) {
|
|
36
|
+
const maturity = signals.declared;
|
|
37
|
+
return {
|
|
38
|
+
maturity,
|
|
39
|
+
reasons: [`maturity declared by user as "${maturity}"`],
|
|
40
|
+
deepHealthRecommended: maturity === "legacy",
|
|
41
|
+
declared: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const reasons = [];
|
|
46
|
+
let maturity;
|
|
47
|
+
|
|
48
|
+
if (!signals.hasSourceCode || signals.scaffoldingOnly) {
|
|
49
|
+
maturity = "new";
|
|
50
|
+
reasons.push(signals.scaffoldingOnly ? "only scaffolding present, no real code yet" : "no source code found");
|
|
51
|
+
} else {
|
|
52
|
+
const stalled = typeof signals.staleDays === "number" && signals.staleDays > STALE_DAYS_THRESHOLD;
|
|
53
|
+
const gaps = [];
|
|
54
|
+
if (!signals.hasTests) gaps.push("no tests");
|
|
55
|
+
if (!signals.hasCI) gaps.push("no CI");
|
|
56
|
+
if (stalled) gaps.push(`last commit ${signals.staleDays}d old (stalled)`);
|
|
57
|
+
|
|
58
|
+
if (gaps.length > 0) {
|
|
59
|
+
maturity = "legacy";
|
|
60
|
+
reasons.push(`code present but neglected: ${gaps.join(", ")}`);
|
|
61
|
+
} else {
|
|
62
|
+
maturity = "existing";
|
|
63
|
+
reasons.push("code with tests and CI, actively maintained");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
maturity,
|
|
69
|
+
reasons,
|
|
70
|
+
deepHealthRecommended: maturity === "legacy",
|
|
71
|
+
declared: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// KJC-TSK-0569 (Onboard B) — the StartDecidor: the only LLM in the Brain's
|
|
2
|
+
// onboarding. Reads the deterministic assessment + user goal, picks ONE intent
|
|
3
|
+
// from a closed set, and never executes (0570 maps the intent to a safe command).
|
|
4
|
+
// Provider falls back to the coder, model defaults to haiku. Unparseable/unknown
|
|
5
|
+
// output degrades to ASK_USER so `kj start` always has a sane next step.
|
|
6
|
+
import { AgentRole } from "../roles/agent-role.js";
|
|
7
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
8
|
+
import { buildStartDecisionPrompt, INTENTS } from "../prompts/start-decision.js";
|
|
9
|
+
|
|
10
|
+
const FALLBACK_QUESTION = "What would you like to do with this project?";
|
|
11
|
+
|
|
12
|
+
export class StartDecidorRole extends AgentRole {
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
super({ ...opts, name: "start" });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
extractInput(input) {
|
|
18
|
+
const src = typeof input === "string" ? { userMessage: input } : input || {};
|
|
19
|
+
return {
|
|
20
|
+
userMessage: src.userMessage || "",
|
|
21
|
+
assessment: src.assessment || "",
|
|
22
|
+
onOutput: src.onOutput || null,
|
|
23
|
+
...src,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async buildPrompt({ userMessage, assessment }) {
|
|
28
|
+
return {
|
|
29
|
+
prompt: buildStartDecisionPrompt({ userMessage, assessment, instructions: this.instructions }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
parseOutput(raw) {
|
|
34
|
+
return extractFirstJson(raw);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
buildSuccessResult(parsed, provider) {
|
|
38
|
+
const intent = INTENTS.has(parsed.intent) ? parsed.intent : "ASK_USER";
|
|
39
|
+
const rationale = String(parsed.rationale || "").trim();
|
|
40
|
+
const questionToAsk = String(parsed.questionToAsk || "").trim();
|
|
41
|
+
const degraded = intent !== parsed.intent;
|
|
42
|
+
return {
|
|
43
|
+
intent,
|
|
44
|
+
rationale: rationale || (degraded ? "Unrecognized intent, asking the user." : "No rationale provided."),
|
|
45
|
+
questionToAsk: intent === "ASK_USER" ? questionToAsk || FALLBACK_QUESTION : "",
|
|
46
|
+
provider,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
buildSummary(parsed) {
|
|
51
|
+
const intent = INTENTS.has(parsed.intent) ? parsed.intent : "ASK_USER";
|
|
52
|
+
return `Start decision: ${intent}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
handleParseNull(agentResult, provider) {
|
|
56
|
+
return this.degradedDecision({ agentResult, provider, reason: "no JSON found" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleParseError(err, agentResult, provider) {
|
|
60
|
+
return this.degradedDecision({ agentResult, provider, reason: err?.message || "parse error" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Never block onboarding: garbage in → ask the user one open question.
|
|
64
|
+
degradedDecision({ agentResult, provider, reason }) {
|
|
65
|
+
this.logger?.warn?.(`[start] decider output unusable (${reason}) — asking the user.`);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
result: {
|
|
69
|
+
intent: "ASK_USER",
|
|
70
|
+
rationale: `Could not read a decision (${reason}).`,
|
|
71
|
+
questionToAsk: FALLBACK_QUESTION,
|
|
72
|
+
provider,
|
|
73
|
+
degraded: true,
|
|
74
|
+
},
|
|
75
|
+
summary: "Start decision: ASK_USER (fallback)",
|
|
76
|
+
usage: agentResult.usage,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|