karajan-code 4.53.1 → 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.1",
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`);
@@ -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))) {
@@ -812,7 +816,9 @@ process.stdin.on("end", () => {
812
816
  // it need no verifying when nothing after it writes. One mutating segment
813
817
  // and the whole command is judged below, as before.
814
818
  const CD_LITERAL = /^[ \\t]*(cd|pushd)([ \\t]+[^ \\t;|&<>$\`(){}\\n\\r]*)?[ \\t]*$/;
815
- 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, "")));
816
822
  // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
817
823
  if (!readsOnly(stripInertQuotes(cmd))) {
818
824
  // cd/pushd invalida TODO razonamiento textual de rutas posteriores
@@ -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([
@@ -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
+ }