karajan-code 2.4.1 → 2.6.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.
Files changed (115) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +90 -7
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/plan.js +221 -37
  32. package/src/commands/resume.js +1 -1
  33. package/src/commands/run.js +2 -1
  34. package/src/commands/skills.js +49 -0
  35. package/src/config/schema.js +222 -0
  36. package/src/config.js +32 -3
  37. package/src/guards/policy-resolver.js +8 -0
  38. package/src/hu/store.js +9 -0
  39. package/src/infrastructure/command-runner.js +44 -0
  40. package/src/infrastructure/environment.js +38 -0
  41. package/src/infrastructure/file-system-service.js +63 -0
  42. package/src/infrastructure/mocks.js +182 -0
  43. package/src/mcp/handlers/management-handlers.js +7 -2
  44. package/src/mcp/tools.js +10 -3
  45. package/src/orchestrator/config-init.js +16 -1
  46. package/src/orchestrator/flow-runner.js +2085 -0
  47. package/src/orchestrator/pipeline-context.js +17 -0
  48. package/src/orchestrator/preflight-checks.js +78 -4
  49. package/src/orchestrator/solomon-escalation.js +21 -0
  50. package/src/orchestrator/stages/stage-executor.js +155 -0
  51. package/src/orchestrator.js +19 -1884
  52. package/src/plan/plan-executor.js +69 -0
  53. package/src/plan/plan-hu-ops.js +134 -0
  54. package/src/plan/plan-id.js +25 -0
  55. package/src/plan/plan-schema.js +139 -0
  56. package/src/plan/plan-store.js +58 -25
  57. package/src/prompts/architect.js +2 -2
  58. package/src/prompts/coder.js +2 -2
  59. package/src/prompts/prompt-resolver.js +127 -0
  60. package/src/prompts/reviewer.js +2 -2
  61. package/src/roles/architect-role.js +3 -1
  62. package/src/roles/base-role.js +23 -2
  63. package/src/roles/coder-role.js +3 -1
  64. package/src/roles/security-role.js +12 -0
  65. package/src/roles/tester-role.js +11 -0
  66. package/src/session/journal/decisions-writer.js +200 -0
  67. package/src/session/journal/iteration-logger.js +194 -0
  68. package/src/session/journal/summary-writer.js +176 -0
  69. package/src/session/journal/tree-writer.js +164 -0
  70. package/src/skills/semantic-detector.js +147 -0
  71. package/src/skills/skill-detector.js +186 -8
  72. package/src/skills/skill-loader.js +131 -1
  73. package/src/skills/skills-cache.js +128 -0
  74. package/src/types/agent.js +55 -0
  75. package/src/types/config.js +114 -0
  76. package/src/types/hu.js +47 -0
  77. package/src/types/index.js +34 -0
  78. package/src/types/policy.js +43 -0
  79. package/src/types/session.js +74 -0
  80. package/src/types/stage.js +68 -0
  81. package/src/utils/display/event-handlers.js +389 -0
  82. package/src/utils/display/formatters.js +132 -0
  83. package/src/utils/display/header.js +48 -0
  84. package/src/utils/display/session-summary.js +108 -0
  85. package/src/utils/display/solomon.js +42 -0
  86. package/src/utils/port-check.js +45 -0
  87. package/src/utils/port-occupant.js +95 -0
  88. package/src/utils/provider-env.js +116 -0
  89. package/src/utils/with-timeout.js +42 -0
  90. package/templates/roles/architect/anthropic.md +65 -0
  91. package/templates/roles/architect/default.md +63 -0
  92. package/templates/roles/architect/google.md +52 -0
  93. package/templates/roles/architect/openai.md +51 -0
  94. package/templates/roles/architect/opencode.md +50 -0
  95. package/templates/roles/coder/anthropic.md +72 -0
  96. package/templates/roles/coder/default.md +69 -0
  97. package/templates/roles/coder/google.md +78 -0
  98. package/templates/roles/coder/openai.md +59 -0
  99. package/templates/roles/coder/opencode.md +49 -0
  100. package/templates/roles/planner/anthropic.md +50 -0
  101. package/templates/roles/planner/default.md +48 -0
  102. package/templates/roles/planner/google.md +66 -0
  103. package/templates/roles/planner/openai.md +46 -0
  104. package/templates/roles/planner/opencode.md +53 -0
  105. package/templates/roles/reviewer/anthropic.md +61 -0
  106. package/templates/roles/reviewer/default.md +62 -0
  107. package/templates/roles/reviewer/google.md +63 -0
  108. package/templates/roles/reviewer/openai.md +57 -0
  109. package/templates/roles/reviewer/opencode.md +42 -0
  110. package/templates/roles/solomon/anthropic.md +71 -0
  111. package/templates/roles/solomon/default.md +83 -0
  112. package/templates/roles/solomon/google.md +61 -0
  113. package/templates/roles/solomon/openai.md +53 -0
  114. package/templates/roles/solomon/opencode.md +55 -0
  115. package/src/utils/display.js +0 -669
