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.
Files changed (47) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/audit/plan-adherence.js +222 -0
  4. package/src/audit/sonar-findings.js +12 -0
  5. package/src/cli/register-meta.js +4 -4
  6. package/src/cli/register-pipeline.js +3 -3
  7. package/src/cli/register-plan.js +1 -1
  8. package/src/commands/board.js +28 -4
  9. package/src/commands/init.js +24 -5
  10. package/src/config/loader.js +32 -1
  11. package/src/git/automation.js +94 -4
  12. package/src/git/hu-automation.js +35 -3
  13. package/src/golden/asserter.js +90 -0
  14. package/src/golden/loader.js +52 -0
  15. package/src/golden/runner.js +92 -0
  16. package/src/golden/schema.js +41 -0
  17. package/src/orchestrator/config-init.js +17 -0
  18. package/src/orchestrator/drivers/post-loop.js +64 -3
  19. package/src/orchestrator/flow-runner.js +41 -0
  20. package/src/orchestrator/stages/sonar-stage.js +19 -0
  21. package/src/session/journal/summary-writer.js +39 -0
  22. package/src/skills/addyosmani-catalog.js +29 -7
  23. package/src/sonar/project-key.js +37 -0
  24. package/src/types/session.js +2 -1
  25. package/src/utils/display/session-summary.js +12 -2
  26. package/src/utils/git.js +132 -1
  27. package/templates/roles/architect/anthropic.md +9 -0
  28. package/templates/roles/architect/default.md +9 -0
  29. package/templates/roles/architect/google.md +9 -0
  30. package/templates/roles/architect/openai.md +9 -0
  31. package/templates/roles/architect/opencode.md +9 -0
  32. package/templates/roles/architect.md +9 -0
  33. package/templates/roles/coder/anthropic.md +9 -0
  34. package/templates/roles/coder/default.md +9 -0
  35. package/templates/roles/coder/google.md +9 -0
  36. package/templates/roles/coder/openai.md +9 -0
  37. package/templates/roles/coder/opencode.md +9 -0
  38. package/templates/roles/coder.md +9 -0
  39. package/templates/roles/planner/anthropic.md +9 -0
  40. package/templates/roles/planner/default.md +9 -0
  41. package/templates/roles/planner/google.md +9 -0
  42. package/templates/roles/planner/openai.md +9 -0
  43. package/templates/roles/planner/opencode.md +9 -0
  44. package/templates/roles/planner.md +9 -0
  45. package/templates/roles/refactorer.md +9 -0
  46. package/templates/roles/repairer.md +9 -0
  47. package/templates/roles/tester.md +9 -0
@@ -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, baseBranch], {});
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 ${baseBranch}: ${res.stderr}`);
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 '${baseBranch}'`);
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: gitResult?.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
- const endDetail = { approved: true, iterations: i, stages: stageResults, git: gitResult, budget: budgetSummary(), deferredIssues };
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).