karajan-code 4.53.0 → 4.54.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.53.0",
3
+ "version": "4.54.0",
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",
package/src/checks/qmd.js CHANGED
@@ -22,14 +22,15 @@ function createQmdCheck() {
22
22
  try {
23
23
  const res = await runCommand("qmd", ["--version"]);
24
24
  if (res.exitCode === 0) {
25
- return { ok: true, severity: "info", detail: `${res.stdout.trim()} — semantic wiki active` };
25
+ // KJC-TSK-1067: say what it is for, not only that it is there.
26
+ return { ok: true, severity: "info", detail: `${res.stdout.trim()} — answers about the project's docs, notes, ADRs and plans from the documents, with fewer tokens (kj env install wires its MCP)` };
26
27
  }
27
28
  } catch { /* not installed */ }
28
29
  const installCmd = getInstallCommand("qmd");
29
30
  return {
30
31
  ok: false,
31
32
  severity: "warn",
32
- detail: "Not found — semantic wiki via QMD available",
33
+ detail: "Not found — qmd answers questions about the project's docs, notes, ADRs and plans from the documents, with fewer tokens and a source",
33
34
  fix: withDocLink(`Install: ${installCmd}`, "qmd_install"),
34
35
  };
35
36
  },
@@ -197,12 +197,17 @@ export function registerMeta(program, { pkgVersion }) {
197
197
  // KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through kj.
198
198
  const handoffCmd = program.command("handoff").description("Handoffs: what a gate denied the agent, registered with the gate's why, for the human to read and run by id");
199
199
  const handoff = (action, flags) => withConfig(pkgVersion, `handoff-${action}`, flags, async ({ config }) => { process.exitCode = await handoffCommand({ action, config, flags }); });
200
- handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you)")
200
+ handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you), or with --why an action only the human can take")
201
201
  .argument("<command...>", "the denied command, as it was run").option("--cwd <dir>", "Directory the command runs in (default: the project)")
202
+ .option("--why <text>", "KJC-TSK-1066: what an action only the human can take is for (publish, seal); marked as the agent's")
202
203
  .action((parts, flags) => handoff("add", { ...flags, command: parts.join(" ") }));
203
204
  handoffCmd.command("list", { isDefault: true }).description("The handoffs registered in this project").action((flags) => handoff("list", flags));
204
205
  handoffCmd.command("show").description("A handoff: the exact command, its sha256 and why the gate denied it")
205
206
  .argument("<id>", "handoff id (8 hex)").option("--json", "Output the record as JSON").action((id, flags) => handoff("show", { ...flags, id }));
207
+ handoffCmd.command("done").description("The human did it: it leaves the pending handoffs and stays in the history")
208
+ .argument("<id>", "handoff id (8 hex)").action((id, flags) => handoff("done", { ...flags, id }));
209
+ handoffCmd.command("watch").description("Your task window: the pending handoffs, then each new one as it lands, with date and why (Ctrl+C to leave)")
210
+ .action((flags) => handoff("watch", flags));
206
211
 
207
212
  // KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
208
213
  identityCmd
@@ -2,18 +2,22 @@
2
2
  * kj handoff add|list|show (KJC-TSK-0968, HUM-E, ADR 0018): the agent registers
3
3
  * what a gate denied it, with the gate's why; the human reads it by id.
4
4
  */
5
- import { addHandoff, listHandoffs, readHandoff } from "../harden/handoff.js";
5
+ import { addHandoff, listHandoffs, markHandoffDone, readHandoff, watchHandoffs } from "../harden/handoff.js";
6
6
 
7
- const line = (h) => `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}`;
7
+ const line = (h) => {
8
+ const why = h.kind === "task" ? " (" + h.why + ")" : "";
9
+ return `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}${why}`;
10
+ };
8
11
 
9
12
  /** @returns {Promise<0|1>} */
