karajan-code 2.10.2 → 2.12.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 +5 -1
- package/package.json +1 -1
- package/src/audit/plan-adherence.js +222 -0
- package/src/audit/sonar-findings.js +12 -0
- package/src/cli/register-meta.js +4 -4
- package/src/cli/register-pipeline.js +3 -3
- package/src/cli/register-plan.js +1 -1
- package/src/commands/board.js +28 -4
- package/src/commands/init.js +24 -5
- package/src/config/loader.js +32 -1
- package/src/git/automation.js +94 -4
- package/src/git/hu-automation.js +35 -3
- package/src/golden/asserter.js +90 -0
- package/src/golden/loader.js +52 -0
- package/src/golden/runner.js +92 -0
- package/src/golden/schema.js +41 -0
- package/src/orchestrator/config-init.js +17 -0
- package/src/orchestrator/drivers/post-loop.js +64 -3
- package/src/orchestrator/flow-runner.js +41 -0
- package/src/orchestrator/stages/sonar-stage.js +19 -0
- package/src/session/journal/summary-writer.js +39 -0
- package/src/skills/addyosmani-catalog.js +29 -7
- package/src/sonar/project-key.js +37 -0
- package/src/types/session.js +2 -1
- package/src/utils/display/session-summary.js +12 -2
- package/src/utils/git.js +132 -1
- package/templates/roles/architect/anthropic.md +9 -0
- package/templates/roles/architect/default.md +9 -0
- package/templates/roles/architect/google.md +9 -0
- package/templates/roles/architect/openai.md +9 -0
- package/templates/roles/architect/opencode.md +9 -0
- package/templates/roles/architect.md +9 -0
- package/templates/roles/coder/anthropic.md +9 -0
- package/templates/roles/coder/default.md +9 -0
- package/templates/roles/coder/google.md +9 -0
- package/templates/roles/coder/openai.md +9 -0
- package/templates/roles/coder/opencode.md +9 -0
- package/templates/roles/coder.md +9 -0
- package/templates/roles/planner/anthropic.md +9 -0
- package/templates/roles/planner/default.md +9 -0
- package/templates/roles/planner/google.md +9 -0
- package/templates/roles/planner/openai.md +9 -0
- package/templates/roles/planner/opencode.md +9 -0
- package/templates/roles/planner.md +9 -0
- package/templates/roles/refactorer.md +9 -0
- package/templates/roles/repairer.md +9 -0
- package/templates/roles/tester.md +9 -0
package/src/git/hu-automation.js
CHANGED
|
@@ -41,6 +41,34 @@ export function resolveHuBase(story, huBranches, baseBranch) {
|
|
|
41
41
|
return baseBranch;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve to an actually-existing local ref. Tries the preferred branch
|
|
46
|
+
* first, then `main` and `master`, then plain `HEAD` as last resort.
|
|
47
|
+
*
|
|
48
|
+
* Why: N6 plan-flow dogfooding (2026-05-07) on a `git init -q` repo
|
|
49
|
+
* with `init.defaultBranch=master` produced 7 identical warnings:
|
|
50
|
+
* `failed to create branch feat/...-001 from main:
|
|
51
|
+
* fatal: 'main' is not a commit and a branch can't be created`.
|
|
52
|
+
* Every HU then fell back to running on the original branch, which
|
|
53
|
+
* defeats the per-HU isolation the sub-pipeline was supposed to give.
|
|
54
|
+
*
|
|
55
|
+
* The configured base_branch is honoured when it exists; otherwise we
|
|
56
|
+
* pick the most likely default. HEAD always exists once the repo has
|
|
57
|
+
* any commit, so the fallback never throws past the gate.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} preferred
|
|
60
|
+
* @returns {Promise<string>}
|
|
61
|
+
*/
|
|
62
|
+
async function resolveExistingBranchRef(preferred) {
|
|
63
|
+
const candidates = [preferred, "main", "master", "HEAD"]
|
|
64
|
+
.filter((c, i, arr) => c && arr.indexOf(c) === i); // unique, truthy
|
|
65
|
+
for (const ref of candidates) {
|
|
66
|
+
const probe = await runCommand("git", ["rev-parse", "--verify", "--quiet", ref], {});
|
|
67
|
+
if (probe.exitCode === 0) return ref;
|
|
68
|
+
}
|
|
69
|
+
return preferred; // give up; caller will surface the original error
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
/**
|
|
45
73
|
* Create a branch for an HU starting from its resolved base.
|
|
46
74
|
* Returns the branch name created (or null if git automation is disabled).
|
|
@@ -57,17 +85,21 @@ export async function prepareHuBranch({ story, huBranches, config, logger }) {
|
|
|
57
85
|
return null;
|
|
58
86
|
}
|
|
59
87
|
const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
|
|
88
|
+
const effectiveBase = await resolveExistingBranchRef(baseBranch);
|
|
89
|
+
if (effectiveBase !== baseBranch) {
|
|
90
|
+
logger.info(`HU git: configured base '${baseBranch}' not found locally — using '${effectiveBase}' instead`);
|
|
91
|
+
}
|
|
60
92
|
const prefix = config.git?.branch_prefix || "feat/";
|
|
61
93
|
const branchName = buildHuBranchName(prefix, story);
|
|
62
94
|
|
|
63
95
|
// Checkout from the resolved base. Use `git checkout -B` to overwrite if rerun.
|
|
64
|
-
const res = await runCommand("git", ["checkout", "-B", branchName,
|
|
96
|
+
const res = await runCommand("git", ["checkout", "-B", branchName, effectiveBase], {});
|
|
65
97
|
if (res.exitCode !== 0) {
|
|
66
|
-
logger.warn(`HU git: failed to create branch ${branchName} from ${
|
|
98
|
+
logger.warn(`HU git: failed to create branch ${branchName} from ${effectiveBase}: ${res.stderr}`);
|
|
67
99
|
return null;
|
|
68
100
|
}
|
|
69
101
|
huBranches.set(story.id, branchName);
|
|
70
|
-
logger.info(`HU ${story.id}: branch '${branchName}' from '${
|
|
102
|
+
logger.info(`HU ${story.id}: branch '${branchName}' from '${effectiveBase}'`);
|
|
71
103
|
return branchName;
|
|
72
104
|
}
|
|
73
105
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden tasks asserter (KJC-TSK-0374). Parses a `summary.md` produced
|
|
3
|
+
* by a real `kj run` and compares it against a GoldenTask's structural
|
|
4
|
+
* expectations. Pure: no I/O, no subprocess. Subprocess driver lands
|
|
5
|
+
* in PR-3a; the 3 fixtures + baseline + spec land in PR-3b.
|
|
6
|
+
*
|
|
7
|
+
* Three of the five assertion families live here (the ones derivable
|
|
8
|
+
* from summary.md alone): expected_commits_min, expected_audit_status,
|
|
9
|
+
* expected_plan_adherence_min. expected_test_files and
|
|
10
|
+
* allowed_loc_range need filesystem access — PR-3a wires those.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const COMMIT_LINE = /^- `([^`]+)`\s+(.*)$/;
|
|
14
|
+
const SCORE_LINE = /^\*\*Score\*\*:\s*(\d+)\s*\/\s*100/m;
|
|
15
|
+
const AUDIT_ROW = /^\|\s*`audit`\s*\|\s*([^|]+?)\s*\|/m;
|
|
16
|
+
|
|
17
|
+
function extractSection(content, heading) {
|
|
18
|
+
const re = new RegExp(`^## ${heading}\\s*$([\\s\\S]*?)(?=^## |\\Z)`, "m");
|
|
19
|
+
const m = content.match(re);
|
|
20
|
+
return m ? m[1].trim() : "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseCommits(content) {
|
|
24
|
+
const section = extractSection(content, "Commits");
|
|
25
|
+
if (!section || /No commits/.test(section)) return [];
|
|
26
|
+
return section
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((line) => line.match(COMMIT_LINE))
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map(([, hash, message]) => ({ hash, message }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseAuditStatus(content) {
|
|
34
|
+
const m = content.match(AUDIT_ROW);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
const cell = m[1];
|
|
37
|
+
if (/✅|pass/i.test(cell)) return "pass";
|
|
38
|
+
if (/❌|fail/i.test(cell)) return "fail";
|
|
39
|
+
if (/⚠|warn/i.test(cell)) return "warning";
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parsePlanAdherence(content) {
|
|
44
|
+
const section = extractSection(content, "Plan adherence");
|
|
45
|
+
if (!section) return null;
|
|
46
|
+
const m = section.match(SCORE_LINE);
|
|
47
|
+
if (!m) return null;
|
|
48
|
+
const score = Number(m[1]);
|
|
49
|
+
return Number.isFinite(score) ? score : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseSummary(content) {
|
|
53
|
+
const text = String(content || "");
|
|
54
|
+
return {
|
|
55
|
+
commits: parseCommits(text),
|
|
56
|
+
auditStatus: parseAuditStatus(text),
|
|
57
|
+
planAdherenceScore: parsePlanAdherence(text),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function assertFromSummary(task, parsed) {
|
|
62
|
+
const failures = [];
|
|
63
|
+
|
|
64
|
+
const fail = (kind, expected, actual, message) => failures.push({ kind, expected, actual, message });
|
|
65
|
+
|
|
66
|
+
if (parsed.commits.length < task.expected_commits_min) {
|
|
67
|
+
fail("commits_min", `>= ${task.expected_commits_min}`, parsed.commits.length,
|
|
68
|
+
`expected at least ${task.expected_commits_min} commit(s); got ${parsed.commits.length}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (parsed.auditStatus !== task.expected_audit_status) {
|
|
72
|
+
const got = parsed.auditStatus === null ? "no audit row in summary" : `"${parsed.auditStatus}"`;
|
|
73
|
+
fail("audit_status", task.expected_audit_status, parsed.auditStatus,
|
|
74
|
+
`expected audit "${task.expected_audit_status}"; got ${got}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof task.expected_plan_adherence_min === "number") {
|
|
78
|
+
const score = parsed.planAdherenceScore;
|
|
79
|
+
const min = task.expected_plan_adherence_min;
|
|
80
|
+
if (score === null) {
|
|
81
|
+
fail("plan_adherence_missing", `>= ${min}`, null,
|
|
82
|
+
`expected plan adherence >= ${min}; summary has no Plan adherence section`);
|
|
83
|
+
} else if (score < min) {
|
|
84
|
+
fail("plan_adherence_below", `>= ${min}`, score,
|
|
85
|
+
`expected plan adherence >= ${min}; got ${score}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: failures.length === 0, failures };
|
|
90
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden tasks loader (KJC-TSK-0374). Reads + validates JSON files
|
|
3
|
+
* under a directory; aggregates errors; rejects duplicate ids.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { safeParseGoldenTask } from "./schema.js";
|
|
9
|
+
|
|
10
|
+
export async function loadGoldenTasks(dir) {
|
|
11
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch((err) => {
|
|
12
|
+
throw new Error(`golden tasks directory not readable: ${dir} (${err.code || err.message})`);
|
|
13
|
+
});
|
|
14
|
+
const jsonFiles = entries
|
|
15
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
16
|
+
.map((e) => path.join(dir, e.name))
|
|
17
|
+
.sort();
|
|
18
|
+
|
|
19
|
+
const tasks = [];
|
|
20
|
+
const errors = [];
|
|
21
|
+
const seenIds = new Map();
|
|
22
|
+
|
|
23
|
+
for (const filePath of jsonFiles) {
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
27
|
+
raw = JSON.parse(content);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
errors.push(`${path.basename(filePath)}: invalid JSON (${err.message})`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const result = safeParseGoldenTask(raw);
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
const summary = result.issues.map((iss) => ` - ${iss.path || "(root)"}: ${iss.message}`).join("\n");
|
|
35
|
+
errors.push(`${path.basename(filePath)}:\n${summary}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const existing = seenIds.get(result.task.id);
|
|
39
|
+
if (existing) {
|
|
40
|
+
errors.push(`${path.basename(filePath)}: duplicate id "${result.task.id}" (also in ${existing})`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
seenIds.set(result.task.id, path.basename(filePath));
|
|
44
|
+
tasks.push(result.task);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (errors.length > 0) {
|
|
48
|
+
throw new Error(`golden tasks failed validation:\n${errors.join("\n")}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { tasks };
|
|
52
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden tasks subprocess runner (KJC-TSK-0374). Spawns `kj run` in
|
|
3
|
+
* a sandbox dir, reads back summary.md, evaluates the schema's
|
|
4
|
+
* filesystem-dependent assertions (expected_test_files, allowed_loc_range),
|
|
5
|
+
* and merges them with `assertFromSummary` into a single result.
|
|
6
|
+
*
|
|
7
|
+
* The kj subprocess + diff calls are injected so unit tests can drive
|
|
8
|
+
* the runner without spawning anything (real e2e is opt-in via PR-3b).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { runCommand as defaultRun } from "../utils/process.js";
|
|
14
|
+
import { parseSummary, assertFromSummary } from "./asserter.js";
|
|
15
|
+
|
|
16
|
+
async function findLatestSummary(workDir) {
|
|
17
|
+
const journalRoot = path.join(workDir, ".karajan", "sessions");
|
|
18
|
+
const sessions = await fs.readdir(journalRoot).catch(() => []);
|
|
19
|
+
if (sessions.length === 0) return null;
|
|
20
|
+
const stats = await Promise.all(sessions.map(async (id) => {
|
|
21
|
+
const file = path.join(journalRoot, id, "summary.md");
|
|
22
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
23
|
+
return stat ? { file, mtimeMs: stat.mtimeMs } : null;
|
|
24
|
+
}));
|
|
25
|
+
const valid = stats.filter(Boolean).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
26
|
+
return valid[0]?.file ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function findMissingTestFiles(workDir, paths) {
|
|
30
|
+
const checks = await Promise.all(paths.map(async (rel) =>
|
|
31
|
+
[rel, await fs.stat(path.join(workDir, rel)).then(() => true).catch(() => false)]));
|
|
32
|
+
return checks.filter(([, ok]) => !ok).map(([rel]) => rel);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function totalLocSince(workDir, baseSha, run) {
|
|
36
|
+
const res = await run("git", ["diff", "--numstat", `${baseSha}..HEAD`], { cwd: workDir }).catch(() => null);
|
|
37
|
+
if (!res || res.exitCode !== 0) return null;
|
|
38
|
+
return String(res.stdout || "").split("\n").filter(Boolean)
|
|
39
|
+
.map((l) => l.split("\t")).filter(([a]) => a !== "-")
|
|
40
|
+
.reduce((sum, [a]) => sum + Number(a || 0), 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runGoldenTask(task, opts = {}) {
|
|
44
|
+
const run = opts.run || defaultRun;
|
|
45
|
+
const fsApi = opts.fs || fs;
|
|
46
|
+
const workDir = opts.workDir;
|
|
47
|
+
if (!workDir) throw new Error("runGoldenTask requires opts.workDir");
|
|
48
|
+
|
|
49
|
+
const baseSha = opts.baseSha || null;
|
|
50
|
+
const kjResult = await run("kj", ["run", task.prompt, "-y"], {
|
|
51
|
+
cwd: workDir,
|
|
52
|
+
timeout: (task.timeout_minutes || 30) * 60_000,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const summaryPath = opts.summaryPath || await findLatestSummary(workDir);
|
|
56
|
+
if (!summaryPath) {
|
|
57
|
+
return { ok: false, kjExit: kjResult?.exitCode ?? -1, failures: [{
|
|
58
|
+
kind: "no_summary", expected: "summary.md", actual: null,
|
|
59
|
+
message: "kj run finished without producing a summary.md",
|
|
60
|
+
}] };
|
|
61
|
+
}
|
|
62
|
+
const content = await fsApi.readFile(summaryPath, "utf8");
|
|
63
|
+
const parsed = parseSummary(content);
|
|
64
|
+
const summaryAssertion = assertFromSummary(task, parsed);
|
|
65
|
+
|
|
66
|
+
const failures = [...summaryAssertion.failures];
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(task.expected_test_files) && task.expected_test_files.length > 0) {
|
|
69
|
+
const missing = await findMissingTestFiles(workDir, task.expected_test_files);
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
failures.push({ kind: "test_files_missing", expected: task.expected_test_files, actual: missing,
|
|
72
|
+
message: `expected test files not produced: ${missing.join(", ")}` });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (baseSha) {
|
|
77
|
+
const loc = await totalLocSince(workDir, baseSha, run);
|
|
78
|
+
const [min, max] = task.allowed_loc_range;
|
|
79
|
+
if (loc !== null && (loc < min || loc > max)) {
|
|
80
|
+
failures.push({ kind: "loc_out_of_range", expected: `${min}..${max}`, actual: loc,
|
|
81
|
+
message: `LOC added (${loc}) is outside the allowed range [${min}, ${max}]` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: failures.length === 0,
|
|
87
|
+
kjExit: kjResult?.exitCode ?? null,
|
|
88
|
+
summaryPath,
|
|
89
|
+
parsed,
|
|
90
|
+
failures,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden tasks regression suite — schema (KJC-TSK-0374). Pure data,
|
|
3
|
+
* no I/O. See docs/golden-tasks.md (PR-3) for the full spec.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as v from "valibot";
|
|
7
|
+
|
|
8
|
+
const NonEmptyString = v.pipe(v.string(), v.minLength(1));
|
|
9
|
+
const Percent = v.pipe(v.number(), v.minValue(0), v.maxValue(100));
|
|
10
|
+
|
|
11
|
+
const NonNegInt = v.pipe(v.number(), v.integer(), v.minValue(0));
|
|
12
|
+
const LocRangeSchema = v.pipe(v.array(NonNegInt), v.length(2),
|
|
13
|
+
v.check((arr) => arr[0] <= arr[1], "allowed_loc_range: min must be <= max"));
|
|
14
|
+
|
|
15
|
+
export const GoldenTaskSchema = v.looseObject({
|
|
16
|
+
id: v.pipe(NonEmptyString, v.regex(/^[a-z0-9_-]+$/, "id must be lowercase a-z0-9_-")),
|
|
17
|
+
title: NonEmptyString,
|
|
18
|
+
prompt: NonEmptyString,
|
|
19
|
+
description: v.optional(v.string()),
|
|
20
|
+
timeout_minutes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 30),
|
|
21
|
+
expected_commits_min: NonNegInt,
|
|
22
|
+
expected_audit_status: v.picklist(["pass", "warning", "fail"]),
|
|
23
|
+
expected_test_files: v.array(NonEmptyString),
|
|
24
|
+
allowed_loc_range: LocRangeSchema,
|
|
25
|
+
expected_plan_adherence_min: v.optional(Percent),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** @typedef {import("valibot").InferOutput<typeof GoldenTaskSchema>} GoldenTask */
|
|
29
|
+
|
|
30
|
+
export function parseGoldenTask(raw) {
|
|
31
|
+
return v.parse(GoldenTaskSchema, raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function safeParseGoldenTask(raw) {
|
|
35
|
+
const result = v.safeParse(GoldenTaskSchema, raw);
|
|
36
|
+
if (result.success) return { ok: true, task: result.output };
|
|
37
|
+
return { ok: false, issues: result.issues.map((iss) => ({
|
|
38
|
+
path: (iss.path || []).map((p) => p.key).join("."),
|
|
39
|
+
message: iss.message,
|
|
40
|
+
})) };
|
|
41
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { computeBaseRef, setSnapshot } from "../review/diff-generator.js";
|
|
8
|
+
import { revParse } from "../utils/git.js";
|
|
8
9
|
import { buildCoderPrompt } from "../prompts/coder.js";
|
|
9
10
|
import { buildReviewerPrompt } from "../prompts/reviewer.js";
|
|
10
11
|
import { resolveRole } from "../config.js";
|
|
@@ -367,12 +368,28 @@ export async function initializeSession({ task, config, flags, pgTaskId, pgProje
|
|
|
367
368
|
const projectDir = config.projectDir || process.cwd();
|
|
368
369
|
const project_id = projectSlug(projectDir);
|
|
369
370
|
|
|
371
|
+
// Capture HEAD at the very start of the run, separately from base_ref.
|
|
372
|
+
// session_start_sha was being conflated with base_ref (used for `git diff`)
|
|
373
|
+
// — when the user's repo had a single commit and no remote main, base_ref
|
|
374
|
+
// fell through to git's empty-tree SHA. The post-loop journal then read
|
|
375
|
+
// session_start_sha to enumerate "commits made by this run" and got back
|
|
376
|
+
// the entire history, including pre-existing commits. Capturing actual
|
|
377
|
+
// HEAD here makes "what did the run produce?" deterministic and correct.
|
|
378
|
+
// Falls back to baseRef when HEAD doesn't exist (zero-commits repo); in
|
|
379
|
+
// that case "everything since baseRef" still equals "everything the run
|
|
380
|
+
// produced" because there was no prior history.
|
|
381
|
+
let headAtStart;
|
|
382
|
+
try {
|
|
383
|
+
headAtStart = (await revParse("HEAD")) || baseRef;
|
|
384
|
+
} catch { headAtStart = baseRef; }
|
|
385
|
+
|
|
370
386
|
const sessionInit = {
|
|
371
387
|
task,
|
|
372
388
|
project_id,
|
|
373
389
|
config_snapshot: config,
|
|
374
390
|
base_ref: baseRef,
|
|
375
391
|
session_start_sha: baseRef,
|
|
392
|
+
head_at_start: headAtStart,
|
|
376
393
|
last_reviewer_feedback: null,
|
|
377
394
|
repeated_issue_count: 0,
|
|
378
395
|
sonar_retry_count: 0,
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
import { generateDiff } from "../../review/diff-generator.js";
|
|
25
25
|
import { finalizeGitAutomation } from "../../git/automation.js";
|
|
26
|
+
import { listCommitsBetween, listFilesChangedSince } from "../../utils/git.js";
|
|
27
|
+
import { loadPlan, projectSlug as slugFor } from "../../plan/plan-store.js";
|
|
28
|
+
import { computePlanAdherenceScore } from "../../audit/plan-adherence.js";
|
|
26
29
|
import { saveSession, markSessionStatus } from "../../session/store.js";
|
|
27
30
|
import {
|
|
28
31
|
setReviewerFeedback, setBudget, setRtkSavings, resetRetryCount,
|
|
@@ -151,6 +154,50 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
151
154
|
const { writeSummaryJournal: writeSummaryJournalV2 } =
|
|
152
155
|
await import("../../session/journal/summary-writer.js");
|
|
153
156
|
const startedAt = session._startedAt ? new Date(session._startedAt).toISOString() : undefined;
|
|
157
|
+
// 2026-05-07 dogfooding: gitResult.commits only carries the post-loop
|
|
158
|
+
// commit (scaffold), not the coder's own commit. The summary's
|
|
159
|
+
// "Commits" section was therefore misleading — it claimed the run
|
|
160
|
+
// produced one chore commit when in fact the coder had also written
|
|
161
|
+
// a docs/feat commit. Compute the full range from session_start_sha
|
|
162
|
+
// so every commit between then and HEAD shows up in execution order.
|
|
163
|
+
// try/catch (instead of `.catch(...)`) so we tolerate both promise
|
|
164
|
+
// rejection AND a sync return of undefined — the latter happens
|
|
165
|
+
// when a test mocks listCommitsBetween with a plain vi.fn() that
|
|
166
|
+
// resetAllMocks has stripped of its mockResolvedValue.
|
|
167
|
+
let allCommits = [];
|
|
168
|
+
try { allCommits = (await listCommitsBetween(session.head_at_start || session.session_start_sha)) || []; }
|
|
169
|
+
catch { allCommits = gitResult?.commits || []; }
|
|
170
|
+
const summaryCommits = allCommits.length > 0 ? allCommits : (gitResult?.commits || []);
|
|
171
|
+
|
|
172
|
+
// KJC-TSK-0376 — plan adherence metric (deepeval-inspired). When the
|
|
173
|
+
// run executed against a known plan (`kj run --plan <id>`), score how
|
|
174
|
+
// faithfully the coder followed it. Pure offline; safe-by-default
|
|
175
|
+
// (try/catch swallows missing helper or unreadable plan).
|
|
176
|
+
let planAdherence = null;
|
|
177
|
+
try {
|
|
178
|
+
const planId = session._planRef?.planId
|
|
179
|
+
|| stageResults?.huReviewer?.planId
|
|
180
|
+
|| null;
|
|
181
|
+
if (planId) {
|
|
182
|
+
const projectDir = config?.projectDir
|
|
183
|
+
|| session.config_snapshot?.projectDir
|
|
184
|
+
|| process.cwd();
|
|
185
|
+
const plan = await loadPlan(projectDir, planId).catch(() => null);
|
|
186
|
+
if (plan && Array.isArray(plan.hus) && plan.hus.length > 0) {
|
|
187
|
+
const filesChanged = await listFilesChangedSince(
|
|
188
|
+
session.head_at_start || session.session_start_sha,
|
|
189
|
+
).catch(() => []);
|
|
190
|
+
planAdherence = computePlanAdherenceScore({
|
|
191
|
+
commits: summaryCommits,
|
|
192
|
+
plan,
|
|
193
|
+
filesChanged,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
logger?.warn?.(`Plan adherence calculation skipped: ${err.message}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
154
201
|
await writeSummaryJournalV2(journalDir, {
|
|
155
202
|
task: session.task,
|
|
156
203
|
result: "APPROVED",
|
|
@@ -159,7 +206,7 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
159
206
|
durationMs: Date.now() - (session._startedAt || Date.now()),
|
|
160
207
|
budget: budgetSummary(),
|
|
161
208
|
stages: stageResults,
|
|
162
|
-
commits:
|
|
209
|
+
commits: summaryCommits,
|
|
163
210
|
files: journalFiles,
|
|
164
211
|
startedAt,
|
|
165
212
|
finishedAt: new Date().toISOString(),
|
|
@@ -173,6 +220,7 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
173
220
|
installed: session.autoInstalledSkills,
|
|
174
221
|
recommended: session.skillsRecommended,
|
|
175
222
|
},
|
|
223
|
+
planAdherence,
|
|
176
224
|
}, { logger });
|
|
177
225
|
logger.info(`Session journal written to ${journalDir}`);
|
|
178
226
|
} catch (err) {
|
|
@@ -180,7 +228,21 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
180
228
|
}
|
|
181
229
|
}
|
|
182
230
|
|
|
183
|
-
|
|
231
|
+
// Mirror the same enriched commit list into the session:end event so the
|
|
232
|
+
// terminal `🏁 Result` banner (printSessionGit) shows every commit the
|
|
233
|
+
// run produced, not just the post-loop scaffold one. We compute again
|
|
234
|
+
// (cheap: one git log call) rather than threading a variable through
|
|
235
|
+
// because the journal block is wrapped in try/catch and may have set
|
|
236
|
+
// `summaryCommits` to a fallback we don't want for the event detail.
|
|
237
|
+
// try/catch tolerates both promise rejection and sync undefined returns
|
|
238
|
+
// (see the journal block above for the detailed rationale).
|
|
239
|
+
let eventCommits = null;
|
|
240
|
+
try { eventCommits = await listCommitsBetween(session.head_at_start || session.session_start_sha); }
|
|
241
|
+
catch { /* leave null — we'll use gitResult unchanged */ }
|
|
242
|
+
const gitForEvent = (Array.isArray(eventCommits) && eventCommits.length > 0)
|
|
243
|
+
? { ...gitResult, commits: eventCommits }
|
|
244
|
+
: gitResult;
|
|
245
|
+
const endDetail = { approved: true, iterations: i, stages: stageResults, git: gitForEvent, budget: budgetSummary(), deferredIssues };
|
|
184
246
|
if (rtkSavings) endDetail.rtk_savings = rtkSavings;
|
|
185
247
|
emitProgress(
|
|
186
248
|
emitter,
|
|
@@ -332,7 +394,6 @@ export async function tryAutoStartBoard(config, logger, emitter, eventBase) {
|
|
|
332
394
|
|
|
333
395
|
try {
|
|
334
396
|
const { startBoard, renderBoardBanner } = await import("../../commands/board.js");
|
|
335
|
-
const { projectSlug: slugFor } = await import("../../plan/plan-store.js");
|
|
336
397
|
const boardPort = config.hu_board.port || 4000;
|
|
337
398
|
// Scope the board URL to the current run's project (`/p/<slug>`) so
|
|
338
399
|
// the user lands on a filtered view, not the global dashboard.
|
|
@@ -60,6 +60,45 @@ import { runSecurityStage, runFinalAuditStage } from "./post-loop-stages.js";
|
|
|
60
60
|
// PG card "In Progress" logic moved to src/planning-game/pipeline-adapter.js → initPgAdapter()
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Boundary guard: make sure the session's terminal status reflects what
|
|
65
|
+
* actually happened, even if some inner exit path forgot to call
|
|
66
|
+
* `markSessionStatus`. KJC-BUG-0037 (N1 dogfooding 2026-05-07): runs
|
|
67
|
+
* that took the Brain "max_iterations approved" / "Solomon-after-error
|
|
68
|
+
* approved" / "Brain solomon-approved" exits returned `{approved:true}`
|
|
69
|
+
* upstream but left `session.status = "running"` on disk indefinitely.
|
|
70
|
+
* `kj status` then showed "Pipeline RUNNING" forever and the HU Board
|
|
71
|
+
* carried a perma-zombie.
|
|
72
|
+
*
|
|
73
|
+
* Maps the result shape to a terminal status:
|
|
74
|
+
* approved → "approved"
|
|
75
|
+
* paused → "paused"
|
|
76
|
+
* cancelled → "cancelled"
|
|
77
|
+
* anything else → "failed"
|
|
78
|
+
*
|
|
79
|
+
* Idempotent: only writes when the current status is still "running"
|
|
80
|
+
* (or unset). If an inner path already sealed it correctly, this is a
|
|
81
|
+
* no-op.
|
|
82
|
+
*
|
|
83
|
+
* Safe-by-default: never throws. A failure here must not block the
|
|
84
|
+
* top-level result from being returned to the caller.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} session
|
|
87
|
+
* @param {object} [result]
|
|
88
|
+
*/
|
|
89
|
+
export async function sealSessionStatusIfStillRunning(session, result) {
|
|
90
|
+
try {
|
|
91
|
+
if (!session) return;
|
|
92
|
+
const current = session.status;
|
|
93
|
+
if (current && current !== "running") return;
|
|
94
|
+
let target = "failed";
|
|
95
|
+
if (result?.approved === true) target = "approved";
|
|
96
|
+
else if (result?.paused === true) target = "paused";
|
|
97
|
+
else if (result?.cancelled === true) target = "cancelled";
|
|
98
|
+
await markSessionStatus(session, target);
|
|
99
|
+
} catch { /* non-blocking */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
63
102
|
export async function runFlow(opts) {
|
|
64
103
|
// TSK-0338: isolate per-run state (git/diff runner, projectDir, snapshot)
|
|
65
104
|
// inside an AsyncLocalStorage scope. Two concurrent `runFlow` invocations
|
|
@@ -193,12 +232,14 @@ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null,
|
|
|
193
232
|
// policies + acceptance-tests gate + Sonar + tester translation.
|
|
194
233
|
const huBatch = await runHuBatch({ ctx, task, askQuestion, emitter, logger });
|
|
195
234
|
if (huBatch.handled) {
|
|
235
|
+
await sealSessionStatusIfStillRunning(ctx.session, huBatch.result);
|
|
196
236
|
return huBatch.result;
|
|
197
237
|
}
|
|
198
238
|
|
|
199
239
|
// --- Standard single-task pipeline (1 HU or no HU reviewer) ---
|
|
200
240
|
const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
|
|
201
241
|
await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
|
|
242
|
+
await sealSessionStatusIfStillRunning(ctx.session, result);
|
|
202
243
|
return result;
|
|
203
244
|
} finally {
|
|
204
245
|
// --- Cleanup auto-installed skills ---
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
12
12
|
import { invokeSolomon } from "../solomon-escalation.js";
|
|
13
13
|
import { sonarUp, isSonarReachable } from "../../sonar/manager.js";
|
|
14
|
+
import { canResolveSonarProjectKey } from "../../sonar/project-key.js";
|
|
14
15
|
import { addEntries } from "../feedback-queue.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -222,6 +223,24 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
|
|
|
222
223
|
logger.info("SonarQube is ready");
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
// 2026-05-07 (N4 dogfooding): when the working tree has neither a git
|
|
227
|
+
// remote nor an explicit `sonarqube.project_key`, the scanner inside
|
|
228
|
+
// SonarRole would throw "Missing git remote.origin.url" — the Brain
|
|
229
|
+
// logs that as a sonar error, the iteration cap is reached, and the
|
|
230
|
+
// run finalises as "approved" by exhaustion fallback even though no
|
|
231
|
+
// sonar gate ever ran. KJC-TSK-0373 silenced this for the audit
|
|
232
|
+
// collector but the run loop hit it on the parallel code path.
|
|
233
|
+
// Skipping cleanly here means the gate reports "SKIPPED" once and
|
|
234
|
+
// the iteration loop progresses on the next stage.
|
|
235
|
+
if (!await canResolveSonarProjectKey(config)) {
|
|
236
|
+
logger.info("Sonar gate skipped: no git remote and no sonarqube.project_key configured");
|
|
237
|
+
emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
|
|
238
|
+
status: "skip",
|
|
239
|
+
message: "Sonar skipped — no git remote and no sonarqube.project_key configured (run `git remote add origin …` or set `sonarqube.project_key` to enable)"
|
|
240
|
+
}));
|
|
241
|
+
return { action: "ok", stageResult: { gateStatus: "SKIPPED", reason: "no git remote and no sonarqube.project_key configured" } };
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
const sonarRole = new SonarRole({ config, logger, emitter });
|
|
226
245
|
await sonarRole.init({ iteration });
|
|
227
246
|
const sonarStart = Date.now();
|
|
@@ -199,11 +199,50 @@ export function buildSummaryMarkdown(input) {
|
|
|
199
199
|
|
|
200
200
|
sections.push("", "## Commits", "", renderCommits(input.commits));
|
|
201
201
|
|
|
202
|
+
// KJC-TSK-0376 — plan adherence metric (deepeval-inspired). Only
|
|
203
|
+
// rendered when the run executed against a known plan and the
|
|
204
|
+
// calculation produced a non-null score (a run with no commits or
|
|
205
|
+
// no files changed yields nulls everywhere → omit the section).
|
|
206
|
+
const planAdherenceSection = renderPlanAdherence(input.planAdherence);
|
|
207
|
+
if (planAdherenceSection) {
|
|
208
|
+
sections.push("", "## Plan adherence", "", planAdherenceSection);
|
|
209
|
+
}
|
|
210
|
+
|
|
202
211
|
sections.push("", "## Journal files", "", renderJournalLinks(input.files));
|
|
203
212
|
|
|
204
213
|
return sections.join("\n") + "\n";
|
|
205
214
|
}
|
|
206
215
|
|
|
216
|
+
function renderPlanAdherence(planAdherence) {
|
|
217
|
+
if (!planAdherence || typeof planAdherence !== "object") return "";
|
|
218
|
+
if (planAdherence.score === null || planAdherence.score === undefined) return "";
|
|
219
|
+
const lines = [`**Score**: ${planAdherence.score}/100`, ""];
|
|
220
|
+
const breakdown = planAdherence.breakdown || {};
|
|
221
|
+
lines.push("| Component | Score | Weight |");
|
|
222
|
+
lines.push("| --- | --- | --- |");
|
|
223
|
+
const rows = [
|
|
224
|
+
["Commit attribution", breakdown.commit_attribution, "40%"],
|
|
225
|
+
["Acceptance tests", breakdown.acceptance_tests, "30%"],
|
|
226
|
+
["Scope discipline", breakdown.scope_discipline, "20%"],
|
|
227
|
+
["Dependency order", breakdown.dependency_order, "10%"],
|
|
228
|
+
];
|
|
229
|
+
for (const [label, value, weight] of rows) {
|
|
230
|
+
const cell = value === null || value === undefined ? "n/a" : `${value}/100`;
|
|
231
|
+
lines.push(`| ${label} | ${cell} | ${weight} |`);
|
|
232
|
+
}
|
|
233
|
+
const huScores = Array.isArray(planAdherence.hu_scores) ? planAdherence.hu_scores : [];
|
|
234
|
+
if (huScores.length > 0) {
|
|
235
|
+
const unattributed = huScores.filter((h) => !h.attributed);
|
|
236
|
+
if (unattributed.length > 0) {
|
|
237
|
+
lines.push("", `_${unattributed.length} HU(s) without an attributed commit:_`);
|
|
238
|
+
for (const h of unattributed) {
|
|
239
|
+
lines.push(`- \`${h.huId}\``);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return lines.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
207
246
|
/**
|
|
208
247
|
* Write summary.md to the given journal directory. Always writes (the file
|
|
209
248
|
* is the entry point; even a barebones session should have one).
|