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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|