10
13
  export async function handoffCommand({ action = "list", config, flags = {}, log = console.log }) {
11
14
  const projectDir = config?.projectDir || process.cwd();
12
15
  try {
13
16
  if (action === "add") {
14
- const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir });
17
+ const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir, why: flags.why ?? null });
15
18
  log(`encargo ${record.id} registrado: ${record.command}`);
16
- log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
19
+ if (record.kind === "task") log(`para qué (propuesto por el agente): ${record.why}`);
20
+ else log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
17
21
  log(`tu usuario lo revisa con kj handoff show ${record.id}; nadie copia y pega el comando.`);
18
22
  return 0;
19
23
  }
@@ -24,7 +28,21 @@ export async function handoffCommand({ action = "list", config, flags = {}, log
24
28
  log(`comando ${h.command}`);
25
29
  log(`sha256 ${h.sha256}`);
26
30
  log(`cwd ${h.cwd}`);
27
- log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
31
+ if (h.kind === "task") log(`para qué ${h.why}\n (propuesto por el agente, no lo negó ningún gate)`);
32
+ else log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
33
+ if (h.doneAt) log(`hecho ${h.doneAt}`);
34
+ return 0;
35
+ }
36
+ if (action === "done") {
37
+ const h = markHandoffDone({ projectDir, id: flags.id });
38
+ log(`encargo ${h.id} hecho: ${h.command}`);
39
+ return 0;
40
+ }
41
+ if (action === "watch") {
42
+ log("kj handoff watch: lo que te toca hacer a ti, según llega (Ctrl+C para salir)");
43
+ const ctl = new AbortController();
44
+ process.once("SIGINT", () => ctl.abort());
45
+ await watchHandoffs({ projectDir, log, signal: ctl.signal });
28
46
  return 0;
29
47
  }
30
48
  const all = listHandoffs({ projectDir });
@@ -11,6 +11,7 @@ import { savePlan, listPlans, loadPlan } from "../plan/plan-store.js";
11
11
  import { detectHostAgent } from "../utils/agent-detect.js";
12
12
  import { readIdentity } from "../identity/store.js";
13
13
  import { effectiveGitEmail } from "../identity/detect.js";
14
+ import { roleSkills } from "../skills/role-skills.js";
14
15
 
15
16
  // KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
16
17
  // board must never show work nobody can trace. Host agent env → that agent;
@@ -238,6 +239,13 @@ export async function huCommand({ config = null, action, args = [], flags = {},
238
239
  if (starting || takenOver || unclaimedRunning) Object.assign(hu, { claimed_by: who ?? null, claimed_at: who ? new Date().toISOString() : null });
239
240
  if (starting && !who) console.warn(`⚠ ${label} is not reserved: kj cannot tell who works this clone — declare it with kj identity set`);
240
241
  await savePlan(projectDir, plan);
242
+ // KJC-TSK-1065: the host session starts the card here; it learns which skills apply and where to read them.
243
+ if (starting && !flags.json) {
244
+ const text = [hu.title, hu.scope, ...(Array.isArray(hu.acceptance_criteria) ? hu.acceptance_criteria : [])].filter(Boolean).join(" ");
245
+ const { skills } = await (deps.skills ?? roleSkills)({ role: "coder", task: text, projectDir });
246
+ const where = (s) => (s.path ? ": read " + s.path : " (native)");
247
+ if (skills.length) console.log([`skills for ${label} (read them before you start):`, ...skills.map((s) => "- " + s.name + where(s))].join("\n"));
248
+ }
241
249
  return emit({ id: hu.id, status, claimed_by: hu.claimed_by ?? null }, `✓ ${label} → ${status}`);
242
250
  }
243
251
 
@@ -8,10 +8,28 @@
8
8
  * entries; invalid JSON is left untouched (preserve, never clobber).
9
9
  */
10
10
 
11
+ import { spawnSync } from "node:child_process";
11
12
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
12
14
  import { join } from "node:path";
13
15
 
14
- export function installProjectMcp({ projectDir = process.cwd(), logger = console } = {}) {
16
+ // KJC-TSK-1067: a server spec that launches `qmd mcp` (command or args, any path).
17
+ const launchesQmd = (s) => Boolean(s) && typeof s === "object"
18
+ && [s.command, ...(Array.isArray(s.args) ? s.args : [])].some((a) => typeof a === "string" && /(^|\/)qmd$/.test(a))
19
+ && Array.isArray(s.args) && s.args.includes("mcp");
20
+
21
+ const defaultQmd = {
22
+ installed: () => spawnSync("qmd", ["--version"], { stdio: "ignore" }).status === 0,
23
+ // The user's own Claude Code config: global servers and every project's.
24
+ wiredGlobally: () => {
25
+ try {
26
+ const c = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
27
+ return [c.mcpServers, ...Object.values(c.projects || {}).map((p) => p?.mcpServers)].some((m) => Object.values(m || {}).some(launchesQmd));
28
+ } catch { return false; }
29
+ },
30
+ };
31
+
32
+ export function installProjectMcp({ projectDir = process.cwd(), logger = console, qmd = defaultQmd } = {}) {
15
33
  const mcpPath = join(projectDir, ".mcp.json");
16
34
  let cfg = {};
17
35
  if (existsSync(mcpPath)) {
@@ -37,8 +55,17 @@ export function installProjectMcp({ projectDir = process.cwd(), logger = console
37
55
  const present = Object.values(cfg.mcpServers).some(launchesRagMcp);
38
56
  if (!present) {
39
57
  cfg.mcpServers["kj-rag"] = { command: "kj-rag-mcp", args: [] };
40
- writeFileSync(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`);
41
58
  logger.info?.("✓ kj_rag_query wired as a NATIVE tool (.mcp.json → kj-rag-mcp): querying the RAG is now the agent's cheapest path");
42
59
  }
43
- return { wired: true, added: !present };
60
+ // KJC-TSK-1067: qmd answers about the project's documents with fewer tokens and a source.
61
+ let qmdState = "absent";
62
+ if (Object.values(cfg.mcpServers).some(launchesQmd)) qmdState = "present";
63
+ else if (qmd.installed() && qmd.wiredGlobally()) qmdState = "global";
64
+ else if (qmd.installed()) {
65
+ cfg.mcpServers.qmd = { command: "qmd", args: ["mcp"] };
66
+ qmdState = "added";
67
+ logger.info?.("✓ qmd wired as a NATIVE tool (.mcp.json → qmd mcp): questions about docs, notes, ADRs and plans answer from the documents, with fewer tokens");
68
+ }
69
+ if (!present || qmdState === "added") writeFileSync(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`);
70
+ return { wired: true, added: !present, qmd: qmdState };
44
71
  }
@@ -61,7 +61,8 @@ Invariants (the git gates enforce these — they are not suggestions):
61
61
 
62
62
  - The project RAG answers before you assume: \`kj rag query\` — never guess
63
63
  what the codebase does. In MCP hosts the same index is the native
64
- \`kj_rag_query\` tool: reach for it before grepping by hand.
64
+ \`kj_rag_query\` tool: reach for it before grepping by hand. Questions about
65
+ the project's docs, notes, ADRs or plans: ask qmd (its MCP) first.
65
66
  - ${trackingLine(stateBackend, boardName)}
66
67
  - Tests prove behavior: the failing test exists first (TDD), and the suite
67
68
  is never left red. Green is not proof: with \`method_gates.mutation\`
@@ -103,6 +104,8 @@ Hit a kj bug or friction? Diagnose it and file it upstream with
103
104
  \`kj report-issue\` (sanitized; ask your user before \`--publish\`).
104
105
  kj announces a newer version? Tell your user what it brings and ask —
105
106
  never run \`kj update\` on your own.
107
+ Something only your user can do (publish, seal, run a script)? Register it:
108
+ \`kj handoff add <command> --why "<what for>"\`; they follow \`kj handoff watch\`.
106
109
  `;
107
110
 
108
111
  export function renderPlaybook({ stateBackend = "hu-board", boardName = null, config = null } = {}) {
@@ -30,19 +30,69 @@ export function findDenial({ projectDir, command }) {
30
30
  * exact bytes there is nothing to hand off: the agent does not write the why.
31
31
  * @returns {{id: string, command: string, sha256: string, why: string}}
32
32
  */
33
- export function addHandoff({ projectDir, command, cwd = projectDir }) {
33
+ export function addHandoff({ projectDir, command, cwd = projectDir, why = null }) {
34
34
  if (typeof command !== "string" || !command.trim()) throw new Error("kj handoff add: falta el comando exacto que el gate negó");
35
35
  const denial = findDenial({ projectDir, command });
36
- if (!denial) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018)");
36
+ // KJC-TSK-1066: an action only the human can take (publish, seal) is a TASK, with
37
+ // the agent's why marked as the agent's. A command a gate denied keeps the gate's.
38
+ const task = !denial && typeof why === "string" && why.trim();
39
+ if (!denial && !task) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018). Para una acción que solo puede hacer el humano, di para qué con --why");
37
40
  const createdAt = new Date().toISOString();
38
41
  const digest = sha256(command);
39
42
  const id = sha256(`${digest}:${createdAt}`).slice(0, 8);
40
- const record = { id, command, sha256: digest, cwd, createdAt, status: "pending", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
43
+ const base = { id, command, sha256: digest, cwd, createdAt, status: "pending" };
44
+ const record = task ? { ...base, kind: "task", why: why.trim(), denial: null } : { ...base, kind: "denial", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
45
+ writeRecord(projectDir, record);
46
+ return record;
47
+ }
48
+
49
+ const writeRecord = (projectDir, record) => {
41
50
  mkdirSync(join(projectDir, HANDOFFS_DIR), { recursive: true });
42
- writeFileSync(join(projectDir, HANDOFFS_DIR, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
51
+ writeFileSync(join(projectDir, HANDOFFS_DIR, `${record.id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
52
+ };
53
+
54
+ /** KJC-TSK-1066: the human did it; it leaves the pending ones and stays in the history. */
55
+ export function markHandoffDone({ projectDir, id }) {
56
+ const record = { ...readHandoff({ projectDir, id }), status: "done", doneAt: new Date().toISOString() };
57
+ writeRecord(projectDir, record);
43
58
  return record;
44
59
  }
45
60
 
61
+ /** The handoff as the watch window shows it: local date and time, the command, and why. */
62
+ export function formatHandoff(h) {
63
+ const at = new Date(h.createdAt);
64
+ const pad = (n) => String(n).padStart(2, "0");
65
+ const when = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
66
+ const why = h.kind === "task" ? `${h.why} (propuesto por el agente)` : `lo negó el gate: ${String(h.denial?.why ?? "").split("\n")[0]}`;
67
+ return `[${when}] ${h.id} ${h.command}\n ${why}`;
68
+ }
69
+
70
+ /**
71
+ * KJC-TSK-1066: kj handoff watch. Shows the pending handoffs, then each new or
72
+ * changed one as it lands, until the signal aborts (Ctrl+C in the CLI).
73
+ */
74
+ export async function watchHandoffs({ projectDir, log, intervalMs = 2000, signal }) {
75
+ const seen = new Map();
76
+ const tick = () => {
77
+ for (const h of listHandoffs({ projectDir })) {
78
+ if (seen.get(h.id) === h.status) continue;
79
+ const first = !seen.has(h.id);
80
+ seen.set(h.id, h.status);
81
+ if (h.status === "pending") log(formatHandoff(h));
82
+ else if (!first) log(`[hecho] ${h.id} ${h.command}`);
83
+ }
84
+ };
85
+ tick();
86
+ // One abort promise for the whole watch, raced against each pause: no listener per tick.
87
+ const aborted = new Promise((resolve) => signal?.addEventListener("abort", resolve, { once: true }));
88
+ while (!signal?.aborted) {
89
+ let timer;
90
+ await Promise.race([aborted, new Promise((resolve) => { timer = setTimeout(resolve, intervalMs); })]);
91
+ clearTimeout(timer);
92
+ if (!signal?.aborted) tick();
93
+ }
94
+ }
95
+
46
96
  export function readHandoff({ projectDir, id }) {
47
97
  if (!/^[0-9a-f]{8}$/.test(id ?? "")) throw new Error(`kj handoff: "${id}" no es un identificador de encargo (8 hex)`);
48
98
  const path = join(projectDir, HANDOFFS_DIR, `${id}.json`);
@@ -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
  };
@@ -216,7 +216,11 @@ process.stdin.on("end", () => {
216
216
  // KJC-BUG-0293: the host may hand the answer as {content:[parts]}, as the
217
217
  // bare array of parts, or as the text itself; all three are the same answer.
218
218
  const parts = Array.isArray(response?.content) ? response.content : Array.isArray(response) ? response : [{ text: typeof response === "string" ? response : "" }];
219
- recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
219
+ let found = parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || ""))));
220
+ // KJC-BUG-0365 (#2208): an answer cut short (whole code chunks) is no
221
+ // longer valid JSON, but every "source" it carries still names a hit.
222
+ if (found.length === 0) found = [...text.matchAll(/\\\\?"source\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/g)].map((m) => m[1]);
223
+ recordRag(input.text, found, parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
220
224
  process.exit(0);
221
225
  }
222
226
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
@@ -473,7 +477,7 @@ import { homedir } from "node:os";
473
477
  import { fileURLToPath } from "node:url";
474
478
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
475
479
  // 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";
480
+ import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
477
481
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
478
482
  import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
479
483
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -606,7 +610,8 @@ process.stdin.on("end", () => {
606
610
  // — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
607
611
  // solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
608
612
  {
609
- const enrollCmd = String(input.command || "");
613
+ // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es enrolar.
614
+ const enrollCmd = stripTextOnlyCommandValues(String(input.command || ""));
610
615
  const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
611
616
  const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
612
617
  if ((tool === "Bash" && namesEnroll) || writesEnroll) {
@@ -618,11 +623,13 @@ process.stdin.on("end", () => {
618
623
  // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
619
624
  // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
620
625
  // (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
626
+ // KJC-BUG-0354: salvo en el texto entrecomillado de kj hu add, kj adr add y
627
+ // git commit, que solo guardan texto (decision del usuario, 16-oct).
621
628
  // ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
622
629
  // arriba: un env var lo pone tambien un agente — el supervisor no se
623
630
  // apaga con interruptores al alcance del supervisado.
624
631
  {
625
- const kjCmd = String(input.command || "");
632
+ const kjCmd = stripTextOnlyCommandValues(String(input.command || ""));
626
633
  if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
627
634
  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
635
  process.exit(2);
@@ -809,7 +816,9 @@ process.stdin.on("end", () => {
809
816
  // it need no verifying when nothing after it writes. One mutating segment
810
817
  // and the whole command is judged below, as before.
811
818
  const CD_LITERAL = /^[ \\t]*(cd|pushd)([ \\t]+[^ \\t;|&<>$\`(){}\\n\\r]*)?[ \\t]*$/;
812
- const readsOnly = (s) => s.split(/&&|\\|\\||[;|]/).every((seg) => READONLY.test(seg) || CD_LITERAL.test(seg));
819
+ // KJC-BUG-0365: 2>&1 and 2>/dev/null only fold stderr, they write nothing.
820
+ const QUIET = /[ \\t]2>(?:&1|\\/dev\\/null)(?=[ \\t]|$)/g;
821
+ const readsOnly = (s) => s.split(/&&|\\|\\||[;|]/).every((seg) => READONLY.test(seg.replace(QUIET, "")) || CD_LITERAL.test(seg.replace(QUIET, "")));
813
822
  // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
814
823
  if (!readsOnly(stripInertQuotes(cmd))) {
815
824
  // cd/pushd invalida TODO razonamiento textual de rutas posteriores
@@ -1171,15 +1180,20 @@ process.stdin.on("end", () => {
1171
1180
  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
1181
  const mergeM = cmd.match(MERGE);
1173
1182
  const pend = pendingMoves(load().sessions?.[sid]);
1183
+ // KJC-BUG-0364: one board-gate decision per command; the push gate below
1184
+ // reuses it, so a second slow gh cannot contradict the first answer.
1185
+ let boardDecision = null;
1174
1186
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1175
1187
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1176
1188
  // por su primera mitad (boardGate, en la lib).
1177
- const { blocking, carried } = boardGate(sid, pend);
1189
+ boardDecision = boardGate(sid, pend);
1190
+ const { blocking, carried, reason } = boardDecision;
1178
1191
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1179
1192
  // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1180
1193
  // are already decided above (KJC-BUG-0230, 0198).
1194
+ // KJC-BUG-0363: when kj could not check, its reason travels with the block.
1181
1195
  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"));
1196
+ 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
1197
  process.exit(2);
1184
1198
  }
1185
1199
  }
@@ -1225,9 +1239,10 @@ process.stdin.on("end", () => {
1225
1239
  // ADR 0015 (KJC-TSK-0925): with no escape.
1226
1240
  // KJC-BUG-0262: a carried card does not block the push the commit let through.
1227
1241
  const sess = load().sessions?.[sid];
1228
- const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
1242
+ const decision = boardDecision || boardGate(sid, pendingMoves(sess));
1243
+ const v = violations(sess, branchOf(), decision.blocking);
1229
1244
  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"));
1245
+ 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
1246
  process.exit(2);
1232
1247
  }
1233
1248
  }
@@ -1,5 +1,5 @@
1
1
  import { buildRtkInstructions } from "./rtk-snippet.js";
2
- import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
+ import { roleSkills, buildRoleSkillSection } from "../skills/role-skills.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "./prompt-layout.js";
5
5
 
@@ -215,10 +215,10 @@ export async function buildCoderPromptLayout({ task, reviewerFeedback = null, so
215
215
 
216
216
  const stackRule = buildStackSection({ stack, testFramework });
217
217
  const rtkSnippet = buildRtkInstructions({ rtkAvailable });
218
+ // KJC-TSK-1065: the project's skills and the catalog's for this task, up to 3.
218
219
  let skillSection = null;
219
220
  if (projectDir) {
220
- const skills = await loadAvailableSkills(projectDir);
221
- skillSection = buildSkillSection(skills, { provider });
221
+ skillSection = buildRoleSkillSection(await roleSkills({ role: "coder", task, projectDir }), { provider }) || null;
222
222
  }
223
223
 
224
224
  // Volatile sections keep the legacy relative order — order matters:
@@ -1,5 +1,5 @@
1
1
  import { buildRtkInstructions } from "./rtk-snippet.js";
2
- import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
+ import { roleSkills, buildRoleSkillSection } from "../skills/role-skills.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "./prompt-layout.js";
5
5
  import { clipDiff } from "./diff-clip.js";
@@ -42,10 +42,10 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
42
42
 
43
43
  const langInstruction = getLanguageInstruction(language);
44
44
  const rtkSnippet = buildRtkInstructions({ rtkAvailable });
45
+ // KJC-TSK-1065: the reviewer gets the skills of its role and of the task, up to 3.
45
46
  let skillSection = null;
46
47
  if (projectDir) {
47
- const skills = await loadAvailableSkills(projectDir);
48
- skillSection = buildSkillSection(skills, { provider, role: "reviewer" });
48
+ skillSection = buildRoleSkillSection(await roleSkills({ role: "reviewer", task, projectDir }), { provider }) || null;
49
49
  }
50
50
 
51
51
  return buildPromptLayout([
@@ -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
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * KJC-TSK-1065: the skills kj picks for a role and a task reach the prompt.
3
+ * Before, the addyosmani catalog was resolved and only written to the journal.
4
+ * Order: the project's own skills (.agent/skills and friends), then the local
5
+ * catalog by TASK (a UI task gets frontend-ui-engineering) and by ROLE, up to
6
+ * MAX_SKILLS; the rest are named, never silently lost. The maintainer's
7
+ * choices (2026-10-17): local catalog + project skills, 3 per role.
8
+ */
9
+ import { loadAvailableSkills, filterSkillsForRole } from "./skill-loader.js";
10
+ import { listAvailableSlugs, loadSkillsBySlugs } from "./addyosmani-catalog.js";
11
+ import { resolveAddyosmaniSlugs } from "./addyosmani-role-map.js";
12
+
13
+ export const MAX_SKILLS = 3;
14
+
15
+ const defaultDeps = {
16
+ loadLocal: async (projectDir, role) => {
17
+ const all = projectDir ? await loadAvailableSkills(projectDir) : [];
18
+ return role === "coder" ? all : filterSkillsForRole(all, role);
19
+ },
20
+ listCatalog: () => listAvailableSlugs(),
21
+ loadCatalog: (slugs) => loadSkillsBySlugs(slugs),
22
+ };
23
+
24
+ /** @returns {Promise<{skills: Array<{name: string, content: string, path?: string, catalog?: true}>, dropped: string[]}>} */
25
+ export async function roleSkills({ role, task, projectDir, max = MAX_SKILLS, deps = defaultDeps }) {
26
+ const local = await deps.loadLocal(projectDir, role);
27
+ const available = new Set(await deps.listCatalog());
28
+ const ordered = [...new Set([...resolveAddyosmaniSlugs({ task }), ...resolveAddyosmaniSlugs({ role })])].filter((s) => available.has(s));
29
+ const room = Math.max(0, max - local.length);
30
+ const catalog = (await deps.loadCatalog(ordered.slice(0, room))).map((s) => ({ name: s.name, content: s.content, path: s.path, catalog: true }));
31
+ const dropped = [...local.slice(max).map((s) => s.name), ...ordered.slice(room)];
32
+ return { skills: [...local.slice(0, max), ...catalog], dropped };
33
+ }
34
+
35
+ /**
36
+ * The prompt section. For claude, the project's skills are native to its
37
+ * runtime, but a catalog skill is not: it is named with the path to read.
38
+ * Other providers get the content inline.
39
+ */
40
+ export function buildRoleSkillSection({ skills, dropped }, { provider } = {}) {
41
+ if (skills.length === 0) return "";
42
+ const lines = ["## Domain Skills", ""];
43
+ if (["claude", "anthropic"].includes(String(provider).toLowerCase())) {
44
+ const native = skills.filter((s) => !s.catalog).map((s) => s.name);
45
+ if (native.length) lines.push(`Skills available natively via your Agent Skills capability: ${native.join(", ")}.`);
46
+ const toRead = skills.filter((s) => s.catalog);
47
+ if (toRead.length) lines.push("Read these skills before you start, and follow them:", ...toRead.map((s) => `- ${s.name}: read ${s.path}`));
48
+ } else {
49
+ lines.push("The following domain-specific knowledge is available for this task:", ...skills.map((s) => `### ${s.name}\n${s.content}`));
50
+ }
51
+ if (dropped.length) lines.push("", `Left out (limit ${MAX_SKILLS}): ${dropped.join(", ")}`);
52
+ return lines.join("\n");
53
+ }