karajan-code 4.42.0 → 4.44.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/cli/register-meta.js +10 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/identity.js +9 -0
- 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 +87 -12
- package/src/git/hu-automation.js +67 -5
- package/src/git/run-identity.js +48 -0
- package/src/harden/hook-templates.js +6 -0
- package/src/harden/phone-invite.js +53 -0
- package/src/harden/sentinel/sessionstart.mjs +5 -0
- package/src/harden/sentinel-hooks.js +52 -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/drivers/run-hu-batch.js +16 -8
- package/src/orchestrator/hu-sub-pipeline.js +2 -0
- package/src/orchestrator/preflight-checks.js +21 -0
- package/src/orchestrator/stages/suite-green-stage.js +55 -0
- package/src/planning-game/pipeline-adapter.js +14 -5
- 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 +62 -20
package/package.json
CHANGED
package/src/cli/register-meta.js
CHANGED
|
@@ -169,6 +169,16 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
|
|
173
|
+
identityCmd
|
|
174
|
+
.command("phone-invite")
|
|
175
|
+
.description("Print, once per kj version, the invitation to enroll the phone when none is enrolled (nothing otherwise)")
|
|
176
|
+
.action(async (flags) => {
|
|
177
|
+
await withConfig(pkgVersion, "identity-phone-invite", flags, async ({ config }) => {
|
|
178
|
+
process.exitCode = await identityCommand({ action: "phone-invite", config, flags: { ...flags, version: pkgVersion } });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
172
182
|
// BOOT-A (KJC-TSK-0857, cold-start ADR): the pieces existed, the ORDER did
|
|
173
183
|
// not. One command from an empty directory to a project under the method.
|
|
174
184
|
program
|
package/src/commands/doctor.js
CHANGED
|
@@ -21,6 +21,7 @@ import { getBinaryChecks } from "../checks/binaries.js";
|
|
|
21
21
|
import { getSonarChecks } from "../checks/sonar.js";
|
|
22
22
|
import { getOllamaChecks } from "../checks/ollama.js";
|
|
23
23
|
import { getHarnessScorecardChecks } from "../checks/harness-scorecard.js";
|
|
24
|
+
import { getPhoneChecks } from "../harden/phone-invite.js";
|
|
24
25
|
import { getCiChecks } from "../checks/ci.js";
|
|
25
26
|
import { getRtkChecks } from "../checks/rtk.js";
|
|
26
27
|
import { getSqueezrChecks } from "../checks/squeezr.js";
|
|
@@ -67,6 +68,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
|
|
|
67
68
|
...getSonarChecks(),
|
|
68
69
|
...getOllamaChecks(),
|
|
69
70
|
...getHarnessScorecardChecks(),
|
|
71
|
+
...getPhoneChecks(),
|
|
70
72
|
...getPortChecks(),
|
|
71
73
|
...getTokenChecks(config),
|
|
72
74
|
...getMcpHealthChecks(),
|
package/src/commands/identity.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { addSigner, enrollPhone } from "../harden/phone-sign.js";
|
|
12
|
+
import { phoneInvite } from "../harden/phone-invite.js";
|
|
12
13
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
13
14
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
14
15
|
import { compareIdentity } from "../identity/compare.js";
|
|
@@ -39,6 +40,14 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
39
40
|
return 0;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// KJC-TSK-0986 (HUM-F): the once-per-version invitation to enroll the phone,
|
|
44
|
+
// printed for the SessionStart hook (which cannot import kj) and for scripts.
|
|
45
|
+
if (action === "phone-invite") {
|
|
46
|
+
const text = phoneInvite({ home: deps.home, version: flags.version });
|
|
47
|
+
if (text) log(text);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
if (action === "set") {
|
|
43
52
|
const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
|
|
44
53
|
if (!candidate.gh_user || !candidate.git_email) {
|
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,10 @@
|
|
|
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";
|
|
9
|
+
import { pushRemedy } from "./hu-automation.js";
|
|
10
|
+
import { resolveRunIdentity } from "./run-identity.js";
|
|
11
|
+
import { getRunContext } from "../utils/run-context.js";
|
|
8
12
|
import {
|
|
9
13
|
ensureGitRepo,
|
|
10
14
|
currentBranch,
|
|
@@ -13,6 +17,7 @@ import {
|
|
|
13
17
|
syncBaseBranch,
|
|
14
18
|
ensureBranchUpToDateWithBase,
|
|
15
19
|
createBranch,
|
|
20
|
+
checkoutBranch,
|
|
16
21
|
buildBranchName,
|
|
17
22
|
commitAll,
|
|
18
23
|
pushBranch,
|
|
@@ -79,7 +84,9 @@ export function commitMessageFromTask(task, taskType = null) {
|
|
|
79
84
|
.replaceAll(/\s+/g, " ")
|
|
80
85
|
.trim();
|
|
81
86
|
const prefix = prefixForTaskType(taskType);
|
|
82
|
-
|
|
87
|
+
// KJC-BUG-0294 (#1993): cut on a word, never mid-word (a title with no blank keeps the hard cut).
|
|
88
|
+
const cut = clean.length > 72 ? clean.slice(0, 72).replace(/\s+\S*$/, "") : clean;
|
|
89
|
+
return `${prefix}: ${cut || "karajan update"}`;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
/**
|
|
@@ -101,6 +108,17 @@ export function buildFinalizeCommitMessage({ task, taskType, pendingPaths }) {
|
|
|
101
108
|
return commitMessageFromTask(task, taskType);
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
// KJC-BUG-0249 (#1896): a branch named after the card is the same on a second
|
|
112
|
+
// run of that card, so one already there is taken, not recreated.
|
|
113
|
+
async function checkoutRunBranch(created) {
|
|
114
|
+
try {
|
|
115
|
+
await createBranch(created);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (!/already exists/i.test(err.message)) throw err;
|
|
118
|
+
await checkoutBranch(created);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
104
122
|
export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
105
123
|
const enabled = config.git.auto_commit || config.git.auto_push || config.git.auto_pr;
|
|
106
124
|
if (!enabled) return { enabled: false };
|
|
@@ -109,18 +127,37 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
109
127
|
throw new Error("Git automation requested but current directory is not a git repository");
|
|
110
128
|
}
|
|
111
129
|
|
|
130
|
+
// KJC-BUG-0246 (#1893): one identity for every commit of the run, resolved
|
|
131
|
+
// once: the clone's declaration, else the repository's config, else the
|
|
132
|
+
// global one. From the global config it is said, because another session
|
|
133
|
+
// may switch it mid-run. Nothing resolvable: git refuses the commit loudly.
|
|
134
|
+
const identity = resolveRunIdentity({ projectDir: config.projectDir || process.cwd() });
|
|
135
|
+
const runContext = getRunContext();
|
|
136
|
+
if (identity && runContext) runContext.gitIdentity = identity;
|
|
137
|
+
if (identity) {
|
|
138
|
+
logger.info(`Git identity for this run: ${identity.name} <${identity.email}> (${identity.source})`);
|
|
139
|
+
if (identity.source === "global") logger.warn("The identity comes from the global git config, which another session may switch mid-run: declare this clone's with kj identity set, or set user.email in this repository");
|
|
140
|
+
} else {
|
|
141
|
+
logger.warn("No git identity resolvable (kj identity set, or git config user.name and user.email): the first commit will fail");
|
|
142
|
+
}
|
|
143
|
+
|
|
112
144
|
const baseBranch = config.base_branch;
|
|
113
145
|
const autoRebase = config.git.auto_rebase !== false;
|
|
146
|
+
// KJC-BUG-0294 (#1993): what is dirty or untracked NOW is not the run's; the
|
|
147
|
+
// pipeline commits only what it changes. Both names of a pending rename are
|
|
148
|
+
// kept. Fails closed: with no answer from git the run stops here rather than
|
|
149
|
+
// commit the person's pending changes later.
|
|
150
|
+
const preexisting = await listPendingPaths();
|
|
114
151
|
const repoHasCommits = await hasCommits();
|
|
115
152
|
|
|
116
153
|
// New repo without commits: create branch directly (no fetch/sync possible)
|
|
117
154
|
if (!repoHasCommits) {
|
|
118
|
-
const created = buildBranchName(config.git.branch_prefix || "feat/", task);
|
|
155
|
+
const created = buildBranchName(config.git.branch_prefix || "feat/", task, session?.pg_task_id || null);
|
|
119
156
|
// git checkout -b works even without commits (creates orphan-like branch on first commit)
|
|
120
157
|
await createBranch(created);
|
|
121
158
|
logger.info(`New repo — created working branch: ${created}`);
|
|
122
159
|
await addCheckpoint(session, { stage: "git-prep", branch: created, created: true, newRepo: true });
|
|
123
|
-
return { enabled: true, branch: created, baseBranch, autoRebase };
|
|
160
|
+
return { enabled: true, branch: created, baseBranch, autoRebase, preexisting };
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
await fetchBase(baseBranch).catch(() => {
|
|
@@ -140,8 +177,8 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
140
177
|
await syncBaseBranch({ baseBranch, autoRebase }).catch(() => {
|
|
141
178
|
// No remote tracking — skip sync for new projects
|
|
142
179
|
});
|
|
143
|
-
const created = buildBranchName(config.git.branch_prefix || "feat/", task);
|
|
144
|
-
await
|
|
180
|
+
const created = buildBranchName(config.git.branch_prefix || "feat/", task, session?.pg_task_id || null);
|
|
181
|
+
await checkoutRunBranch(created);
|
|
145
182
|
branch = created;
|
|
146
183
|
logger.info(`Created working branch: ${branch}`);
|
|
147
184
|
await addCheckpoint(session, { stage: "git-prep", branch, created: true });
|
|
@@ -153,7 +190,7 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
153
190
|
await addCheckpoint(session, { stage: "git-prep", branch, created: false });
|
|
154
191
|
}
|
|
155
192
|
|
|
156
|
-
return { enabled: true, branch, baseBranch, autoRebase };
|
|
193
|
+
return { enabled: true, branch, baseBranch, autoRebase, preexisting };
|
|
157
194
|
}
|
|
158
195
|
|
|
159
196
|
export function buildPrBody({ task: _task, stageResults }) {
|
|
@@ -194,7 +231,7 @@ export async function earlyPrCreation({ gitCtx, task, logger, session, stageResu
|
|
|
194
231
|
if (!gitCtx?.enabled) return null;
|
|
195
232
|
|
|
196
233
|
const commitMsg = commitMessageFromTask(task);
|
|
197
|
-
const commitResult = await commitAll(commitMsg);
|
|
234
|
+
const commitResult = await commitAll(commitMsg, null, { exclude: gitCtx.preexisting || [] });
|
|
198
235
|
if (!commitResult.committed) {
|
|
199
236
|
logger.info("earlyPrCreation: no changes to commit");
|
|
200
237
|
return null;
|
|
@@ -230,7 +267,7 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
|
|
|
230
267
|
if (!gitCtx?.enabled) return null;
|
|
231
268
|
|
|
232
269
|
const commitMsg = commitMessageFromTask(task);
|
|
233
|
-
const commitResult = await commitAll(commitMsg);
|
|
270
|
+
const commitResult = await commitAll(commitMsg, null, { exclude: gitCtx.preexisting || [] });
|
|
234
271
|
if (!commitResult.committed) {
|
|
235
272
|
logger.info("incrementalPush: no changes to commit");
|
|
236
273
|
return null;
|
|
@@ -246,8 +283,18 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
|
|
|
246
283
|
return { commits };
|
|
247
284
|
}
|
|
248
285
|
|
|
249
|
-
|
|
286
|
+
const DEFAULT_PR_SIZE_LIMIT = 200;
|
|
287
|
+
|
|
288
|
+
export async function finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults = null, review = null, measureSize = branchSize }) {
|
|
250
289
|
if (!gitCtx?.enabled) return { git: "disabled", commits: [] };
|
|
290
|
+
// KJC-BUG-0297 (#1993): the pipeline commits only what a reviewer approved. An
|
|
291
|
+
// approval that reviewed nothing (reviewer disabled) stamps no verdict and
|
|
292
|
+
// commits nothing: the changes stay in the tree, and the log says why.
|
|
293
|
+
if (review && review.reviewed === false) {
|
|
294
|
+
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)");
|
|
295
|
+
await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "unreviewed" });
|
|
296
|
+
return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], unreviewed: true };
|
|
297
|
+
}
|
|
251
298
|
|
|
252
299
|
// Take a snapshot of pending paths BEFORE staging so we can decide
|
|
253
300
|
// whether what's about to be committed is just Karajan scaffolding
|
|
@@ -268,13 +315,31 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
268
315
|
|
|
269
316
|
let committed = false;
|
|
270
317
|
const commits = [];
|
|
318
|
+
// KJC-BUG-0299 (#1993): the PR budget is applied BEFORE the commit, with the
|
|
319
|
+
// CI gate's own count (kj pr-size), over what this commit will contain. A
|
|
320
|
+
// step planned at 150 lines once committed 960. Where the branch cannot be
|
|
321
|
+
// measured (a fresh repo with no base yet) kj says so and goes on.
|
|
271
322
|
if (config.git.auto_commit) {
|
|
323
|
+
const limit = Number(config.git.pr_size_limit) > 0 ? Number(config.git.pr_size_limit) : DEFAULT_PR_SIZE_LIMIT;
|
|
324
|
+
let size = null;
|
|
325
|
+
try {
|
|
326
|
+
size = await measureSize({ projectDir: config.projectDir || process.cwd(), base: gitCtx.baseBranch, exclude: gitCtx.preexisting || [] });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
logger.warn(`pr-size: the branch could not be measured (${err.message}); the budget is not checked for this commit`);
|
|
329
|
+
}
|
|
330
|
+
if (size && size.added > limit) {
|
|
331
|
+
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`);
|
|
332
|
+
await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, limit });
|
|
333
|
+
return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, limit } };
|
|
334
|
+
}
|
|
272
335
|
// ENV-F1: this path only runs after the pipeline's reviewer approved,
|
|
273
336
|
// so stamp that verdict for the staged diff — the v4 pre-commit gate
|
|
274
337
|
// (when the repo opted in) accepts the pipeline's own commit.
|
|
275
338
|
const commitResult = await commitAll(commitMsg, null, {
|
|
276
|
-
|
|
339
|
+
exclude: gitCtx.preexisting || [],
|
|
340
|
+
beforeCommit: ({ paths } = {}) => stampStagedVerdict({
|
|
277
341
|
projectDir: config?.projectDir || process.cwd(),
|
|
342
|
+
paths,
|
|
278
343
|
reviewer: config?.reviewer || "pipeline-reviewer",
|
|
279
344
|
summary: `kj run session ${session?.id || ""}: reviewer approved`.trim(),
|
|
280
345
|
// KJC-TSK-0838: the sonar stage result travels with the stamp.
|
|
@@ -285,7 +350,12 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
285
350
|
if (commitResult.commit) {
|
|
286
351
|
commits.push(commitResult.commit);
|
|
287
352
|
}
|
|
288
|
-
|
|
353
|
+
// KJC-BUG-0294: what was pending before the run stays out of the commit and is named;
|
|
354
|
+
// if the run also touched one of those files, its edit is in the tree, not in the commit.
|
|
355
|
+
if (commitResult.leftOut?.length) {
|
|
356
|
+
logger.warn(`Left out of the commit, pending before the run (review and commit them yourself): ${commitResult.leftOut.join(", ")}`);
|
|
357
|
+
}
|
|
358
|
+
await addCheckpoint(session, { stage: "git-commit", committed, leftOut: commitResult.leftOut || [] });
|
|
289
359
|
logger.info(committed ? "Committed changes" : "No changes to commit");
|
|
290
360
|
}
|
|
291
361
|
|
|
@@ -312,7 +382,12 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
312
382
|
}
|
|
313
383
|
|
|
314
384
|
if (remoteAvailable && (config.git.auto_push || config.git.auto_pr)) {
|
|
315
|
-
|
|
385
|
+
// KJC-BUG-0248 (#1895): a refused push stops the run loudly, with its remedy.
|
|
386
|
+
try {
|
|
387
|
+
await pushBranch(gitCtx.branch);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
throw new Error(`push of ${gitCtx.branch} failed: ${err.message}. ${pushRemedy(err.message, gitCtx.branch)}`, { cause: err });
|
|
390
|
+
}
|
|
316
391
|
await addCheckpoint(session, { stage: "git-push", branch: gitCtx.branch });
|
|
317
392
|
logger.info(`Pushed branch: ${gitCtx.branch}`);
|
|
318
393
|
}
|
package/src/git/hu-automation.js
CHANGED
|
@@ -69,6 +69,33 @@ async function resolveExistingBranchRef(preferred) {
|
|
|
69
69
|
return preferred; // give up; caller will surface the original error
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Whether a branch name references the card, as a whole token (BB-002 is not BB-0022). */
|
|
73
|
+
export function branchReferencesCard(branch, cardId) {
|
|
74
|
+
const id = String(cardId).replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
75
|
+
return new RegExp(`(^|[^a-z0-9])${id}([^a-z0-9]|$)`, "i").test(String(branch || ""));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function cardBranch({ cardId, story, config, logger }) {
|
|
79
|
+
const head = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {});
|
|
80
|
+
const current = head.exitCode === 0 ? head.stdout.trim() : "";
|
|
81
|
+
if (branchReferencesCard(current, cardId)) {
|
|
82
|
+
logger.info(`HU ${story.id}: on '${current}', the branch of card ${cardId}`);
|
|
83
|
+
return current;
|
|
84
|
+
}
|
|
85
|
+
const branchName = buildHuBranchName(config.git?.branch_prefix || "feat/", { id: cardId, title: story.title });
|
|
86
|
+
// A card branch left by an earlier run keeps its commits: taken, never reset.
|
|
87
|
+
const exists = await runCommand("git", ["rev-parse", "--verify", "--quiet", branchName], {});
|
|
88
|
+
const base = exists.exitCode === 0 ? null : await resolveExistingBranchRef(config.base_branch || "main");
|
|
89
|
+
const how = base ? `created from '${base}'` : "taken";
|
|
90
|
+
const res = await runCommand("git", base ? ["checkout", "-b", branchName, base] : ["checkout", branchName], {});
|
|
91
|
+
if (res.exitCode !== 0) {
|
|
92
|
+
logger.warn(`HU git: branch ${branchName} could not be ${how}: ${res.stderr}`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
logger.info(`HU ${story.id}: branch '${branchName}' ${how} for card ${cardId}`);
|
|
96
|
+
return branchName;
|
|
97
|
+
}
|
|
98
|
+
|
|
72
99
|
/**
|
|
73
100
|
* Create a branch for an HU starting from its resolved base.
|
|
74
101
|
* Returns the branch name created (or null if git automation is disabled).
|
|
@@ -80,10 +107,22 @@ async function resolveExistingBranchRef(preferred) {
|
|
|
80
107
|
* @param {object} params.logger
|
|
81
108
|
* @returns {Promise<string|null>}
|
|
82
109
|
*/
|
|
83
|
-
export async function prepareHuBranch({ story, huBranches, config, logger }) {
|
|
110
|
+
export async function prepareHuBranch({ story, huBranches, config, logger, cardId = null }) {
|
|
84
111
|
if (!config.git?.auto_commit && !config.git?.auto_push && !config.git?.auto_pr) {
|
|
85
112
|
return null;
|
|
86
113
|
}
|
|
114
|
+
// KJC-BUG-0249 (#1896): with a Planning Game card the run is that card's
|
|
115
|
+
// work and lives on ONE branch named after it: the current one when it
|
|
116
|
+
// already references the card, else feat/<CARD-ID>-<slug>, created once
|
|
117
|
+
// and shared by every HU. HU ids name branches only when there is no card.
|
|
118
|
+
if (cardId) {
|
|
119
|
+
const known = huBranches.get(cardId);
|
|
120
|
+
const branch = known ?? (await cardBranch({ cardId, story, config, logger }));
|
|
121
|
+
if (!branch) return null;
|
|
122
|
+
huBranches.set(cardId, branch);
|
|
123
|
+
huBranches.set(story.id, branch);
|
|
124
|
+
return branch;
|
|
125
|
+
}
|
|
87
126
|
const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
|
|
88
127
|
const effectiveBase = await resolveExistingBranchRef(baseBranch);
|
|
89
128
|
if (effectiveBase !== baseBranch) {
|
|
@@ -103,18 +142,37 @@ export async function prepareHuBranch({ story, huBranches, config, logger }) {
|
|
|
103
142
|
return branchName;
|
|
104
143
|
}
|
|
105
144
|
|
|
145
|
+
/**
|
|
146
|
+
* KJC-BUG-0248 (#1895): the remedy for a push git refused, read from its
|
|
147
|
+
* message. GitHub refuses a push that adds or changes a workflow file when
|
|
148
|
+
* the token lacks the `workflow` scope; that one has an exact fix.
|
|
149
|
+
*/
|
|
150
|
+
export function pushRemedy(message, branch) {
|
|
151
|
+
if (/workflow/i.test(message) && /scope/i.test(message)) {
|
|
152
|
+
return `the token lacks the workflow scope: run gh auth refresh -h github.com -s workflow, then git push -u origin ${branch}`;
|
|
153
|
+
}
|
|
154
|
+
return `push the branch yourself: git push -u origin ${branch}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The warnings a run reports when the push or the PR of any HU failed. */
|
|
158
|
+
export function collectGitWarnings(results) {
|
|
159
|
+
return results.flatMap((r) => (r.result?.git?.errors ?? []).map((e) => `${r.huId}: ${e.step} failed: ${e.message}. ${e.remedy}`));
|
|
160
|
+
}
|
|
161
|
+
|
|
106
162
|
/**
|
|
107
163
|
* After an HU is approved, commit its changes and optionally push + create PR.
|
|
164
|
+
* A push or PR that fails is not only a warn line: it travels in `errors`
|
|
165
|
+
* with its remedy, so the run's result says it (KJC-BUG-0248).
|
|
108
166
|
*
|
|
109
167
|
* @param {object} params
|
|
110
168
|
* @param {object} params.story
|
|
111
169
|
* @param {string} params.branchName
|
|
112
170
|
* @param {object} params.config
|
|
113
171
|
* @param {object} params.logger
|
|
114
|
-
* @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null}>}
|
|
172
|
+
* @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null, branch: string|null, errors: Array<{step: string, message: string, remedy: string}>}>}
|
|
115
173
|
*/
|
|
116
174
|
export async function finalizeHuCommit({ story, branchName, config, logger, cwd = null }) {
|
|
117
|
-
const result = { committed: false, pushed: false, prUrl: null };
|
|
175
|
+
const result = { committed: false, pushed: false, prUrl: null, branch: branchName || null, errors: [] };
|
|
118
176
|
if (!branchName) return result;
|
|
119
177
|
|
|
120
178
|
const changed = await hasChanges(cwd);
|
|
@@ -139,7 +197,9 @@ export async function finalizeHuCommit({ story, branchName, config, logger, cwd
|
|
|
139
197
|
result.pushed = true;
|
|
140
198
|
logger.info(`HU ${story.id}: pushed '${branchName}'`);
|
|
141
199
|
} catch (err) {
|
|
142
|
-
|
|
200
|
+
const remedy = pushRemedy(err.message, branchName);
|
|
201
|
+
result.errors.push({ step: "push", message: err.message, remedy });
|
|
202
|
+
logger.warn(`HU ${story.id}: push failed: ${err.message} (${remedy})`);
|
|
143
203
|
}
|
|
144
204
|
}
|
|
145
205
|
|
|
@@ -163,7 +223,9 @@ export async function finalizeHuCommit({ story, branchName, config, logger, cwd
|
|
|
163
223
|
result.prUrl = url;
|
|
164
224
|
logger.info(`HU ${story.id}: PR created ${url}`);
|
|
165
225
|
} catch (err) {
|
|
166
|
-
|
|
226
|
+
const remedy = `open the PR yourself: gh pr create --base ${config.base_branch || "main"} --head ${branchName}`;
|
|
227
|
+
result.errors.push({ step: "pr", message: err.message, remedy });
|
|
228
|
+
logger.warn(`HU ${story.id}: PR creation failed: ${err.message} (${remedy})`);
|
|
167
229
|
}
|
|
168
230
|
}
|
|
169
231
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0246 (#1893): one git identity for the whole run.
|
|
3
|
+
*
|
|
4
|
+
* Of five commits of one run, three carried one account and two another: the
|
|
5
|
+
* pipeline let git resolve the author at every commit, and the host works with
|
|
6
|
+
* several global identities that other sessions switch at will. The identity
|
|
7
|
+
* is resolved ONCE, before the first commit, in a fixed order: the clone's
|
|
8
|
+
* declaration (`kj identity set`, .karajan/identity.local.yml), else the
|
|
9
|
+
* repository's own config, else the global one. Nothing resolvable is null,
|
|
10
|
+
* never a guess: git itself refuses the commit loudly in that case.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { readIdentity } from "../identity/store.js";
|
|
14
|
+
|
|
15
|
+
const defaultGitFn = (projectDir, args) =>
|
|
16
|
+
execFileSync("git", ["config", ...args], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
17
|
+
|
|
18
|
+
const configValue = (gitFn, projectDir, args) => {
|
|
19
|
+
try {
|
|
20
|
+
const out = String(gitFn(projectDir, args) || "").trim();
|
|
21
|
+
return out.length > 0 ? out : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{projectDir: string, gitFn?: (projectDir: string, args: string[]) => string}} opts
|
|
29
|
+
* @returns {{name: string, email: string, source: "declared"|"repo"|"global"}|null}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveRunIdentity({ projectDir, gitFn = defaultGitFn }) {
|
|
32
|
+
const declared = readIdentity(projectDir);
|
|
33
|
+
const local = (key) => configValue(gitFn, projectDir, ["--local", "--get", `user.${key}`]);
|
|
34
|
+
const effective = (key) => configValue(gitFn, projectDir, ["--get", `user.${key}`]);
|
|
35
|
+
const localEmail = local("email");
|
|
36
|
+
const email = declared?.git_email ?? localEmail ?? effective("email");
|
|
37
|
+
const name = local("name") ?? effective("name");
|
|
38
|
+
if (!email || !name) return null;
|
|
39
|
+
let source = "global";
|
|
40
|
+
if (declared) source = "declared";
|
|
41
|
+
else if (localEmail) source = "repo";
|
|
42
|
+
return { name, email, source };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The environment that pins author and committer for one git command. */
|
|
46
|
+
export const identityEnv = ({ name, email }) => ({
|
|
47
|
+
GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email,
|
|
48
|
+
});
|
|
@@ -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),",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0986 (HUM-F, ADR 0018): whoever runs kj without a phone enrolled
|
|
3
|
+
* learns it once per version, not the day the maximum level (HUM-D) refuses a
|
|
4
|
+
* seal. One text, used by the SessionStart notice, by `kj doctor` and, when
|
|
5
|
+
* HUM-D lands, by the deny itself: what the signature protects, how to enroll,
|
|
6
|
+
* and how one proceeds today without it.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { isPhoneEnrolled } from "./phone-sign.js";
|
|
13
|
+
import { STRATEGY } from "../checks/types.js";
|
|
14
|
+
|
|
15
|
+
export const PHONE_INVITE =
|
|
16
|
+
"El móvil no está enrolado: los sellos (kj harden --commit), las aprobaciones y los comandos del catálogo se firman hoy con un código tecleado, con menos garantía. Enrólalo con kj identity enroll-phone <clave pública de la app>: cada acto queda atado a tu clave privada, que nunca sale del móvil (ADR 0018).";
|
|
17
|
+
|
|
18
|
+
const markerPath = (home) => join(home ?? homedir(), ".karajan", "phone-invite.json");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The invitation, once per kj version, or null (phone enrolled, or already said
|
|
22
|
+
* for this version). Saying it records it; a marker that cannot be written
|
|
23
|
+
* never silences the invitation.
|
|
24
|
+
* @param {{home?: string, version?: string}} [opts]
|
|
25
|
+
* @returns {string|null}
|
|
26
|
+
*/
|
|
27
|
+
export function phoneInvite({ home, version = "unknown" } = {}) {
|
|
28
|
+
if (isPhoneEnrolled({ home })) return null;
|
|
29
|
+
let seen = null;
|
|
30
|
+
try { seen = JSON.parse(readFileSync(markerPath(home), "utf8")).version; } catch { /* never invited */ }
|
|
31
|
+
if (seen === version) return null;
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dirname(markerPath(home)), { recursive: true });
|
|
34
|
+
writeFileSync(markerPath(home), `${JSON.stringify({ version, at: new Date().toISOString() })}\n`, "utf8");
|
|
35
|
+
} catch { /* the invitation still goes out */ }
|
|
36
|
+
return PHONE_INVITE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `kj doctor`: WARN while the phone is not enrolled, with the same text. */
|
|
40
|
+
export function createPhoneCheck({ home } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
name: "phone-enrolled",
|
|
43
|
+
label: "móvil enrolado (firma de sellos y aprobaciones)",
|
|
44
|
+
strategy: STRATEGY.MANUAL,
|
|
45
|
+
describe: "Whether a phone's public key is enrolled for the supervisor seal and approvals (ADR 0018)",
|
|
46
|
+
async detect() {
|
|
47
|
+
if (isPhoneEnrolled({ home })) return { ok: true, severity: "info", detail: "clave pública en ~/.karajan/supervisor-phone.json" };
|
|
48
|
+
return { ok: false, severity: "warn", detail: PHONE_INVITE, fix: "kj identity enroll-phone <clave pública de la app>" };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const getPhoneChecks = (opts) => [createPhoneCheck(opts)];
|
|
@@ -38,6 +38,11 @@ process.stdin.on("end", () => {
|
|
|
38
38
|
lines.push("Karajan (reglas que la compactación se lleva):", ...RULES, `Estado: rama ${branch}, card ${card}${unmoved}.`);
|
|
39
39
|
}
|
|
40
40
|
if (drift) lines.push(`Karajan: ${drift}`);
|
|
41
|
+
// KJC-TSK-0986 (HUM-F, ADR 0018): once per kj version, the invitation to
|
|
42
|
+
// enroll the phone when none is; kj decides and remembers, the hook relays.
|
|
43
|
+
const invite = spawnSync("kj", ["identity", "phone-invite"], { encoding: "utf8", timeout: 8000 });
|
|
44
|
+
const inviteText = invite.status === 0 ? String(invite.stdout || "").trim() : "";
|
|
45
|
+
if (inviteText) lines.push(`Karajan: ${inviteText}`);
|
|
41
46
|
if (lines.length === 0) process.exit(0);
|
|
42
47
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
|
|
43
48
|
} catch { /* never fails a session */ }
|
|
@@ -570,6 +570,25 @@ process.stdin.on("end", () => {
|
|
|
570
570
|
}
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
|
+
// KJC-BUG-0252 (#1899): una issue para karajan-code sale por kj report-issue
|
|
574
|
+
// (sanitiza y busca duplicados), nunca por gh issue create a mano.
|
|
575
|
+
{
|
|
576
|
+
const ghIssue = String(input.command || "");
|
|
577
|
+
if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghIssue) && /(^|[^a-z])issue([^a-z]|$)/.test(ghIssue) && /(^|[^a-z])create([^a-z]|$)/.test(ghIssue)) {
|
|
578
|
+
// The target: --repo/-R, an inline or inherited GH_REPO, else the
|
|
579
|
+
// checkout's own origin, which is what gh takes (review catches).
|
|
580
|
+
const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
|
|
581
|
+
let target = optionValues(ghIssue, ["--repo", "-R"]).at(-1) || (inlineRepo ? inlineRepo[2] : "") || String(process.env.GH_REPO || "");
|
|
582
|
+
if (!target) {
|
|
583
|
+
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: ROOT, encoding: "utf8" });
|
|
584
|
+
target = String(origin.stdout || "").trim();
|
|
585
|
+
}
|
|
586
|
+
if (/karajan-code([^a-z0-9]|$)/i.test(target)) {
|
|
587
|
+
console.error("karajan sentinel: las issues para karajan-code salen por kj report-issue (sanitiza y busca issues parecidas), no por gh issue create." + doc("report-issue"));
|
|
588
|
+
process.exit(2);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
573
592
|
// KJC-TSK-0822 v1.1: enrolar (o pisar) la clave del movil es acto humano
|
|
574
593
|
// — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
|
|
575
594
|
// solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
|
|
@@ -1096,6 +1115,39 @@ process.stdin.on("end", () => {
|
|
|
1096
1115
|
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
1116
|
process.exit(2);
|
|
1098
1117
|
}
|
|
1118
|
+
// KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
|
|
1119
|
+
// git add -A / git add . / git add -u put a Claude scheduler lock into
|
|
1120
|
+
// main; and the agent's runtime files (.claude/*.lock, .kj/, the local
|
|
1121
|
+
// identity, the Sentinel state, the harness) are never staged at all.
|
|
1122
|
+
// (No backticks in this template: they would end the string.)
|
|
1123
|
+
const RUNTIME = /^(\\.\\/)?(\\.claude\\/[^/]*\\.lock|\\.kj\\/|\\.karajan\\/identity\\.local\\.yml|\\.karajan\\/harness\\/sentinel-state\\.json)/;
|
|
1124
|
+
for (const ws of shellSegments(cmd)) {
|
|
1125
|
+
const gi = ws.findIndex((x) => x.slice(x.lastIndexOf("/") + 1) === "git");
|
|
1126
|
+
if (gi < 0) continue;
|
|
1127
|
+
// git's own options come before the verb: -C <dir>, -c k=v, --git-dir=…
|
|
1128
|
+
let vi = gi + 1;
|
|
1129
|
+
while (vi < ws.length && ws[vi].startsWith("-")) vi += ws[vi] === "-C" || ws[vi] === "-c" ? 2 : 1;
|
|
1130
|
+
if (ws[vi] !== "add") continue;
|
|
1131
|
+
const args = ws.slice(vi + 1);
|
|
1132
|
+
// The pathspec: after "--" if present, else every non-option word. "." is
|
|
1133
|
+
// the whole tree whichever way it is written; -A/-u with no pathspec too.
|
|
1134
|
+
const dash = args.indexOf("--");
|
|
1135
|
+
const spec = dash >= 0 ? args.slice(dash + 1) : args.filter((a) => !a.startsWith("-"));
|
|
1136
|
+
const whole = spec.some((a) => a === "." || a === "./");
|
|
1137
|
+
const blanket = whole || (args.some((a) => a === "-A" || a === "--all" || a === "-u" || a === "--update") && spec.length === 0);
|
|
1138
|
+
if (blanket) {
|
|
1139
|
+
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"));
|
|
1140
|
+
process.exit(2);
|
|
1141
|
+
}
|
|
1142
|
+
// Directories too: .kj is runtime whole; .claude and .karajan hold the
|
|
1143
|
+
// contract, so only a forced add (-f, past the ignore) reaches their locks.
|
|
1144
|
+
const force = args.some((a) => a === "-f" || a === "--force");
|
|
1145
|
+
const runtime = spec.find((a) => RUNTIME.test(a) || /^(\\.\\/)?\\.kj\\/?$/.test(a) || (force && /^(\\.\\/)?(\\.claude|\\.karajan)\\/?$/.test(a)));
|
|
1146
|
+
if (runtime) {
|
|
1147
|
+
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"));
|
|
1148
|
+
process.exit(2);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1099
1151
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
1100
1152
|
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
1153
|
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) {
|
|
@@ -57,7 +57,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
57
57
|
// Per-HU pipeline: focused max_iterations, fresh Brain state, own git branch.
|
|
58
58
|
const huMaxIterations = ctx.config.hu_max_iterations ?? 3;
|
|
59
59
|
const huBranches = new Map();
|
|
60
|
-
const { prepareHuBranch, finalizeHuCommit } = await import("../../git/hu-automation.js");
|
|
60
|
+
const { prepareHuBranch, finalizeHuCommit, collectGitWarnings } = await import("../../git/hu-automation.js");
|
|
61
61
|
|
|
62
62
|
// PR F (v2.7.5): the per-HU loop needs three pieces of plan context:
|
|
63
63
|
// • ADRs (loaded once from disk, applies to every HU on this plan)
|
|
@@ -142,7 +142,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
142
142
|
branchName = `kj-hu-${story.id}`;
|
|
143
143
|
huBranches.set(story.id, branchName);
|
|
144
144
|
} else {
|
|
145
|
-
branchName = await prepareHuBranch({ story, huBranches, config: laneConfig, logger });
|
|
145
|
+
branchName = await prepareHuBranch({ story, huBranches, config: laneConfig, logger, cardId: ctx.session?.pg_task_id || null });
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
// KJC-TSK-0408 step 2: snapshot del workspace ANTES de invocar al
|
|
@@ -257,8 +257,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
257
257
|
);
|
|
258
258
|
if (pendingGherkinTests.length === 0) {
|
|
259
259
|
logger.info(`HU ${story.id}: all shell acceptance tests PASSED, no Gherkin to translate — approved`);
|
|
260
|
-
await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
261
|
-
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
|
|
260
|
+
const git = await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
261
|
+
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed", git };
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
// Shell tests passed; Gherkin needs translation. Invoke the
|
|
@@ -276,8 +276,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
276
276
|
const testerStage = testerOutcome?.stageResult;
|
|
277
277
|
if (testerOutcome?.action !== "continue" && testerStage?.verdict === "pass") {
|
|
278
278
|
logger.info(`HU ${story.id}: tester translated Gherkin and verdict=pass — approved`);
|
|
279
|
-
await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
280
|
-
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
|
|
279
|
+
const git = await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
280
|
+
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed", git };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// Tester rejected (translated tests failed or coverage
|
|
@@ -375,7 +375,10 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
375
375
|
const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask };
|
|
376
376
|
const result = await runIterationLoop(laneCtx, { task: huTask, askQuestion, emitter, logger });
|
|
377
377
|
if (result?.approved) {
|
|
378
|
-
|
|
378
|
+
// KJC-BUG-0248: what the HU's push or PR said joins what the loop recorded.
|
|
379
|
+
const git = await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
380
|
+
const prev = result.git ?? {};
|
|
381
|
+
return { ...result, git: { ...prev, prUrl: prev.prUrl ?? git.prUrl, errors: [...(prev.errors ?? []), ...git.errors] } };
|
|
379
382
|
}
|
|
380
383
|
return result;
|
|
381
384
|
},
|
|
@@ -415,12 +418,17 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
415
418
|
}
|
|
416
419
|
}
|
|
417
420
|
|
|
421
|
+
// KJC-BUG-0248 (#1895): a push or a PR that failed is said in the result
|
|
422
|
+
// the host reads, not only in a warn line of the log.
|
|
423
|
+
const warnings = collectGitWarnings(subPipelineResult.results);
|
|
424
|
+
for (const w of warnings) logger.warn(`git automation: ${w}`);
|
|
418
425
|
const finalResult = {
|
|
419
426
|
approved: subPipelineResult.approved,
|
|
420
427
|
sessionId: ctx.session.id,
|
|
421
428
|
huResults: subPipelineResult.results,
|
|
422
429
|
blockedIds: subPipelineResult.blockedIds,
|
|
423
|
-
planId: ctx.session._planRef?.planId || null
|
|
430
|
+
planId: ctx.session._planRef?.planId || null,
|
|
431
|
+
...(warnings.length > 0 ? { warnings } : {})
|
|
424
432
|
};
|
|
425
433
|
await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
|
|
426
434
|
return { handled: true, result: finalResult };
|
|
@@ -172,6 +172,8 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
|
|
|
172
172
|
if (Array.isArray(r.deferredIssues)) {
|
|
173
173
|
for (const d of r.deferredIssues) blockers.push(typeof d === "string" ? d : d.message || JSON.stringify(d));
|
|
174
174
|
}
|
|
175
|
+
// KJC-BUG-0248: a push or PR that failed is a blocker the board shows.
|
|
176
|
+
for (const e of git.errors ?? []) blockers.push(`${e.step} failed: ${e.message}. ${e.remedy}`);
|
|
175
177
|
// Plain-Spanish, one-or-two sentences.
|
|
176
178
|
let summary;
|
|
177
179
|
if (r.review?.summary) summary = String(r.review.summary);
|
|
@@ -33,6 +33,7 @@ import { getMcpHealthChecks } from "../checks/mcp-health.js";
|
|
|
33
33
|
import { getSkillsChecks } from "../checks/skills.js";
|
|
34
34
|
import { getProjectChecks } from "../checks/project-checks.js";
|
|
35
35
|
import { resolveTestHarness } from "../config/test-harness.js";
|
|
36
|
+
import { collectGuaranteeFacts } from "../checks/guarantee-level.js";
|
|
36
37
|
|
|
37
38
|
function parseJsonSafe(text) {
|
|
38
39
|
try {
|
|
@@ -231,6 +232,26 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
|
|
|
231
232
|
errors: [],
|
|
232
233
|
};
|
|
233
234
|
|
|
235
|
+
// KJC-BUG-0252 (#1899): on a Claude host without the Sentinel harness nothing
|
|
236
|
+
// governs the host's own actions (issues by hand, commits, pushes, merges);
|
|
237
|
+
// a run reported Tier A OFF and the agent routed around every gate. kj never
|
|
238
|
+
// fakes that supervision: the run stops here, with the remedy.
|
|
239
|
+
const guarantee = collectGuaranteeFacts({ projectDir: config.projectDir || process.cwd() });
|
|
240
|
+
if (guarantee.host === "claude" && !guarantee.tierA.active) {
|
|
241
|
+
result.ok = false;
|
|
242
|
+
result.checks.push({ name: "sentinel-harness", ok: false, detail: guarantee.tierA.reason });
|
|
243
|
+
result.errors.push({
|
|
244
|
+
check: "sentinel-harness",
|
|
245
|
+
message: "Tier A tool-time is OFF: a Claude host without the Sentinel harness, so nothing governs the host's own actions outside this run.",
|
|
246
|
+
fix: "Run kj harden (kj init installs it) and seal it with kj harden --commit; then run again.",
|
|
247
|
+
});
|
|
248
|
+
logger.error("Preflight: Sentinel harness not installed on a Claude host — run kj harden");
|
|
249
|
+
emitProgress(emitter, makeEvent("preflight:end", { ...eventBase, stage: "preflight" }, {
|
|
250
|
+
status: "fail", message: "Preflight FAILED — Sentinel harness not installed", detail: result
|
|
251
|
+
}));
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
234
255
|
// Resolve extended preflight opt-in early so the short-circuit below can see it.
|
|
235
256
|
// Post-v2.7.5 this no longer reads globalThis directly — config.testHarness
|
|
236
257
|
// is populated by the loader from the global or the default.
|
|
@@ -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
|
+
}
|
|
@@ -195,10 +195,19 @@ export function accumulateCommit(session, commitInfo) {
|
|
|
195
195
|
// --- Internal helpers ---
|
|
196
196
|
|
|
197
197
|
async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
|
|
198
|
+
const { fetchCard, updateCard } = await import("./client.js");
|
|
199
|
+
// KJC-BUG-0247 (#1894): a card that cannot be read was one warn line and
|
|
200
|
+
// null, and the run went on without the card's acceptance criteria, invented
|
|
201
|
+
// its own HUs and said approved. With a card named, no card is a loud stop.
|
|
202
|
+
let pgCard;
|
|
198
203
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new Error(`Planning Game: card ${pgTaskId} could not be read (${err.message}): the run would implement without the card's acceptance criteria. Check PG_API_URL, or run without pgTask.`, { cause: err });
|
|
207
|
+
}
|
|
208
|
+
if (!pgCard) throw new Error(`Planning Game: card ${pgTaskId} not found in project ${pgProject}: the run would implement without the card's acceptance criteria. Check the id, or run without pgTask.`);
|
|
209
|
+
try {
|
|
210
|
+
if (pgCard.status !== "In Progress") {
|
|
202
211
|
await updateCard({
|
|
203
212
|
projectId: pgProject,
|
|
204
213
|
cardId: pgTaskId,
|
|
@@ -212,11 +221,11 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
|
|
|
212
221
|
});
|
|
213
222
|
logger.info(`Planning Game: ${pgTaskId} → In Progress`);
|
|
214
223
|
}
|
|
215
|
-
return pgCard;
|
|
216
224
|
} catch (err) {
|
|
225
|
+
// The card was read: its criteria govern the run; the status move is best-effort.
|
|
217
226
|
logger.warn(`Planning Game: could not update ${pgTaskId}: ${err.message}`);
|
|
218
|
-
return null;
|
|
219
227
|
}
|
|
228
|
+
return pgCard;
|
|
220
229
|
}
|
|
221
230
|
|
|
222
231
|
/**
|
|
@@ -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runCommand } from "./process.js";
|
|
2
2
|
import { getRunContext } from "./run-context.js";
|
|
3
|
+
import { identityEnv } from "../git/run-identity.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Module-scoped runner — legacy back-compat for callers outside a
|
|
@@ -162,9 +163,20 @@ export async function createBranch(branchName) {
|
|
|
162
163
|
await runGit(["checkout", "-b", branchName]);
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
export function
|
|
166
|
+
export async function checkoutBranch(branchName) {
|
|
167
|
+
await runGit(["checkout", branchName]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* KJC-BUG-0249 (#1896): with a Planning Game card the branch carries its id,
|
|
172
|
+
* `feat/<CARD-ID>-<slug>`, the name the card-first gate reads; the stamp is
|
|
173
|
+
* for a run with no card, where nothing else makes the name unique.
|
|
174
|
+
*/
|
|
175
|
+
export function buildBranchName(prefix, task, cardId = null) {
|
|
176
|
+
const slug = slugifyTask(task) || "task";
|
|
177
|
+
if (cardId) return `${prefix}${cardId}-${slug}`;
|
|
166
178
|
const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-").slice(0, 16);
|
|
167
|
-
return `${prefix}${
|
|
179
|
+
return `${prefix}${slug}-${stamp}`;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
export async function hasChanges(cwd = null) {
|
|
@@ -189,25 +201,32 @@ export async function hasChanges(cwd = null) {
|
|
|
189
201
|
* is structural here — without it the slice(3) below would crop one
|
|
190
202
|
* character off the first path.
|
|
191
203
|
*/
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
/**
|
|
205
|
+
* KJC-BUG-0294: the pending entries as `git status --porcelain -z` reports them,
|
|
206
|
+
* NUL-separated so a path with blanks or quotes arrives whole. A rename is one
|
|
207
|
+
* entry "R new\0old": `path` is the new name, `oldPath` the one that goes away.
|
|
208
|
+
* @returns {Promise<{path: string, oldPath?: string}[]>}
|
|
209
|
+
*/
|
|
210
|
+
export async function listPendingEntries(cwd = null) {
|
|
211
|
+
const res = await run("git", ["status", "--porcelain", "-z"], cwd ? { cwd } : {});
|
|
194
212
|
if (res.exitCode !== 0) {
|
|
195
213
|
throw new Error(`git status --porcelain failed: ${res.stderr || res.stdout}`);
|
|
196
214
|
}
|
|
197
|
-
const
|
|
198
|
-
if (!stdout) return [];
|
|
199
|
-
const lines = stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter(Boolean);
|
|
215
|
+
const tokens = String(res.stdout || "").split("\0").filter(Boolean);
|
|
200
216
|
const out = [];
|
|
201
|
-
for (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const arrow = after.indexOf(" -> ");
|
|
206
|
-
out.push(arrow !== -1 ? after.slice(arrow + 4) : after);
|
|
217
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
218
|
+
const entry = { path: tokens[i].slice(3) };
|
|
219
|
+
if (/^[RC]/.test(tokens[i]) || /^.[RC]/.test(tokens[i])) entry.oldPath = tokens[++i];
|
|
220
|
+
out.push(entry);
|
|
207
221
|
}
|
|
208
222
|
return out;
|
|
209
223
|
}
|
|
210
224
|
|
|
225
|
+
/** The pending paths; a rename contributes both its names (the old one is a pending deletion). */
|
|
226
|
+
export async function listPendingPaths(cwd = null) {
|
|
227
|
+
return (await listPendingEntries(cwd)).flatMap((e) => (e.oldPath ? [e.oldPath, e.path] : [e.path]));
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
// Locale-tolerant detection of "git commit refused because the working
|
|
212
231
|
// tree was already clean by the time we ran it". Observed in N4
|
|
213
232
|
// dogfooding (2026-05-07) — `hasChanges()` reported true after `add -A`
|
|
@@ -229,17 +248,40 @@ function isNothingToCommit(message) {
|
|
|
229
248
|
return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
|
|
230
249
|
}
|
|
231
250
|
|
|
232
|
-
|
|
251
|
+
/**
|
|
252
|
+
* KJC-BUG-0294 (#1993): the pipeline commits what the run changed, never what
|
|
253
|
+
* was already dirty or untracked before it (`exclude`: the paths pending when
|
|
254
|
+
* the run started, the harness kj generates among them). `git add -A` staged
|
|
255
|
+
* 22 untracked files and 960 lines of contract into one step's commit.
|
|
256
|
+
*/
|
|
257
|
+
export async function commitAll(message, cwd = null, { beforeCommit = null, exclude = [] } = {}) {
|
|
233
258
|
const opts = cwd ? { cwd } : {};
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
259
|
+
const skip = new Set(exclude);
|
|
260
|
+
const pending = await listPendingEntries(cwd);
|
|
261
|
+
const names = (e) => (e.oldPath ? [e.oldPath, e.path] : [e.path]);
|
|
262
|
+
// A rename of a file that was dirty before the run is that file under a new name.
|
|
263
|
+
const theirs = (e) => names(e).some((p) => skip.has(p));
|
|
264
|
+
// A rename stages its old name too (the deletion), or git keeps the old file.
|
|
265
|
+
const paths = pending.filter((e) => !theirs(e)).flatMap(names);
|
|
266
|
+
// A path dirty before the run may hold the run's edits too; they cannot be told
|
|
267
|
+
// apart, so the path stays out and is NAMED (`leftOut`) for the person to commit.
|
|
268
|
+
const leftOut = pending.filter(theirs).flatMap(names);
|
|
269
|
+
const said = leftOut.length > 0 ? { leftOut } : {};
|
|
270
|
+
if (paths.length === 0) return { committed: false, ...said };
|
|
271
|
+
await runGit(["add", "--", ...paths], opts);
|
|
237
272
|
// ENV-F1 (KJC-TSK-0643): runs between staging and committing — the only
|
|
238
273
|
// window where the staged diff is exactly what the commit will contain
|
|
239
274
|
// (used to stamp the pipeline's review verdict for the v4 gate).
|
|
240
|
-
|
|
275
|
+
// The callback learns the exact paths, so a verdict stamped there binds to the
|
|
276
|
+
// diff this commit will contain and not to whatever else the index holds.
|
|
277
|
+
if (beforeCommit) await beforeCommit({ paths });
|
|
278
|
+
// KJC-BUG-0246 (#1893): every commit of a run carries the identity the run
|
|
279
|
+
// resolved once (prepareGitAutomation), never what git resolves at that moment.
|
|
280
|
+
const identity = getRunContext()?.gitIdentity;
|
|
281
|
+
const commitOpts = identity ? { ...opts, env: { ...process.env, ...identityEnv(identity) } } : opts;
|
|
241
282
|
try {
|
|
242
|
-
|
|
283
|
+
// --only: these paths and nothing else; what the person had staged stays staged.
|
|
284
|
+
await runGit(["commit", "-m", message, "--only", "--", ...paths], commitOpts);
|
|
243
285
|
} catch (err) {
|
|
244
286
|
// `git status --porcelain` and `git commit` disagreed about whether
|
|
245
287
|
// there was anything to commit. Don't escalate — the only outcome
|
|
@@ -252,7 +294,7 @@ export async function commitAll(message, cwd = null, { beforeCommit = null } = {
|
|
|
252
294
|
}
|
|
253
295
|
const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"], opts);
|
|
254
296
|
const [hash, commitMessage] = raw.split("\x1f");
|
|
255
|
-
return { committed: true, commit: { hash, message: commitMessage } };
|
|
297
|
+
return { committed: true, commit: { hash, message: commitMessage }, ...said };
|
|
256
298
|
}
|
|
257
299
|
|
|
258
300
|
/**
|