karajan-code 1.40.0 → 1.42.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": "1.40.0",
3
+ "version": "1.42.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",
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFile, execFileSync } from "node:child_process";
4
+ import { getKarajanHome } from "../utils/paths.js";
5
+
6
+ const SOURCE_EXTENSIONS = new Set([
7
+ ".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".tsx", ".jsx",
8
+ ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".cpp", ".h",
9
+ ".vue", ".svelte", ".astro", ".php", ".cs", ".sh"
10
+ ]);
11
+
12
+ const EXCLUDE_DIRS = new Set([
13
+ "node_modules", ".git", "dist", "build", "coverage", ".next",
14
+ ".nuxt", ".output", "__pycache__", ".cache", ".parcel-cache"
15
+ ]);
16
+
17
+ function slugify(projectDir) {
18
+ return path.basename(projectDir).replace(/[^a-zA-Z0-9_-]/g, "_");
19
+ }
20
+
21
+ function walkSourceFiles(dir) {
22
+ // Use git ls-files for speed (instant vs recursive walk)
23
+ try {
24
+ const output = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: dir, encoding: "utf8", timeout: 5000 });
25
+ return output.trim().split("\n")
26
+ .filter(f => f && SOURCE_EXTENSIONS.has(path.extname(f)) && !EXCLUDE_DIRS.has(f.split("/")[0]))
27
+ .map(f => path.join(dir, f));
28
+ } catch {
29
+ // Fallback to recursive walk if not a git repo
30
+ const files = [];
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(dir, { withFileTypes: true });
34
+ } catch {
35
+ return files;
36
+ }
37
+ for (const entry of entries) {
38
+ if (EXCLUDE_DIRS.has(entry.name)) continue;
39
+ const full = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ files.push(...walkSourceFiles(full));
42
+ } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
43
+ files.push(full);
44
+ }
45
+ }
46
+ return files;
47
+ }
48
+ }
49
+
50
+ function countLines(filePath) {
51
+ try {
52
+ const content = fs.readFileSync(filePath, "utf8");
53
+ return content.split("\n").length;
54
+ } catch {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ function countDependencies(projectDir) {
60
+ const pkgPath = path.join(projectDir, "package.json");
61
+ try {
62
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
63
+ const deps = Object.keys(pkg.dependencies || {});
64
+ const devDeps = Object.keys(pkg.devDependencies || {});
65
+ return { dependencies: deps.length, devDependencies: devDeps.length, total: deps.length + devDeps.length };
66
+ } catch {
67
+ return { dependencies: 0, devDependencies: 0, total: 0 };
68
+ }
69
+ }
70
+
71
+ function runDepcheck(projectDir) {
72
+ return new Promise((resolve) => {
73
+ execFile("npx", ["depcheck", "--json"], { cwd: projectDir, timeout: 30000 }, (err, stdout) => {
74
+ if (err && !stdout) {
75
+ resolve({ unused: [], note: "depcheck not available or failed" });
76
+ return;
77
+ }
78
+ try {
79
+ const result = JSON.parse(stdout);
80
+ const unused = [
81
+ ...(result.dependencies || []),
82
+ ...(result.devDependencies || [])
83
+ ];
84
+ resolve({ unused });
85
+ } catch {
86
+ resolve({ unused: [], note: "depcheck output not parseable" });
87
+ }
88
+ });
89
+ });
90
+ }
91
+
92
+ function findDeadExports(sourceFiles) {
93
+ const exportMap = new Map(); // file -> [exportName]
94
+ const importedNames = new Set();
95
+
96
+ const exportRe = /export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
97
+ const namedExportRe = /export\s*\{([^}]+)\}/g;
98
+ const importRe = /import\s+\{([^}]+)\}\s+from/g;
99
+ const defaultImportRe = /import\s+(\w+)\s+from/g;
100
+
101
+ for (const file of sourceFiles) {
102
+ let content;
103
+ try {
104
+ content = fs.readFileSync(file, "utf8");
105
+ } catch {
106
+ continue;
107
+ }
108
+
109
+ const exports = [];
110
+ for (const match of content.matchAll(exportRe)) {
111
+ exports.push(match[1]);
112
+ }
113
+ for (const match of content.matchAll(namedExportRe)) {
114
+ const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/).pop().trim());
115
+ exports.push(...names);
116
+ }
117
+ if (exports.length > 0) {
118
+ exportMap.set(file, exports);
119
+ }
120
+
121
+ for (const match of content.matchAll(importRe)) {
122
+ const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim());
123
+ for (const name of names) importedNames.add(name);
124
+ }
125
+ for (const match of content.matchAll(defaultImportRe)) {
126
+ importedNames.add(match[1]);
127
+ }
128
+ }
129
+
130
+ const dead = [];
131
+ for (const [file, exports] of exportMap) {
132
+ for (const name of exports) {
133
+ if (!importedNames.has(name)) {
134
+ dead.push({ file, name });
135
+ }
136
+ }
137
+ }
138
+ return dead;
139
+ }
140
+
141
+ export async function measureBasalCost(projectDir) {
142
+ const sourceFiles = walkSourceFiles(projectDir);
143
+ let totalLines = 0;
144
+ for (const f of sourceFiles) {
145
+ totalLines += countLines(f);
146
+ }
147
+
148
+ const depInfo = countDependencies(projectDir);
149
+ const depcheck = await runDepcheck(projectDir);
150
+ const deadExports = findDeadExports(sourceFiles);
151
+
152
+ return {
153
+ totalLines,
154
+ totalFiles: sourceFiles.length,
155
+ dependencies: depInfo,
156
+ unusedDependencies: depcheck,
157
+ deadExports
158
+ };
159
+ }
160
+
161
+ function auditDir() {
162
+ return path.join(getKarajanHome(), "audits");
163
+ }
164
+
165
+ export function loadPreviousAudit(projectDir) {
166
+ const file = path.join(auditDir(), slugify(projectDir), "latest.json");
167
+ try {
168
+ return JSON.parse(fs.readFileSync(file, "utf8"));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ export function saveAuditSnapshot(projectDir, metrics) {
175
+ const dir = path.join(auditDir(), slugify(projectDir));
176
+ fs.mkdirSync(dir, { recursive: true });
177
+ const snapshot = { ...metrics, timestamp: new Date().toISOString() };
178
+ fs.writeFileSync(path.join(dir, "latest.json"), JSON.stringify(snapshot, null, 2));
179
+ return snapshot;
180
+ }
181
+
182
+ export function computeGrowthDelta(current, previous) {
183
+ if (!previous) return null;
184
+ return {
185
+ lines: current.totalLines - previous.totalLines,
186
+ files: current.totalFiles - previous.totalFiles,
187
+ deps: current.dependencies.total - (previous.dependencies?.total ?? 0),
188
+ since: previous.timestamp || null
189
+ };
190
+ }
@@ -63,7 +63,7 @@ export async function architectCommand({ task, config, logger, context, json })
63
63
  logger.info(`Architect (${architectRole.provider}) starting...`);
64
64
 
65
65
  const agent = createAgent(architectRole.provider, config, logger);
66
- const prompt = buildArchitectPrompt({ task, researchContext: context });
66
+ const prompt = await buildArchitectPrompt({ task, researchContext: context });
67
67
  const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
68
68
  const result = await agent.runTask({ prompt, onOutput, role: "architect" });
69
69
 
@@ -17,7 +17,7 @@ export async function codeCommand({ task, config, logger }) {
17
17
  try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
18
18
  }
19
19
  }
20
- const prompt = buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology || "tdd" });
20
+ const prompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology || "tdd" });
21
21
  const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
