karajan-code 4.41.0 → 4.43.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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/commands/harden.js +14 -0
  3. package/src/commands/init.js +7 -1
  4. package/src/commands/pr-size.js +10 -4
  5. package/src/config/defaults.js +5 -1
  6. package/src/config/schema.js +5 -0
  7. package/src/config/test-harness.js +5 -0
  8. package/src/environment/contract-commit.js +11 -1
  9. package/src/git/automation.js +49 -8
  10. package/src/git/coder-commit-guard.js +40 -0
  11. package/src/harden/hook-templates.js +6 -0
  12. package/src/harden/sentinel/sentinel-shell.mjs +7 -3
  13. package/src/harden/sentinel-hooks.js +83 -11
  14. package/src/orchestrator/config-init.js +4 -2
  15. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +7 -0
  16. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +3 -1
  17. package/src/orchestrator/drivers/post-loop.js +1 -1
  18. package/src/orchestrator/preflight-checks.js +11 -8
  19. package/src/orchestrator/stages/coder-stage.js +16 -0
  20. package/src/orchestrator/stages/reviewer-stage.js +13 -11
  21. package/src/orchestrator/stages/suite-green-stage.js +55 -0
  22. package/src/prompts/coder.js +3 -1
  23. package/src/rag/indexer.js +8 -6
  24. package/src/review/gate-gitignore.js +2 -0
  25. package/src/review/verdict-store.js +4 -2
  26. package/src/roles/domain-curator-role.js +6 -13
  27. package/src/roles/reviewer-role.js +23 -12
  28. package/src/sonar/scanner.js +22 -5
  29. package/src/utils/git.js +44 -18
  30. package/src/utils/run-log.js +8 -0
  31. package/templates/coder-rules.md +1 -1
  32. package/templates/roles/coder/anthropic.md +2 -2
  33. package/templates/roles/coder/default.md +2 -2
  34. package/templates/roles/coder/google.md +2 -2
  35. package/templates/roles/coder/openai.md +2 -2
  36. package/templates/roles/coder/opencode.md +1 -1
  37. package/templates/roles/coder.md +2 -2
  38. package/templates/roles/refactorer.md +1 -1
  39. package/templates/skills/kj-code.md +1 -1
  40. package/templates/skills/kj-review.md +1 -1
  41. package/templates/skills/kj-sonar.md +4 -3
@@ -29,9 +29,11 @@ export async function runReviewerGateStage({ pipelineFlags, reviewerRole, config
29
29
  };
30
30
  const reviewerResult = await runStage(stageRegistry.get("reviewer"), reviewerCtx);
31
31
  if (reviewerResult === null) {
32
+ // KJC-BUG-0297 (#1993): `reviewed: false` so the pipeline never stamps a
33
+ // verdict nobody gave nor commits on it (finalizeGitAutomation).
32
34
  return {
33
35
  action: "ok",
34
- review: { approved: true, blocking_issues: [], non_blocking_suggestions: [], summary: "Reviewer disabled by pipeline", confidence: 1 }
36
+ review: { approved: true, reviewed: false, blocking_issues: [], non_blocking_suggestions: [], summary: "Reviewer disabled by pipeline: nothing was reviewed", confidence: 0 }
35
37
  };
36
38
  }
37
39
  if (reviewerResult.action === "pause") return { action: "return", result: reviewerResult.result };
@@ -107,7 +107,7 @@ export async function handlePostLoopStages({ config, session, emitter, eventBase
107
107
  }
108
108
 
109
109
  export async function finalizeApprovedSession({ config, gitCtx, task, logger, session, stageResults, emitter, eventBase, budgetSummary, pgCard, pgProject, review, i, rtkTracker }) {
110
- const gitResult = await finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults });
110
+ const gitResult = await finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults, review });
111
111
 
112
112
  // Accumulate final commits for PG card lifecycle tracking
