karajan-code 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/package.json +5 -2
- package/src/agents/aider-agent.js +1 -2
- package/src/agents/base-agent.js +63 -1
- package/src/agents/claude-agent.js +3 -4
- package/src/agents/codex-agent.js +1 -2
- package/src/agents/gemini-agent.js +1 -2
- package/src/agents/index.js +11 -2
- package/src/agents/opencode-agent.js +1 -2
- package/src/brain/decision-tracker.js +123 -0
- package/src/brain/decisor.js +243 -0
- package/src/brain/solomon-consult.js +163 -0
- package/src/budget/comparison.js +96 -0
- package/src/checks/binaries.js +123 -0
- package/src/checks/ci.js +105 -0
- package/src/checks/config-files.js +217 -0
- package/src/checks/dir-setup.js +63 -0
- package/src/checks/mcp-health.js +157 -0
- package/src/checks/node.js +61 -0
- package/src/checks/ports.js +110 -0
- package/src/checks/rtk.js +36 -0
- package/src/checks/runner.js +267 -0
- package/src/checks/skills.js +49 -0
- package/src/checks/sonar.js +67 -0
- package/src/checks/system.js +53 -0
- package/src/checks/tokens.js +96 -0
- package/src/checks/types.js +105 -0
- package/src/cli.js +90 -7
- package/src/commands/board.js +23 -0
- package/src/commands/doctor.js +130 -362
- package/src/commands/plan.js +221 -37
- package/src/commands/resume.js +1 -1
- package/src/commands/run.js +2 -1
- package/src/commands/skills.js +49 -0
- package/src/config/schema.js +222 -0
- package/src/config.js +32 -3
- package/src/guards/policy-resolver.js +8 -0
- package/src/hu/store.js +9 -0
- package/src/infrastructure/command-runner.js +44 -0
- package/src/infrastructure/environment.js +38 -0
- package/src/infrastructure/file-system-service.js +63 -0
- package/src/infrastructure/mocks.js +182 -0
- package/src/mcp/handlers/management-handlers.js +7 -2
- package/src/mcp/tools.js +10 -3
- package/src/orchestrator/config-init.js +16 -1
- package/src/orchestrator/flow-runner.js +2085 -0
- package/src/orchestrator/pipeline-context.js +17 -0
- package/src/orchestrator/preflight-checks.js +78 -4
- package/src/orchestrator/solomon-escalation.js +21 -0
- package/src/orchestrator/stages/stage-executor.js +155 -0
- package/src/orchestrator.js +19 -1884
- package/src/plan/plan-executor.js +69 -0
- package/src/plan/plan-hu-ops.js +134 -0
- package/src/plan/plan-id.js +25 -0
- package/src/plan/plan-schema.js +139 -0
- package/src/plan/plan-store.js +58 -25
- package/src/prompts/architect.js +2 -2
- package/src/prompts/coder.js +2 -2
- package/src/prompts/prompt-resolver.js +127 -0
- package/src/prompts/reviewer.js +2 -2
- package/src/roles/architect-role.js +3 -1
- package/src/roles/base-role.js +23 -2
- package/src/roles/coder-role.js +3 -1
- package/src/roles/security-role.js +12 -0
- package/src/roles/tester-role.js +11 -0
- package/src/session/journal/decisions-writer.js +200 -0
- package/src/session/journal/iteration-logger.js +194 -0
- package/src/session/journal/summary-writer.js +176 -0
- package/src/session/journal/tree-writer.js +164 -0
- package/src/skills/semantic-detector.js +147 -0
- package/src/skills/skill-detector.js +186 -8
- package/src/skills/skill-loader.js +131 -1
- package/src/skills/skills-cache.js +128 -0
- package/src/types/agent.js +55 -0
- package/src/types/config.js +114 -0
- package/src/types/hu.js +47 -0
- package/src/types/index.js +34 -0
- package/src/types/policy.js +43 -0
- package/src/types/session.js +74 -0
- package/src/types/stage.js +68 -0
- package/src/utils/display/event-handlers.js +389 -0
- package/src/utils/display/formatters.js +132 -0
- package/src/utils/display/header.js +48 -0
- package/src/utils/display/session-summary.js +108 -0
- package/src/utils/display/solomon.js +42 -0
- package/src/utils/port-check.js +45 -0
- package/src/utils/port-occupant.js +95 -0
- package/src/utils/provider-env.js +116 -0
- package/src/utils/with-timeout.js +42 -0
- package/templates/roles/architect/anthropic.md +65 -0
- package/templates/roles/architect/default.md +63 -0
- package/templates/roles/architect/google.md +52 -0
- package/templates/roles/architect/openai.md +51 -0
- package/templates/roles/architect/opencode.md +50 -0
- package/templates/roles/coder/anthropic.md +72 -0
- package/templates/roles/coder/default.md +69 -0
- package/templates/roles/coder/google.md +78 -0
- package/templates/roles/coder/openai.md +59 -0
- package/templates/roles/coder/opencode.md +49 -0
- package/templates/roles/planner/anthropic.md +50 -0
- package/templates/roles/planner/default.md +48 -0
- package/templates/roles/planner/google.md +66 -0
- package/templates/roles/planner/openai.md +46 -0
- package/templates/roles/planner/opencode.md +53 -0
- package/templates/roles/reviewer/anthropic.md +61 -0
- package/templates/roles/reviewer/default.md +62 -0
- package/templates/roles/reviewer/google.md +63 -0
- package/templates/roles/reviewer/openai.md +57 -0
- package/templates/roles/reviewer/opencode.md +42 -0
- package/templates/roles/solomon/anthropic.md +71 -0
- package/templates/roles/solomon/default.md +83 -0
- package/templates/roles/solomon/google.md +61 -0
- package/templates/roles/solomon/openai.md +53 -0
- package/templates/roles/solomon/opencode.md +55 -0
- package/src/utils/display.js +0 -669
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executive session summary (`summary.md`) — the entry point to the journal
|
|
3
|
+
* directory. Pulls together every useful signal from the session into a
|
|
4
|
+
* single scannable Markdown page with links to the rest of the journal.
|
|
5
|
+
*
|
|
6
|
+
* Acceptance criterion (KJC-TSK-0289):
|
|
7
|
+
* summary.md contains: task, result (approved/rejected/paused), iterations,
|
|
8
|
+
* duration, budget, stages + status, list of journal files, commits.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} SummaryInput
|
|
16
|
+
* @property {string} sessionId
|
|
17
|
+
* @property {string} task
|
|
18
|
+
* @property {"APPROVED"|"REJECTED"|"PAUSED"|"FAILED"|string} result
|
|
19
|
+
* @property {number} iterations
|
|
20
|
+
* @property {number} durationMs
|
|
21
|
+
* @property {{ total_cost_usd?: number, total_tokens?: number, breakdown_by_role?: Object }} [budget]
|
|
22
|
+
* @property {Record<string, { ok?: boolean, summary?: string }>} [stages]
|
|
23
|
+
* @property {Array<{ hash?: string, message?: string, date?: string, author?: string }>} [commits]
|
|
24
|
+
* @property {string[]} [files] - names of other journal files in the same dir
|
|
25
|
+
* @property {string} [startedAt] - ISO timestamp (optional, auto-derived if absent)
|
|
26
|
+
* @property {string} [finishedAt] - ISO timestamp (optional)
|
|
27
|
+
* @property {number} [solomonInvocations]
|
|
28
|
+
* @property {number} [brainDecisions]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function esc(text) {
|
|
32
|
+
return String(text ?? "").replace(/\|/g, "\\|");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatDuration(ms) {
|
|
36
|
+
if (!Number.isFinite(ms) || ms < 0) return "—";
|
|
37
|
+
if (ms < 1000) return `${ms}ms`;
|
|
38
|
+
const s = Math.round(ms / 1000);
|
|
39
|
+
if (s < 60) return `${s}s`;
|
|
40
|
+
const m = Math.floor(s / 60);
|
|
41
|
+
const rs = s % 60;
|
|
42
|
+
if (m < 60) return `${m}m ${rs}s`;
|
|
43
|
+
const h = Math.floor(m / 60);
|
|
44
|
+
const rm = m % 60;
|
|
45
|
+
return `${h}h ${rm}m ${rs}s`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatBudget(budget) {
|
|
49
|
+
if (!budget) return "—";
|
|
50
|
+
const cost = Number(budget.total_cost_usd ?? 0);
|
|
51
|
+
const tokens = Number(budget.total_tokens ?? 0);
|
|
52
|
+
const costStr = `$${cost.toFixed(2)}`;
|
|
53
|
+
const tokStr = tokens > 0 ? `${tokens.toLocaleString("en-US")} tokens` : "—";
|
|
54
|
+
return `${costStr} · ${tokStr}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resultBadge(result) {
|
|
58
|
+
const r = String(result || "").toUpperCase();
|
|
59
|
+
const map = {
|
|
60
|
+
APPROVED: "✅ APPROVED",
|
|
61
|
+
REJECTED: "❌ REJECTED",
|
|
62
|
+
PAUSED: "⏸ PAUSED",
|
|
63
|
+
FAILED: "❌ FAILED",
|
|
64
|
+
};
|
|
65
|
+
return map[r] || r;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderStagesTable(stages) {
|
|
69
|
+
if (!stages || Object.keys(stages).length === 0) return "_No stages recorded._";
|
|
70
|
+
const header = "| Stage | Status | Summary |\n|---|---|---|";
|
|
71
|
+
const rows = Object.entries(stages).map(([name, stageResult]) => {
|
|
72
|
+
const ok = stageResult?.ok !== false;
|
|
73
|
+
const status = ok ? "✅ pass" : "❌ fail";
|
|
74
|
+
const summary = esc(stageResult?.summary || "").slice(0, 120);
|
|
75
|
+
return `| \`${esc(name)}\` | ${status} | ${summary} |`;
|
|
76
|
+
});
|
|
77
|
+
return [header, ...rows].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderCommits(commits) {
|
|
81
|
+
if (!Array.isArray(commits) || commits.length === 0) return "_No commits in this session._";
|
|
82
|
+
return commits
|
|
83
|
+
.map((c) => {
|
|
84
|
+
const hash = (c.hash || "").slice(0, 7) || "?";
|
|
85
|
+
return `- \`${hash}\` ${esc(c.message || "(no message)")}`;
|
|
86
|
+
})
|
|
87
|
+
.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderBreakdownByRole(breakdown) {
|
|
91
|
+
if (!breakdown || typeof breakdown !== "object") return null;
|
|
92
|
+
const entries = Object.entries(breakdown)
|
|
93
|
+
.map(([role, m]) => ({
|
|
94
|
+
role,
|
|
95
|
+
tokens: Number(m?.total_tokens || 0),
|
|
96
|
+
cost: Number(m?.total_cost_usd || 0),
|
|
97
|
+
}))
|
|
98
|
+
.filter((e) => e.tokens > 0 || e.cost > 0);
|
|
99
|
+
if (entries.length === 0) return null;
|
|
100
|
+
const rows = entries
|
|
101
|
+
.sort((a, b) => b.cost - a.cost)
|
|
102
|
+
.map((e) => `| \`${esc(e.role)}\` | ${e.tokens.toLocaleString("en-US")} | $${e.cost.toFixed(4)} |`);
|
|
103
|
+
return ["| Role | Tokens | Cost |", "|---|---|---|", ...rows].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderJournalLinks(files) {
|
|
107
|
+
if (!Array.isArray(files) || files.length === 0) return "_No additional journal files._";
|
|
108
|
+
return files.map((f) => `- [${esc(f)}](./${f})`).join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the summary.md content.
|
|
113
|
+
*
|
|
114
|
+
* @param {SummaryInput} input
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function buildSummaryMarkdown(input) {
|
|
118
|
+
const sessionId = input.sessionId || "(unknown)";
|
|
119
|
+
const result = resultBadge(input.result);
|
|
120
|
+
const startedAt = input.startedAt || "(unknown)";
|
|
121
|
+
const finishedAt = input.finishedAt || new Date().toISOString();
|
|
122
|
+
|
|
123
|
+
const sections = [
|
|
124
|
+
`# Session summary — ${sessionId}`,
|
|
125
|
+
"",
|
|
126
|
+
`- **Result**: ${result}`,
|
|
127
|
+
`- **Task**: ${esc(input.task || "(no task)")}`,
|
|
128
|
+
`- **Iterations**: ${input.iterations ?? 0}`,
|
|
129
|
+
`- **Duration**: ${formatDuration(input.durationMs)}`,
|
|
130
|
+
`- **Budget**: ${formatBudget(input.budget)}`,
|
|
131
|
+
`- **Started**: ${startedAt}`,
|
|
132
|
+
`- **Finished**: ${finishedAt}`,
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
if (Number.isFinite(input.brainDecisions) && input.brainDecisions > 0) {
|
|
136
|
+
sections.push(`- **Brain decisions**: ${input.brainDecisions}`);
|
|
137
|
+
}
|
|
138
|
+
if (Number.isFinite(input.solomonInvocations) && input.solomonInvocations > 0) {
|
|
139
|
+
sections.push(`- **Solomon invocations**: ${input.solomonInvocations}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
sections.push("", "---", "", "## Stages", "", renderStagesTable(input.stages));
|
|
143
|
+
|
|
144
|
+
const breakdown = renderBreakdownByRole(input.budget?.breakdown_by_role);
|
|
145
|
+
if (breakdown) {
|
|
146
|
+
sections.push("", "## Budget breakdown (by role)", "", breakdown);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sections.push("", "## Commits", "", renderCommits(input.commits));
|
|
150
|
+
|
|
151
|
+
sections.push("", "## Journal files", "", renderJournalLinks(input.files));
|
|
152
|
+
|
|
153
|
+
return sections.join("\n") + "\n";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write summary.md to the given journal directory. Always writes (the file
|
|
158
|
+
* is the entry point; even a barebones session should have one).
|
|
159
|
+
*
|
|
160
|
+
* @param {string} journalDir
|
|
161
|
+
* @param {SummaryInput} input
|
|
162
|
+
* @param {{ logger?: Object }} [options]
|
|
163
|
+
* @returns {Promise<{ written: boolean, path: string|null }>}
|
|
164
|
+
*/
|
|
165
|
+
export async function writeSummaryJournal(journalDir, input, options = {}) {
|
|
166
|
+
const filePath = path.join(journalDir, "summary.md");
|
|
167
|
+
try {
|
|
168
|
+
await fs.mkdir(journalDir, { recursive: true });
|
|
169
|
+
await fs.writeFile(filePath, buildSummaryMarkdown(input), "utf8");
|
|
170
|
+
options.logger?.info?.(`Wrote session summary: ${filePath}`);
|
|
171
|
+
return { written: true, path: filePath };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
options.logger?.warn?.(`Failed to write session summary (non-blocking): ${err.message}`);
|
|
174
|
+
return { written: false, path: null };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a directory-grouped tree view of files affected during the session,
|
|
3
|
+
* rendered as `.reviews/{sessionId}/tree.txt`.
|
|
4
|
+
*
|
|
5
|
+
* Acceptance criterion (KJC-TSK-0288):
|
|
6
|
+
* - Given a kj run that modifies files, when the session finishes, then
|
|
7
|
+
* tree.txt contains the tree of affected files (modified/added/deleted)
|
|
8
|
+
* grouped by directory, generated from `git diff --name-status` against
|
|
9
|
+
* the base ref.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { runCommand } from "../../utils/process.js";
|
|
15
|
+
|
|
16
|
+
const STATUS_LABELS = {
|
|
17
|
+
M: "modified",
|
|
18
|
+
A: "added",
|
|
19
|
+
D: "deleted",
|
|
20
|
+
R: "renamed",
|
|
21
|
+
C: "copied",
|
|
22
|
+
T: "typechange",
|
|
23
|
+
U: "unmerged",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} DiffEntry
|
|
28
|
+
* @property {string} file - path relative to repo root
|
|
29
|
+
* @property {string} status - "modified" | "added" | "deleted" | ...
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse `git diff --name-status` output into structured entries.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} rawDiff
|
|
36
|
+
* @returns {DiffEntry[]}
|
|
37
|
+
*/
|
|
38
|
+
export function parseNameStatus(rawDiff) {
|
|
39
|
+
if (!rawDiff || !rawDiff.trim()) return [];
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (const line of rawDiff.split("\n")) {
|
|
42
|
+
if (!line.trim()) continue;
|
|
43
|
+
const parts = line.split("\t");
|
|
44
|
+
const status = parts[0];
|
|
45
|
+
// Renames and copies report "R<score>\told\tnew". Take the new path.
|
|
46
|
+
const file = parts.length >= 3 ? parts[2] : parts[1];
|
|
47
|
+
if (!status || !file) continue;
|
|
48
|
+
const key = status[0];
|
|
49
|
+
const label = STATUS_LABELS[key] || "other";
|
|
50
|
+
entries.push({ file, status: label });
|
|
51
|
+
}
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Group entries into a nested directory tree.
|
|
57
|
+
* Each node is { name, children: Map<string, node>, entries: DiffEntry[] }.
|
|
58
|
+
*
|
|
59
|
+
* @param {DiffEntry[]} entries
|
|
60
|
+
* @returns {{name:string, children:Map<string, any>, files:DiffEntry[]}}
|
|
61
|
+
*/
|
|
62
|
+
export function buildTree(entries) {
|
|
63
|
+
const root = { name: "", children: new Map(), files: [] };
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const parts = entry.file.split("/");
|
|
66
|
+
let node = root;
|
|
67
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
68
|
+
const dir = parts[i];
|
|
69
|
+
if (!node.children.has(dir)) {
|
|
70
|
+
node.children.set(dir, { name: dir, children: new Map(), files: [] });
|
|
71
|
+
}
|
|
72
|
+
node = node.children.get(dir);
|
|
73
|
+
}
|
|
74
|
+
node.files.push({ file: parts.at(-1), status: entry.status });
|
|
75
|
+
}
|
|
76
|
+
return root;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render a tree node as indented text. Directories come before files at each
|
|
81
|
+
* level; both are sorted alphabetically (case-insensitive).
|
|
82
|
+
*/
|
|
83
|
+
function renderNode(node, depth, lines) {
|
|
84
|
+
const indent = " ".repeat(depth);
|
|
85
|
+
const dirs = Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
86
|
+
for (const dir of dirs) {
|
|
87
|
+
lines.push(`${indent}${dir.name}/`);
|
|
88
|
+
renderNode(dir, depth + 1, lines);
|
|
89
|
+
}
|
|
90
|
+
const files = [...node.files].sort((a, b) => a.file.localeCompare(b.file, undefined, { sensitivity: "base" }));
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
lines.push(`${indent}${f.file} [${f.status}]`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render the tree as a self-describing text document.
|
|
98
|
+
* Returns null when there are no entries.
|
|
99
|
+
*
|
|
100
|
+
* @param {DiffEntry[]} entries
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
export function renderTree(entries) {
|
|
104
|
+
if (!entries || entries.length === 0) return null;
|
|
105
|
+
const tree = buildTree(entries);
|
|
106
|
+
const lines = ["# Affected files (grouped by directory)", ""];
|
|
107
|
+
renderNode(tree, 0, lines);
|
|
108
|
+
lines.push("");
|
|
109
|
+
|
|
110
|
+
// Summary counts at the bottom for quick scanning.
|
|
111
|
+
const counts = entries.reduce((acc, e) => {
|
|
112
|
+
acc[e.status] = (acc[e.status] || 0) + 1;
|
|
113
|
+
return acc;
|
|
114
|
+
}, {});
|
|
115
|
+
const summary = Object.entries(counts)
|
|
116
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
117
|
+
.map(([status, n]) => `${n} ${status}`)
|
|
118
|
+
.join(", ");
|
|
119
|
+
lines.push(`Total: ${entries.length} file${entries.length === 1 ? "" : "s"} — ${summary}`);
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run `git diff --name-status <baseRef>...HEAD` and return structured entries.
|
|
125
|
+
* Returns [] if git exits with an error or produces no output.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} baseRef
|
|
128
|
+
* @returns {Promise<DiffEntry[]>}
|
|
129
|
+
*/
|
|
130
|
+
export async function collectAffectedFiles(baseRef) {
|
|
131
|
+
if (!baseRef) return [];
|
|
132
|
+
try {
|
|
133
|
+
const result = await runCommand("git", ["diff", "--name-status", `${baseRef}...HEAD`]);
|
|
134
|
+
if (result.exitCode !== 0) return [];
|
|
135
|
+
return parseNameStatus(result.stdout || "");
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* High-level: generate the tree content and persist it to `tree.txt` under
|
|
143
|
+
* the given journal directory. Returns `{ written, path }`.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} journalDir
|
|
146
|
+
* @param {string} baseRef
|
|
147
|
+
* @param {{ logger?: Object }} [options]
|
|
148
|
+
* @returns {Promise<{ written: boolean, path: string|null }>}
|
|
149
|
+
*/
|
|
150
|
+
export async function writeTreeJournal(journalDir, baseRef, options = {}) {
|
|
151
|
+
const entries = await collectAffectedFiles(baseRef);
|
|
152
|
+
const content = renderTree(entries);
|
|
153
|
+
if (!content) return { written: false, path: null };
|
|
154
|
+
const filePath = path.join(journalDir, "tree.txt");
|
|
155
|
+
try {
|
|
156
|
+
await fs.mkdir(journalDir, { recursive: true });
|
|
157
|
+
await fs.writeFile(filePath, content + "\n", "utf8");
|
|
158
|
+
options.logger?.info?.(`Wrote file tree journal: ${filePath}`);
|
|
159
|
+
return { written: true, path: filePath };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
options.logger?.warn?.(`Failed to write tree journal (non-blocking): ${err.message}`);
|
|
162
|
+
return { written: false, path: null };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic skill detection using the triage classifier.
|
|
3
|
+
*
|
|
4
|
+
* The regex-based detector (skill-detector.js) is fast but shallow: it only
|
|
5
|
+
* catches dependencies that name themselves explicitly. A task like
|
|
6
|
+
* "add a feature flag system" might benefit from an "a/b-testing" skill
|
|
7
|
+
* that regex cannot guess without an exhaustive keyword list.
|
|
8
|
+
*
|
|
9
|
+
* Semantic mode asks the classifier agent to pick skills from a known
|
|
10
|
+
* catalog given the task text and the skills already detected by regex.
|
|
11
|
+
* The catalog is intentionally small and curated — we don't hit the
|
|
12
|
+
* openskills.cc marketplace API (not available). If the classifier is
|
|
13
|
+
* unavailable (missing provider / missing API key), we return [] and the
|
|
14
|
+
* pipeline keeps working with the regex-only result.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createAgent } from "../agents/index.js";
|
|
18
|
+
import { resolveRole } from "../config.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Curated catalog of skills the classifier is allowed to suggest. Keeping
|
|
22
|
+
* this small prevents the model from hallucinating non-existent skills and
|
|
23
|
+
* bounds the tokens spent on the prompt.
|
|
24
|
+
*/
|
|
25
|
+
export const SEMANTIC_CATALOG = [
|
|
26
|
+
"astro", "nextjs", "react", "vue", "svelte", "angular",
|
|
27
|
+
"express", "fastify", "nestjs",
|
|
28
|
+
"java", "kotlin", "go", "rust", "ruby", "php", "dotnet", "elixir",
|
|
29
|
+
"python", "python-data", "python-ml", "pytest-patterns",
|
|
30
|
+
"django", "flask", "fastapi",
|
|
31
|
+
"sql-analysis", "prisma", "mongodb",
|
|
32
|
+
"owasp-top-10", "security-checklist",
|
|
33
|
+
"java-code-review", "python-code-review",
|
|
34
|
+
"frontend-design", "csv-transform", "data-report",
|
|
35
|
+
"vitest-patterns", "jest-patterns", "playwright-patterns", "cypress-patterns",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const PROMPT = (task, catalog, alreadyDetected) => `You are a classifier that picks which domain skills would help solve a task.
|
|
39
|
+
|
|
40
|
+
Task:
|
|
41
|
+
"""${task}"""
|
|
42
|
+
|
|
43
|
+
Already detected skills (do NOT repeat, but consider the task context they imply):
|
|
44
|
+
${alreadyDetected.length > 0 ? alreadyDetected.join(", ") : "(none)"}
|
|
45
|
+
|
|
46
|
+
From this exact catalog, output a JSON array with the names of 0 to 5 additional skills that would meaningfully help. Output ONLY the JSON array, no prose.
|
|
47
|
+
|
|
48
|
+
Catalog (pick strict matches only):
|
|
49
|
+
${catalog.join(", ")}
|
|
50
|
+
|
|
51
|
+
Rules:
|
|
52
|
+
- Only names from the catalog. Do NOT invent.
|
|
53
|
+
- Prefer precision over recall: empty array is a valid answer when nothing fits.
|
|
54
|
+
- Do NOT include already-detected skills.
|
|
55
|
+
- Respond with a single JSON array, e.g. ["python-data","sql-analysis"]
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
function tryParseArray(raw) {
|
|
59
|
+
if (!raw) return [];
|
|
60
|
+
try {
|
|
61
|
+
const m = String(raw).match(/\[[^\]]*\]/);
|
|
62
|
+
if (!m) return [];
|
|
63
|
+
const parsed = JSON.parse(m[0]);
|
|
64
|
+
if (!Array.isArray(parsed)) return [];
|
|
65
|
+
return parsed.filter((s) => typeof s === "string");
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ask the classifier for additional skills.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} args
|
|
75
|
+
* @param {string} args.task
|
|
76
|
+
* @param {string[]} args.alreadyDetected
|
|
77
|
+
* @param {Object} args.config - Karajan config
|
|
78
|
+
* @param {Object} [args.logger]
|
|
79
|
+
* @returns {Promise<string[]>}
|
|
80
|
+
*/
|
|
81
|
+
export async function refineSkillsSemantically({ task, alreadyDetected = [], config, logger }) {
|
|
82
|
+
if (!task || typeof task !== "string") return [];
|
|
83
|
+
let provider;
|
|
84
|
+
try {
|
|
85
|
+
provider = resolveRole(config, "triage")?.provider
|
|
86
|
+
|| resolveRole(config, "planner")?.provider
|
|
87
|
+
|| resolveRole(config, "coder")?.provider;
|
|
88
|
+
} catch {
|
|
89
|
+
provider = null;
|
|
90
|
+
}
|
|
91
|
+
if (!provider) {
|
|
92
|
+
logger?.debug?.("semantic-detector: no classifier provider — skipping");
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let agent;
|
|
97
|
+
try {
|
|
98
|
+
agent = createAgent(provider, config, logger);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger?.debug?.(`semantic-detector: classifier unavailable (${err.message}) — skipping`);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
if (!agent || typeof agent.runTask !== "function") {
|
|
104
|
+
logger?.debug?.("semantic-detector: classifier agent has no runTask() — skipping");
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const prompt = PROMPT(task, SEMANTIC_CATALOG, alreadyDetected);
|
|
109
|
+
let res;
|
|
110
|
+
try {
|
|
111
|
+
res = await agent.runTask(prompt, { timeoutMs: 30_000 });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger?.warn?.(`semantic-detector: classifier call failed (${err.message}) — skipping`);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
if (!res?.ok) {
|
|
117
|
+
logger?.debug?.("semantic-detector: classifier returned not-ok — skipping");
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const suggested = tryParseArray(res.output || res.result?.raw || "");
|
|
122
|
+
const allowed = new Set(SEMANTIC_CATALOG);
|
|
123
|
+
const alreadySet = new Set(alreadyDetected.map((s) => s.toLowerCase()));
|
|
124
|
+
return suggested
|
|
125
|
+
.map((s) => String(s).trim())
|
|
126
|
+
.filter((s) => s && allowed.has(s) && !alreadySet.has(s.toLowerCase()))
|
|
127
|
+
.slice(0, 5);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the effective skills mode from config + flags.
|
|
132
|
+
* Precedence: flags.skillsMode > config.skills.mode > globalThis default > "auto".
|
|
133
|
+
*
|
|
134
|
+
* The global default lets the test harness opt out of semantic detection
|
|
135
|
+
* without each orchestrator test having to mock this module (tests/setup.js
|
|
136
|
+
* sets globalThis.__KJ_DEFAULT_SKILLS_MODE = "regex").
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} config
|
|
139
|
+
* @param {Object} [flags]
|
|
140
|
+
* @returns {"auto"|"regex"|"semantic"|"none"}
|
|
141
|
+
*/
|
|
142
|
+
export function resolveSkillsMode(config, flags = {}) {
|
|
143
|
+
const defaultMode = globalThis.__KJ_DEFAULT_SKILLS_MODE || "auto";
|
|
144
|
+
const candidate = flags.skillsMode ?? config?.skills?.mode ?? defaultMode;
|
|
145
|
+
const valid = new Set(["auto", "regex", "semantic", "none"]);
|
|
146
|
+
return valid.has(candidate) ? candidate : "auto";
|
|
147
|
+
}
|