22
22
  const result = await coder.runTask({ prompt, onOutput, role: "coder" });
23
23
  if (!result.ok) {
@@ -32,7 +32,7 @@ export async function reviewCommand({ task, config, logger, baseRef }) {
32
32
 
33
33
  const { rules } = await resolveReviewProfile({ mode: config.review_mode, projectDir: process.cwd() });
34
34
 
35
- const prompt = buildReviewerPrompt({ task, diff, reviewRules: rules, mode: config.review_mode });
35
+ const prompt = await buildReviewerPrompt({ task, diff, reviewRules: rules, mode: config.review_mode });
36
36
  const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
37
37
  const result = await reviewer.reviewTask({ prompt, onOutput, role: "reviewer" });
38
38
  if (!result.ok) {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Lazy HU Planner — refines HUs one at a time using context from completed HUs.
3
+ *
4
+ * Instead of fully planning all HUs upfront, only the first HU is decomposed
5
+ * with full acceptance criteria. Subsequent HUs are refined lazily before
6
+ * execution, incorporating learnings from previously completed HUs.
7
+ */
8
+ import { createAgent } from "../agents/index.js";
9
+ import { extractFirstJson } from "../utils/json-extract.js";
10
+
11
+ const SUBAGENT_PREAMBLE = [
12
+ "IMPORTANT: You are running as a Karajan sub-agent.",
13
+ "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
14
+ "Do NOT use any MCP tools. Focus only on refining the user story."
15
+ ].join(" ");
16
+
17
+ /**
18
+ * Build a prompt asking the AI to refine an HU based on context from completed HUs.
19
+ * @param {object} hu - The HU to refine (with at least id, original.text or certified.text).
20
+ * @param {Array<object>} completedHus - Array of completed HU results with summaries.
21
+ * @returns {string} The assembled refinement prompt.
22
+ */
23
+ export function buildRefinementPrompt(hu, completedHus) {
24
+ const sections = [SUBAGENT_PREAMBLE];
25
+
26
+ sections.push("## HU Refinement");
27
+ sections.push(
28
+ "You are a senior product owner. Refine the following user story by adding or updating its acceptance criteria.",
29
+ "Use the context from previously completed HUs to make the criteria more precise and avoid redundant work."
30
+ );
31
+
32
+ if (completedHus.length > 0) {
33
+ sections.push("## Previously Completed HUs");
34
+ for (const completed of completedHus) {
35
+ const title = completed.certified?.title || completed.id;
36
+ const summary = completed.resultSummary || completed.certified?.text || completed.original?.text || "No summary available";
37
+ sections.push(`### ${completed.id} — ${title}\nResult: ${summary}`);
38
+ }
39
+ }
40
+
41
+ const huText = hu.certified?.text || hu.original?.text || `Implement HU ${hu.id}`;
42
+ sections.push(`## HU to Refine\n### ${hu.id}\n${huText}`);
43
+
44
+ sections.push(
45
+ "Return a single valid JSON object and nothing else.",
46
+ `JSON schema: {"id":string,"title":string,"text":string,"acceptanceCriteria":[string]}`,
47
+ "Rules:",
48
+ "- Keep the same HU id",
49
+ "- Refine the description to be more specific based on what was learned",
50
+ "- Add concrete, testable acceptance criteria",
51
+ "- Avoid repeating work already done in completed HUs",
52
+ "- The text field should be the full story description ready for implementation"
53
+ );
54
+
55
+ return sections.join("\n\n");
56
+ }
57
+
58
+ /**
59
+ * Refine an HU using AI, incorporating context from completed HUs.
60
+ * @param {object} hu - The HU story object to refine.
61
+ * @param {Array<object>} completedHus - Completed HU stories with results.
62
+ * @param {object} config - Pipeline configuration.
63
+ * @param {object} logger - Logger instance.
64
+ * @returns {Promise<object>} Updated HU object with refined description and AC.
65
+ */
66
+ export async function refineHuWithContext(hu, completedHus, config, logger) {
67
+ const provider = config?.roles?.hu_reviewer?.provider || config?.roles?.coder?.provider || "claude";
68
+ const prompt = buildRefinementPrompt(hu, completedHus);
69
+
70
+ logger.info(`Lazy planner: refining HU ${hu.id} with context from ${completedHus.length} completed HU(s)`);
71
+
72
+ const agent = createAgent(provider, config, logger);
73
+ let result;
74
+ try {
75
+ result = await agent.runTask({ prompt, role: "hu-reviewer" });
76
+ } catch (err) {
77
+ logger.warn(`Lazy planner: refinement failed for HU ${hu.id}: ${err.message}`);
78
+ return hu;
79
+ }
80
+
81
+ if (!result.ok) {
82
+ logger.warn(`Lazy planner: refinement returned not-ok for HU ${hu.id}`);
83
+ return hu;
84
+ }
85
+
86
+ const parsed = extractFirstJson(result.output);
87
+ if (!parsed || !parsed.text) {
88
+ logger.warn(`Lazy planner: could not parse refinement output for HU ${hu.id}`);
89
+ return hu;
90
+ }
91
+
92
+ // Update the HU with refined content
93
+ const refined = { ...hu };
94
+ refined.certified = {
95
+ ...(hu.certified || {}),
96
+ text: parsed.text,
97
+ title: parsed.title || hu.certified?.title || hu.id,
98
+ acceptanceCriteria: parsed.acceptanceCriteria || []
99
+ };
100
+ refined.needsRefinement = false;
101
+
102
+ logger.info(`Lazy planner: HU ${hu.id} refined successfully`);
103
+ return refined;
104
+ }
@@ -488,7 +488,7 @@ export async function handleCodeDirect(a, server, extra) {
488
488
  try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
489
489
  }
490
490
  }
491
- const prompt = buildCoderPrompt({ task: a.task, coderRules, methodology: config.development?.methodology || "tdd" });
491
+ const prompt = await buildCoderPrompt({ task: a.task, coderRules, methodology: config.development?.methodology || "tdd" });
492
492
  sendTrackerLog(server, "coder", "running", coderRole.provider);
493
493
  runLog.logText(`[coder] agent launched, waiting for response...`);
494
494
  let result;
@@ -535,7 +535,7 @@ export async function handleReviewDirect(a, server, extra) {
535
535
  const diff = await generateDiff({ baseRef: resolvedBase });
536
536
  const { rules } = await resolveReviewProfile({ mode: config.review_mode, projectDir: process.cwd() });
537
537
 
538
- const prompt = buildReviewerPrompt({ task: a.task, diff, reviewRules: rules, mode: config.review_mode });
538
+ const prompt = await buildReviewerPrompt({ task: a.task, diff, reviewRules: rules, mode: config.review_mode });
539
539
  sendTrackerLog(server, "reviewer", "running", reviewerRole.provider);
540
540
  runLog.logText(`[reviewer] agent launched, waiting for response...`);
541
541
  let result;
@@ -989,6 +989,40 @@ async function handleSuggest(a) {
989
989
  });
990
990
  }
991
991
 
992
+ async function handleSkills(a) {
993
+ const action = a.action;
994
+ if (!action) return failPayload("Missing required field: action");
995
+
996
+ const { isOpenSkillsAvailable, installSkill, removeSkill, listSkills, readSkill } =
997
+ await import("../skills/openskills-client.js");
998
+
999
+ const opts = { projectDir: a.projectDir || null };
1000
+
1001
+ switch (action) {
1002
+ case "install": {
1003
+ if (!a.source) return failPayload("Missing required field: source (required for install)");
1004
+ const available = await isOpenSkillsAvailable();
1005
+ if (!available) {
1006
+ return failPayload("OpenSkills CLI is not available. Install it with: npm install -g openskills");
1007
+ }
1008
+ return installSkill(a.source, { ...opts, global: a.global || false });
1009
+ }
1010
+ case "remove": {
1011
+ if (!a.name) return failPayload("Missing required field: name (required for remove)");
1012
+ return removeSkill(a.name, opts);
1013
+ }
1014
+ case "list": {
1015
+ return listSkills(opts);
1016
+ }
1017
+ case "read": {
1018
+ if (!a.name) return failPayload("Missing required field: name (required for read)");
1019
+ return readSkill(a.name, opts);
1020
+ }
1021
+ default:
1022
+ return failPayload(`Unknown skills action: ${action}`);
1023
+ }
1024
+ }
1025
+
992
1026
  /* ── Handler dispatch map ─────────────────────────────────────────── */
993
1027
 
994
1028
  const toolHandlers = {
@@ -1013,7 +1047,8 @@ const toolHandlers = {
1013
1047
  kj_audit: (a, server, extra) => handleAudit(a, server, extra),
1014
1048
  kj_board: (a) => handleBoard(a),
1015
1049
  kj_hu: (a, server) => handleHu(a, server),
1016
- kj_suggest: (a) => handleSuggest(a)
1050
+ kj_suggest: (a) => handleSuggest(a),
1051
+ kj_skills: (a) => handleSkills(a)
1017
1052
  };
1018
1053
 
1019
1054
  export async function handleToolCall(name, args, server, extra) {
package/src/mcp/tools.js CHANGED
@@ -347,5 +347,20 @@ export const tools = [
347
347
  projectDir: { type: "string", description: "Project directory" }
348
348
  }
349
349
  }
350
+ },
351
+ {
352
+ name: "kj_skills",
353
+ description: "Manage OpenSkills for domain-specific agent knowledge. Install skills from the marketplace or GitHub repos to give coders domain expertise.",
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ action: { type: "string", enum: ["install", "remove", "list", "read"], description: "Action to perform" },
358
+ source: { type: "string", description: "Skill source: marketplace name, GitHub URL, or local path (for install)" },
359
+ name: { type: "string", description: "Skill name (for remove/read)" },
360
+ global: { type: "boolean", description: "Install globally (~/.agent/skills/) instead of project-local" },
361
+ projectDir: { type: "string", description: "Project directory" }
362
+ },
363
+ required: ["action"]
364
+ }
350
365
  }