113
113
  if (gitResult?.commits?.length) {
@@ -409,8 +409,8 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
409
409
  /**
410
410
  * Run the doctor-style complementary checks and merge their outcomes into
411
411
  * the existing preflight result shape. Runs with yes:true (non-interactive)
412
- * and auto-remediation enabled. FAIL/TIMEOUT are appended to errors (blocking),
413
- * WARN are appended to warnings (non-blocking), FIXED status is recorded as a
412
+ * and auto-remediation enabled. FAIL is appended to errors (blocking), WARN and
413
+ * TIMEOUT to warnings (non-blocking: a timeout is "not checked"), FIXED status is recorded as a
414
414
  * remediation, and runtime overrides from auto-fixes are merged into
415
415
  * configOverrides.
416
416
  */
@@ -437,11 +437,12 @@ async function runExtendedPreflight({ config, result, emitter, eventBase, logger
437
437
 
438
438
  for (const c of report.checks) {
439
439
  result.checks.push({ name: c.name, ok: c.status === STATUS.OK || c.status === STATUS.FIXED || c.status === STATUS.SKIPPED, detail: c.detail });
440
- const eventStatus = c.status === STATUS.FAIL || c.status === STATUS.TIMEOUT
441
- ? "fail"
442
- : c.status === STATUS.WARN
443
- ? "warn"
444
- : "ok";
440
+ // KJC-BUG-0287 (#1982): a timeout is "not checked", never "broken". GitHub
441
+ // unreachable for the SHA-pinned actions blocked a docs-only run that needed
442
+ // no network at all; the runner itself calls TIMEOUT warn-level.
443
+ let eventStatus = "ok";
444
+ if (c.status === STATUS.FAIL) eventStatus = "fail";
445
+ else if (c.status === STATUS.WARN || c.status === STATUS.TIMEOUT) eventStatus = "warn";
445
446
  emitProgress(emitter, makeEvent("preflight:check", { ...eventBase, stage: "preflight" }, {
446
447
  status: eventStatus,
447
448
  message: `${c.label}: ${c.detail}`,
@@ -449,9 +450,11 @@ async function runExtendedPreflight({ config, result, emitter, eventBase, logger
449
450
  }));
450
451
  if (c.status === STATUS.FIXED) {
451
452
  result.remediations.push(`${c.label}: ${c.detail}`);
452
- } else if (c.status === STATUS.FAIL || c.status === STATUS.TIMEOUT) {
453
+ } else if (c.status === STATUS.FAIL) {
453
454
  result.ok = false;
454
455
  result.errors.push({ check: c.name, message: `${c.label}: ${c.detail}`, fix: c.fix });
456
+ } else if (c.status === STATUS.TIMEOUT) {
457
+ result.warnings.push(`${c.label}: not checked, it did not answer in time (${c.detail}); the run goes on`);
455
458
  } else if (c.status === STATUS.WARN) {
456
459
  result.warnings.push(`${c.label}: ${c.detail}`);
457
460
  }
@@ -17,6 +17,7 @@ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js"
17
17
  import { runCoderWithFallback } from "../agent-fallback.js";
18
18
  import { invokeSolomon } from "../solomon-escalation.js";
19
19
  import { detectRateLimit } from "../../utils/rate-limit-detector.js";
20
+ import { headSha, undoCommitsSince } from "../../git/coder-commit-guard.js";
20
21
  import { createStallDetector } from "../../utils/stall-detector.js";
21
22
  import { buildStandbyState } from "../../brain/standby-store.js";
22
23
  import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
@@ -54,6 +55,10 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
54
55
  // Claude run `cd /home/manu/assistant && pnpm init …` and 36 MB
55
56
  // of code landed outside projectDir, outside any git repo.
56
57
  const homeSnapshotBeforeCoder = snapshotHomeTopLevel();
58
+ // KJC-BUG-0285 (#1982): where HEAD stands before the coder runs. The coder
59
+ // commits nothing; the pipeline does, once the review approves.
60
+ const stageDir = config?.projectDir || process.cwd();
61
+ const headBefore = headSha(stageDir);
57
62
  let coderExecResult;
58
63
  try {
59
64
  coderExecResult = await coderRoleInstance.execute({
@@ -84,6 +89,17 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
84
89
  }
85
90
  trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
86
91
  applyQuotaSimulation(coderExecResult, { iteration, agent: coderRole.provider });
92
+ // KJC-BUG-0285 (#1982): a commit the coder made on its own carried the error the
93
+ // reviewer then rejected, and stayed on the branch. Undone softly: the changes
94
+ // stay in the tree for the review; the pipeline commits what it approves.
95
+ if (headBefore) {
96
+ const undone = undoCommitsSince(headBefore, stageDir);
97
+ if (undone.undone > 0) {
98
+ const message = `Coder committed on its own (${undone.undone} commit(s)); undone softly, the changes stay in the tree: the pipeline commits once the review approves`;
99
+ logger.warn(message);
100
+ emitProgress(emitter, makeEvent("coder:commit-undone", { ...eventBase, stage: "coder" }, { message, detail: { commits: undone.undone, headBefore } }));
101
+ }
102
+ }
87
103
 
88
104
  if (!coderExecResult.ok) {
89
105
  const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
@@ -335,18 +335,20 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
335
335
  confidence: reviewResult.confidence ?? 0
336
336
  });
337
337
  } catch (error_) {
338
+ // KJC-BUG-0284 (#1982): a verdict kj cannot read is the reviewer's failure,
339
+ // never a PARSE_ERROR defect handed to the coder (it sent one run in circles).
340
+ const details = `the verdict could not be read: ${error_.message}`;
338
341
  logger.warn(`Reviewer output validation failed: ${error_.message}`);
339
- review = {
340
- approved: false,
341
- blocking_issues: [{
342
- id: "PARSE_ERROR",
343
- severity: "high",
344
- description: `Reviewer output could not be parsed: ${error_.message}`
345
- }],
346
- non_blocking_suggestions: [],
347
- summary: `Parse error: ${error_.message}`,
348
- confidence: 0
349
- };
342
+ await markSessionStatus(session, "failed");
343
+ emitProgress(
344
+ emitter,
345
+ makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
346
+ status: "fail",
347
+ message: `Reviewer failed: ${details}`,
348
+ detail: { provider: reviewerRole.provider, executorType: "agent" }
349
+ })
350
+ );
351
+ throw new Error(`Reviewer failed: ${details}`, { cause: error_ });
350
352
  }
351
353
  // --- Scope filter: auto-defer out-of-scope blocking issues ---
352
354
  const { review: filteredReview, demoted, deferred, allDemoted } = filterReviewScope(review, diff);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * KJC-BUG-0298 (#1993): with TDD, a red suite never reaches the reviewer. A
3
+ * step whose test was added without its implementation was approved with one
4
+ * failing file, because nothing in the iteration RAN the tests: the
5
+ * verification gate counts changed lines, the TDD check asks that tests changed
6
+ * alongside sources, and the red-then-green discipline is opt-in. This stage
7
+ * runs the project's own test command (the one kj harden resolves: npm test, a
8
+ * known framework, pytest, go test) and hands a red suite back to the coder.
9
+ * Opt-out: `development.require_green_suite: false`.
10
+ */
11
+ import { addCheckpoint, saveSession } from "../../session/store.js";
12
+ import { setReviewerFeedback } from "../../session/mutators.js";
13
+ import { emitProgress, makeEvent } from "../../utils/events.js";
14
+ import { runCommand } from "../../utils/process.js";
15
+ import { resolveCmds } from "../../commands/harden.js";
16
+
17
+ const TAIL_CHARS = 2000;
18
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
19
+
20
+ /** @returns {Promise<{action: "ok"|"continue", result: object}>} */
21
+ export async function runSuiteGreenStage({ config, logger, emitter, eventBase, session, iteration, resolveCmdsImpl = resolveCmds, runTestsImpl = null }) {
22
+ const projectDir = config?.projectDir || process.cwd();
23
+ let command = null;
24
+ try {
25
+ command = (await resolveCmdsImpl(projectDir))?.test || null;
26
+ } catch (err) {
27
+ logger?.warn?.(`suite-green: could not resolve the test command (${err.message})`);
28
+ }
29
+ if (!command) {
30
+ const message = "suite-green: no test command for this project (package.json scripts.test or a known framework): the suite was NOT run";
31
+ logger?.warn?.(message);
32
+ emitProgress(emitter, makeEvent("tests:result", { ...eventBase, stage: "tests" }, { status: "warn", message, detail: { ran: false } }));
33
+ return { action: "ok", result: { ran: false } };
34
+ }
35
+ const timeout = Number(config?.development?.test_timeout_ms) > 0 ? Number(config.development.test_timeout_ms) : DEFAULT_TIMEOUT_MS;
36
+ const run = runTestsImpl || (async () => {
37
+ const res = await runCommand("bash", ["-lc", command], { cwd: projectDir, timeout });
38
+ return { exitCode: res.exitCode ?? 1, output: `${res.stdout || ""}\n${res.stderr || ""}` };
39
+ });
40
+ const res = await run(command);
41
+ const ok = res.exitCode === 0;
42
+ const verdict = ok ? "green" : `red (exit ${res.exitCode})`;
43
+ emitProgress(emitter, makeEvent("tests:result", { ...eventBase, stage: "tests" }, {
44
+ status: ok ? "ok" : "fail",
45
+ message: `${command}: ${verdict}`,
46
+ detail: { ran: true, ok, command, exitCode: res.exitCode, executorType: "local" },
47
+ }));
48
+ await addCheckpoint(session, { stage: "suite-green", iteration, ok, command });
49
+ if (ok) return { action: "ok", result: { ran: true, ok: true, command } };
50
+ const tail = String(res.output || "").trim().slice(-TAIL_CHARS);
51
+ setReviewerFeedback(session, `The test suite is RED (${command}, exit ${res.exitCode}). Make it green before anything else:\n${tail}`);
52
+ await saveSession(session);
53
+ logger?.warn?.(`suite-green: ${command} exited ${res.exitCode}; back to the coder`);
54
+ return { action: "continue", result: { ran: true, ok: false, command, exitCode: res.exitCode } };
55
+ }
@@ -251,7 +251,9 @@ export async function buildCoderPromptLayout({ task, reviewerFeedback = null, so
251
251
  // .gitignore, a rogue verify worktree). Anchor the scope hard.
252
252
  section("SCOPE: this is an EXISTING project. Implement ONLY the current HU against the code already here. NEVER scaffold, bootstrap or initialize project structure, never add a generic multi-stack .gitignore, and never create git worktrees (`git worktree add`) — your workspace is already prepared. If a step ever reads like 'set up the project', treat it as a misread of an existing repo: re-read the HU and make the real change.", STABLE),
253
253
  section("Follow SOLID principles. Write small, focused functions (< 30 lines).", STABLE),
254
- section("Make atomic commits: 1 logical change = 1 commit. Keep PRs small and reviewable.", STABLE),
254
+ // KJC-BUG-0285 (#1982): "atomic commits" read as "commit": the coder committed
255
+ // before the review and the rejected commit stayed on the branch.
256
+ section("One logical change per task; keep it small and reviewable. NEVER run git commit, git push or open a PR: the pipeline commits once the cross-AI review approves.", STABLE),
255
257
  section("Security: validate all input, parameterize queries, never expose secrets. Use the auth pattern that matches the stack (e.g. httpOnly cookies for web sessions).", STABLE),
256
258
  section("Use the project's standard logger and type system (no console.log when a structured logger exists; use the project's type idiom — TS types, JSDoc, Python type hints, Go interfaces…).", STABLE),
257
259
  section(SUBPROCESS_CONSTRAINTS, STABLE),
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { execa } from "execa";
12
12
 
13
13
  import { chunkMarkdown, chunkPlan, chunkSource } from "./chunker.js";
14
- import { insertChunk, deleteChunksBySource, findChunkByHash } from "./vec-store.js";
14
+ import { insertChunk, deleteChunksBySource, findChunkByHash, getEmbeddingsByIds } from "./vec-store.js";
15
15
  import { detectAdaptersForProject, getAllCodeExtensions } from "../lang/registry.js";
16
16
  import { indexableReason, listProjectFiles } from "./indexable.js";
17
17
 
@@ -71,15 +71,17 @@ export async function indexFile(path, { db, embedder, logger = console, project
71
71
  let skipped = 0;
72
72
  for (const ch of chunks) {
73
73
  // KJC-TSK-0484 — Skip re-embedding identical bodies for the same project.
74
- // The chunks row is still rewritten under the new source so deletions of
75
- // an alias don't orphan a row whose owner was the dedup target.
74
+ // KJC-BUG-0281 (#1980): the row is STILL written under the new source, with
75
+ // the stored vector: a file whose every chunk duplicates another (CLAUDE.md
76
+ // next to the AGENTS.md kj harden writes) was left with no rows at all, and
77
+ // the coverage check reported it missing after every reindex.
76
78
  const contentHash = createHash("sha256").update(ch.text).digest("hex");
77
79
  try {
78
80
  const dup = findChunkByHash(db, contentHash, project);
79
- if (dup) { skipped += 1; continue; }
80
- const embedding = await embedder.embed(ch.text);
81
+ const stored = dup ? getEmbeddingsByIds(db, [dup.id]).get(Number(dup.id)) : null;
82
+ const embedding = stored ?? await embedder.embed(ch.text);
81
83
  insertChunk(db, { source: path, kind: ch.metadata.kind || kind, text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
82
- indexed += 1;
84
+ if (stored) skipped += 1; else indexed += 1;
83
85
  } catch (err) {
84
86
  failed += 1;
85
87
  logger.warn?.(`[rag-indexer] embed failed for ${path} (${chunkLabel(ch)}): ${err.message}`);
@@ -18,6 +18,8 @@ import { runCommand } from "../utils/process.js";
18
18
  // init and the orchestrator's autoInit write this same block instead of a
19
19
  // bare `.karajan/` exclude (which git cannot re-include children of).
20
20
  export const CONTRACT_BLOCK = [
21
+ // KJC-BUG-0296 (#1989): the host agent's runtime locks never travel.
22
+ ".claude/*.lock",
21
23
  ".karajan/*",
22
24
  "# …except the v4 environment contract, which the whole team inherits:",
23
25
  "!.karajan/review-gate",
@@ -98,14 +98,16 @@ export async function loadVerdict(projectDir, hash) {
98
98
  * (KJC-BUG-0115).
99
99
  * @returns {Promise<{stamped: boolean}>}
100
100
  */
101
- export async function stampStagedVerdict({ projectDir, reviewer, summary = "", sonar = null }) {
101
+ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", sonar = null, paths = null }) {
102
102
  const dir = projectDir || process.cwd();
103
103
  try {
104
104
  await fs.access(path.join(dir, ".karajan", "review-gate"));
105
105
  } catch {
106
106
  return { stamped: false };
107
107
  }
108
- const res = await runCommand("git", ["diff", "--cached"], { cwd: dir });
108
+ // KJC-BUG-0294: the pipeline commits by path (`--only`); the verdict must bind
109
+ // to that diff, not to other files the person had staged.
110
+ const res = await runCommand("git", ["diff", "--cached", ...(paths?.length ? ["--", ...paths] : [])], { cwd: dir });
109
111
  if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
110
112
  await saveVerdict(dir, res.stdout, {
111
113
  verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
@@ -42,20 +42,13 @@ export class DomainCuratorRole {
42
42
  // 3. Deduplicate (file domains take precedence over registry entries)
43
43
  const allDomains = deduplicateDomains(fileDomains, registryDomains);
44
44
 
45
- // 4. No domains found
45
+ // 4. No domains found. KJC-BUG-0295 (#1992): this used to ASK the user
46
+ // ("do you have domain documents?") and then do nothing with the answer:
47
+ // a cryptic, English-only question with no consequence, raised even when
48
+ // the card already carried the acceptance criteria. A question that changes
49
+ // nothing is not asked; what kj uses instead is said.
46
50
  if (allDomains.length === 0) {
47
- if (askQuestion?.interactive) {
48
- const answer = await askQuestion({
49
- message: "No domain knowledge found for this task.\nDo you have domain documents to provide, or should the pipeline continue without domain context?",
50
- type: "confirm"
51
- });
52
- // For now, just log. Future: support document ingestion.
53
- if (answer === true) {
54
- this.logger.info("Domain Curator: user indicated domain documents available — future feature");
55
- }
56
- } else {
57
- this.logger.warn("Domain Curator: no domain knowledge found, continuing without domain context");
58
- }
51
+ this.logger.info("Domain Curator: no domain documents in .karajan/domains/ nor the registry; the task's own text, the card's acceptance criteria and the repo are the context");
59
52
 
60
53
  return {
61
54
  ok: true,
@@ -1,6 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import { AgentRole } from "./agent-role.js";
2
4
  import { buildRtkInstructions } from "../prompts/rtk-snippet.js";
3
5
  import { extractFirstJson } from "../utils/json-extract.js";
6
+ import { reportUnparseableVerdict } from "../review/unparseable-verdict.js";
4
7
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "../prompts/prompt-layout.js";
5
8
  import { clipDiff } from "../prompts/diff-clip.js";
6
9
  import { buildSplitSignal } from "../prompts/split-signal.js";
@@ -66,7 +69,13 @@ export class ReviewerRole extends AgentRole {
66
69
  parseOutput(raw) {
67
70
  const parsed = extractFirstJson(raw);
68
71
  if (!parsed) throw new Error("Failed to parse reviewer output: no JSON found");
69
- return parsed;
72
+ // KJC-BUG-0284 (#1982): a reviewer wrapped its verdict in the role envelope
73
+ // {ok, result: {approved…}, summary}; the envelope is not the verdict.
74
+ const verdict = typeof parsed.approved !== "boolean" && typeof parsed.result?.approved === "boolean"
75
+ ? { ...parsed.result, summary: parsed.result.summary ?? parsed.summary }
76
+ : parsed;
77
+ if (typeof verdict.approved !== "boolean") throw new Error("Failed to parse reviewer output: no boolean approved");
78
+ return verdict;
70
79
  }
71
80
 
72
81
  buildSuccessResult(parsed, provider, agentResult) {
@@ -87,18 +96,20 @@ export class ReviewerRole extends AgentRole {
87
96
  : `Rejected: ${blockingIssues.length} blocking issue(s) — ${parsed.summary || ""}`;
88
97
  }
89
98
 
90
- handleParseError(err, agentResult, _provider) {
99
+ // KJC-BUG-0284 (#1982): an unreadable answer is the reviewer's failure, not
100
+ // the coder's. Returned as ok=false so the retry and the fallback reviewer
101
+ // engage (reviewer-fallback.js); the raw answer is saved for inspection.
102
+ // Before, it came back as a PARSE_ERROR rejection: the coder read it as a
103
+ // defect of its code and one run went in circles for twenty minutes.
104
+ async handleParseError(err, agentResult, provider) {
105
+ const output = agentResult?.output;
106
+ const hash = createHash("sha256").update(String(output ?? "")).digest("hex").slice(0, 12);
107
+ const error = await reportUnparseableVerdict({ projectDir: this.config?.projectDir ?? process.cwd(), reviewer: provider, output, hash });
91
108
  return {
92
- ok: true,
93
- result: {
94
- ...agentResult,
95
- approved: false,
96
- blocking_issues: [{ id: "PARSE_ERROR", severity: "high", description: `Reviewer output could not be parsed: ${err.message}` }],
97
- non_blocking_suggestions: [],
98
- confidence: 0,
99
- raw_summary: `Parse error: ${err.message}`
100
- },
101
- summary: `Reviewer output parse error: ${err.message}`
109
+ ok: false,
110
+ result: { ...agentResult, error: `${err.message}\n${error}`, provider },
111
+ summary: `Reviewer output parse error: ${err.message}`,
112
+ usage: agentResult?.usage
102
113
  };
103
114
  }
104
115
  }
@@ -240,7 +240,12 @@ export function respectRepoProperties(scanner = {}, props = {}) {
240
240
  return kept;
241
241
  }
242
242
 
243
- /** @returns {Promise<{existed: boolean, declaredKey: string|null}>} */
243
+ // KJC-BUG-0280 (#1979): the scanner aborts, with a stack trace, on a declared
244
+ // folder that does not exist. Only the folders the repo HAS are declared.
245
+ const SOURCE_DIRS = ["src", "lib", "app"];
246
+ const TEST_DIRS = ["tests", "test"];
247
+
248
+ /** @returns {Promise<{existed: boolean, declaredKey: string|null, written?: boolean, note?: string, nothingToScan?: string}>} */
244
249
  export async function ensureSonarProjectProperties(cwd = process.cwd()) {
245
250
  const propsPath = path.join(cwd, "sonar-project.properties");
246
251
  try {
@@ -257,17 +262,26 @@ export async function ensureSonarProjectProperties(cwd = process.cwd()) {
257
262
  } catch {
258
263
  // no package.json or invalid JSON — use defaults
259
264
  }
265
+ const existing = (dirs) => dirs.filter((d) => fs.existsSync(path.join(cwd, d)));
266
+ const sources = existing(SOURCE_DIRS);
267
+ const tests = existing(TEST_DIRS);
268
+ if (sources.length === 0) {
269
+ const wanted = SOURCE_DIRS.map((d) => `${d}/`).join(", ");
270
+ return { existed: false, declaredKey: null, written: false, nothingToScan: `no ${wanted} folder to analyse: write a sonar-project.properties declaring sonar.sources when the code arrives` };
271
+ }
260
272
  const projectKey = (pkg.name || path.basename(cwd)).replaceAll(/[^a-zA-Z0-9_.-]/g, "_");
273
+ const layout = [`sonar.sources=${sources.join(",")}`, ...(tests.length ? [`sonar.tests=${tests.join(",")}`] : [])];
261
274
  const props = [
262
275
  `sonar.projectKey=${projectKey}`,
263
276
  `sonar.projectName=${pkg.name || path.basename(cwd)}`,
264
- `sonar.sources=src`,
265
- `sonar.tests=tests`,
277
+ ...layout,
266
278
  `sonar.javascript.lcov.reportPaths=coverage/lcov.info`,
267
279
  `sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/coverage/**`,
268
280
  ].join("\n");
269
281
  await fsPromises.writeFile(propsPath, props + "\n", "utf8");
270
- return { existed: false, declaredKey: null };
282
+ // Said out loud: the file is new in the working tree and the team should see it.
283
+ const note = `sonar: wrote sonar-project.properties (${layout.join(", ")}): review it and commit it`;
284
+ return { existed: false, declaredKey: null, written: true, note };
271
285
  }
272
286
  }
273
287
 
@@ -277,6 +291,9 @@ export async function runSonarScan(config, projectKey = null, { verbose = false,
277
291
  // directory under the key its properties declare — so the proof of
278
292
  // coverage is about that package, not the root's `sonar.sources`.
279
293
  const repoProps = await ensureSonarProjectProperties(scanCwd);
294
+ if (repoProps.nothingToScan) {
295
+ return { ok: false, skipped: true, projectKey: null, stdout: "", stderr: `sonar: ${repoProps.nothingToScan}`, exitCode: 0 };
296
+ }
280
297
  let effectiveProjectKey;
281
298
  if (cwd && !projectKey && repoProps.declaredKey) {
282
299
  effectiveProjectKey = repoProps.declaredKey;
@@ -403,7 +420,7 @@ export async function runSonarScan(config, projectKey = null, { verbose = false,
403
420
  projectKey: effectiveProjectKey,
404
421
  cwd: scanCwd,
405
422
  scanner: pick.type,
406
- note: [note, analysisNote].filter(Boolean).join("; ") || null,
423
+ note: [repoProps.note, note, analysisNote].filter(Boolean).join("; ") || null,
407
424
  stdout: result.stdout,
408
425
  // A scan that failed on the server's side says so, not with the scanner's own log.
409
426
  stderr: !ok && result.exitCode === 0 ? analysisNote : result.stderr,
package/src/utils/git.js CHANGED
@@ -189,25 +189,32 @@ export async function hasChanges(cwd = null) {
189
189
  * is structural here — without it the slice(3) below would crop one
190
190
  * character off the first path.
191
191
  */
192
- export async function listPendingPaths() {
193
- const res = await run("git", ["status", "--porcelain"]);
192
+ /**
193
+ * KJC-BUG-0294: the pending entries as `git status --porcelain -z` reports them,
194
+ * NUL-separated so a path with blanks or quotes arrives whole. A rename is one
195
+ * entry "R new\0old": `path` is the new name, `oldPath` the one that goes away.
196
+ * @returns {Promise<{path: string, oldPath?: string}[]>}
197
+ */
198
+ export async function listPendingEntries(cwd = null) {
199
+ const res = await run("git", ["status", "--porcelain", "-z"], cwd ? { cwd } : {});
194
200
  if (res.exitCode !== 0) {
195
201
  throw new Error(`git status --porcelain failed: ${res.stderr || res.stdout}`);
196
202
  }
197
- const stdout = res.stdout;
198
- if (!stdout) return [];
199
- const lines = stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter(Boolean);
203
+ const tokens = String(res.stdout || "").split("\0").filter(Boolean);
200
204
  const out = [];
201
- for (const line of lines) {
202
- // Each line is "XY <path>" (XY = 2-char status, space, path).
203
- // Renames look like "R old -> new"; we want the new path.
204
- const after = line.slice(3);
205
- const arrow = after.indexOf(" -> ");
206
- out.push(arrow !== -1 ? after.slice(arrow + 4) : after);
205
+ for (let i = 0; i < tokens.length; i += 1) {
206
+ const entry = { path: tokens[i].slice(3) };
207
+ if (/^[RC]/.test(tokens[i]) || /^.[RC]/.test(tokens[i])) entry.oldPath = tokens[++i];
208
+ out.push(entry);
207
209
  }
208
210
  return out;
209
211
  }
210
212
 
213
+ /** The pending paths; a rename contributes both its names (the old one is a pending deletion). */
214
+ export async function listPendingPaths(cwd = null) {
215
+ return (await listPendingEntries(cwd)).flatMap((e) => (e.oldPath ? [e.oldPath, e.path] : [e.path]));
216
+ }
217
+
211
218
  // Locale-tolerant detection of "git commit refused because the working
212
219
  // tree was already clean by the time we ran it". Observed in N4
213
220
  // dogfooding (2026-05-07) — `hasChanges()` reported true after `add -A`
@@ -229,17 +236,36 @@ function isNothingToCommit(message) {
229
236
  return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
230
237
  }
231
238
 
232
- export async function commitAll(message, cwd = null, { beforeCommit = null } = {}) {
239
+ /**
240
+ * KJC-BUG-0294 (#1993): the pipeline commits what the run changed, never what
241
+ * was already dirty or untracked before it (`exclude`: the paths pending when
242
+ * the run started, the harness kj generates among them). `git add -A` staged
243
+ * 22 untracked files and 960 lines of contract into one step's commit.
244
+ */
245
+ export async function commitAll(message, cwd = null, { beforeCommit = null, exclude = [] } = {}) {
233
246
  const opts = cwd ? { cwd } : {};
234
- await runGit(["add", "-A"], opts);
235
- const changed = await hasChanges(cwd);
236
- if (!changed) return { committed: false };
247
+ const skip = new Set(exclude);
248
+ const pending = await listPendingEntries(cwd);
249
+ const names = (e) => (e.oldPath ? [e.oldPath, e.path] : [e.path]);
250
+ // A rename of a file that was dirty before the run is that file under a new name.
251
+ const theirs = (e) => names(e).some((p) => skip.has(p));
252
+ // A rename stages its old name too (the deletion), or git keeps the old file.
253
+ const paths = pending.filter((e) => !theirs(e)).flatMap(names);
254
+ // A path dirty before the run may hold the run's edits too; they cannot be told
255
+ // apart, so the path stays out and is NAMED (`leftOut`) for the person to commit.
256
+ const leftOut = pending.filter(theirs).flatMap(names);
257
+ const said = leftOut.length > 0 ? { leftOut } : {};
258
+ if (paths.length === 0) return { committed: false, ...said };
259
+ await runGit(["add", "--", ...paths], opts);
237
260
  // ENV-F1 (KJC-TSK-0643): runs between staging and committing — the only
238
261
  // window where the staged diff is exactly what the commit will contain
239
262
  // (used to stamp the pipeline's review verdict for the v4 gate).
240
- if (beforeCommit) await beforeCommit();
263
+ // The callback learns the exact paths, so a verdict stamped there binds to the
264
+ // diff this commit will contain and not to whatever else the index holds.
265
+ if (beforeCommit) await beforeCommit({ paths });
241
266
  try {
242
- await runGit(["commit", "-m", message], opts);
267
+ // --only: these paths and nothing else; what the person had staged stays staged.
268
+ await runGit(["commit", "-m", message, "--only", "--", ...paths], opts);
243
269
  } catch (err) {
244
270
  // `git status --porcelain` and `git commit` disagreed about whether
245
271
  // there was anything to commit. Don't escalate — the only outcome
@@ -252,7 +278,7 @@ export async function commitAll(message, cwd = null, { beforeCommit = null } = {
252
278
  }
253
279
  const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"], opts);
254
280
  const [hash, commitMessage] = raw.split("\x1f");
255
- return { committed: true, commit: { hash, message: commitMessage } };
281
+ return { committed: true, commit: { hash, message: commitMessage }, ...said };
256
282
  }
257
283
 
258
284
  /**
@@ -267,6 +267,14 @@ export function readRunLog(projectDir, maxLines = 50) {
267
267
  const total = lines.length;
268
268
  const shown = lines.slice(-maxLines);
269
269
  const status = parseRunStatus(lines);
270
+ // KJC-BUG-0288 (#1982): a run that died with its host session never logs
271
+ // "finished", so the log said "running" forever. The lock names the run's
272
+ // process (KJC-BUG-0250): no live holder, no run.
273
+ if (status.isRunning && !isPidAlive(readLock(path.join(resolveLogDir(projectDir), LOCK_FILENAME))?.pid)) {
274
+ status.isRunning = false;
275
+ status.died = true;
276
+ status.note = "the run's process is gone (it died with its host session, or was killed): kj resume continues it";
277
+ }
270
278
  const MAX_LINE_CHARS = 2000;
271
279
  const truncated = shown.map(l => l.length > MAX_LINE_CHARS ? l.slice(0, MAX_LINE_CHARS) + "… [truncated]" : l);
272
280
  return {
@@ -5,7 +5,7 @@
5
5
  - NEVER overwrite existing files entirely. Always make targeted, minimal edits.
6
6
  - When adding new code to an existing file, insert only the new lines at the correct location.
7
7
  - After each edit, verify with `git diff` that ONLY the intended lines changed.
8
- - If unintended changes are detected, revert immediately with `git checkout -- <file>`.
8
+ - If unintended changes are detected, undo ONLY those hunks with a targeted edit. Never revert the whole file: it may hold edits that are not yours.
9
9
  - Pay special attention to CSS, HTML, and config files where full rewrites destroy prior work (brand colors, layouts, styles).
10
10
 
11
11
  ## Multi-agent / multi-developer environment
@@ -44,13 +44,13 @@ An incomplete implementation is worse than an error — never report success if
44
44
  <file_safety>
45
45
  - NEVER overwrite a file wholesale. Make targeted edits only.
46
46
  - After each edit, inspect `git diff` to confirm only the intended lines changed.
47
- - If unintended changes appear, revert immediately with `git checkout -- <file>`.
47
+ - If unintended changes appear, undo ONLY those hunks with a targeted edit. Never revert the whole file: it may hold edits that are not yours.
48
48
  - CSS, HTML, config files are especially high-risk for full-rewrite damage.
49
49
  </file_safety>
50
50
 
51
51
  <quality>
52
52
  - SOLID principles. Functions < 30 lines, single responsibility.
53
- - Atomic commits: 1 logical change = 1 commit.
53
+ - One logical change per task. NEVER run git commit or git push: the pipeline commits once the review approves.
54
54
  - No `console.log` in production — use a structured logger.
55
55
  - No `any` types — use JSDoc `@typedef` / `@param` / `@returns`.
56
56
  </quality>
@@ -42,13 +42,13 @@ Before reporting done, verify that ALL parts of the task are addressed:
42
42
  - NEVER overwrite existing files entirely. Always make targeted, minimal edits.
43
43
  - When adding new code to an existing file, insert only the new lines at the correct location.
44
44
  - After each edit, verify with `git diff` that ONLY the intended lines changed.
45
- - If unintended changes are detected, revert immediately with `git checkout -- <file>`.
45
+ - If unintended changes are detected, undo ONLY those hunks with a targeted edit. Never revert the whole file: it may hold edits that are not yours.
46
46
  - Pay special attention to CSS, HTML, and config files where full rewrites destroy prior work.
47
47
 
48
48
  ## Code Quality Rules
49
49
 
50
50
  - Follow SOLID principles. Write small, focused functions (< 30 lines).
51
- - Make atomic commits: 1 logical change = 1 commit. Keep PRs small and reviewable.
51
+ - One logical change per task; keep it small and reviewable. NEVER run git commit or git push: the pipeline commits once the review approves.
52
52
  - Security: use httpOnly cookies for auth tokens, validate all input, parameterize queries, never expose secrets.
53
53
  - No console.log in production code -- use a structured logger. No 'any' types -- use JSDoc annotations.
54
54
 
@@ -56,11 +56,11 @@ An incomplete implementation is worse than an error — don't claim success if p
56
56
  ## File safety
57
57
 
58
58
  - NEVER overwrite a file entirely. Targeted edits only.
59
- - Verify each edit with `git diff`. Unintended lines → revert with `git checkout -- <file>`.
59
+ - Verify each edit with `git diff`. Unintended lines → undo only those hunks, never the whole file (it may hold edits that are not yours).
60
60
 
61
61
  ## Quality
62
62
 
63
- - SOLID. Small functions (< 30 lines). Atomic commits. No `console.log` in production, no `any` types (JSDoc instead).
63
+ - SOLID. Small functions (< 30 lines). Never commit or push (the pipeline commits after review). No `console.log` in production, no `any` types (JSDoc instead).
64
64
 
65
65
  ## Output
66
66
 
@@ -32,13 +32,13 @@ An incomplete implementation is worse than an error. Do NOT claim success if any
32
32
 
33
33
  - NEVER overwrite a file wholesale. Make targeted edits.
34
34
  - Verify after each edit with `git diff` that ONLY intended lines changed.
35
- - If unintended changes appear, revert with `git checkout -- <file>`.
35
+ - If unintended changes appear, undo ONLY those hunks with a targeted edit. Never revert the whole file: it may hold edits that are not yours.
36
36
  - CSS/HTML/config files are high-risk for destructive rewrites.
37
37
 
38
38
  ## QUALITY
39
39
 
40
40
  - SOLID. Small focused functions (< 30 lines).
41
- - Atomic commits (1 logical change = 1 commit).
41
+ - One logical change per task. NEVER run git commit or git push: the pipeline commits once the review approves.
42
42
  - No `console.log` in production. Use a structured logger.
43
43
  - No `any` types. Use JSDoc annotations.
44
44
 
@@ -12,7 +12,7 @@ Role: write code and tests for the given task.
12
12
  6. Code must compile. Tests must pass.
13
13
  7. Never hardcode secrets. Use environment variables (`process.env` in Node, `os.environ` in Python).
14
14
  8. Never overwrite a file entirely. Make targeted edits only.
15
- 9. Small functions (< 30 lines). Atomic commits. No `console.log` in production. No `any` types.
15
+ 9. Small functions (< 30 lines). Never commit or push (the pipeline commits after review). No `console.log` in production. No `any` types.
16
16
 
17
17
  ## Before reporting done
18
18