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.
- package/package.json +1 -1
- package/src/commands/harden.js +14 -0
- package/src/commands/init.js +7 -1
- package/src/commands/pr-size.js +10 -4
- package/src/config/defaults.js +5 -1
- package/src/config/schema.js +5 -0
- package/src/config/test-harness.js +5 -0
- package/src/environment/contract-commit.js +11 -1
- package/src/git/automation.js +49 -8
- package/src/git/coder-commit-guard.js +40 -0
- package/src/harden/hook-templates.js +6 -0
- package/src/harden/sentinel/sentinel-shell.mjs +7 -3
- package/src/harden/sentinel-hooks.js +83 -11
- package/src/orchestrator/config-init.js +4 -2
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +7 -0
- package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +3 -1
- package/src/orchestrator/drivers/post-loop.js +1 -1
- package/src/orchestrator/preflight-checks.js +11 -8
- package/src/orchestrator/stages/coder-stage.js +16 -0
- package/src/orchestrator/stages/reviewer-stage.js +13 -11
- package/src/orchestrator/stages/suite-green-stage.js +55 -0
- package/src/prompts/coder.js +3 -1
- package/src/rag/indexer.js +8 -6
- package/src/review/gate-gitignore.js +2 -0
- package/src/review/verdict-store.js +4 -2
- package/src/roles/domain-curator-role.js +6 -13
- package/src/roles/reviewer-role.js +23 -12
- package/src/sonar/scanner.js +22 -5
- package/src/utils/git.js +44 -18
- package/src/utils/run-log.js +8 -0
- package/templates/coder-rules.md +1 -1
- package/templates/roles/coder/anthropic.md +2 -2
- package/templates/roles/coder/default.md +2 -2
- package/templates/roles/coder/google.md +2 -2
- package/templates/roles/coder/openai.md +2 -2
- package/templates/roles/coder/opencode.md +1 -1
- package/templates/roles/coder.md +2 -2
- package/templates/roles/refactorer.md +1 -1
- package/templates/skills/kj-code.md +1 -1
- package/templates/skills/kj-review.md +1 -1
- 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:
|
|
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
|
|
413
|
-
*
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}
|
package/src/prompts/coder.js
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/rag/indexer.js
CHANGED
|
@@ -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
|
-
//
|
|
75
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
93
|
-
result: {
|
|
94
|
-
|
|
95
|
-
|
|
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
|
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -240,7 +240,12 @@ export function respectRepoProperties(scanner = {}, props = {}) {
|
|
|
240
240
|
return kept;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
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 (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/utils/run-log.js
CHANGED
|
@@ -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 {
|
package/templates/coder-rules.md
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
-
|
|
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,
|
|
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
|
-
-
|
|
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 →
|
|
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).
|
|
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,
|
|
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
|
-
-
|
|
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).
|
|
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
|
|