351
366
  ];
@@ -5,6 +5,7 @@
5
5
  import { topologicalSort } from "../hu/graph.js";
6
6
  import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/store.js";
7
7
  import { emitProgress, makeEvent } from "../utils/events.js";
8
+ import { refineHuWithContext } from "../hu/lazy-planner.js";
8
9
 
9
10
  /**
10
11
  * Determine whether the HU reviewer result requires a sub-pipeline
@@ -72,7 +73,7 @@ function buildHuTask(story) {
72
73
  * @param {object} params.logger - Logger instance
73
74
  * @returns {Promise<{approved: boolean, results: object[], blockedIds: string[]}>}
74
75
  */
75
- export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger }) {
76
+ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null }) {
76
77
  const batchSessionId = huReviewerResult.batchSessionId;
77
78
  let batch;
78
79
  try {
@@ -108,6 +109,40 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
108
109
  continue;
109
110
  }
110
111
 
112
+ // --- Lazy refinement: refine HU if it needs it, using completed HU context ---
113
+ if (story.needsRefinement && config) {
114
+ const completedHus = results
115
+ .filter(r => r.approved)
116
+ .map(r => {
117
+ const completedStory = batch.stories.find(s => s.id === r.huId);
118
+ return {
119
+ ...completedStory,
120
+ resultSummary: r.result?.summary || r.result?.reason || null
121
+ };
122
+ });
123
+
124
+ emitProgress(emitter, makeEvent("hu:refine-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
125
+ message: `Refining HU ${story.id} with context from ${completedHus.length} completed HU(s)`,
126
+ detail: { huId: story.id, completedCount: completedHus.length }
127
+ }));
128
+
129
+ try {
130
+ const refined = await refineHuWithContext(story, completedHus, config, logger);
131
+ // Apply refinement to the batch story in-place
132
+ Object.assign(story, refined);
133
+ story.needsRefinement = false;
134
+ await saveHuBatch(batchSessionId, batch);
135
+
136
+ emitProgress(emitter, makeEvent("hu:refine-end", { ...eventBase, stage: "hu-sub-pipeline" }, {
137
+ message: `HU ${story.id} refined successfully`,
138
+ detail: { huId: story.id }
139
+ }));
140
+ } catch (err) {
141
+ logger.warn(`Lazy refinement failed for HU ${story.id}: ${err.message} — proceeding with original`);
142
+ story.needsRefinement = false;
143
+ }
144
+ }
145
+
111
146
  const huTask = buildHuTask(story);
112
147
 
113
148
  // --- hu:start ---
@@ -646,11 +646,17 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
646
646
  config, logger, emitter, eventBase, session, coderRole, trackBudget
647
647
  });
