karajan-code 4.42.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/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/git/automation.js +49 -8
- package/src/harden/hook-templates.js +6 -0
- package/src/harden/sentinel-hooks.js +33 -0
- 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/stages/suite-green-stage.js +55 -0
- 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/utils/git.js +44 -18
package/package.json
CHANGED
package/src/commands/pr-size.js
CHANGED
|
@@ -20,13 +20,19 @@ function mergeBase(projectDir, base) {
|
|
|
20
20
|
throw new Error(`pr-size: no merge base with ${base} (nor origin/${base})`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @param {{projectDir?: string, base?: string, exclude?: string[]}} opts
|
|
25
|
+
* `exclude` (KJC-BUG-0299): paths that will not be part of the commit (what
|
|
26
|
+
* was pending before a pipeline run) are not the branch's size either.
|
|
27
|
+
* @returns {Promise<{added: number, exempt: number, testAdded: number, base: string}>}
|
|
28
|
+
*/
|
|
29
|
+
export async function branchSize({ projectDir = process.cwd(), base = "main", exclude = [] } = {}) {
|
|
25
30
|
const from = mergeBase(projectDir, base);
|
|
31
|
+
const skip = new Set(exclude);
|
|
26
32
|
// Working tree against the merge base: committed and uncommitted together.
|
|
27
|
-
let numstat = git(projectDir, ["diff", "--numstat", from]);
|
|
33
|
+
let numstat = git(projectDir, ["diff", "--numstat", from]).split("\n").filter((l) => l && !skip.has(l.split("\t").slice(2).join("\t"))).map((l) => `${l}\n`).join("");
|
|
28
34
|
// New files not yet added are where a branch grows while it is written.
|
|
29
|
-
for (const file of git(projectDir, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(
|
|
35
|
+
for (const file of git(projectDir, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter((f) => f && !skip.has(f))) {
|
|
30
36
|
let text;
|
|
31
37
|
try { text = readFileSync(join(projectDir, file), "utf8"); } catch { continue; }
|
|
32
38
|
if (text.includes("\0")) continue; // binary
|
package/src/config/defaults.js
CHANGED
|
@@ -115,6 +115,9 @@ const DEFAULTS = {
|
|
|
115
115
|
// tests actually fail without the implementation. Off by default to
|
|
116
116
|
// preserve current iteration latency.
|
|
117
117
|
require_red_then_green: false,
|
|
118
|
+
// KJC-BUG-0298: with TDD the project's test suite runs every iteration and a
|
|
119
|
+
// red suite goes back to the coder, never to the reviewer.
|
|
120
|
+
require_green_suite: true,
|
|
118
121
|
test_file_patterns: ["/tests/", "/__tests__/", ".test.", ".spec."],
|
|
119
122
|
source_file_extensions: [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"]
|
|
120
123
|
},
|
|
@@ -200,7 +203,8 @@ const DEFAULTS = {
|
|
|
200
203
|
}
|
|
201
204
|
},
|
|
202
205
|
ci: { enabled: false, review_event: "kj-review", comment_event: "kj-comment", comment_prefix: true },
|
|
203
|
-
|
|
206
|
+
// KJC-BUG-0299: pr_size_limit = lines added (CI budget count) over which the pipeline commits nothing.
|
|
207
|
+
git: { pr_size_limit: 200, auto_commit: false, auto_push: false, auto_pr: false, auto_rebase: true, branch_prefix: "feat/" },
|
|
204
208
|
output: { report_dir: "./.reviews", log_level: "info", quiet: true },
|
|
205
209
|
budget: {
|
|
206
210
|
warn_threshold_pct: 80,
|
package/src/config/schema.js
CHANGED
|
@@ -118,12 +118,17 @@ const Development = v.optional(v.looseObject({
|
|
|
118
118
|
require_test_changes: v.optional(v.boolean()),
|
|
119
119
|
// KJC-TSK-0398: opt-in flag; module lands in PR1, integration in PR3.
|
|
120
120
|
require_red_then_green: v.optional(v.boolean()),
|
|
121
|
+
// KJC-BUG-0298: run the suite every iteration under TDD (default true).
|
|
122
|
+
require_green_suite: v.optional(v.boolean()),
|
|
123
|
+
test_timeout_ms: v.optional(v.number()),
|
|
121
124
|
test_file_patterns: v.optional(v.array(v.string())),
|
|
122
125
|
source_file_extensions: v.optional(v.array(v.string())),
|
|
123
126
|
}));
|
|
124
127
|
|
|
125
128
|
const Git = v.optional(v.looseObject({
|
|
126
129
|
auto_commit: v.optional(v.boolean()),
|
|
130
|
+
// KJC-BUG-0299: lines added (CI budget count) over which the pipeline commits nothing.
|
|
131
|
+
pr_size_limit: v.optional(v.number()),
|
|
127
132
|
auto_push: v.optional(v.boolean()),
|
|
128
133
|
auto_pr: v.optional(v.boolean()),
|
|
129
134
|
auto_rebase: v.optional(v.boolean()),
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* | config.testHarness key | globalThis key | prod default |
|
|
30
30
|
* |--------------------------------|----------------------------------|--------------|
|
|
31
31
|
* | disableSonarStage | __KJ_DISABLE_SONAR_STAGE | false |
|
|
32
|
+
* | disableSuiteGreenStage | __KJ_DISABLE_SUITE_GREEN_STAGE | false |
|
|
32
33
|
* | defaultSkillsMode | __KJ_DEFAULT_SKILLS_MODE | "auto" |
|
|
33
34
|
* | defaultPreflightExtended | __KJ_DEFAULT_PREFLIGHT_EXTENDED | true |
|
|
34
35
|
* | defaultBrainDecisor | __KJ_DEFAULT_BRAIN_DECISOR | true |
|
|
@@ -37,6 +38,9 @@
|
|
|
37
38
|
|
|
38
39
|
const GLOBAL_KEYS = {
|
|
39
40
|
disableSonarStage: "__KJ_DISABLE_SONAR_STAGE",
|
|
41
|
+
// KJC-BUG-0298: the suite-green gate runs the project's REAL test command;
|
|
42
|
+
// under Vitest that is this repo's own suite inside a test. Off in tests/setup.js.
|
|
43
|
+
disableSuiteGreenStage: "__KJ_DISABLE_SUITE_GREEN_STAGE",
|
|
40
44
|
defaultSkillsMode: "__KJ_DEFAULT_SKILLS_MODE",
|
|
41
45
|
defaultPreflightExtended: "__KJ_DEFAULT_PREFLIGHT_EXTENDED",
|
|
42
46
|
defaultBrainDecisor: "__KJ_DEFAULT_BRAIN_DECISOR",
|
|
@@ -45,6 +49,7 @@ const GLOBAL_KEYS = {
|
|
|
45
49
|
|
|
46
50
|
const PROD_DEFAULTS = Object.freeze({
|
|
47
51
|
disableSonarStage: false,
|
|
52
|
+
disableSuiteGreenStage: false,
|
|
48
53
|
defaultSkillsMode: "auto",
|
|
49
54
|
defaultPreflightExtended: true,
|
|
50
55
|
defaultBrainDecisor: true,
|
package/src/git/automation.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { addCheckpoint } from "../session/store.js";
|
|
7
7
|
import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
|
|
8
|
+
import { branchSize } from "../commands/pr-size.js";
|
|
8
9
|
import {
|
|
9
10
|
ensureGitRepo,
|
|
10
11
|
currentBranch,
|
|
@@ -79,7 +80,9 @@ export function commitMessageFromTask(task, taskType = null) {
|
|
|
79
80
|
.replaceAll(/\s+/g, " ")
|
|
80
81
|
.trim();
|
|
81
82
|
const prefix = prefixForTaskType(taskType);
|
|
82
|
-
|
|
83
|
+
// KJC-BUG-0294 (#1993): cut on a word, never mid-word (a title with no blank keeps the hard cut).
|
|
84
|
+
const cut = clean.length > 72 ? clean.slice(0, 72).replace(/\s+\S*$/, "") : clean;
|
|
85
|
+
return `${prefix}: ${cut || "karajan update"}`;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/**
|
|
@@ -111,6 +114,11 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
111
114
|
|
|
112
115
|
const baseBranch = config.base_branch;
|
|
113
116
|
const autoRebase = config.git.auto_rebase !== false;
|
|
117
|
+
// KJC-BUG-0294 (#1993): what is dirty or untracked NOW is not the run's; the
|
|
118
|
+
// pipeline commits only what it changes. Both names of a pending rename are
|
|
119
|
+
// kept. Fails closed: with no answer from git the run stops here rather than
|
|
120
|
+
// commit the person's pending changes later.
|
|
121
|
+
const preexisting = await listPendingPaths();
|
|
114
122
|
const repoHasCommits = await hasCommits();
|
|
115
123
|
|
|
116
124
|
// New repo without commits: create branch directly (no fetch/sync possible)
|
|
@@ -120,7 +128,7 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
120
128
|
await createBranch(created);
|
|
121
129
|
logger.info(`New repo — created working branch: ${created}`);
|
|
122
130
|
await addCheckpoint(session, { stage: "git-prep", branch: created, created: true, newRepo: true });
|
|
123
|
-
return { enabled: true, branch: created, baseBranch, autoRebase };
|
|
131
|
+
return { enabled: true, branch: created, baseBranch, autoRebase, preexisting };
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
await fetchBase(baseBranch).catch(() => {
|
|
@@ -153,7 +161,7 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
153
161
|
await addCheckpoint(session, { stage: "git-prep", branch, created: false });
|
|
154
162
|
}
|
|
155
163
|
|
|
156
|
-
return { enabled: true, branch, baseBranch, autoRebase };
|
|
164
|
+
return { enabled: true, branch, baseBranch, autoRebase, preexisting };
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
export function buildPrBody({ task: _task, stageResults }) {
|
|
@@ -194,7 +202,7 @@ export async function earlyPrCreation({ gitCtx, task, logger, session, stageResu
|
|
|
194
202
|
if (!gitCtx?.enabled) return null;
|
|
195
203
|
|
|
196
204
|
const commitMsg = commitMessageFromTask(task);
|
|
197
|
-
const commitResult = await commitAll(commitMsg);
|
|
205
|
+
const commitResult = await commitAll(commitMsg, null, { exclude: gitCtx.preexisting || [] });
|
|
198
206
|
if (!commitResult.committed) {
|
|
199
207
|
logger.info("earlyPrCreation: no changes to commit");
|
|
200
208
|
return null;
|
|
@@ -230,7 +238,7 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
|
|
|
230
238
|
if (!gitCtx?.enabled) return null;
|
|
231
239
|
|
|
232
240
|
const commitMsg = commitMessageFromTask(task);
|
|
233
|
-
const commitResult = await commitAll(commitMsg);
|
|
241
|
+
const commitResult = await commitAll(commitMsg, null, { exclude: gitCtx.preexisting || [] });
|
|
234
242
|
if (!commitResult.committed) {
|
|
235
243
|
logger.info("incrementalPush: no changes to commit");
|
|
236
244
|
return null;
|
|
@@ -246,8 +254,18 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
|
|
|
246
254
|
return { commits };
|
|
247
255
|
}
|
|
248
256
|
|
|
249
|
-
|
|
257
|
+
const DEFAULT_PR_SIZE_LIMIT = 200;
|
|
258
|
+
|
|
259
|
+
export async function finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults = null, review = null, measureSize = branchSize }) {
|
|
250
260
|
if (!gitCtx?.enabled) return { git: "disabled", commits: [] };
|
|
261
|
+
// KJC-BUG-0297 (#1993): the pipeline commits only what a reviewer approved. An
|
|
262
|
+
// approval that reviewed nothing (reviewer disabled) stamps no verdict and
|
|
263
|
+
// commits nothing: the changes stay in the tree, and the log says why.
|
|
264
|
+
if (review && review.reviewed === false) {
|
|
265
|
+
logger.warn("No review, no commit: the reviewer was disabled, so the pipeline commits nothing; the changes stay in the tree for a review (kj review --staged)");
|
|
266
|
+
await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "unreviewed" });
|
|
267
|
+
return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], unreviewed: true };
|
|
268
|
+
}
|
|
251
269
|
|
|
252
270
|
// Take a snapshot of pending paths BEFORE staging so we can decide
|
|
253
271
|
// whether what's about to be committed is just Karajan scaffolding
|
|
@@ -268,13 +286,31 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
268
286
|
|
|
269
287
|
let committed = false;
|
|
270
288
|
const commits = [];
|
|
289
|
+
// KJC-BUG-0299 (#1993): the PR budget is applied BEFORE the commit, with the
|
|
290
|
+
// CI gate's own count (kj pr-size), over what this commit will contain. A
|
|
291
|
+
// step planned at 150 lines once committed 960. Where the branch cannot be
|
|
292
|
+
// measured (a fresh repo with no base yet) kj says so and goes on.
|
|
271
293
|
if (config.git.auto_commit) {
|
|
294
|
+
const limit = Number(config.git.pr_size_limit) > 0 ? Number(config.git.pr_size_limit) : DEFAULT_PR_SIZE_LIMIT;
|
|
295
|
+
let size = null;
|
|
296
|
+
try {
|
|
297
|
+
size = await measureSize({ projectDir: config.projectDir || process.cwd(), base: gitCtx.baseBranch, exclude: gitCtx.preexisting || [] });
|
|
298
|
+
} catch (err) {
|
|
299
|
+
logger.warn(`pr-size: the branch could not be measured (${err.message}); the budget is not checked for this commit`);
|
|
300
|
+
}
|
|
301
|
+
if (size && size.added > limit) {
|
|
302
|
+
logger.warn(`pr-size: ${size.added} line(s) added against ${gitCtx.baseBranch}, over the budget of ${limit}: nothing committed. Split the step (several commits, several HUs); the changes stay in the tree`);
|
|
303
|
+
await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, limit });
|
|
304
|
+
return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, limit } };
|
|
305
|
+
}
|
|
272
306
|
// ENV-F1: this path only runs after the pipeline's reviewer approved,
|
|
273
307
|
// so stamp that verdict for the staged diff — the v4 pre-commit gate
|
|
274
308
|
// (when the repo opted in) accepts the pipeline's own commit.
|
|
275
309
|
const commitResult = await commitAll(commitMsg, null, {
|
|
276
|
-
|
|
310
|
+
exclude: gitCtx.preexisting || [],
|
|
311
|
+
beforeCommit: ({ paths } = {}) => stampStagedVerdict({
|
|
277
312
|
projectDir: config?.projectDir || process.cwd(),
|
|
313
|
+
paths,
|
|
278
314
|
reviewer: config?.reviewer || "pipeline-reviewer",
|
|
279
315
|
summary: `kj run session ${session?.id || ""}: reviewer approved`.trim(),
|
|
280
316
|
// KJC-TSK-0838: the sonar stage result travels with the stamp.
|
|
@@ -285,7 +321,12 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
285
321
|
if (commitResult.commit) {
|
|
286
322
|
commits.push(commitResult.commit);
|
|
287
323
|
}
|
|
288
|
-
|
|
324
|
+
// KJC-BUG-0294: what was pending before the run stays out of the commit and is named;
|
|
325
|
+
// if the run also touched one of those files, its edit is in the tree, not in the commit.
|
|
326
|
+
if (commitResult.leftOut?.length) {
|
|
327
|
+
logger.warn(`Left out of the commit, pending before the run (review and commit them yourself): ${commitResult.leftOut.join(", ")}`);
|
|
328
|
+
}
|
|
329
|
+
await addCheckpoint(session, { stage: "git-commit", committed, leftOut: commitResult.leftOut || [] });
|
|
289
330
|
logger.info(committed ? "Committed changes" : "No changes to commit");
|
|
290
331
|
}
|
|
291
332
|
|
|
@@ -133,6 +133,12 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
133
133
|
`if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' ':(exclude).karajan/harness/' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
|
|
134
134
|
" echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
|
|
135
135
|
"fi",
|
|
136
|
+
"# KJC-BUG-0296 (issue #1989): the host agent's runtime and local state never",
|
|
137
|
+
"# enter a commit, however they were staged (a blanket `git add -A` put a",
|
|
138
|
+
"# Claude scheduler lock into main). Checked on the staged list itself.",
|
|
139
|
+
"if git diff --cached --name-only | grep -qE '^(\\.claude/[^/]*\\.lock|\\.claude/scheduled_tasks\\.lock|\\.kj/|\\.karajan/identity\\.local\\.yml|\\.karajan/harness/sentinel-state\\.json)'; then",
|
|
140
|
+
" echo 'kj harden: agent runtime files are staged (.claude/*.lock, .kj/, identity.local.yml, sentinel-state.json) — unstage them: git restore --staged <path>'; exit 1",
|
|
141
|
+
"fi",
|
|
136
142
|
"# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
|
|
137
143
|
"# a staged diff only enters with a recorded cross-AI approved verdict.",
|
|
138
144
|
"# KJC-BUG-0165: the gate is active once the marker is COMMITTED (HEAD),",
|
|
@@ -1096,6 +1096,39 @@ process.stdin.on("end", () => {
|
|
|
1096
1096
|
console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
|
|
1097
1097
|
process.exit(2);
|
|
1098
1098
|
}
|
|
1099
|
+
// KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
|
|
1100
|
+
// git add -A / git add . / git add -u put a Claude scheduler lock into
|
|
1101
|
+
// main; and the agent's runtime files (.claude/*.lock, .kj/, the local
|
|
1102
|
+
// identity, the Sentinel state, the harness) are never staged at all.
|
|
1103
|
+
// (No backticks in this template: they would end the string.)
|
|
1104
|
+
const RUNTIME = /^(\\.\\/)?(\\.claude\\/[^/]*\\.lock|\\.kj\\/|\\.karajan\\/identity\\.local\\.yml|\\.karajan\\/harness\\/sentinel-state\\.json)/;
|
|
1105
|
+
for (const ws of shellSegments(cmd)) {
|
|
1106
|
+
const gi = ws.findIndex((x) => x.slice(x.lastIndexOf("/") + 1) === "git");
|
|
1107
|
+
if (gi < 0) continue;
|
|
1108
|
+
// git's own options come before the verb: -C <dir>, -c k=v, --git-dir=…
|
|
1109
|
+
let vi = gi + 1;
|
|
1110
|
+
while (vi < ws.length && ws[vi].startsWith("-")) vi += ws[vi] === "-C" || ws[vi] === "-c" ? 2 : 1;
|
|
1111
|
+
if (ws[vi] !== "add") continue;
|
|
1112
|
+
const args = ws.slice(vi + 1);
|
|
1113
|
+
// The pathspec: after "--" if present, else every non-option word. "." is
|
|
1114
|
+
// the whole tree whichever way it is written; -A/-u with no pathspec too.
|
|
1115
|
+
const dash = args.indexOf("--");
|
|
1116
|
+
const spec = dash >= 0 ? args.slice(dash + 1) : args.filter((a) => !a.startsWith("-"));
|
|
1117
|
+
const whole = spec.some((a) => a === "." || a === "./");
|
|
1118
|
+
const blanket = whole || (args.some((a) => a === "-A" || a === "--all" || a === "-u" || a === "--update") && spec.length === 0);
|
|
1119
|
+
if (blanket) {
|
|
1120
|
+
console.error("karajan sentinel: git add -A / . / -u stagea lo que no has revisado — stagea por nombre (git add -- <ruta>...) y mira git diff --cached --stat antes de commitear." + doc("commit-gate"));
|
|
1121
|
+
process.exit(2);
|
|
1122
|
+
}
|
|
1123
|
+
// Directories too: .kj is runtime whole; .claude and .karajan hold the
|
|
1124
|
+
// contract, so only a forced add (-f, past the ignore) reaches their locks.
|
|
1125
|
+
const force = args.some((a) => a === "-f" || a === "--force");
|
|
1126
|
+
const runtime = spec.find((a) => RUNTIME.test(a) || /^(\\.\\/)?\\.kj\\/?$/.test(a) || (force && /^(\\.\\/)?(\\.claude|\\.karajan)\\/?$/.test(a)));
|
|
1127
|
+
if (runtime) {
|
|
1128
|
+
console.error("karajan sentinel: " + runtime + " es runtime del agente (locks de .claude, .kj/, identidad local, estado del Sentinel) y no se commitea — fuera del stage." + doc("commit-gate"));
|
|
1129
|
+
process.exit(2);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1099
1132
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
1100
1133
|
const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
|
|
1101
1134
|
const mergeM = cmd.match(MERGE);
|
|
@@ -360,10 +360,12 @@ export function applyAutoSimplify({ pipelineFlags, triageLevel, config, flags, l
|
|
|
360
360
|
if (flags.mode) return false;
|
|
361
361
|
if (flags.enableReviewer !== undefined || flags.enableTester !== undefined) return false;
|
|
362
362
|
|
|
363
|
-
|
|
363
|
+
// KJC-BUG-0297 (#1993): a simple task still gets its cross-AI review. With
|
|
364
|
+
// the reviewer off, the gate's stub read as an approval and the pipeline
|
|
365
|
+
// stamped a verdict nobody gave and committed it. Only the tester is lighter.
|
|
364
366
|
pipelineFlags.testerEnabled = false;
|
|
365
367
|
|
|
366
|
-
const disabledRoles = ["
|
|
368
|
+
const disabledRoles = ["tester"];
|
|
367
369
|
logger.info(`Simple task (${triageLevel}) — lightweight pipeline (disabled: ${disabledRoles.join(", ")})`);
|
|
368
370
|
emitProgress(
|
|
369
371
|
emitter,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { runImpeccableStage } from "../../post-loop-stages.js";
|
|
22
22
|
import { runPerfStage } from "../../stages/perf-stage.js";
|
|
23
23
|
import { runTddDisciplineStage } from "../../stages/tdd-discipline-stage.js";
|
|
24
|
+
import { runSuiteGreenStage } from "../../stages/suite-green-stage.js";
|
|
24
25
|
import { runToolJudgeStage } from "../../stages/tool-judge-stage.js";
|
|
25
26
|
import { tryCiComment } from "../../ci-integration.js";
|
|
26
27
|
|
|
@@ -29,6 +30,12 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
|
|
|
29
30
|
if (tddResult.action === "pause") return { action: "return", result: tddResult.result };
|
|
30
31
|
if (tddResult.action === "continue") return { action: "continue" };
|
|
31
32
|
|
|
33
|
+
// KJC-BUG-0298 (#1993): with TDD, a red suite never reaches the reviewer.
|
|
34
|
+
if (config.development?.methodology === "tdd" && config.development?.require_green_suite !== false && config?.testHarness?.disableSuiteGreenStage !== true) {
|
|
35
|
+
const green = await runSuiteGreenStage({ config, logger, emitter, eventBase, session, iteration: i });
|
|
36
|
+
if (green.action === "continue") return { action: "continue" };
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
// KJC-TSK-0398 PR3: opt-in red-then-green check.
|
|
33
40
|
if (config.development?.require_red_then_green) {
|
|
34
41
|
const disc = await runTddDisciplineStage({
|
|
@@ -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) {
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
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
|
/**
|