karajan-code 4.53.0 → 4.53.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.53.0",
3
+ "version": "4.53.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",
@@ -155,6 +155,65 @@ export const stripTextOptionValues = (cmd) => cmd
155
155
  .replace(KJ_TITLE, (m, pre, head, val) => `${pre}${head}${val[0]}${val[0]}`)
156
156
  .replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
157
157
 
158
+ // KJC-BUG-0354: commands that only STORE their text: there, a quoted text value
159
+ // is never run. Anywhere else it may be (flock --command '...'), so it stays.
160
+ // git's global options that only pick where and how it runs may precede commit.
161
+ const GIT_GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
162
+ const GIT_GLOBAL_FLAG = /^(?:--no-pager|--(?:git-dir|work-tree|namespace)=\S+)$/;
163
+
164
+ const isGitCommit = (words) => {
165
+ let i = 1;
166
+ while (i < words.length) {
167
+ if (GIT_GLOBAL_WITH_VALUE.has(words[i])) i += 2;
168
+ else if (GIT_GLOBAL_FLAG.test(words[i])) i += 1;
169
+ else break;
170
+ }
171
+ return words[0] === "git" && words[i] === "commit";
172
+ };
173
+
174
+ const isTextOnlyHead = (seg) => /^kj\s+(?:hu|adr)\s+add\b/.test(seg) || isGitCommit(seg.split(/\s+/));
175
+
176
+ /**
177
+ * KJC-BUG-0354: blank the quoted text values (stripTextOptionValues) only when the
178
+ * whole input is a text-only command, so the human-act guards stop reading a
179
+ * title or a commit message that merely mentions a guarded command.
180
+ * @param {string} cmd
181
+ */
182
+ export const stripTextOnlyCommandValues = (cmd) => {
183
+ // Only a whole input that is ONE simple text-storage command: a chained
184
+ // segment could follow a shell function that turns the stored text into code.
185
+ const bare = stripInertQuotes(cmd);
186
+ if (splitOutsideQuotes(cmd).length !== 1 || /[(){}<>`$\n]/.test(bare) || !isTextOnlyHead(cmd.trim())) return cmd;
187
+ return stripTextOptionValues(cmd);
188
+ };
189
+
190
+ /** Segments and the ; && || | between them, cut only outside quotes (the pieces join back to cmd). */
191
+ function splitOutsideQuotes(cmd) {
192
+ const parts = [];
193
+ let start = 0;
194
+ let quote = null;
195
+ let i = 0;
196
+ while (i < cmd.length) {
197
+ const c = cmd[i];
198
+ const op = quote ? null : /^(?:&&|\|\||;|\|)/.exec(cmd.slice(i))?.[0];
199
+ if (quote) {
200
+ // inside double quotes a backslash escapes the next character
201
+ if (c === "\\" && quote === '"') i += 1;
202
+ else if (c === quote) quote = null;
203
+ } else if (c === "'" || c === '"') {
204
+ quote = c;
205
+ } else if (op) {
206
+ parts.push(cmd.slice(start, i), op);
207
+ start = i + op.length;
208
+ i = start;
209
+ continue;
210
+ }
211
+ i += 1;
212
+ }
213
+ parts.push(cmd.slice(start));
214
+ return parts;
215
+ }
216
+
158
217
  /**
159
218
  * KJC-BUG-0245: the values of the named options, read by the shell reader, so a
160
219
  * `;`, `&&` or `|` right after a path ends the word instead of joining it.
@@ -88,7 +88,7 @@ export const boardGate = (sid, pend) => {
88
88
  if (!pend.length) return { blocking: [], carried: [] };
89
89
  const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
90
90
  if (!bg.error && (bg.status === 0 || bg.status === 2)) {
91
- try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [] }; } catch { /* unreadable: as always */ }
91
+ try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [], reason: d.reason || "" }; } catch { /* unreadable: as always */ }
92
92
  }
93
93
  return { blocking: pend, carried: [] };
94
94
  };
@@ -473,7 +473,7 @@ import { homedir } from "node:os";
473
473
  import { fileURLToPath } from "node:url";
474
474
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
475
475
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
476
- import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
476
+ import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
477
477
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
478
478
  import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
479
479
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -606,7 +606,8 @@ process.stdin.on("end", () => {
606
606
  // — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
607
607
  // solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
608
608
  {
609
- const enrollCmd = String(input.command || "");
609
+ // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es enrolar.
610
+ const enrollCmd = stripTextOnlyCommandValues(String(input.command || ""));
610
611
  const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
611
612
  const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
612
613
  if ((tool === "Bash" && namesEnroll) || writesEnroll) {
@@ -618,11 +619,13 @@ process.stdin.on("end", () => {
618
619
  // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
619
620
  // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
620
621
  // (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
622
+ // KJC-BUG-0354: salvo en el texto entrecomillado de kj hu add, kj adr add y
623
+ // git commit, que solo guardan texto (decision del usuario, 16-oct).
621
624
  // ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
622
625
  // arriba: un env var lo pone tambien un agente — el supervisor no se
623
626
  // apaga con interruptores al alcance del supervisado.
624
627
  {
625
- const kjCmd = String(input.command || "");
628
+ const kjCmd = stripTextOnlyCommandValues(String(input.command || ""));
626
629
  if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
627
630
  console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
628
631
  process.exit(2);
@@ -1171,15 +1174,20 @@ process.stdin.on("end", () => {
1171
1174
  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)
1172
1175
  const mergeM = cmd.match(MERGE);
1173
1176
  const pend = pendingMoves(load().sessions?.[sid]);
1177
+ // KJC-BUG-0364: one board-gate decision per command; the push gate below
1178
+ // reuses it, so a second slow gh cannot contradict the first answer.
1179
+ let boardDecision = null;
1174
1180
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1175
1181
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1176
1182
  // por su primera mitad (boardGate, en la lib).
1177
- const { blocking, carried } = boardGate(sid, pend);
1183
+ boardDecision = boardGate(sid, pend);
1184
+ const { blocking, carried, reason } = boardDecision;
1178
1185
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1179
1186
  // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1180
1187
  // are already decided above (KJC-BUG-0230, 0198).
1188
+ // KJC-BUG-0363: when kj could not check, its reason travels with the block.
1181
1189
  if (blocking.length > 0) {
1182
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + doc("board-sync"));
1190
+ console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + (reason ? "\\n(" + reason + ")" : "") + doc("board-sync"));
1183
1191
  process.exit(2);
1184
1192
  }
1185
1193
  }
@@ -1225,9 +1233,10 @@ process.stdin.on("end", () => {
1225
1233
  // ADR 0015 (KJC-TSK-0925): with no escape.
1226
1234
  // KJC-BUG-0262: a carried card does not block the push the commit let through.
1227
1235
  const sess = load().sessions?.[sid];
1228
- const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
1236
+ const decision = boardDecision || boardGate(sid, pendingMoves(sess));
1237
+ const v = violations(sess, branchOf(), decision.blocking);
1229
1238
  if (v.length) {
1230
- console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1239
+ console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + (decision.reason ? "\\n(" + decision.reason + ")" : "") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1231
1240
  process.exit(2);
1232
1241
  }
1233
1242
  }
@@ -11,7 +11,9 @@
11
11
  * One verifiable fact carries a pending, never a promise in prose: the same card
12
12
  * has another OPEN pull request. A branch named after the card is not enough,
13
13
  * because the merged branch carries that name too, so accepting it would forgive
14
- * every merge made from its own lane.
14
+ * every merge made from its own lane. KJC-BUG-0362 narrows that, never widens
15
+ * it: the current branch carries only with unmerged commits and when it is not
16
+ * the merged PR's head.
15
17
  */
16
18
 
17
19
  import { execFileSync } from "node:child_process";
@@ -31,9 +33,24 @@ const cardOf = (text) => {
31
33
  *
32
34
  * @returns {{blocking: Array<object>, carried: Array<{card: string, why: string}>, reason?: string}}
33
35
  */
34
- export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [] }) {
36
+ /**
37
+ * KJC-BUG-0362 (#2199): the next PR of a split card may not exist yet (committed,
38
+ * not pushed). The branch the session stands on carries the card when it names
39
+ * that card, holds commits the base does not have, and is NOT the merged PR's
40
+ * head: the merged lane stays unforgiven, as above.
41
+ */
42
+ function carriedByLocalBranch(pending, card, localBranch, mergedHeads) {
43
+ if (!localBranch?.name || !(localBranch.unique > 0)) return null;
44
+ const mergedHead = mergedHeads[Number(pending.pr)];
45
+ if (!mergedHead || mergedHead === localBranch.name || cardOf(localBranch.name) !== card) return null;
46
+ return { card, why: `sigue entregándose en la rama ${localBranch.name}, con ${localBranch.unique} commit(s) sin mergear` };
47
+ }
48
+
49
+ export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [], localBranch = null, mergedHeads = {} }) {
35
50
  if (pendings.length === 0) return { blocking: [], carried: [] };
36
- if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta (gh no respondió) — se bloquea" };
51
+ // KJC-BUG-0363 (#2201): the guard runs BEFORE the command, so a gh auth switch
52
+ // inside it has not happened yet, and other sessions move the active account.
53
+ if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta: gh no pudo listar las PR con la cuenta activa. Si el repo es de otra cuenta, cambiala en un comando aparte (gh auth switch --user <cuenta>) y repite: el guard corre antes que el comando" };
37
54
  const openCards = new Set();
38
55
  for (const [i, head] of openPrHeads.entries()) {
39
56
  // A pending must never forgive itself: the PR it came from is merged, and if
@@ -46,7 +63,9 @@ export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers
46
63
  for (const p of pendings) {
47
64
  const card = p.card ? String(p.card).toUpperCase() : null;
48
65
  if (!card) { blocking.push(p); continue; } // nothing to check against
49
- if (openCards.has(card)) carried.push({ card, why: "sigue entregándose en una PR abierta" });
66
+ if (openCards.has(card)) { carried.push({ card, why: "sigue entregándose en una PR abierta" }); continue; }
67
+ const byBranch = carriedByLocalBranch(p, card, localBranch, mergedHeads);
68
+ if (byBranch) carried.push(byBranch);
50
69
  else blocking.push(p);
51
70
  }
52
71
  return { blocking, carried };
@@ -60,9 +79,26 @@ export function boardGate({ projectDir = process.cwd(), sessionId = "default", d
60
79
  const state = deps.readState?.() ?? readJson(join(projectDir, ".karajan", "harness", "sentinel-state.json"));
61
80
  const pendings = state?.sessions?.[sessionId]?.pending_moves;
62
81
  if (!Array.isArray(pendings) || pendings.length === 0) return { blocking: [], carried: [] };
63
- const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }));
82
+ // KJC-BUG-0364: gh can take several seconds; a timeout reads as "could not check" and blocks.
83
+ const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }));
64
84
  let open = null;
65
85
  try { open = JSON.parse(run("gh", ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,headRefName"])); } catch { /* fail closed */ }
66
86
  const listed = Array.isArray(open) ? open : null;
67
- return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [] });
87
+ return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [], ...localFacts(run, pendings) });
88
+ }
89
+
90
+ /** The current branch, its commits outside the base, and each merged PR's head; unreadable facts forgive nothing. */
91
+ function localFacts(run, pendings) {
92
+ const facts = { localBranch: null, mergedHeads: {} };
93
+ try {
94
+ const name = run("git", ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
95
+ let base = "origin/main"; // a clone without origin/HEAD measures against origin/main
96
+ try { base = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).trim() || base; } catch { /* keep origin/main */ }
97
+ const unique = Number(run("git", ["rev-list", "--count", `${base}..HEAD`]).trim());
98
+ if (name && name !== "HEAD" && Number.isInteger(unique)) facts.localBranch = { name, unique };
99
+ } catch { return facts; }
100
+ for (const p of pendings) {
101
+ try { facts.mergedHeads[Number(p.pr)] = JSON.parse(run("gh", ["pr", "view", String(p.pr), "--json", "headRefName"])).headRefName; } catch { /* unknown head: not forgiven */ }
102
+ }
103
+ return facts;
68
104
  }