648
648
  if (decomposed && decomposed.length > 1) {
649
- stories = decomposed.map(hu => ({
650
- id: hu.id,
651
- text: `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}\nAcceptance Criteria:\n${hu.acceptanceCriteria.map(ac => `- ${ac}`).join("\n")}`,
652
- blocked_by: hu.dependsOn || []
653
- }));
649
+ stories = decomposed.map((hu, idx) => {
650
+ const isFirst = idx === 0;
651
+ const fullText = `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}\nAcceptance Criteria:\n${hu.acceptanceCriteria.map(ac => `- ${ac}`).join("\n")}`;
652
+ const skeletonText = `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}`;
653
+ return {
654
+ id: hu.id,
655
+ text: isFirst ? fullText : skeletonText,
656
+ blocked_by: hu.dependsOn || [],
657
+ needsRefinement: !isFirst
658
+ };
659
+ });
654
660
  } else {
655
661
  stories = [{ id: "HU-AUTO-001", text: session.task }];
656
662
  }
@@ -40,6 +40,8 @@ import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js"
40
40
  import { detectTestFramework } from "./utils/project-detect.js";
41
41
  import { runPreflightChecks } from "./orchestrator/preflight-checks.js";
42
42
  import { detectRtk } from "./utils/rtk-detect.js";
43
+ import { detectNeededSkills, autoInstallSkills, cleanupAutoInstalledSkills } from "./skills/skill-detector.js";
44
+ import { isOpenSkillsAvailable } from "./skills/openskills-client.js";
43
45
 
44
46
 
45
47
  // --- Product Context loader ---
@@ -97,8 +99,8 @@ async function handleDryRun({ task, config, flags, emitter, pipelineFlags }) {
97
99
  const projectDir = config.projectDir || process.cwd();
98
100
  const { rules: reviewRules } = await resolveReviewProfile({ mode: config.review_mode, projectDir });
99
101
  const coderRules = await loadFirstExisting(resolveRoleMdPath("coder", projectDir));
100
- const coderPrompt = buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), productContext: config.productContext || null });
101
- const reviewerPrompt = buildReviewerPrompt({ task, diff: "(dry-run: no diff)", reviewRules, mode: config.review_mode, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), productContext: config.productContext || null });
102
+ const coderPrompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), productContext: config.productContext || null });
103
+ const reviewerPrompt = await buildReviewerPrompt({ task, diff: "(dry-run: no diff)", reviewRules, mode: config.review_mode, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), productContext: config.productContext || null });
102
104
 
103
105
  const summary = {
104
106
  dry_run: true,
@@ -845,6 +847,29 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
845
847
  applyTriageOverrides(pipelineFlags, triageResult.roleOverrides);
846
848
  stageResults.triage = triageResult.stageResult;
847
849
 
850
+ // --- Auto-install skills based on task + project detection ---
851
+ const projectDir = config.projectDir || process.cwd();
852
+ try {
853
+ const osAvailable = await isOpenSkillsAvailable();
854
+ if (osAvailable) {
855
+ const neededSkills = await detectNeededSkills(task, projectDir);
856
+ if (neededSkills.length > 0) {
857
+ const skillResult = await autoInstallSkills(neededSkills, projectDir);
858
+ if (skillResult.installed.length > 0) {
859
+ session.autoInstalledSkills = skillResult.installed;
860
+ }
861
+ emitProgress(emitter, makeEvent("skills:auto-install", { ...eventBase, stage: "triage" }, {
862
+ message: skillResult.installed.length > 0
863
+ ? `Auto-installed ${skillResult.installed.length} skill(s): ${skillResult.installed.join(", ")}`
864
+ : `Skills detected (${neededSkills.join(", ")}) — all already installed or unavailable`,
865
+ detail: skillResult
866
+ }));
867
+ }
868
+ }
869
+ } catch (err) {
870
+ logger.warn(`Skill auto-install failed (non-blocking): ${err.message}`);
871
+ }
872
+
848
873
  // --- HU Reviewer auto-activation from triage (post-triage, no huFile needed) ---
849
874
  const triageRoles = new Set(triageResult.stageResult?.roles || []);
850
875
  if (triageRoles.has("hu-reviewer") && !stageResults.huReviewer) {
@@ -1387,44 +1412,61 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1387
1412
 
1388
1413
  const ctx = await initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags });
1389
1414
 
1390
- // --- HU Sub-Pipeline: run each certified HU as an independent iteration loop ---
1391
- if (needsSubPipeline(ctx.stageResults.huReviewer)) {
1392
- logger.info(`HU sub-pipeline: ${ctx.stageResults.huReviewer.certified} certified stories — running each as a sub-pipeline`);
1393
- emitProgress(emitter, makeEvent("hu:sub-pipeline:start", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
1394
- message: `Running ${ctx.stageResults.huReviewer.certified} HUs as sub-pipelines`,
1395
- detail: { total: ctx.stageResults.huReviewer.total, certified: ctx.stageResults.huReviewer.certified }
1396
- }));
1415
+ try {
1416
+ // --- HU Sub-Pipeline: run each certified HU as an independent iteration loop ---
1417
+ if (needsSubPipeline(ctx.stageResults.huReviewer)) {
1418
+ logger.info(`HU sub-pipeline: ${ctx.stageResults.huReviewer.certified} certified stories — running each as a sub-pipeline`);
1419
+ emitProgress(emitter, makeEvent("hu:sub-pipeline:start", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
1420
+ message: `Running ${ctx.stageResults.huReviewer.certified} HUs as sub-pipelines`,
1421
+ detail: { total: ctx.stageResults.huReviewer.total, certified: ctx.stageResults.huReviewer.certified }
1422
+ }));
1397
1423
 
1398
- const subPipelineResult = await runHuSubPipeline({
1399
- huReviewerResult: ctx.stageResults.huReviewer,
1400
- runIterationFn: async (huTask) => runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger }),
1401
- emitter,
1402
- eventBase: ctx.eventBase,
1403
- logger
1404
- });
1424
+ const subPipelineResult = await runHuSubPipeline({
1425
+ huReviewerResult: ctx.stageResults.huReviewer,
1426
+ runIterationFn: async (huTask) => runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger }),
1427
+ emitter,
1428
+ eventBase: ctx.eventBase,
1429
+ logger,
1430
+ config: ctx.config
1431
+ });
1405
1432
 
1406
- emitProgress(emitter, makeEvent("hu:sub-pipeline:end", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
1407
- status: subPipelineResult.approved ? "ok" : "fail",
1408
- message: subPipelineResult.approved
1409
- ? "All HUs completed successfully"
1410
- : `Sub-pipeline finished with failures (${subPipelineResult.blockedIds.length} blocked)`,
1411
- detail: { results: subPipelineResult.results, blockedIds: subPipelineResult.blockedIds }
1412
- }));
1433
+ emitProgress(emitter, makeEvent("hu:sub-pipeline:end", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
1434
+ status: subPipelineResult.approved ? "ok" : "fail",
1435
+ message: subPipelineResult.approved
1436
+ ? "All HUs completed successfully"
1437
+ : `Sub-pipeline finished with failures (${subPipelineResult.blockedIds.length} blocked)`,
1438
+ detail: { results: subPipelineResult.results, blockedIds: subPipelineResult.blockedIds }
1439
+ }));
1413
1440
 
