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
package/package.json
CHANGED
package/src/commands/harden.js
CHANGED
|
@@ -26,6 +26,7 @@ import { detectStackRoots } from "../harden/stack-roots.js";
|
|
|
26
26
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
27
27
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
28
28
|
import { ensureIdentity } from "../identity/bootstrap.js";
|
|
29
|
+
import { commitContract, contractChanges } from "../environment/contract-commit.js";
|
|
29
30
|
|
|
30
31
|
const JS_TEST_CMD = {
|
|
31
32
|
vitest: "npx vitest run",
|
|
@@ -104,8 +105,12 @@ export async function hardenCommand({
|
|
|
104
105
|
exclude = [],
|
|
105
106
|
commitSupervisor = false,
|
|
106
107
|
kjVersion = null,
|
|
108
|
+
// KJC-BUG-0289: the contract files already dirty before anything was generated
|
|
109
|
+
// (kj init captures them before writing its own). What is dirty NOW is the person's.
|
|
110
|
+
contractBefore = null,
|
|
107
111
|
logger = console,
|
|
108
112
|
} = {}) {
|
|
113
|
+
const dirtyBefore = contractBefore ?? contractChanges(projectDir);
|
|
109
114
|
const repoCfg = readHardenConfig(projectDir);
|
|
110
115
|
const onlyDirs = [...(repoCfg.only ?? []), ...only];
|
|
111
116
|
const excludeDirs = [...(repoCfg.exclude ?? []), ...exclude];
|
|
@@ -200,6 +205,15 @@ export async function hardenCommand({
|
|
|
200
205
|
workflows: wf?.workflows ?? [],
|
|
201
206
|
guidelines: gl?.guidelines ?? [],
|
|
202
207
|
};
|
|
208
|
+
// KJC-BUG-0289 (#1984): what kj harden generates, kj commits (KJC-BUG-0273's
|
|
209
|
+
// path): only that, never on the base branch. Left to the agent, 3.2k generated
|
|
210
|
+
// lines exceeded its own PR size and stayed untracked. The seal (--commit, ADR
|
|
211
|
+
// 0009) stays a human act over the supervisor alone.
|
|
212
|
+
if (!dryRun) {
|
|
213
|
+
out.contract = commitContract({ projectDir, before: dirtyBefore, baseBranch });
|
|
214
|
+
if (out.contract.committed) logger.info?.(`kj harden: contract committed by kj, ${out.contract.files.length} generated file(s)`);
|
|
215
|
+
else if (!out.contract.reason.startsWith("nothing")) logger.warn?.(`kj harden: the contract kj generated is NOT committed (${out.contract.reason})`);
|
|
216
|
+
}
|
|
203
217
|
|
|
204
218
|
if (json) {
|
|
205
219
|
logger.info?.(JSON.stringify(out));
|
package/src/commands/init.js
CHANGED
|
@@ -26,6 +26,7 @@ import { installQmd } from "../utils/qmd-install.js";
|
|
|
26
26
|
import { registerQmdCollections } from "../utils/qmd-collection.js";
|
|
27
27
|
import { isGitRepo } from "../harden/harden-engine.js";
|
|
28
28
|
import { hardenCommand } from "./harden.js";
|
|
29
|
+
import { contractChanges } from "../environment/contract-commit.js";
|
|
29
30
|
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
30
31
|
import { bootstrapSonarToken } from "../sonar/token-bootstrap.js";
|
|
31
32
|
|
|
@@ -771,6 +772,11 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
771
772
|
const toStderr = (...args) => console.error(...args);
|
|
772
773
|
logger = { ...logger, info: toStderr, warn: toStderr, error: toStderr };
|
|
773
774
|
}
|
|
775
|
+
// KJC-BUG-0289: what is dirty NOW is the person's; kj harden commits only what
|
|
776
|
+
// init and harden generate below (skills, settings, hooks, configs, guidelines).
|
|
777
|
+
// Outside a repository (ensureGitRepo runs later) git cannot answer and the
|
|
778
|
+
// baseline is empty: everything generated afterwards is kj's.
|
|
779
|
+
const contractBefore = contractChanges(process.cwd());
|
|
774
780
|
const karajanHome = getKarajanHome();
|
|
775
781
|
await ensureDir(karajanHome);
|
|
776
782
|
logger.info(`Ensured ${karajanHome} exists`);
|
|
@@ -928,7 +934,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
928
934
|
logger.info("Quality harness skipped (--no-harden).");
|
|
929
935
|
} else if (ensureGitRepo({ projectDir: process.cwd(), logger })) {
|
|
930
936
|
logger.info("Installing quality harness (kj harden)...");
|
|
931
|
-
await hardenCommand({ projectDir: process.cwd(), logger });
|
|
937
|
+
await hardenCommand({ projectDir: process.cwd(), logger, contractBefore });
|
|
932
938
|
hardened = true;
|
|
933
939
|
} else {
|
|
934
940
|
logger.warn("Quality harness NOT installed: this is not a git repository and `git init` failed — Karajan's guarantees live in git hooks. Fix git, then run `kj harden`.");
|
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,
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* seal (ADR 0009), which stays a human act.
|
|
9
9
|
*/
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { GENERATED_CONFIG_FILES } from "../harden/config-templates.js";
|
|
11
14
|
|
|
12
15
|
/** What kj generates and the whole team must inherit by cloning (prefixes). */
|
|
13
16
|
export const CONTRACT_PATHS = [
|
|
@@ -23,13 +26,20 @@ export const CONTRACT_PATHS = [
|
|
|
23
26
|
"GEMINI.md",
|
|
24
27
|
// KJC-TSK-0879: in a Rulesync repo kj's rules live in .rulesync/rules/karajan.md.
|
|
25
28
|
".rulesync/",
|
|
29
|
+
// KJC-BUG-0289 (#1984): the governance the whole team inherits (ADR 0016, KJC-BUG-0269).
|
|
30
|
+
".karajan/rules.yml",
|
|
31
|
+
".karajan/policy-anchor.json",
|
|
32
|
+
".karajan/supervisor-signers.json",
|
|
26
33
|
];
|
|
27
34
|
|
|
28
35
|
const FRESH_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
|
|
29
36
|
const REGEN_MESSAGE = "chore(kj): el contrato del método, generado por kj";
|
|
30
37
|
|
|
31
38
|
const runner = (projectDir, env) => (args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
|
|
32
|
-
|
|
39
|
+
// KJC-BUG-0289: the lint/format/commit configs harden generates sit at the root
|
|
40
|
+
// or under a stack root (a fullstack monorepo is hardened on every side).
|
|
41
|
+
const isContract = (file) =>
|
|
42
|
+
CONTRACT_PATHS.some((p) => (p.endsWith("/") || p.endsWith("-") ? file.startsWith(p) : file === p)) || GENERATED_CONFIG_FILES.has(basename(file));
|
|
33
43
|
|
|
34
44
|
/** The contract files git sees as changed, with their porcelain code (`??` untracked). */
|
|
35
45
|
function contractStatus(git) {
|
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
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0285 (#1982): the coder commits nothing; the pipeline does, once the
|
|
3
|
+
* review approves. With --auto-commit the coder ran git commit inside its
|
|
4
|
+
* iteration, before the reviewer, and the rejected commit stayed on the branch.
|
|
5
|
+
* The stage reads HEAD before the coder runs and, when HEAD moved on top of it,
|
|
6
|
+
* undoes those commits SOFTLY: the changes stay in the tree and the index for
|
|
7
|
+
* the review. A rewritten branch (HEAD no longer descends from the start) is
|
|
8
|
+
* left alone. Plain git, no injected runner: it never throws, it says.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const git = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
|
|
14
|
+
/** @returns {string|null} the sha HEAD points at, or null outside a repo with commits */
|
|
15
|
+
export function headSha(cwd = process.cwd()) {
|
|
16
|
+
try {
|
|
17
|
+
const res = git(["rev-parse", "HEAD"], cwd);
|
|
18
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @returns {{undone: number, reason?: string}} */
|
|
25
|
+
export function undoCommitsSince(sha, cwd = process.cwd()) {
|
|
26
|
+
if (!sha) return { undone: 0, reason: "no starting commit" };
|
|
27
|
+
try {
|
|
28
|
+
const head = headSha(cwd);
|
|
29
|
+
if (!head || head === sha) return { undone: 0 };
|
|
30
|
+
if (git(["merge-base", "--is-ancestor", sha, "HEAD"], cwd).status !== 0) {
|
|
31
|
+
return { undone: 0, reason: "HEAD no longer descends from the iteration's start" };
|
|
32
|
+
}
|
|
33
|
+
const undone = Number(git(["rev-list", "--count", `${sha}..HEAD`], cwd).stdout.trim());
|
|
34
|
+
const reset = git(["reset", "--soft", sha], cwd);
|
|
35
|
+
if (reset.status !== 0) return { undone: 0, reason: `git reset --soft failed: ${reset.stderr.trim()}` };
|
|
36
|
+
return { undone };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { undone: 0, reason: err.message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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),",
|
|
@@ -64,10 +64,14 @@ export const headIndex = (words, heads) => {
|
|
|
64
64
|
return k < 0 ? words.length : i + k;
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
// KJC-BUG-0286: a $ expands only when something expandable follows it ($x, ${x},
|
|
68
|
+
// $(cmd), $1, $@...). A lone "$ " in a grep pattern is a literal dollar sign.
|
|
69
|
+
const LIVE_EXPANSION = /\$(?=[A-Za-z0-9_{(@*#?$!-])|`/;
|
|
70
|
+
|
|
67
71
|
/**
|
|
68
72
|
* KJC-BUG-0243: blank the quoted text that cannot run: single-quoted spans, and
|
|
69
|
-
* double-quoted spans with no $ or backtick. What remains is what the shell
|
|
70
|
-
* still expand or execute, so operator and substitution checks read only that.
|
|
73
|
+
* double-quoted spans with no live $ or backtick. What remains is what the shell
|
|
74
|
+
* can still expand or execute, so operator and substitution checks read only that.
|
|
71
75
|
* @param {string} cmd
|
|
72
76
|
*/
|
|
73
77
|
export const stripInertQuotes = (cmd) => {
|
|
@@ -79,7 +83,7 @@ export const stripInertQuotes = (cmd) => {
|
|
|
79
83
|
while (j < cmd.length && cmd[j] !== q) j += q === '"' && cmd[j] === "\\" ? 2 : 1;
|
|
80
84
|
if (j >= cmd.length) return out + cmd.slice(i); // unclosed: left as is, for the caller to deny
|
|
81
85
|
const body = cmd.slice(i + 1, j);
|
|
82
|
-
out += q === "'" ||
|
|
86
|
+
out += q === "'" || !LIVE_EXPANSION.test(body) ? q + q : q + body + q;
|
|
83
87
|
i = j;
|
|
84
88
|
}
|
|
85
89
|
return out;
|
|
@@ -135,7 +135,8 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
135
135
|
// a tool call (PostToolUse, always exit 0).
|
|
136
136
|
import console from "node:console";
|
|
137
137
|
import process from "node:process";
|
|
138
|
-
import {
|
|
138
|
+
import { realpathSync } from "node:fs";
|
|
139
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
139
140
|
import { spawnSync } from "node:child_process";
|
|
140
141
|
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
141
142
|
import { remindersFor } from "./sentinel-reminders.mjs";
|
|
@@ -170,20 +171,37 @@ process.stdin.on("end", () => {
|
|
|
170
171
|
// every question with silence, so the gate would demand what no query can
|
|
171
172
|
// deliver. kj says so explicitly, in both its human and json forms.
|
|
172
173
|
const EMPTY_INDEX = /No chunks indexed yet|"empty"\\s*:\\s*true/;
|
|
174
|
+
// KJC-BUG-0293 (#1996, #1995): the index names its sources through whatever
|
|
175
|
+
// spelling of the project directory indexed them (a symlinked workspace, an
|
|
176
|
+
// alias). Compared as TEXT against the harness root, every hit fell outside
|
|
177
|
+
// the tree and was dropped in silence: rag_hits stayed empty and only new
|
|
178
|
+
// files passed. Both sides are compared by REAL path, and what still falls
|
|
179
|
+
// outside is written down for the gate to name.
|
|
180
|
+
// A path whose file is gone (or not yet there) resolves through its deepest
|
|
181
|
+
// existing ancestor, so an alias of the directory still lands in the tree.
|
|
182
|
+
const realOf = (p) => {
|
|
183
|
+
try { return realpathSync(p); } catch { /* resolve the parent instead */ }
|
|
184
|
+
const d = dirname(p);
|
|
185
|
+
return !d || d === p ? p : join(realOf(d), basename(p));
|
|
186
|
+
};
|
|
187
|
+
const ROOT_REAL = realOf(ROOT);
|
|
173
188
|
const recordRag = (query, sources, answer = "") => {
|
|
174
189
|
const state = load();
|
|
175
190
|
const s = session(state, sid);
|
|
176
191
|
s.at = Date.now();
|
|
177
192
|
if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
|
|
178
193
|
const hits = [];
|
|
194
|
+
const dropped = [];
|
|
179
195
|
for (const src of sources) {
|
|
180
|
-
const rel = relative(
|
|
181
|
-
if (!rel || rel.startsWith("
|
|
182
|
-
hits.push(rel);
|
|
196
|
+
const rel = relative(ROOT_REAL, realOf(String(src))).split(String.fromCharCode(92)).join("/");
|
|
197
|
+
if (!rel || rel === ".." || rel.startsWith("../") || rel.startsWith("/")) { dropped.push(String(src)); continue; }
|
|
198
|
+
if (!hits.includes(rel)) hits.push(rel);
|
|
183
199
|
}
|
|
184
200
|
(s.rag_queries ||= []).push({ ts: Date.now(), text: String(query || "").slice(0, 200), hits });
|
|
185
201
|
const all = (s.rag_hits ||= []);
|
|
186
202
|
for (const h of hits) if (!all.includes(h)) all.push(h);
|
|
203
|
+
if (hits.length === 0 && dropped.length > 0) s.rag_dropped = { root: ROOT_REAL, count: dropped.length, sample: dropped.slice(0, 3) };
|
|
204
|
+
else if (hits.length > 0) delete s.rag_dropped; // an answer inside the tree ends the diagnosis
|
|
187
205
|
save(state);
|
|
188
206
|
};
|
|
189
207
|
// Sources are read STRUCTURALLY, never through the stringified response:
|
|
@@ -195,7 +213,9 @@ process.stdin.on("end", () => {
|
|
|
195
213
|
};
|
|
196
214
|
const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
|
|
197
215
|
if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
198
|
-
|
|
216
|
+
// KJC-BUG-0293: the host may hand the answer as {content:[parts]}, as the
|
|
217
|
+
// bare array of parts, or as the text itself; all three are the same answer.
|
|
218
|
+
const parts = Array.isArray(response?.content) ? response.content : Array.isArray(response) ? response : [{ text: typeof response === "string" ? response : "" }];
|
|
199
219
|
recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
|
|
200
220
|
process.exit(0);
|
|
201
221
|
}
|
|
@@ -292,7 +312,9 @@ process.stdin.on("end", () => {
|
|
|
292
312
|
const s = session(state, sid);
|
|
293
313
|
s.at = Date.now();
|
|
294
314
|
const rel = relative(ROOT, file).replaceAll("\\\\", "/");
|
|
295
|
-
|
|
315
|
+
// KJC-BUG-0279 (#1981): a file outside the repository is not one of its sources.
|
|
316
|
+
const inside = rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/");
|
|
317
|
+
const bucket = !inside ? null : TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
|
|
296
318
|
if (bucket && !bucket.includes(rel)) bucket.push(rel);
|
|
297
319
|
// KJC-TSK-0910: the branch size while it is written, with the CI budget
|
|
298
320
|
// (kj pr-size), said once per threshold crossed. Context, never a block.
|
|
@@ -747,8 +769,15 @@ process.stdin.on("end", () => {
|
|
|
747
769
|
// (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
|
|
748
770
|
// carril los caza el escaner de abajo.)
|
|
749
771
|
const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame))\\b[^;|&<>$\`(){}\\n\\r]*$/;
|
|
772
|
+
// KJC-BUG-0286 (#1982): a command whose EVERY segment only reads mutates
|
|
773
|
+
// nothing, however it is chained (grep | grep, git show | grep, cd && git
|
|
774
|
+
// status). A cd with a literal target is one such segment: the paths after
|
|
775
|
+
// it need no verifying when nothing after it writes. One mutating segment
|
|
776
|
+
// and the whole command is judged below, as before.
|
|
777
|
+
const CD_LITERAL = /^[ \\t]*(cd|pushd)([ \\t]+[^ \\t;|&<>$\`(){}\\n\\r]*)?[ \\t]*$/;
|
|
778
|
+
const readsOnly = (s) => s.split(/&&|\\|\\||[;|]/).every((seg) => READONLY.test(seg) || CD_LITERAL.test(seg));
|
|
750
779
|
// KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
|
|
751
|
-
if (!
|
|
780
|
+
if (!readsOnly(stripInertQuotes(cmd))) {
|
|
752
781
|
// cd/pushd invalida TODO razonamiento textual de rutas posteriores
|
|
753
782
|
// (carrera de bypasses confirmada en review: destino bare, con $,
|
|
754
783
|
// relativas post-cd...): en un comando NO-read-only, cambiar de
|
|
@@ -960,7 +989,8 @@ process.stdin.on("end", () => {
|
|
|
960
989
|
} catch { /* no report or unreadable — the Steward is not adopted here */ }
|
|
961
990
|
const file = input.file_path || input.notebook_path;
|
|
962
991
|
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
963
|
-
|
|
992
|
+
// KJC-BUG-0279 (#1981): outside the repository there is no source to govern.
|
|
993
|
+
if (rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/") && CODE.test(rel) && !TESTS.test(rel)) {
|
|
964
994
|
const branch = branchOf();
|
|
965
995
|
const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
|
|
966
996
|
// ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
|
|
@@ -1017,7 +1047,13 @@ process.stdin.on("end", () => {
|
|
|
1017
1047
|
console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
|
|
1018
1048
|
process.exit(2);
|
|
1019
1049
|
} else {
|
|
1020
|
-
|
|
1050
|
+
// KJC-BUG-0293 (#1996): hits that fell outside the tree are named, so the
|
|
1051
|
+
// person learns the index was built from another path instead of querying again.
|
|
1052
|
+
const dropped = rs.rag_dropped;
|
|
1053
|
+
const outside = dropped?.count
|
|
1054
|
+
? " La ultima consulta devolvio " + dropped.count + " hit(s) fuera de este arbol (" + dropped.root + "): " + (dropped.sample || []).join(", ") + " — el indice se construyo desde otra ruta; reindexa desde esta: kj rag index --with-sources."
|
|
1055
|
+
: "";
|
|
1056
|
+
console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>." + outside + doc("rag-first"));
|
|
1021
1057
|
process.exit(2);
|
|
1022
1058
|
}
|
|
1023
1059
|
}
|
|
@@ -1034,7 +1070,10 @@ process.stdin.on("end", () => {
|
|
|
1034
1070
|
// bandera no puede apagar el gate de commit, y mover core.hooksPath lo
|
|
1035
1071
|
// apaga entero. El --no-verify que kj harden --commit usa por dentro no
|
|
1036
1072
|
// pasa por aqui: no es una tool call. Escape humano, sellado como el resto.
|
|
1037
|
-
|
|
1073
|
+
// KJC-BUG-0286 (#1982): judged on the git commit SEGMENT alone, read by the
|
|
1074
|
+
// shell reader: a "commit" inside a grep pattern piped after git show, or
|
|
1075
|
+
// the -n of a grep in the same pipeline, is not a skipped hook.
|
|
1076
|
+
const commitSegments = shellSegments(cmd).map((ws) => ws.map((w) => w.slice(w.lastIndexOf("/") + 1))).filter((ws) => inOrder(["git", "commit"], ws));
|
|
1038
1077
|
// git acepta abreviaturas no ambiguas de las opciones largas, asi que
|
|
1039
1078
|
// --no-ver salta el hook igual que --no-verify (catch de la review).
|
|
1040
1079
|
const shortWithN = (w) => w.length > 1 && w[0] === "-" && w[1] !== "-" && w.includes("n");
|
|
@@ -1051,12 +1090,45 @@ process.stdin.on("end", () => {
|
|
|
1051
1090
|
// --config-env=core.hooksPath=VAR) y una propiedad de seguridad no puede
|
|
1052
1091
|
// depender de como parta las palabras el separador (catch de la review).
|
|
1053
1092
|
const movesHooks = cmd.toLowerCase().includes("core.hookspath");
|
|
1054
|
-
if ((
|
|
1093
|
+
if (commitSegments.some((ws) => ws.some(skipsVerify)) || movesHooks) {
|
|
1055
1094
|
// ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
|
|
1056
1095
|
// if it ever must happen, is the user's act in their own terminal.
|
|
1057
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"));
|
|
1058
1097
|
process.exit(2);
|
|
1059
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
|
+
}
|
|
1060
1132
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
1061
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)
|
|
1062
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({
|