@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { loadAvailableSkills } from "./skill-loader.js";
9
9
  import { isOpenSkillsAvailable, installSkill, removeSkill } from "./openskills-client.js";
10
+ import { isFresh as isCacheFresh, recordInstall as recordSkillInstall } from "./skills-cache.js";
10
11
 
11
12
  /**
12
13
  * Known frameworks detectable from package.json dependencies.
@@ -23,6 +24,39 @@ const PKG_JSON_FRAMEWORKS = {
23
24
  express: "express",
24
25
  fastify: "fastify",
25
26
  "@nestjs/core": "nestjs",
27
+ // DB / ORM
28
+ prisma: "prisma",
29
+ "@prisma/client": "prisma",
30
+ knex: "sql-analysis",
31
+ sequelize: "sql-analysis",
32
+ typeorm: "sql-analysis",
33
+ mongoose: "mongodb",
34
+ "better-sqlite3": "sql-analysis",
35
+ // Testing
36
+ vitest: "vitest-patterns",
37
+ jest: "jest-patterns",
38
+ "@playwright/test": "playwright-patterns",
39
+ cypress: "cypress-patterns",
40
+ };
41
+
42
+ /**
43
+ * Python dependency names (from requirements.txt or pyproject.toml) mapping
44
+ * to their canonical skill.
45
+ */
46
+ const PYTHON_DEPS = {
47
+ pandas: "python-data",
48
+ numpy: "python-data",
49
+ scipy: "python-data",
50
+ "scikit-learn": "python-ml",
51
+ sklearn: "python-ml",
52
+ tensorflow: "python-ml",
53
+ torch: "python-ml",
54
+ pytorch: "python-ml",
55
+ pytest: "pytest-patterns",
56
+ django: "python-django",
57
+ flask: "python-flask",
58
+ fastapi: "python-fastapi",
59
+ sqlalchemy: "sql-analysis",
26
60
  };
27
61
 
28
62
  /**
@@ -36,14 +70,44 @@ const LANGUAGE_FILE_MARKERS = [
36
70
  { file: "go.mod", skill: "go" },
37
71
  { file: "Cargo.toml", skill: "rust" },
38
72
  { file: "pyproject.toml", skill: "python" },
73
+ { file: "requirements.txt", skill: "python" },
39
74
  { file: "setup.py", skill: "python" },
40
75
  { file: "Gemfile", skill: "ruby" },
41
76
  { file: "pubspec.yaml", skill: "flutter" },
42
77
  { file: "Package.swift", skill: "swift" },
43
78
  { file: "phpunit.xml", skill: "php" },
44
79
  { file: "composer.json", skill: "php" },
80
+ { file: "mix.exs", skill: "elixir" },
81
+ { file: "rebar.config", skill: "erlang" },
82
+ ];
83
+
84
+ /**
85
+ * File EXTENSIONS whose presence anywhere under projectDir adds a skill.
86
+ * Uses a shallow-recursive glob with a cutoff to avoid walking node_modules.
87
+ */
88
+ const EXTENSION_MARKERS = [
89
+ { ext: ".csproj", skill: "dotnet" },
90
+ { ext: ".sln", skill: "dotnet" },
91
+ { ext: ".ipynb", skill: "python-data" },
92
+ { ext: ".prisma", skill: "prisma" },
93
+ ];
94
+
95
+ /**
96
+ * Files detected as "SQL / database schema" when they exist at repo root.
97
+ * Used in addition to EXTENSION_MARKERS for *.sql anywhere in the repo.
98
+ */
99
+ const SQL_MARKER_FILES = [
100
+ "schema.sql",
101
+ "schema.prisma",
102
+ "migrations",
103
+ "db/migrate",
45
104
  ];