1414
- const finalResult = {
1415
- approved: subPipelineResult.approved,
1416
- sessionId: ctx.session.id,
1417
- huResults: subPipelineResult.results,
1418
- blockedIds: subPipelineResult.blockedIds
1419
- };
1420
- await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
1421
- return finalResult;
1422
- }
1441
+ const finalResult = {
1442
+ approved: subPipelineResult.approved,
1443
+ sessionId: ctx.session.id,
1444
+ huResults: subPipelineResult.results,
1445
+ blockedIds: subPipelineResult.blockedIds
1446
+ };
1447
+ await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
1448
+ return finalResult;
1449
+ }
1423
1450
 
1424
- // --- Standard single-task pipeline (1 HU or no HU reviewer) ---
1425
- const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
1426
- await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
1427
- return result;
1451
+ // --- Standard single-task pipeline (1 HU or no HU reviewer) ---
1452
+ const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
1453
+ await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
1454
+ return result;
1455
+ } finally {
1456
+ // --- Cleanup auto-installed skills ---
1457
+ const autoSkills = ctx.session?.autoInstalledSkills;
1458
+ if (autoSkills && autoSkills.length > 0) {
1459
+ const cleanProjectDir = ctx.config?.projectDir || process.cwd();
1460
+ try {
1461
+ const cleanupResult = await cleanupAutoInstalledSkills(autoSkills, cleanProjectDir);
1462
+ if (cleanupResult.removed.length > 0) {
1463
+ logger.info(`Cleaned up ${cleanupResult.removed.length} auto-installed skill(s): ${cleanupResult.removed.join(", ")}`);
1464
+ }
1465
+ } catch (err) {
1466
+ logger.warn(`Skill cleanup failed (non-blocking): ${err.message}`);
1467
+ }
1468
+ }
1469
+ }
1428
1470
  }
1429
1471
 
