karajan-code 4.43.0 → 4.44.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.43.0",
3
+ "version": "4.44.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -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
@@ -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(),
@@ -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) {
@@ -6,6 +6,9 @@
6
6
  import { addCheckpoint } from "../session/store.js";
7
7
  import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
8
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";
9
12
  import {
10
13
  ensureGitRepo,
11
14
  currentBranch,
@@ -14,6 +17,7 @@ import {
14
17
  syncBaseBranch,
15
18
  ensureBranchUpToDateWithBase,
16
19
  createBranch,
20
+ checkoutBranch,
17
21
  buildBranchName,
18
22
  commitAll,
19
23
  pushBranch,
@@ -104,6 +108,17 @@ export function buildFinalizeCommitMessage({ task, taskType, pendingPaths }) {
104
108
  return commitMessageFromTask(task, taskType);
105
109
  }
106
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
+
107
122
  export async function prepareGitAutomation({ config, task, logger, session }) {
108
123
  const enabled = config.git.auto_commit || config.git.auto_push || config.git.auto_pr;
109
124
  if (!enabled) return { enabled: false };
@@ -112,6 +127,20 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
112
127
  throw new Error("Git automation requested but current directory is not a git repository");
113
128
  }
114
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
+
115
144
  const baseBranch = config.base_branch;
116
145
  const autoRebase = config.git.auto_rebase !== false;
117
146
  // KJC-BUG-0294 (#1993): what is dirty or untracked NOW is not the run's; the
@@ -123,7 +152,7 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
123
152
 
124
153
  // New repo without commits: create branch directly (no fetch/sync possible)
125
154
  if (!repoHasCommits) {
126
- const created = buildBranchName(config.git.branch_prefix || "feat/", task);
155
+ const created = buildBranchName(config.git.branch_prefix || "feat/", task, session?.pg_task_id || null);
127
156
  // git checkout -b works even without commits (creates orphan-like branch on first commit)
128
157
  await createBranch(created);
129
158
  logger.info(`New repo — created working branch: ${created}`);
@@ -148,8 +177,8 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
148
177
  await syncBaseBranch({ baseBranch, autoRebase }).catch(() => {
149
178
  // No remote tracking — skip sync for new projects
150
179
  });
151
- const created = buildBranchName(config.git.branch_prefix || "feat/", task);
152
- await createBranch(created);
180
+ const created = buildBranchName(config.git.branch_prefix || "feat/", task, session?.pg_task_id || null);
181
+ await checkoutRunBranch(created);
153
182
  branch = created;
154
183
  logger.info(`Created working branch: ${branch}`);
155
184
  await addCheckpoint(session, { stage: "git-prep", branch, created: true });
@@ -353,7 +382,12 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
353
382
  }
354
383
 
355
384
  if (remoteAvailable && (config.git.auto_push || config.git.auto_pr)) {
356
- await pushBranch(gitCtx.branch);
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
+ }
357
391
  await addCheckpoint(session, { stage: "git-push", branch: gitCtx.branch });
358
392
  logger.info(`Pushed branch: ${gitCtx.branch}`);
359
393
  }
@@ -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
- logger.warn(`HU ${story.id}: push failed: ${err.message}`);
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
- logger.warn(`HU ${story.id}: PR creation failed: ${err.message}`);
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
+ });
@@ -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.
@@ -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
- await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
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.
@@ -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
- const { fetchCard, updateCard } = await import("./client.js");
200
- const pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
201
- if (pgCard && pgCard.status !== "In Progress") {
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
  /**
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 buildBranchName(prefix, task) {
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}${slugifyTask(task) || "task"}-${stamp}`;
179
+ return `${prefix}${slug}-${stamp}`;
168
180
  }
169
181
 
170
182
  export async function hasChanges(cwd = null) {
@@ -263,9 +275,13 @@ export async function commitAll(message, cwd = null, { beforeCommit = null, excl
263
275
  // The callback learns the exact paths, so a verdict stamped there binds to the
264
276
  // diff this commit will contain and not to whatever else the index holds.
265
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;
266
282
  try {
267
283
  // --only: these paths and nothing else; what the person had staged stays staged.
268
- await runGit(["commit", "-m", message, "--only", "--", ...paths], opts);
284
+ await runGit(["commit", "-m", message, "--only", "--", ...paths], commitOpts);
269
285
  } catch (err) {
270
286
  // `git status --porcelain` and `git commit` disagreed about whether
271
287
  // there was anything to commit. Don't escalate — the only outcome