46
105
 
106
+ const DIRECTORIES_TO_SKIP = new Set([
107
+ "node_modules", ".git", ".kj", ".karajan", "dist", "build", ".next", "out",
108
+ "target", ".venv", "venv", "__pycache__", ".vitest-cache", "coverage",
109
+ ]);
110
+
47
111
  /**
48
112
  * Patterns to detect framework/skill mentions in the task text.
49
113
  * Each entry: regex pattern (case-insensitive) -> skill name.
@@ -91,14 +155,21 @@ const TASK_TEXT_PATTERNS = [
91
155
 
92
156
  /**
93
157
  * Analyze the task and project to determine what skills might be needed.
158
+ *
94
159
  * @param {string} task - The task description text.
95
160
  * @param {string} projectDir - Absolute path to project root.
161
+ * @param {{ includeExtensions?: boolean, maxDepth?: number }} [options]
162
+ * - includeExtensions: scan for file extensions (.csproj, .ipynb, .sql...)
163
+ * that signal a stack. Default true. Disable to avoid the shallow walk
164
+ * in very large repos or in tests.
165
+ * - maxDepth: bound the walk (default 3 directories deep).
96
166
  * @returns {Promise<string[]>} Array of unique skill names to search for.
97
167
  */
98
- export async function detectNeededSkills(task, projectDir) {
168
+ export async function detectNeededSkills(task, projectDir, options = {}) {
169
+ const { includeExtensions = true, maxDepth = 3 } = options;
99
170
  const needed = new Set();
100
171
 
101
- // 1. Scan package.json for known frameworks
172
+ // 1. Scan package.json for known frameworks and JS-ecosystem deps
102
173
  if (projectDir) {
103
174
  try {
104
175
  const pkgRaw = await fs.readFile(path.join(projectDir, "package.json"), "utf8");
@@ -111,16 +182,32 @@ export async function detectNeededSkills(task, projectDir) {
111
182
  }
112
183
  } catch { /* no package.json or parse error — skip */ }
113
184
 
114
- // 2. Check for language markers
185
+ // 2. Language marker files at repo root
115
186
  for (const marker of LANGUAGE_FILE_MARKERS) {
116
187
  try {
117
188
  await fs.access(path.join(projectDir, marker.file));
118
189
  needed.add(marker.skill);
119
190
  } catch { /* file not found — skip */ }
120
191
  }
192
+
193
+ // 3. Python deps from requirements.txt / pyproject.toml
194
+ await collectPythonDeps(projectDir, needed);
195
+
196
+ // 4. File extension markers (.csproj, .ipynb, .prisma, *.sql)
197
+ if (includeExtensions) {
198
+ await collectExtensionMarkers(projectDir, maxDepth, needed);
199
+ }
200
+
201
+ // 5. SQL / DB schema markers at well-known paths
202
+ for (const candidate of SQL_MARKER_FILES) {
203
+ try {
204
+ await fs.access(path.join(projectDir, candidate));
205
+ needed.add("sql-analysis");
206
+ } catch { /* skip */ }
207
+ }
121
208
  }
122
209
 
123
- // 3. Check task text for framework mentions
210
+ // 6. Task text patterns
124
211
  if (task) {
125
212
  for (const { pattern, skill } of TASK_TEXT_PATTERNS) {
126
213
  if (pattern.test(task)) {
@@ -132,14 +219,91 @@ export async function detectNeededSkills(task, projectDir) {
132
219
  return Array.from(needed);
133
220
  }
134
221
 
222
+ async function collectPythonDeps(projectDir, needed) {
223
+ // requirements.txt — each non-comment line is "name==ver" or "name>=ver"
224
+ try {
225
+ const raw = await fs.readFile(path.join(projectDir, "requirements.txt"), "utf8");
226
+ for (const line of raw.split("\n")) {
227
+ const trimmed = line.trim().split("#")[0].trim();
228
+ if (!trimmed) continue;
229
+ const name = trimmed.split(/[<>=!~\[\s]/)[0].toLowerCase();
230
+ const skill = PYTHON_DEPS[name];
231
+ if (skill) needed.add(skill);
232
+ }
233
+ } catch { /* no requirements.txt */ }
234
+
235
+ // pyproject.toml — simple heuristic scan for dependency names. A full TOML
236
+ // parse would need a dep; we just look for quoted names, which is fine.
237
+ try {
238
+ const raw = await fs.readFile(path.join(projectDir, "pyproject.toml"), "utf8");
239
+ const lower = raw.toLowerCase();
240
+ for (const [name, skill] of Object.entries(PYTHON_DEPS)) {
241
+ if (lower.includes(`"${name}"`) || lower.includes(`'${name}'`)) {
242
+ needed.add(skill);
243
+ }
244
+ }
245
+ } catch { /* no pyproject.toml */ }
246
+ }
247
+
248
+ async function collectExtensionMarkers(projectDir, maxDepth, needed) {
249
+ // Build a lookup by extension for fast decisions.
250
+ const byExt = new Map(EXTENSION_MARKERS.map((m) => [m.ext, m.skill]));
251
+ const extensions = new Set(byExt.keys());
252
+ extensions.add(".sql");
253
+ let sqlFound = false;
254
+
255
+ async function walk(dir, depth) {
256
+ if (depth > maxDepth) return;
257
+ let entries;
258
+ try {
259
+ entries = await fs.readdir(dir, { withFileTypes: true });
260
+ } catch {
261
+ return;
262
+ }
263
+ for (const entry of entries) {
264
+ if (entry.isDirectory()) {
265
+ if (DIRECTORIES_TO_SKIP.has(entry.name)) continue;
266
+ if (entry.name.startsWith(".")) continue;
267
+ await walk(path.join(dir, entry.name), depth + 1);
268
+ continue;
269
+ }
270
+ if (!entry.isFile()) continue;
271
+ const ext = path.extname(entry.name).toLowerCase();
272
+ if (!extensions.has(ext)) continue;
273
+ if (ext === ".sql") {
274
+ sqlFound = true;
275
+ continue;
276
+ }
277
+ const skill = byExt.get(ext);
278
+ if (skill) needed.add(skill);
279
+ }
280
+ }
281
+
282
+ await walk(projectDir, 0);
283
+ if (sqlFound) needed.add("sql-analysis");
284
+ }
285
+
135
286
  /**
136
287
  * Auto-install needed skills that are not yet installed.
288
+ *
289
+ * When the `openskills` CLI is not available, this function degrades
290
+ * gracefully: it returns `osAvailable: false` and `wouldHaveUsed` with the
291
+ * list of skills that would have been installed if the CLI had been present.
292
+ * Callers (e.g. the orchestrator) can surface this info in the session
293
+ * report without blocking execution.
294
+ *
137
295
  * @param {string[]} neededSkills - Skill names to ensure are installed.
138
296
  * @param {string} projectDir - Absolute path to project root.
139
- * @returns {Promise<{installed: string[], failed: string[], alreadyInstalled: string[]}>}
297
+ * @returns {Promise<{
298
+ * installed: string[],
299
+ * failed: string[],
300
+ * alreadyInstalled: string[],
301
+ * wouldHaveUsed: string[],
302
+ * osAvailable: boolean
303
+ * }>}
140
304
  */
141
305
  export async function autoInstallSkills(neededSkills, projectDir) {
142
- const result = { installed: [], failed: [], alreadyInstalled: [] };
306
+ const result = { installed: [], failed: [], alreadyInstalled: [], wouldHaveUsed: [], osAvailable: true };
143
307
 
144
308
  if (!neededSkills || neededSkills.length === 0) {
145
309
  return result;
@@ -152,7 +316,10 @@ export async function autoInstallSkills(neededSkills, projectDir) {
152
316
  // Check if OpenSkills is available
153
317
  const osAvailable = await isOpenSkillsAvailable();
154
318
  if (!osAvailable) {
155
- // All skills count as "failed" silently — caller should check osAvailable separately
319
+ result.osAvailable = false;
320
+ // Report what we would have installed so the session report can surface
321
+ // the recommendation — "install openskills globally to enable: X, Y, Z".
322
+ result.wouldHaveUsed = neededSkills.filter((s) => !existingNames.has(s.toLowerCase()));
156
323
  return result;
157
324
  }
158
325
 
@@ -162,10 +329,21 @@ export async function autoInstallSkills(neededSkills, projectDir) {
162
329
  continue;
163
330
  }
164
331
 
332
+ // Cache short-circuit: if we installed this skill in the last 7 days,
333
+ // assume it's still good and skip the `npx openskills install` call
334
+ // (which can take 10-30s). Running sessions can opt out by clearing
335
+ // the cache with `kj skills clear-cache`.
336
+ if (await isCacheFresh(skillName)) {
337
+ result.alreadyInstalled.push(skillName);
338
+ continue;
339
+ }
340
+
165
341
  try {
166
342
  const installResult = await installSkill(skillName, { projectDir });
167
343
  if (installResult.ok) {
168
- result.installed.push(installResult.name || skillName);
344
+ const installedName = installResult.name || skillName;
345
+ result.installed.push(installedName);
346
+ await recordSkillInstall(installedName, { source: skillName }).catch(() => {});
169
347
  } else {
170
348
  result.failed.push(skillName);
171
349
  }
@@ -69,12 +69,122 @@ export async function loadAvailableSkills(projectDir, options = {}) {
69
69
 
70
70
  /**
71
71
  * Builds a prompt section from loaded skills.
72
+ *
73
+ * For Anthropic (Claude Sonnet 4.5+), skills are delivered as Agent Skills
74
+ * natively (loaded by the runtime from SKILL.md) — duplicating their full
75
+ * content in the prompt wastes tokens and risks drifting from the canonical
76
+ * source. When `provider === "anthropic"`, this function emits a lightweight
77
+ * reference list; Claude will load the SKILL.md files on demand.
78
+ *
79
+ * For every other provider, the full SKILL.md body is inlined (previous
80
+ * behaviour preserved).
81
+ *
82
+ * When `role` is supplied, skills are filtered to those relevant to the role
83
+ * (e.g. a reviewer only sees `*-code-review`, `*-security-*`, and general
84
+ * patterns; see ROLE_SKILL_PATTERNS). Setting role to null/undefined keeps
85
+ * the legacy behavior of injecting every skill (useful for the coder role,
86
+ * which benefits from broad context).
87
+ *
72
88
  * @param {Array<{name: string, content: string}>} skills
89
+ * @param {{ provider?: string, role?: string }} [options]
73
90
  * @returns {string} prompt section or empty string
74
91
  */
75
- export function buildSkillSection(skills) {
92
+ export function buildSkillSection(skills, options = {}) {
76
93
  if (!skills || skills.length === 0) return "";
77
94
 
95
+ const filtered = options.role ? filterSkillsForRole(skills, options.role) : skills;
96
+ if (filtered.length === 0) return "";
97
+
98
+ const providerSlug = normalize(options.provider);
99
+
100
+ if (providerSlug === "anthropic") {
101
+ return buildReferenceSection(filtered);
102
+ }
103
+
104
+ return buildInlineSection(filtered);
105
+ }
106
+
107
+ /**
108
+ * Role → substring patterns that skills must match to be included. A skill
109
+ * matches the role if its name contains ANY of the role's patterns OR if it
110
+ * appears in the role's explicit allowlist. Coder is omitted on purpose: it
111
+ * receives everything.
112
+ *
113
+ * The patterns target the skill NAMING convention shipped by openskills.cc
114
+ * where skills are typically named like `react-patterns`, `java-code-review`,
115
+ * `pytest-patterns`, `owasp-top-10`, etc.
116
+ */
117
+ /**
118
+ * Role → filter config.
119
+ *
120
+ * Two modes:
121
+ * - `includes` present: skill name must contain ANY include AND NOT contain
122
+ * any exclude. `allow` bypasses both checks (explicit allowlist by name).
123
+ * - `includes` absent (or empty) + `excludes` present: skill passes unless
124
+ * its name contains an exclude pattern. This is the "accept broad, trim
125
+ * obvious mismatches" mode used by architect.
126
+ */
127
+ const ROLE_SKILL_PATTERNS = {
128
+ reviewer: {
129
+ includes: ["code-review", "security", "lint", "style", "antipatterns", "owasp"],
130
+ excludes: [],
131
+ allow: ["sql-analysis"],
132
+ },
133
+ architect: {
134
+ // Architect sees everything except test-framework patterns. Architecture
135
+ // decisions touch every domain, so keep the filter permissive.
136
+ includes: null,
137
+ excludes: ["pytest", "vitest", "jest", "playwright", "cypress"],
138
+ allow: [],
139
+ },
140
+ tester: {
141
+ includes: ["test", "pytest", "vitest", "jest", "playwright", "cypress"],
142
+ excludes: [],
143
+ allow: [],
144
+ },
145
+ security: {
146
+ includes: ["security", "owasp", "sast"],
147
+ excludes: [],
148
+ allow: [],
149
+ },
150
+ planner: {
151
+ // Planner gets broad stack context except test frameworks.
152
+ includes: null,
153
+ excludes: ["pytest", "vitest", "jest", "playwright", "cypress"],
154
+ allow: [],
155
+ },
156
+ solomon: {
157
+ includes: ["security", "code-review"],
158
+ excludes: [],
159
+ allow: [],
160
+ },
161
+ };
162
+
163
+ /**
164
+ * Filter skills for a given role using the ROLE_SKILL_PATTERNS map. Unknown
165
+ * roles get ALL skills (conservative default for the coder role and any
166
+ * future addition). Matching is case-insensitive substring.
167
+ *
168
+ * @param {Array<{name: string}>} skills
169
+ * @param {string} role
170
+ * @returns {Array} filtered array
171
+ */
172
+ export function filterSkillsForRole(skills, role) {
173
+ const cfg = ROLE_SKILL_PATTERNS[role];
174
+ if (!cfg) return skills; // unknown role (e.g. coder) → no filter
175
+ const includes = Array.isArray(cfg.includes) ? cfg.includes.map((s) => s.toLowerCase()) : null;
176
+ const excludes = (cfg.excludes || []).map((s) => s.toLowerCase());
177
+ const allow = new Set((cfg.allow || []).map((s) => s.toLowerCase()));
178
+ return skills.filter((s) => {
179
+ const name = (s.name || "").toLowerCase();
180
+ if (allow.has(name)) return true;
181
+ if (excludes.some((pat) => name.includes(pat))) return false;
182
+ if (!includes || includes.length === 0) return true; // exclude-only mode
183
+ return includes.some((pat) => name.includes(pat));
184
+ });
185
+ }
186
+
187
+ function buildInlineSection(skills) {
78
188
  const header = [
79
189
  "## Domain Skills",
80
190
  "",
@@ -87,3 +197,23 @@ export function buildSkillSection(skills) {
87
197
 
88
198
  return [header, ...blocks].join("\n\n");
89
199
  }
200
+
201
+ function buildReferenceSection(skills) {
202
+ const names = skills.map((s) => s.name).join(", ");
203
+ return [
204
+ "## Domain Skills",
205
+ "",
206
+ `Skills available natively via your Agent Skills capability: ${names}.`,
207
+ "Each skill's SKILL.md is already loaded in your runtime; invoke its guidance as needed without re-reading the full content."
208
+ ].join("\n");
209
+ }
210
+
211
+ function normalize(value) {
212
+ if (!value) return null;
213
+ const lc = String(value).toLowerCase();
214
+ if (lc === "claude" || lc === "anthropic") return "anthropic";
215
+ if (lc === "codex" || lc === "openai") return "openai";
216
+ if (lc === "gemini" || lc === "google") return "google";
217
+ if (lc === "opencode") return "opencode";
218
+ return lc;
219
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Local cache for installed OpenSkills, backed by `~/.karajan/skills-cache/`.
3
+ *
4
+ * The cache records WHEN each skill was last installed so we can skip
5
+ * redundant `npx openskills install X` calls during the 7-day TTL window
6
+ * (each call can take 10-30s even when the skill is already present).
7
+ *
8
+ * This is intentionally simple: one directory per skill name with a
9
+ * `meta.json` describing the last install time. Skill content is NOT copied
10
+ * to the cache — OpenSkills owns the real content on disk; we only track
11
+ * freshness.
12
+ */
13
+
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+ import { getKarajanHome } from "../utils/paths.js";
17
+
18
+ const CACHE_DIR_NAME = "skills-cache";
19
+ const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
20
+
21
+ export function getCacheRoot() {
22
+ return path.join(getKarajanHome(), CACHE_DIR_NAME);
23
+ }
24
+
25
+ function sanitize(name) {
26
+ // Allow safe skill name characters, replace everything else with "_".
27
+ return String(name || "").replace(/[^A-Za-z0-9._-]/g, "_");
28
+ }
29
+
30
+ function metaPath(name) {
31
+ return path.join(getCacheRoot(), sanitize(name), "meta.json");
32
+ }
33
+
34
+ /**
35
+ * Read cached metadata for a skill. Returns null if missing or unreadable.
36
+ * @param {string} name
37
+ * @returns {Promise<{cachedAt: string, source?: string}|null>}
38
+ */
39
+ export async function getCachedMeta(name) {
40
+ if (!name) return null;
41
+ try {
42
+ const raw = await fs.readFile(metaPath(name), "utf8");
43
+ const parsed = JSON.parse(raw);
44
+ if (typeof parsed?.cachedAt !== "string") return null;
45
+ return parsed;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * True iff the skill was cached more recently than `ttlMs` ago.
53
+ * @param {string} name
54
+ * @param {number} [ttlMs=DEFAULT_TTL_MS]
55
+ * @returns {Promise<boolean>}
56
+ */
57
+ export async function isFresh(name, ttlMs = DEFAULT_TTL_MS) {
58
+ const meta = await getCachedMeta(name);
59
+ if (!meta) return false;
60
+ const cachedTime = Date.parse(meta.cachedAt);
61
+ if (Number.isNaN(cachedTime)) return false;
62
+ return Date.now() - cachedTime < ttlMs;
63
+ }
64
+
65
+ /**
66
+ * Record that a skill was just (re-)installed. Creates the cache subdir if
67
+ * needed.
68
+ * @param {string} name
69
+ * @param {{ source?: string }} [opts]
70
+ * @returns {Promise<void>}
71
+ */
72
+ export async function recordInstall(name, opts = {}) {
73
+ if (!name) return;
74
+ const dir = path.join(getCacheRoot(), sanitize(name));
75
+ await fs.mkdir(dir, { recursive: true });
76
+ const meta = {
77
+ name,
78
+ cachedAt: new Date().toISOString(),
79
+ ...(opts.source ? { source: opts.source } : {}),
80
+ };
81
+ await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2), "utf8");
82
+ }
83
+
84
+ /**
85
+ * Remove all cached skill metadata (does not touch real installed skills,
86
+ * only clears the freshness tracker).
87
+ * @returns {Promise<{removed: number}>}
88
+ */
89
+ export async function clearCache() {
90
+ const root = getCacheRoot();
91
+ let removed = 0;
92
+ try {
93
+ const entries = await fs.readdir(root, { withFileTypes: true });
94
+ for (const entry of entries) {
95
+ if (!entry.isDirectory()) continue;
96
+ await fs.rm(path.join(root, entry.name), { recursive: true, force: true });
97
+ removed++;
98
+ }
99
+ } catch { /* cache dir does not exist — nothing to clear */ }
100
+ return { removed };
101
+ }
102
+
103
+ /**
104
+ * List cached skill names with their cachedAt timestamp, sorted oldest first.
105
+ * @returns {Promise<Array<{name: string, cachedAt: string, fresh: boolean}>>}
106
+ */
107
+ export async function listCached(ttlMs = DEFAULT_TTL_MS) {
108
+ const root = getCacheRoot();
109
+ const out = [];
110
+ let entries;
111
+ try {
112
+ entries = await fs.readdir(root, { withFileTypes: true });
113
+ } catch {
114
+ return [];
115
+ }
116
+ for (const entry of entries) {
117
+ if (!entry.isDirectory()) continue;
118
+ const meta = await getCachedMeta(entry.name);
119
+ if (!meta) continue;
120
+ const cachedTime = Date.parse(meta.cachedAt);
121
+ const fresh = !Number.isNaN(cachedTime) && (Date.now() - cachedTime < ttlMs);
122
+ out.push({ name: meta.name || entry.name, cachedAt: meta.cachedAt, fresh });
123
+ }
124
+ out.sort((a, b) => a.cachedAt.localeCompare(b.cachedAt));
125
+ return out;
126
+ }
127
+
128
+ export { DEFAULT_TTL_MS };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Agent result / response typedefs. Every provider agent (claude, codex,
3
+ * gemini, opencode, aider) returns a shape that's a superset of AgentResult.
4
+ *
5
+ * AgentResponse<T> is the generic wrapper used when a caller has parsed
6
+ * the raw output into a structured payload of type T.
7
+ */
8
+
9
+ /**
10
+ * Raw agent result as returned by agent.runTask() / agent.reviewTask().
11
+ *
12
+ * @typedef {Object} AgentResult
13
+ * @property {boolean} ok - whether the call succeeded
14
+ * @property {string} [output] - raw stdout / response text
15
+ * @property {string} [summary] - short human summary
16
+ * @property {Object} [result] - structured result (stage-specific)
17
+ * @property {string} [error]
18
+ * @property {string} [stderr]
19
+ * @property {string} [stdout]
20
+ * @property {number} [exitCode]
21
+ * @property {AgentUsage} [usage]
22
+ * @property {string} [provider]
23
+ * @property {string} [model]
24
+ */
25
+
26
+ /**
27
+ * Token/cost reporting shape. Fields optional because not all providers
28
+ * report all metrics.
29
+ *
30
+ * @typedef {Object} AgentUsage
31
+ * @property {number} [input_tokens]
32
+ * @property {number} [output_tokens]
33
+ * @property {number} [cached_tokens]
34
+ * @property {number} [total_tokens]
35
+ * @property {number} [cost_usd]
36
+ */
37
+
38
+ /**
39
+ * Generic agent response — AgentResult whose `result` field is typed.
40
+ *
41
+ * Example:
42
+ * /** @type {import("../types/agent.js").AgentResponse<import("./review-result.js").ReviewResult>} *\/
43
+ *
44
+ * @template T
45
+ * @typedef {Object} AgentResponse
46
+ * @property {boolean} ok
47
+ * @property {T} [result]
48
+ * @property {string} [output]
49
+ * @property {string} [summary]
50
+ * @property {string} [error]
51
+ * @property {AgentUsage} [usage]
52
+ * @property {string} [provider]
53
+ */
54
+
55
+ export {};