1430
1472
  export async function resumeFlow({ sessionId, answer, config, logger, flags = {}, emitter = null, askQuestion = null }) {
@@ -1,3 +1,5 @@
1
+ import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
+
1
3
  const SUBAGENT_PREAMBLE = [
2
4
  "IMPORTANT: You are running as a Karajan sub-agent.",
3
5
  "Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
@@ -6,7 +8,7 @@ const SUBAGENT_PREAMBLE = [
6
8
 
7
9
  export const VALID_VERDICTS = new Set(["ready", "needs_clarification"]);
8
10
 
9
- export function buildArchitectPrompt({ task, instructions, researchContext = null, productContext = null }) {
11
+ export async function buildArchitectPrompt({ task, instructions, researchContext = null, productContext = null, projectDir = null }) {
10
12
  const sections = [SUBAGENT_PREAMBLE];
11
13
 
12
14
  if (instructions) {
@@ -41,6 +43,14 @@ export function buildArchitectPrompt({ task, instructions, researchContext = nul
41
43
 
42
44
  sections.push(`## Task\n${task}`);
43
45
 
46
+ if (projectDir) {
47
+ const skills = await loadAvailableSkills(projectDir);
48
+ const skillSection = buildSkillSection(skills);
49
+ if (skillSection) {
50
+ sections.push(skillSection);
51
+ }
52
+ }
53
+
44
54
  return sections.join("\n\n");
45
55
  }
46
56
 
@@ -14,7 +14,7 @@ const VALID_SCORES = new Set(["A", "B", "C", "D", "F"]);
14
14
  const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]);
15
15
  const VALID_IMPACT = new Set(["high", "medium", "low"]);
16
16
 
17
- export function buildAuditPrompt({ task, instructions, dimensions = null, context = null }) {
17
+ export function buildAuditPrompt({ task, instructions, dimensions = null, context = null, basalCost = null, growthDelta = null }) {
18
18
  const sections = [SUBAGENT_PREAMBLE];
19
19
 
20
20
  if (instructions) {
@@ -112,6 +112,40 @@ export function buildAuditPrompt({ task, instructions, dimensions = null, contex
112
112
  `Only include dimensions you were asked to analyze: ${activeDimensions.join(", ")}`
113
113
  );
114
114
 
115
+ if (basalCost) {
116
+ const lines = [
117
+ "## Basal Cost",
118
+ `- Total source lines: ${basalCost.totalLines}`,
119
+ `- Total source files: ${basalCost.totalFiles}`,
120
+ `- Dependencies: ${basalCost.dependencies?.total ?? 0} (${basalCost.dependencies?.dependencies ?? 0} prod + ${basalCost.dependencies?.devDependencies ?? 0} dev)`
121
+ ];
122
+ if (basalCost.unusedDependencies?.unused?.length > 0) {
123
+ lines.push(`- Unused dependencies: ${basalCost.unusedDependencies.unused.join(", ")}`);
124
+ } else if (basalCost.unusedDependencies?.note) {
125
+ lines.push(`- Unused dependencies: ${basalCost.unusedDependencies.note}`);
126
+ }
127
+ if (basalCost.deadExports?.length > 0) {
128
+ lines.push(`- Dead exports (exported but never imported): ${basalCost.deadExports.length}`);
129
+ for (const de of basalCost.deadExports.slice(0, 20)) {
130
+ lines.push(` - ${de.name} in ${de.file}`);
131
+ }
132
+ if (basalCost.deadExports.length > 20) {
133
+ lines.push(` - ... and ${basalCost.deadExports.length - 20} more`);
134
+ }
135
+ }
136
+ if (growthDelta) {
137
+ lines.push("");
138
+ lines.push("### Growth since last audit");
139
+ lines.push(`- Lines: ${growthDelta.lines >= 0 ? "+" : ""}${growthDelta.lines}`);
140
+ lines.push(`- Files: ${growthDelta.files >= 0 ? "+" : ""}${growthDelta.files}`);
141
+ lines.push(`- Dependencies: ${growthDelta.deps >= 0 ? "+" : ""}${growthDelta.deps}`);
142
+ if (growthDelta.since) lines.push(`- Since: ${growthDelta.since}`);
143
+ }
144
+ lines.push("");
145
+ lines.push("Flag if basal cost is growing faster than feature delivery. Recommend elimination of unused code and dependencies.");
146
+ sections.push(lines.join("\n"));
147
+ }
148
+
115
149
  if (context) {
116
150
  sections.push(`## Context\n${context}`);
117
151
  }
@@ -1,4 +1,5 @@
1
1
  import { RTK_INSTRUCTIONS } from "./rtk-snippet.js";
2
+ import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
3
 
3
4
  const SUBAGENT_PREAMBLE = [
4
5
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -31,7 +32,7 @@ const SERENA_INSTRUCTIONS = [
31
32
  "Fall back to reading files only when Serena tools are not sufficient."
32
33
  ].join("\n");
33
34
 
34
- export function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, plan = null }) {
35
+ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, plan = null, projectDir = null }) {
35
36
  const sections = [
36
37
  serenaEnabled ? SUBAGENT_PREAMBLE_SERENA : SUBAGENT_PREAMBLE,
37
38
  `Task:\n${task}`,
@@ -83,5 +84,13 @@ export function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary =
83
84
  sections.push(deferredContext);
84
85
  }
85
86
 
87
+ if (projectDir) {
88
+ const skills = await loadAvailableSkills(projectDir);
89
+ const skillSection = buildSkillSection(skills);
90
+ if (skillSection) {
91
+ sections.push(skillSection);
92
+ }
93
+ }
94
+
86
95
  return sections.join("\n\n");
87
96
  }
@@ -1,4 +1,5 @@
1
1
  import { RTK_INSTRUCTIONS } from "./rtk-snippet.js";
2
+ import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
2
3
 
3
4
  const SUBAGENT_PREAMBLE = [
4
5
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -22,7 +23,7 @@ const SERENA_INSTRUCTIONS = [
22
23
  "Fall back to reading files only when Serena tools are not sufficient."
23
24
  ].join("\n");
24
25
 
25
- export function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null }) {
26
+ export async function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, projectDir = null }) {
26
27
  const truncatedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n[TRUNCATED]` : diff;
27
28
 
28
29
  const sections = [
@@ -53,5 +54,13 @@ export function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabl
53
54
  `Git diff:\n${truncatedDiff}`
54
55
  );
55
56
 
57
+ if (projectDir) {
58
+ const skills = await loadAvailableSkills(projectDir);
59
+ const skillSection = buildSkillSection(skills);
60
+ if (skillSection) {
61
+ sections.push(skillSection);
62
+ }
63
+ }
64
+
56
65
  return sections.join("\n\n");
57
66
  }
@@ -59,7 +59,7 @@ export class ArchitectRole extends BaseRole {
59
59
  const provider = resolveProvider(this.config);
60
60
  const agent = this._createAgent(provider, this.config, this.logger);
61
61
 
62
- const prompt = buildArchitectPrompt({ task, instructions: this.instructions, researchContext, productContext: this.config?.productContext || null });
62
+ const prompt = await buildArchitectPrompt({ task, instructions: this.instructions, researchContext, productContext: this.config?.productContext || null });
63
63
  const runArgs = { prompt, role: "architect" };
64
64
  if (onOutput) runArgs.onOutput = onOutput;
65
65
  const result = await agent.runTask(runArgs);
@@ -1,6 +1,7 @@
1
1
  import { BaseRole } from "./base-role.js";
2
2
  import { createAgent as defaultCreateAgent } from "../agents/index.js";
3
3
  import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
4
+ import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
4
5
 
5
6
  function resolveProvider(config) {
6
7
  return (
@@ -57,7 +58,18 @@ export class AuditRole extends BaseRole {
57
58
  const provider = resolveProvider(this.config);
58
59
  const agent = this._createAgent(provider, this.config, this.logger);
59
60
 
60
- const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context });
61
+ const projectDir = this.config?.projectDir || process.cwd();
62
+ let basalCost = null;
63
+ let growthDelta = null;
64
+ try {
65
+ basalCost = await measureBasalCost(projectDir);
66
+ const previous = loadPreviousAudit(projectDir);
67
+ growthDelta = computeGrowthDelta(basalCost, previous);
68
+ } catch {
69
+ // basal cost is best-effort, don't fail the audit
70
+ }
71
+
72
+ const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta });
61
73
  const runArgs = { prompt, role: "audit" };
62
74
  if (onOutput) runArgs.onOutput = onOutput;
63
75
  const result = await agent.runTask(runArgs);
@@ -82,12 +94,18 @@ export class AuditRole extends BaseRole {
82
94
  };
83
95
  }
84
96
 
97
+ if (basalCost) {
98
+ try { saveAuditSnapshot(projectDir, basalCost); } catch { /* best-effort */ }
99
+ }
100
+
85
101
  return {
86
102
  ok: true,
87
103
  result: {
88
104
  summary: parsed.summary,
89
105
  dimensions: parsed.dimensions,
90
106
  topRecommendations: parsed.topRecommendations,
107
+ basalCost: basalCost || undefined,
108
+ growthDelta: growthDelta || undefined,
91
109
  provider
92
110
  },
93
111
  summary: buildSummary(parsed),
@@ -34,7 +34,7 @@ export class CoderRole extends BaseRole {
34
34
  this.logger.info(`Host-as-coder: delegating to host AI (skipping ${provider} subprocess)`);
35
35
  }
36
36
 
37
- const prompt = buildCoderPrompt({
37
+ const prompt = await buildCoderPrompt({
38
38
  task: task || this.context?.task || "",
39
39
  reviewerFeedback: reviewerFeedback || null,
40
40
  sonarSummary: sonarSummary || null,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * OpenSkills CLI client.
3
+ * Wraps `npx openskills` commands for installing, removing, listing, and reading skills.
4
+ */
5
+
6
+ import { runCommand } from "../utils/process.js";
7
+
8
+ const NPX = "npx";
9
+ const PKG = "openskills";
10
+
11
+ function buildCwd(projectDir) {
12
+ return projectDir || process.cwd();
13
+ }
14
+
15
+ /**
16
+ * Check whether the openskills CLI is available.
17
+ * @returns {Promise<boolean>}
18
+ */
19
+ export async function isOpenSkillsAvailable() {
20
+ try {
21
+ const result = await runCommand(NPX, [PKG, "--version"], {
22
+ timeout: 15_000
23
+ });
24
+ return result.exitCode === 0;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Install a skill from a marketplace name, GitHub URL, or local path.
32
+ * @param {string} source - Skill source identifier.
33
+ * @param {object} opts
34
+ * @param {string} [opts.projectDir] - Working directory.
35
+ * @param {boolean} [opts.global] - Install globally (~/.agent/skills/).
36
+ * @returns {Promise<{ok: boolean, name?: string, error?: string}>}
37
+ */
38
+ export async function installSkill(source, { projectDir, global: isGlobal } = {}) {
39
+ if (!source) {
40
+ return { ok: false, error: "source is required for install" };
41
+ }
42
+
43
+ const args = [PKG, "install", source];
44
+ if (isGlobal) args.push("--global");
45
+
46
+ const result = await runCommand(NPX, args, {
47
+ cwd: buildCwd(projectDir),
48
+ timeout: 60_000
49
+ });
50
+
51
+ if (result.exitCode !== 0) {
52
+ const stderr = (result.stderr || "").trim();
53
+ return { ok: false, error: stderr || `install failed (exit ${result.exitCode})` };
54
+ }
55
+
56
+ // Try to extract the skill name from stdout
57
+ const output = (result.stdout || "").trim();
58
+ const nameMatch = output.match(/installed\s+(?:skill\s+)?["']?([^\s"']+)["']?/i)
59
+ || output.match(/([^\s/]+)\s*$/);
60
+ const name = nameMatch ? nameMatch[1] : source;
61
+
62
+ return { ok: true, name };
63
+ }
64
+
65
+ /**
66
+ * Remove an installed skill by name.
67
+ * @param {string} name - Skill name.
68
+ * @param {object} opts
69
+ * @param {string} [opts.projectDir] - Working directory.
70
+ * @returns {Promise<{ok: boolean, error?: string}>}
71
+ */
72
+ export async function removeSkill(name, { projectDir } = {}) {
73
+ if (!name) {
74
+ return { ok: false, error: "name is required for remove" };
75
+ }
76
+
77
+ const result = await runCommand(NPX, [PKG, "remove", name], {
78
+ cwd: buildCwd(projectDir),
79
+ timeout: 30_000
80
+ });
81
+
82
+ if (result.exitCode !== 0) {
83
+ const stderr = (result.stderr || "").trim();
84
+ return { ok: false, error: stderr || `remove failed (exit ${result.exitCode})` };
85
+ }
86
+
87
+ return { ok: true };
88
+ }
89
+
90
+ /**
91
+ * List installed skills.
92
+ * @param {object} opts
93
+ * @param {string} [opts.projectDir] - Working directory.
94
+ * @returns {Promise<{ok: boolean, skills?: Array<{name: string, source?: string, scope?: string}>, error?: string}>}
95
+ */
96
+ export async function listSkills({ projectDir } = {}) {
97
+ const result = await runCommand(NPX, [PKG, "list", "--json"], {
98
+ cwd: buildCwd(projectDir),
99
+ timeout: 30_000
100
+ });
101
+
102
+ if (result.exitCode !== 0) {
103
+ // Fallback: try without --json
104
+ const fallback = await runCommand(NPX, [PKG, "list"], {
105
+ cwd: buildCwd(projectDir),
106
+ timeout: 30_000
107
+ });
108
+
109
+ if (fallback.exitCode !== 0) {
110
+ const stderr = (fallback.stderr || "").trim();
111
+ return { ok: false, error: stderr || `list failed (exit ${fallback.exitCode})` };
112
+ }
113
+
114
+ // Parse text output: each line is a skill name
115
+ const lines = (fallback.stdout || "").split("\n").map(l => l.trim()).filter(Boolean);
116
+ const skills = lines.map(line => ({ name: line }));
117
+ return { ok: true, skills };
118
+ }
119
+
120
+ // Parse JSON output
121
+ const stdout = (result.stdout || "").trim();
122
+ try {
123
+ const parsed = JSON.parse(stdout);
124
+ const skills = Array.isArray(parsed) ? parsed : (parsed.skills || []);
125
+ return { ok: true, skills };
126
+ } catch {
127
+ // If JSON parse fails, treat as text
128
+ const lines = stdout.split("\n").map(l => l.trim()).filter(Boolean);
129
+ const skills = lines.map(line => ({ name: line }));
130
+ return { ok: true, skills };
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Read a skill's content by name.
136
+ * @param {string} name - Skill name.
137
+ * @param {object} opts
138
+ * @param {string} [opts.projectDir] - Working directory.
139
+ * @returns {Promise<{ok: boolean, content?: string, error?: string}>}
140
+ */
141
+ export async function readSkill(name, { projectDir } = {}) {
142
+ if (!name) {
143
+ return { ok: false, error: "name is required for read" };
144
+ }
145
+
146
+ const result = await runCommand(NPX, [PKG, "read", name], {
147
+ cwd: buildCwd(projectDir),
148
+ timeout: 30_000
149
+ });
150
+
151
+ if (result.exitCode !== 0) {
152
+ const stderr = (result.stderr || "").trim();
153
+ return { ok: false, error: stderr || `read failed (exit ${result.exitCode})` };
154
+ }
155
+
156
+ return { ok: true, content: (result.stdout || "").trim() };
157
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Detects needed skills from task description and project files,
3
+ * then auto-installs them via OpenSkills if available.
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { loadAvailableSkills } from "./skill-loader.js";
9
+ import { isOpenSkillsAvailable, installSkill, removeSkill } from "./openskills-client.js";
10
+
11
+ /**
12
+ * Known frameworks detectable from package.json dependencies.
13
+ * Maps dependency name to the skill name to search for.
14
+ */
15
+ const PKG_JSON_FRAMEWORKS = {
16
+ astro: "astro",
17
+ next: "nextjs",
18
+ react: "react",
19
+ "react-dom": "react",
20
+ vue: "vue",
21
+ svelte: "svelte",
22
+ "@angular/core": "angular",
23
+ express: "express",
24
+ fastify: "fastify",
25
+ "@nestjs/core": "nestjs",
26
+ };
27
+
28
+ /**
29
+ * Language markers: file presence maps to a skill name.
30
+ * Reuses the same concept as src/utils/project-detect.js LANGUAGE_MARKERS.
31
+ */
32
+ const LANGUAGE_FILE_MARKERS = [
33
+ { file: "pom.xml", skill: "java" },
34
+ { file: "build.gradle", skill: "java" },
35
+ { file: "build.gradle.kts", skill: "kotlin" },
36
+ { file: "go.mod", skill: "go" },
37
+ { file: "Cargo.toml", skill: "rust" },
38
+ { file: "pyproject.toml", skill: "python" },
39
+ { file: "setup.py", skill: "python" },
40
+ { file: "Gemfile", skill: "ruby" },
41
+ { file: "pubspec.yaml", skill: "flutter" },
42
+ { file: "Package.swift", skill: "swift" },
43
+ { file: "phpunit.xml", skill: "php" },
44
+ { file: "composer.json", skill: "php" },
45
+ ];
46
+
47
+ /**
48
+ * Patterns to detect framework/skill mentions in the task text.
49
+ * Each entry: regex pattern (case-insensitive) -> skill name.
50
+ */
51
+ const TASK_TEXT_PATTERNS = [
52
+ { pattern: /\bastro\b/i, skill: "astro" },
53
+ { pattern: /\bnext\.?js\b/i, skill: "nextjs" },
54
+ { pattern: /\breact\b/i, skill: "react" },
55
+ { pattern: /\bvue\b/i, skill: "vue" },
56
+ { pattern: /\bsvelte\b/i, skill: "svelte" },
57
+ { pattern: /\bangular\b/i, skill: "angular" },
58
+ { pattern: /\bexpress\b/i, skill: "express" },
59
+ { pattern: /\bfastify\b/i, skill: "fastify" },
60
+ { pattern: /\bnest\.?js\b/i, skill: "nestjs" },
61
+ { pattern: /\bjava\b/i, skill: "java" },
62
+ { pattern: /\bkotlin\b/i, skill: "kotlin" },
63
+ { pattern: /\bgo(?:lang)?\b/i, skill: "go" },
64
+ { pattern: /\brust\b/i, skill: "rust" },
65
+ { pattern: /\bpython\b/i, skill: "python" },
66
+ { pattern: /\bruby\b/i, skill: "ruby" },
67
+ { pattern: /\bflutter\b/i, skill: "flutter" },
68
+ { pattern: /\bswift\b/i, skill: "swift" },
69
+ { pattern: /\bphp\b/i, skill: "php" },
70
+ ];
71
+
72
+ /**
73
+ * Analyze the task and project to determine what skills might be needed.
74
+ * @param {string} task - The task description text.
75
+ * @param {string} projectDir - Absolute path to project root.
76
+ * @returns {Promise<string[]>} Array of unique skill names to search for.
77
+ */
78
+ export async function detectNeededSkills(task, projectDir) {
79
+ const needed = new Set();
80
+
81
+ // 1. Scan package.json for known frameworks
82
+ if (projectDir) {
83
+ try {
84
+ const pkgRaw = await fs.readFile(path.join(projectDir, "package.json"), "utf8");
85
+ const pkg = JSON.parse(pkgRaw);
86
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
87
+ for (const [dep, skill] of Object.entries(PKG_JSON_FRAMEWORKS)) {
88
+ if (allDeps[dep]) {
89
+ needed.add(skill);
90
+ }
91
+ }
92
+ } catch { /* no package.json or parse error — skip */ }
93
+
94
+ // 2. Check for language markers
95
+ for (const marker of LANGUAGE_FILE_MARKERS) {
96
+ try {
97
+ await fs.access(path.join(projectDir, marker.file));
98
+ needed.add(marker.skill);
99
+ } catch { /* file not found — skip */ }
100
+ }
101
+ }
102
+
103
+ // 3. Check task text for framework mentions
104
+ if (task) {
105
+ for (const { pattern, skill } of TASK_TEXT_PATTERNS) {
106
+ if (pattern.test(task)) {
107
+ needed.add(skill);
108
+ }
109
+ }
110
+ }
111
+
112
+ return Array.from(needed);
113
+ }
114
+
115
+ /**
116
+ * Auto-install needed skills that are not yet installed.
117
+ * @param {string[]} neededSkills - Skill names to ensure are installed.
118
+ * @param {string} projectDir - Absolute path to project root.
119
+ * @returns {Promise<{installed: string[], failed: string[], alreadyInstalled: string[]}>}
120
+ */
121
+ export async function autoInstallSkills(neededSkills, projectDir) {
122
+ const result = { installed: [], failed: [], alreadyInstalled: [] };
123
+
124
+ if (!neededSkills || neededSkills.length === 0) {
125
+ return result;
126
+ }
127
+
128
+ // Check which skills are already installed locally
129
+ const existingSkills = await loadAvailableSkills(projectDir);
130
+ const existingNames = new Set(existingSkills.map(s => s.name.toLowerCase()));
131
+
132
+ // Check if OpenSkills is available
133
+ const osAvailable = await isOpenSkillsAvailable();
134
+ if (!osAvailable) {
135
+ // All skills count as "failed" silently — caller should check osAvailable separately
136
+ return result;
137
+ }
138
+
139
+ for (const skillName of neededSkills) {
140
+ if (existingNames.has(skillName.toLowerCase())) {
141
+ result.alreadyInstalled.push(skillName);
142
+ continue;
143
+ }
144
+
145
+ try {
146
+ const installResult = await installSkill(skillName, { projectDir });
147
+ if (installResult.ok) {
148
+ result.installed.push(installResult.name || skillName);
149
+ } else {
150
+ result.failed.push(skillName);
151
+ }
152
+ } catch {
153
+ result.failed.push(skillName);
154
+ }
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * Remove skills that were auto-installed during this session.
162
+ * @param {string[]} skillNames - Skill names to remove.
163
+ * @param {string} projectDir - Absolute path to project root.
164
+ * @returns {Promise<{removed: string[], failed: string[]}>}
165
+ */
166
+ export async function cleanupAutoInstalledSkills(skillNames, projectDir) {
167
+ const result = { removed: [], failed: [] };
168
+
169
+ if (!skillNames || skillNames.length === 0) {
170
+ return result;
171
+ }
172
+
173
+ for (const name of skillNames) {
174
+ try {
175
+ const removeResult = await removeSkill(name, { projectDir });
176
+ if (removeResult.ok) {
177
+ result.removed.push(name);
178
+ } else {
179
+ result.failed.push(name);
180
+ }
181
+ } catch {
182
+ result.failed.push(name);
183
+ }
184
+ }
185
+
186
+ return result;
187
+ }
@@ -0,0 +1,58 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const SKILL_DIRS = [".agent/skills", ".claude/skills"];
5
+ const SKILL_FILE = "SKILL.md";
6
+
7
+ /**
8
+ * Scans known skill directories for SKILL.md files.
9
+ * @param {string} projectDir — absolute path to the project root
10
+ * @returns {Promise<Array<{name: string, content: string}>>}
11
+ */
12
+ export async function loadAvailableSkills(projectDir) {
13
+ const skills = [];
14
+
15
+ for (const rel of SKILL_DIRS) {
16
+ const dir = join(projectDir, rel);
17
+ let entries;
18
+ try {
19
+ entries = await readdir(dir, { withFileTypes: true });
20
+ } catch {
21
+ continue; // directory does not exist — skip
22
+ }
23
+
24
+ for (const entry of entries) {
25
+ if (!entry.isDirectory()) continue;
26
+ const skillPath = join(dir, entry.name, SKILL_FILE);
27
+ try {
28
+ const content = await readFile(skillPath, "utf-8");
29
+ skills.push({ name: entry.name, content });
30
+ } catch {
31
+ // no SKILL.md in this subdirectory — skip
32
+ }
33
+ }
34
+ }
35
+
36
+ return skills;
37
+ }
38
+
39
+ /**
40
+ * Builds a prompt section from loaded skills.
41
+ * @param {Array<{name: string, content: string}>} skills
42
+ * @returns {string} prompt section or empty string
43
+ */
44
+ export function buildSkillSection(skills) {
45
+ if (!skills || skills.length === 0) return "";
46
+
47
+ const header = [
48
+ "## Domain Skills",
49
+ "",
50
+ "The following domain-specific knowledge is available for this task:"
51
+ ].join("\n");
52
+
53
+ const blocks = skills.map(
54
+ (s) => `### ${s.name}\n${s.content}`
55
+ );
56
+
57
+ return [header, ...blocks].join("\n\n");
58
+ }