karajan-code 2.5.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -4
- package/package.json +5 -2
- package/src/agents/aider-agent.js +1 -2
- package/src/agents/base-agent.js +63 -1
- package/src/agents/claude-agent.js +3 -4
- package/src/agents/codex-agent.js +1 -2
- package/src/agents/gemini-agent.js +1 -2
- package/src/agents/index.js +11 -2
- package/src/agents/opencode-agent.js +1 -2
- package/src/brain/decision-tracker.js +123 -0
- package/src/brain/decisor.js +243 -0
- package/src/brain/solomon-consult.js +171 -0
- package/src/budget/comparison.js +96 -0
- package/src/checks/binaries.js +123 -0
- package/src/checks/ci.js +105 -0
- package/src/checks/config-files.js +217 -0
- package/src/checks/dir-setup.js +63 -0
- package/src/checks/mcp-health.js +157 -0
- package/src/checks/node.js +61 -0
- package/src/checks/ports.js +110 -0
- package/src/checks/rtk.js +36 -0
- package/src/checks/runner.js +267 -0
- package/src/checks/skills.js +49 -0
- package/src/checks/sonar.js +67 -0
- package/src/checks/system.js +53 -0
- package/src/checks/tokens.js +96 -0
- package/src/checks/types.js +105 -0
- package/src/cli.js +29 -3
- package/src/commands/board.js +23 -0
- package/src/commands/doctor.js +130 -362
- package/src/commands/resume.js +1 -1
- package/src/commands/run.js +2 -1
- package/src/commands/skills.js +49 -0
- package/src/config/schema.js +222 -0
- package/src/config.js +32 -3
- package/src/guards/policy-resolver.js +8 -0
- package/src/hu/store.js +9 -0
- package/src/infrastructure/command-runner.js +44 -0
- package/src/infrastructure/environment.js +38 -0
- package/src/infrastructure/file-system-service.js +63 -0
- package/src/infrastructure/mocks.js +182 -0
- package/src/mcp/handlers/management-handlers.js +7 -2
- package/src/mcp/tools.js +10 -3
- package/src/orchestrator/config-init.js +16 -1
- package/src/orchestrator/flow-runner.js +2085 -0
- package/src/orchestrator/pipeline-context.js +17 -0
- package/src/orchestrator/preflight-checks.js +78 -4
- package/src/orchestrator/solomon-escalation.js +55 -0
- package/src/orchestrator/stages/stage-executor.js +155 -0
- package/src/orchestrator.js +19 -1923
- package/src/prompts/architect.js +2 -2
- package/src/prompts/coder.js +2 -2
- package/src/prompts/prompt-resolver.js +127 -0
- package/src/prompts/reviewer.js +2 -2
- package/src/roles/agent-role.js +9 -0
- package/src/roles/architect-role.js +3 -1
- package/src/roles/base-role.js +40 -2
- package/src/roles/coder-role.js +3 -1
- package/src/roles/security-role.js +12 -0
- package/src/roles/tester-role.js +11 -0
- package/src/session/journal/decisions-writer.js +200 -0
- package/src/session/journal/iteration-logger.js +194 -0
- package/src/session/journal/summary-writer.js +176 -0
- package/src/session/journal/tree-writer.js +164 -0
- package/src/skills/semantic-detector.js +147 -0
- package/src/skills/skill-detector.js +186 -8
- package/src/skills/skill-loader.js +131 -1
- package/src/skills/skills-cache.js +128 -0
- package/src/types/agent.js +55 -0
- package/src/types/config.js +114 -0
- package/src/types/hu.js +47 -0
- package/src/types/index.js +38 -0
- package/src/types/policy.js +43 -0
- package/src/types/session.js +74 -0
- package/src/types/solomon.js +87 -0
- package/src/types/stage.js +68 -0
- package/src/utils/display/event-handlers.js +389 -0
- package/src/utils/display/formatters.js +132 -0
- package/src/utils/display/header.js +48 -0
- package/src/utils/display/session-summary.js +108 -0
- package/src/utils/display/solomon.js +42 -0
- package/src/utils/port-check.js +45 -0
- package/src/utils/port-occupant.js +95 -0
- package/src/utils/provider-env.js +116 -0
- package/src/utils/with-timeout.js +42 -0
- package/templates/roles/architect/anthropic.md +65 -0
- package/templates/roles/architect/default.md +63 -0
- package/templates/roles/architect/google.md +52 -0
- package/templates/roles/architect/openai.md +51 -0
- package/templates/roles/architect/opencode.md +50 -0
- package/templates/roles/coder/anthropic.md +72 -0
- package/templates/roles/coder/default.md +69 -0
- package/templates/roles/coder/google.md +78 -0
- package/templates/roles/coder/openai.md +59 -0
- package/templates/roles/coder/opencode.md +49 -0
- package/templates/roles/planner/anthropic.md +50 -0
- package/templates/roles/planner/default.md +48 -0
- package/templates/roles/planner/google.md +66 -0
- package/templates/roles/planner/openai.md +46 -0
- package/templates/roles/planner/opencode.md +53 -0
- package/templates/roles/reviewer/anthropic.md +61 -0
- package/templates/roles/reviewer/default.md +62 -0
- package/templates/roles/reviewer/google.md +63 -0
- package/templates/roles/reviewer/openai.md +57 -0
- package/templates/roles/reviewer/opencode.md +42 -0
- package/templates/roles/solomon/anthropic.md +71 -0
- package/templates/roles/solomon/default.md +83 -0
- package/templates/roles/solomon/google.md +61 -0
- package/templates/roles/solomon/openai.md +53 -0
- package/templates/roles/solomon/opencode.md +55 -0
- 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.
|
|
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
|
-
//
|
|
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<{
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|