karajan-code 1.39.0 → 1.41.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/README.md +17 -81
- package/package.json +1 -1
- package/src/commands/architect.js +1 -1
- package/src/commands/code.js +1 -1
- package/src/commands/init.js +12 -1
- package/src/commands/review.js +1 -1
- package/src/mcp/server-handlers.js +65 -3
- package/src/mcp/sovereignty-guard.js +112 -0
- package/src/mcp/suggest-handler.js +91 -0
- package/src/mcp/tools.js +28 -0
- package/src/orchestrator/solomon-escalation.js +16 -2
- package/src/orchestrator.js +77 -36
- package/src/prompts/architect.js +11 -1
- package/src/prompts/coder.js +10 -1
- package/src/prompts/reviewer.js +10 -1
- package/src/roles/architect-role.js +1 -1
- package/src/roles/coder-role.js +1 -1
- package/src/skills/openskills-client.js +157 -0
- package/src/skills/skill-detector.js +187 -0
- package/src/skills/skill-loader.js +58 -0
package/README.md
CHANGED
|
@@ -110,87 +110,23 @@ kj-tail --help # Full options
|
|
|
110
110
|
**Typical workflow:**
|
|
111
111
|
|
|
112
112
|
```
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
**Full pipeline example**, a complex task with all roles:
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
┌─ Terminal 1 ─────────────────────────────────────────────────────────────────┐
|
|
136
|
-
│ │
|
|
137
|
-
│ $ claude │
|
|
138
|
-
│ │
|
|
139
|
-
│ > Build a REST API for a booking system. Requirements: │
|
|
140
|
-
│ > - Express + TypeScript with Zod validation on every endpoint │
|
|
141
|
-
│ > - Endpoints: POST /bookings, GET /bookings/:id, PATCH /bookings/:id/cancel│
|
|
142
|
-
│ > - A booking has: id, guestName, roomType (standard|suite|penthouse), │
|
|
143
|
-
│ > checkIn, checkOut, status (confirmed|cancelled) │
|
|
144
|
-
│ > - Validate: checkOut must be after checkIn, no past dates, │
|
|
145
|
-
│ > roomType must be a valid enum value │
|
|
146
|
-
│ > - Cancel returns 409 if already cancelled │
|
|
147
|
-
│ > - Use TDD. Run it through Karajan with architect and planner enabled, │
|
|
148
|
-
│ > paranoid review mode. Coder claude, reviewer codex. │
|
|
149
|
-
│ │
|
|
150
|
-
│ Claude calls kj_run via MCP with: │
|
|
151
|
-
│ --enable-architect --enable-researcher --enable-planner --mode paranoid │
|
|
152
|
-
│ │
|
|
153
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
154
|
-
|
|
155
|
-
┌─ Terminal 2: kj-tail ────────────────────────────────────────────────────────┐
|
|
156
|
-
│ │
|
|
157
|
-
│ kj-tail v1.38.1 — .kj/run.log │
|
|
158
|
-
│ │
|
|
159
|
-
│ ├─ 📋 Triage: medium (sw) — enabling researcher, architect, planner │
|
|
160
|
-
│ ├─ ⚙️ Preflight passed — all checks OK │
|
|
161
|
-
│ ├─ 🔬 Researcher: 8 files analyzed, 3 patterns, 5 constraints │
|
|
162
|
-
│ ├─ 🏗️ Architect: 3-layer design (routes → service → validators) │
|
|
163
|
-
│ ├─ 🧠 Planner: 6 steps — tests first, then routes, service, validators │
|
|
164
|
-
│ │ │
|
|
165
|
-
│ ▶ Iteration 1/5 │
|
|
166
|
-
│ ├─ 🔨 Coder (claude): implemented 3 endpoints + 18 tests │
|
|
167
|
-
│ ├─ 📋 TDD: PASS (3 src, 2 test files) │
|
|
168
|
-
│ ├─ 🔍 Sonar: Quality gate OK — 0 blockers │
|
|
169
|
-
│ ├─ 👁️ Reviewer (codex): REJECTED (2 blocking) │
|
|
170
|
-
│ │ "Missing 404 for GET nonexistent booking" │
|
|
171
|
-
│ │ "Cancel endpoint lacks idempotency test" │
|
|
172
|
-
│ ├─ ⚖️ Solomon: approve_with_conditions (2 conditions) │
|
|
173
|
-
│ │ "Add 404 response and test for GET /bookings/:id with unknown id" │
|
|
174
|
-
│ │ "Add test: cancel already-cancelled booking returns 409, not 500" │
|
|
175
|
-
│ │ │
|
|
176
|
-
│ ▶ Iteration 2/5 │
|
|
177
|
-
│ ├─ 🔨 Coder (claude): fixed — 22 tests now │
|
|
178
|
-
│ ├─ 📋 TDD: PASS │
|
|
179
|
-
│ ├─ 🔍 Sonar: OK │
|
|
180
|
-
│ ├─ 👁️ Reviewer (codex): APPROVED │
|
|
181
|
-
│ ├─ 🧪 Tester: passed — coverage 94%, 22 tests │
|
|
182
|
-
│ ├─ 🔒 Security: passed — 0 critical, 1 low (helmet recommended) │
|
|
183
|
-
│ ├─ 📊 Audit: CERTIFIED (with 3 advisory warnings) │
|
|
184
|
-
│ │ │
|
|
185
|
-
│ 🏁 Result: APPROVED │
|
|
186
|
-
│ 🔬 Research: 8 files, 3 patterns identified │
|
|
187
|
-
│ 🗺 Plan: 6 steps (tests first) │
|
|
188
|
-
│ 🧪 Coverage: 94%, 22 tests │
|
|
189
|
-
│ 🔒 Security: passed │
|
|
190
|
-
│ 🔍 Sonar: OK │
|
|
191
|
-
│ 💰 Budget: $0.42 (claude: $0.38, codex: $0.04) │
|
|
192
|
-
│ │
|
|
193
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
113
|
+
Terminal 1 Terminal 2
|
|
114
|
+
|
|
115
|
+
$ claude $ kj-tail
|
|
116
|
+
> implement the next
|
|
117
|
+
priority task [triage] medium (sw)
|
|
118
|
+
[researcher] 3 patterns, 5 constraints
|
|
119
|
+
(Claude calls kj_run [planner] 6 steps (tests first)
|
|
120
|
+
via MCP, you see [coder] 3 endpoints + 18 tests
|
|
121
|
+
only the final result) [tdd] PASS (3 src, 2 test)
|
|
122
|
+
[sonar] Quality gate OK
|
|
123
|
+
[reviewer] REJECTED (2 blocking)
|
|
124
|
+
[solomon] 2 conditions
|
|
125
|
+
[coder] fixed, 22 tests now
|
|
126
|
+
[reviewer] APPROVED
|
|
127
|
+
[tester] 94% coverage, 22 tests
|
|
128
|
+
[security] passed
|
|
129
|
+
Result: APPROVED
|
|
194
130
|
```
|
|
195
131
|
|
|
196
132
|
[**Watch the full pipeline demo**](https://karajancode.com#demo): triage, architecture, TDD, SonarQube, code review, Solomon arbitration, security audit.
|
package/package.json
CHANGED
|
@@ -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
|
|
package/src/commands/code.js
CHANGED
|
@@ -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) {
|
package/src/commands/init.js
CHANGED
|
@@ -158,6 +158,15 @@ async function setupSonarQube(config, logger) {
|
|
|
158
158
|
logger.info("SonarQube disabled — skipping container setup.");
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
// Check Docker first — if not available, disable sonar gracefully
|
|
163
|
+
const dockerCheck = await runCommand("docker", ["--version"]);
|
|
164
|
+
if (dockerCheck.exitCode !== 0) {
|
|
165
|
+
logger.info("Docker not found — disabling SonarQube. Install Docker to enable it later.");
|
|
166
|
+
config.sonarqube.enabled = false;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
const vmCheck = await checkVmMaxMapCount(os.platform());
|
|
162
171
|
if (!vmCheck.ok) {
|
|
163
172
|
logger.warn(`vm.max_map_count check failed: ${vmCheck.reason}`);
|
|
@@ -168,7 +177,9 @@ async function setupSonarQube(config, logger) {
|
|
|
168
177
|
|
|
169
178
|
const sonar = await sonarUp();
|
|
170
179
|
if (sonar.exitCode !== 0) {
|
|
171
|
-
|
|
180
|
+
logger.info(`SonarQube could not be started — disabling for now. You can enable it later with 'kj sonar start'.`);
|
|
181
|
+
config.sonarqube.enabled = false;
|
|
182
|
+
return;
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
logger.info("SonarQube container started");
|
package/src/commands/review.js
CHANGED
|
@@ -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) {
|
|
@@ -25,6 +25,8 @@ import { createRunLog, readRunLog } from "../utils/run-log.js";
|
|
|
25
25
|
import { currentBranch } from "../utils/git.js";
|
|
26
26
|
import { isPreflightAcked, ackPreflight, getSessionOverrides } from "./preflight.js";
|
|
27
27
|
import { ensureBootstrap } from "../bootstrap.js";
|
|
28
|
+
import { validateSovereignty } from "./sovereignty-guard.js";
|
|
29
|
+
import { handleSuggestion } from "./suggest-handler.js";
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Resolve the user's project directory.
|
|
@@ -486,7 +488,7 @@ export async function handleCodeDirect(a, server, extra) {
|
|
|
486
488
|
try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
|
|
487
489
|
}
|
|
488
490
|
}
|
|
489
|
-
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" });
|
|
490
492
|
sendTrackerLog(server, "coder", "running", coderRole.provider);
|
|
491
493
|
runLog.logText(`[coder] agent launched, waiting for response...`);
|
|
492
494
|
let result;
|
|
@@ -533,7 +535,7 @@ export async function handleReviewDirect(a, server, extra) {
|
|
|
533
535
|
const diff = await generateDiff({ baseRef: resolvedBase });
|
|
534
536
|
const { rules } = await resolveReviewProfile({ mode: config.review_mode, projectDir: process.cwd() });
|
|
535
537
|
|
|
536
|
-
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 });
|
|
537
539
|
sendTrackerLog(server, "reviewer", "running", reviewerRole.provider);
|
|
538
540
|
runLog.logText(`[reviewer] agent launched, waiting for response...`);
|
|
539
541
|
let result;
|
|
@@ -823,6 +825,19 @@ async function handleRun(a, server, extra) {
|
|
|
823
825
|
return failPayload(`Invalid taskType "${a.taskType}". Valid values: ${[...validTypes].join(", ")}`);
|
|
824
826
|
}
|
|
825
827
|
}
|
|
828
|
+
|
|
829
|
+
// Sovereignty guard: sanitise params and check for active sessions
|
|
830
|
+
const projectDir = await resolveProjectDir(server, a.projectDir);
|
|
831
|
+
const sovereignty = validateSovereignty(a, { projectDir });
|
|
832
|
+
if (sovereignty.error) {
|
|
833
|
+
return failPayload(sovereignty.error);
|
|
834
|
+
}
|
|
835
|
+
if (sovereignty.warnings.length > 0) {
|
|
836
|
+
const logger = createLogger("info", "mcp");
|
|
837
|
+
for (const w of sovereignty.warnings) logger.warn(`[sovereignty] ${w}`);
|
|
838
|
+
}
|
|
839
|
+
Object.assign(a, sovereignty.params);
|
|
840
|
+
|
|
826
841
|
await runBootstrapGate(server, a);
|
|
827
842
|
if (!isPreflightAcked()) {
|
|
828
843
|
// Auto-acknowledge with defaults for autonomous operation
|
|
@@ -963,6 +978,51 @@ async function handleScan(a, server) {
|
|
|
963
978
|
return runKjCommand({ command: "scan", options: a });
|
|
964
979
|
}
|
|
965
980
|
|
|
981
|
+
async function handleSuggest(a) {
|
|
982
|
+
if (!a.suggestion) {
|
|
983
|
+
return failPayload("Missing required field: suggestion");
|
|
984
|
+
}
|
|
985
|
+
return handleSuggestion({
|
|
986
|
+
suggestion: a.suggestion,
|
|
987
|
+
context: a.context || null,
|
|
988
|
+
projectDir: a.projectDir || null
|
|
989
|
+
});
|
|
990
|
+
}
|
|
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
|
+
|
|
966
1026
|
/* ── Handler dispatch map ─────────────────────────────────────────── */
|
|
967
1027
|
|
|
968
1028
|
const toolHandlers = {
|
|
@@ -986,7 +1046,9 @@ const toolHandlers = {
|
|
|
986
1046
|
kj_architect: (a, server, extra) => handleArchitect(a, server, extra),
|
|
987
1047
|
kj_audit: (a, server, extra) => handleAudit(a, server, extra),
|
|
988
1048
|
kj_board: (a) => handleBoard(a),
|
|
989
|
-
kj_hu: (a, server) => handleHu(a, server)
|
|
1049
|
+
kj_hu: (a, server) => handleHu(a, server),
|
|
1050
|
+
kj_suggest: (a) => handleSuggest(a),
|
|
1051
|
+
kj_skills: (a) => handleSkills(a)
|
|
990
1052
|
};
|
|
991
1053
|
|
|
992
1054
|
export async function handleToolCall(name, args, server, extra) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sovereignty guard for MCP inputs.
|
|
3
|
+
*
|
|
4
|
+
* Validates kj_run parameters and rejects host-AI attempts to override
|
|
5
|
+
* pipeline decisions that Karajan makes internally (triage, hu-reviewer, etc.).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Parameters that the host AI is allowed to pass through without restriction. */
|
|
12
|
+
const ALLOWED_PARAMS = new Set([
|
|
13
|
+
"task", "projectDir", "pgTask", "pgProject",
|
|
14
|
+
"planner", "coder", "reviewer", "refactorer",
|
|
15
|
+
"plannerModel", "coderModel", "reviewerModel", "refactorerModel",
|
|
16
|
+
"enablePlanner", "enableReviewer", "enableRefactorer", "enableResearcher",
|
|
17
|
+
"enableTester", "enableSecurity", "enableImpeccable",
|
|
18
|
+
"enableDiscover", "enableArchitect",
|
|
19
|
+
"architectModel", "huFile",
|
|
20
|
+
"enableSerena", "enableBecaria",
|
|
21
|
+
"reviewerFallback", "reviewerRetries",
|
|
22
|
+
"mode", "maxIterations", "maxIterationMinutes", "maxTotalMinutes",
|
|
23
|
+
"baseBranch", "baseRef", "methodology",
|
|
24
|
+
"autoCommit", "autoPush", "autoPr", "autoRebase", "branchPrefix",
|
|
25
|
+
"autoSimplify", "smartModels", "checkpointInterval",
|
|
26
|
+
"taskType", "quiet", "noSonar", "enableSonarcloud",
|
|
27
|
+
"kjHome", "sonarToken", "timeoutMs",
|
|
28
|
+
// Sovereign flags — validated below but still "known"
|
|
29
|
+
"enableTriage", "enableHuReviewer",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const MIN_ITERATIONS = 1;
|
|
33
|
+
const MAX_ITERATIONS = 10;
|
|
34
|
+
const ACTIVE_SESSION_THRESHOLD_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a pipeline is already running for this project.
|
|
38
|
+
* Looks at .kj/run.log modification time.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} projectDir - Project root directory
|
|
41
|
+
* @returns {{ active: boolean, message?: string }}
|
|
42
|
+
*/
|
|
43
|
+
export function checkActiveSession(projectDir) {
|
|
44
|
+
if (!projectDir) return { active: false };
|
|
45
|
+
const logPath = path.join(projectDir, ".kj", "run.log");
|
|
46
|
+
try {
|
|
47
|
+
const stat = fs.statSync(logPath);
|
|
48
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
49
|
+
if (ageMs < ACTIVE_SESSION_THRESHOLD_MS) {
|
|
50
|
+
return {
|
|
51
|
+
active: true,
|
|
52
|
+
message:
|
|
53
|
+
"A pipeline is already running for this project. " +
|
|
54
|
+
"Wait for it to complete or use kj_status to check progress.",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// File doesn't exist or can't be read — no active session
|
|
59
|
+
}
|
|
60
|
+
return { active: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate and sanitise kj_run parameters.
|
|
65
|
+
*
|
|
66
|
+
* @param {Record<string, unknown>} params - Raw parameters from the MCP call
|
|
67
|
+
* @param {{ projectDir?: string }} context - Runtime context
|
|
68
|
+
* @returns {{ params: Record<string, unknown>, warnings: string[], error?: string }}
|
|
69
|
+
*/
|
|
70
|
+
export function validateSovereignty(params, context = {}) {
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const sanitized = { ...params };
|
|
73
|
+
|
|
74
|
+
// 1. Strip unknown parameters
|
|
75
|
+
for (const key of Object.keys(sanitized)) {
|
|
76
|
+
if (!ALLOWED_PARAMS.has(key)) {
|
|
77
|
+
warnings.push(`Unknown parameter "${key}" stripped`);
|
|
78
|
+
delete sanitized[key];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. Sovereign flags — pipeline decides, not the host AI
|
|
83
|
+
if (sanitized.enableHuReviewer === false) {
|
|
84
|
+
warnings.push("Pipeline decides hu-reviewer activation, ignoring override");
|
|
85
|
+
delete sanitized.enableHuReviewer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (sanitized.enableTriage === false) {
|
|
89
|
+
warnings.push("Triage is mandatory, ignoring override");
|
|
90
|
+
delete sanitized.enableTriage;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Clamp maxIterations to [1, 10]
|
|
94
|
+
if (sanitized.maxIterations !== undefined) {
|
|
95
|
+
const n = Number(sanitized.maxIterations);
|
|
96
|
+
if (!Number.isFinite(n) || n < MIN_ITERATIONS) {
|
|
97
|
+
warnings.push(`maxIterations ${sanitized.maxIterations} clamped to ${MIN_ITERATIONS}`);
|
|
98
|
+
sanitized.maxIterations = MIN_ITERATIONS;
|
|
99
|
+
} else if (n > MAX_ITERATIONS) {
|
|
100
|
+
warnings.push(`maxIterations ${sanitized.maxIterations} clamped to ${MAX_ITERATIONS}`);
|
|
101
|
+
sanitized.maxIterations = MAX_ITERATIONS;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 4. Active session detection
|
|
106
|
+
const session = checkActiveSession(context.projectDir);
|
|
107
|
+
if (session.active) {
|
|
108
|
+
return { params: sanitized, warnings, error: session.message };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { params: sanitized, warnings };
|
|
112
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for kj_suggest MCP tool.
|
|
3
|
+
* Allows the host AI to propose observations to Solomon without override power.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { getSessionRoot } from "../utils/paths.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find the most recent running session for a given projectDir.
|
|
12
|
+
* If projectDir is not provided, returns the most recent running session overall.
|
|
13
|
+
*/
|
|
14
|
+
async function findActiveSession(projectDir) {
|
|
15
|
+
const sessionRoot = getSessionRoot();
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
24
|
+
// Walk backwards (most recent first)
|
|
25
|
+
for (let i = dirs.length - 1; i >= 0; i--) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await fs.readFile(path.join(sessionRoot, dirs[i], "session.json"), "utf8");
|
|
28
|
+
const session = JSON.parse(raw);
|
|
29
|
+
if (session.status !== "running") continue;
|
|
30
|
+
if (projectDir && session.projectDir && session.projectDir !== projectDir) continue;
|
|
31
|
+
return session;
|
|
32
|
+
} catch {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle a suggestion from the host AI.
|
|
41
|
+
* Logs it to the active session's suggestions array for Solomon to evaluate.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} params
|
|
44
|
+
* @param {string} params.suggestion - The observation or proposal
|
|
45
|
+
* @param {string} [params.context] - Additional context
|
|
46
|
+
* @param {string} [params.projectDir] - Project directory to scope session lookup
|
|
47
|
+
* @returns {Promise<object>} Result with accepted status and message
|
|
48
|
+
*/
|
|
49
|
+
export async function handleSuggestion({ suggestion, context, projectDir }) {
|
|
50
|
+
if (!suggestion || typeof suggestion !== "string" || suggestion.trim() === "") {
|
|
51
|
+
return {
|
|
52
|
+
accepted: false,
|
|
53
|
+
reason: "Suggestion text is required and must be a non-empty string."
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const session = await findActiveSession(projectDir);
|
|
58
|
+
|
|
59
|
+
if (!session) {
|
|
60
|
+
return {
|
|
61
|
+
accepted: false,
|
|
62
|
+
reason: "No active pipeline session. Suggestions are only evaluated during pipeline execution."
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Initialize suggestions array if not present
|
|
67
|
+
if (!Array.isArray(session.suggestions)) {
|
|
68
|
+
session.suggestions = [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entry = {
|
|
72
|
+
suggestion: suggestion.trim(),
|
|
73
|
+
context: context || null,
|
|
74
|
+
timestamp: new Date().toISOString()
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
session.suggestions.push(entry);
|
|
78
|
+
|
|
79
|
+
// Persist the updated session
|
|
80
|
+
const sessionRoot = getSessionRoot();
|
|
81
|
+
const sessionFile = path.join(sessionRoot, session.id, "session.json");
|
|
82
|
+
session.updated_at = new Date().toISOString();
|
|
83
|
+
await fs.writeFile(sessionFile, JSON.stringify(session, null, 2), "utf8");
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
accepted: true,
|
|
87
|
+
status: "logged",
|
|
88
|
+
sessionId: session.id,
|
|
89
|
+
message: "Suggestion logged for Solomon evaluation in next iteration. Solomon will decide whether to act on it."
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/mcp/tools.js
CHANGED
|
@@ -334,5 +334,33 @@ export const tools = [
|
|
|
334
334
|
projectDir: { type: "string", description: "Project directory (defaults to cwd)" }
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: "kj_suggest",
|
|
340
|
+
description: "Propose an observation or suggestion to Karajan's Solomon engine. Solomon will evaluate it and decide whether to accept, reject, or ask the human. You CANNOT override pipeline decisions through this tool, only propose.",
|
|
341
|
+
inputSchema: {
|
|
342
|
+
type: "object",
|
|
343
|
+
required: ["suggestion"],
|
|
344
|
+
properties: {
|
|
345
|
+
suggestion: { type: "string", description: "What you observed or want to propose" },
|
|
346
|
+
context: { type: "string", description: "Additional context (current HU, iteration, etc.)" },
|
|
347
|
+
projectDir: { type: "string", description: "Project directory" }
|
|
348
|
+
}
|
|
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
|
+
}
|
|
337
365
|
}
|
|
338
366
|
];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SolomonRole } from "../roles/solomon-role.js";
|
|
2
|
-
import { addCheckpoint, pauseSession } from "../session-store.js";
|
|
2
|
+
import { addCheckpoint, pauseSession, saveSession } from "../session-store.js";
|
|
3
3
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -80,11 +80,19 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
|
|
|
80
80
|
})
|
|
81
81
|
);
|
|
82
82
|
|
|
83
|
+
// Collect pending suggestions from the session for Solomon's context
|
|
84
|
+
const pendingSuggestions = Array.isArray(session.suggestions) && session.suggestions.length > 0
|
|
85
|
+
? session.suggestions.slice()
|
|
86
|
+
: null;
|
|
87
|
+
|
|
83
88
|
const solomon = new SolomonRole({ config, logger, emitter });
|
|
84
89
|
await solomon.init({ task: conflict.task || session.task, iteration });
|
|
85
90
|
let ruling;
|
|
86
91
|
try {
|
|
87
|
-
|
|
92
|
+
const conflictWithSuggestions = pendingSuggestions
|
|
93
|
+
? { ...conflict, hostSuggestions: pendingSuggestions }
|
|
94
|
+
: conflict;
|
|
95
|
+
ruling = await solomon.run({ conflict: conflictWithSuggestions });
|
|
88
96
|
} catch (err) {
|
|
89
97
|
logger.warn(`Solomon threw: ${err.message}`);
|
|
90
98
|
return escalateToHuman({
|
|
@@ -93,6 +101,12 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
|
|
|
93
101
|
});
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
// Clear processed suggestions from session
|
|
105
|
+
if (pendingSuggestions) {
|
|
106
|
+
session.suggestions = [];
|
|
107
|
+
try { await saveSession(session); } catch { /* best-effort */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
const solomonError = ruling.result?.error;
|
|
97
111
|
if (!ruling.ok && solomonError) {
|
|
98
112
|
logger.warn(`Solomon execution failed: ${solomonError}`);
|
package/src/orchestrator.js
CHANGED
|
@@ -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,60 @@ 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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
+
});
|
|
1405
1431
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1432
|
+
emitProgress(emitter, makeEvent("hu:sub-pipeline:end", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
|
|
1433
|
+
status: subPipelineResult.approved ? "ok" : "fail",
|
|
1434
|
+
message: subPipelineResult.approved
|
|
1435
|
+
? "All HUs completed successfully"
|
|
1436
|
+
: `Sub-pipeline finished with failures (${subPipelineResult.blockedIds.length} blocked)`,
|
|
1437
|
+
detail: { results: subPipelineResult.results, blockedIds: subPipelineResult.blockedIds }
|
|
1438
|
+
}));
|
|
1413
1439
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1440
|
+
const finalResult = {
|
|
1441
|
+
approved: subPipelineResult.approved,
|
|
1442
|
+
sessionId: ctx.session.id,
|
|
1443
|
+
huResults: subPipelineResult.results,
|
|
1444
|
+
blockedIds: subPipelineResult.blockedIds
|
|
1445
|
+
};
|
|
1446
|
+
await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
|
|
1447
|
+
return finalResult;
|
|
1448
|
+
}
|
|
1423
1449
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1450
|
+
// --- Standard single-task pipeline (1 HU or no HU reviewer) ---
|
|
1451
|
+
const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
|
|
1452
|
+
await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
|
|
1453
|
+
return result;
|
|
1454
|
+
} finally {
|
|
1455
|
+
// --- Cleanup auto-installed skills ---
|
|
1456
|
+
const autoSkills = ctx.session?.autoInstalledSkills;
|
|
1457
|
+
if (autoSkills && autoSkills.length > 0) {
|
|
1458
|
+
const cleanProjectDir = ctx.config?.projectDir || process.cwd();
|
|
1459
|
+
try {
|
|
1460
|
+
const cleanupResult = await cleanupAutoInstalledSkills(autoSkills, cleanProjectDir);
|
|
1461
|
+
if (cleanupResult.removed.length > 0) {
|
|
1462
|
+
logger.info(`Cleaned up ${cleanupResult.removed.length} auto-installed skill(s): ${cleanupResult.removed.join(", ")}`);
|
|
1463
|
+
}
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
logger.warn(`Skill cleanup failed (non-blocking): ${err.message}`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1428
1469
|
}
|
|
1429
1470
|
|
|
1430
1471
|
export async function resumeFlow({ sessionId, answer, config, logger, flags = {}, emitter = null, askQuestion = null }) {
|
package/src/prompts/architect.js
CHANGED
|
@@ -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
|
|
package/src/prompts/coder.js
CHANGED
|
@@ -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
|
}
|
package/src/prompts/reviewer.js
CHANGED
|
@@ -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);
|
package/src/roles/coder-role.js
CHANGED
|
@@ -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
|
+
}
|