karajan-code 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +90 -7
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/plan.js +221 -37
  32. package/src/commands/resume.js +1 -1
  33. package/src/commands/run.js +2 -1
  34. package/src/commands/skills.js +49 -0
  35. package/src/config/schema.js +222 -0
  36. package/src/config.js +32 -3
  37. package/src/guards/policy-resolver.js +8 -0
  38. package/src/hu/store.js +9 -0
  39. package/src/infrastructure/command-runner.js +44 -0
  40. package/src/infrastructure/environment.js +38 -0
  41. package/src/infrastructure/file-system-service.js +63 -0
  42. package/src/infrastructure/mocks.js +182 -0
  43. package/src/mcp/handlers/management-handlers.js +7 -2
  44. package/src/mcp/tools.js +10 -3
  45. package/src/orchestrator/config-init.js +16 -1
  46. package/src/orchestrator/flow-runner.js +2085 -0
  47. package/src/orchestrator/pipeline-context.js +17 -0
  48. package/src/orchestrator/preflight-checks.js +78 -4
  49. package/src/orchestrator/solomon-escalation.js +21 -0
  50. package/src/orchestrator/stages/stage-executor.js +155 -0
  51. package/src/orchestrator.js +19 -1884
  52. package/src/plan/plan-executor.js +69 -0
  53. package/src/plan/plan-hu-ops.js +134 -0
  54. package/src/plan/plan-id.js +25 -0
  55. package/src/plan/plan-schema.js +139 -0
  56. package/src/plan/plan-store.js +58 -25
  57. package/src/prompts/architect.js +2 -2
  58. package/src/prompts/coder.js +2 -2
  59. package/src/prompts/prompt-resolver.js +127 -0
  60. package/src/prompts/reviewer.js +2 -2
  61. package/src/roles/architect-role.js +3 -1
  62. package/src/roles/base-role.js +23 -2
  63. package/src/roles/coder-role.js +3 -1
  64. package/src/roles/security-role.js +12 -0
  65. package/src/roles/tester-role.js +11 -0
  66. package/src/session/journal/decisions-writer.js +200 -0
  67. package/src/session/journal/iteration-logger.js +194 -0
  68. package/src/session/journal/summary-writer.js +176 -0
  69. package/src/session/journal/tree-writer.js +164 -0
  70. package/src/skills/semantic-detector.js +147 -0
  71. package/src/skills/skill-detector.js +186 -8
  72. package/src/skills/skill-loader.js +131 -1
  73. package/src/skills/skills-cache.js +128 -0
  74. package/src/types/agent.js +55 -0
  75. package/src/types/config.js +114 -0
  76. package/src/types/hu.js +47 -0
  77. package/src/types/index.js +34 -0
  78. package/src/types/policy.js +43 -0
  79. package/src/types/session.js +74 -0
  80. package/src/types/stage.js +68 -0
  81. package/src/utils/display/event-handlers.js +389 -0
  82. package/src/utils/display/formatters.js +132 -0
  83. package/src/utils/display/header.js +48 -0
  84. package/src/utils/display/session-summary.js +108 -0
  85. package/src/utils/display/solomon.js +42 -0
  86. package/src/utils/port-check.js +45 -0
  87. package/src/utils/port-occupant.js +95 -0
  88. package/src/utils/provider-env.js +116 -0
  89. package/src/utils/with-timeout.js +42 -0
  90. package/templates/roles/architect/anthropic.md +65 -0
  91. package/templates/roles/architect/default.md +63 -0
  92. package/templates/roles/architect/google.md +52 -0
  93. package/templates/roles/architect/openai.md +51 -0
  94. package/templates/roles/architect/opencode.md +50 -0
  95. package/templates/roles/coder/anthropic.md +72 -0
  96. package/templates/roles/coder/default.md +69 -0
  97. package/templates/roles/coder/google.md +78 -0
  98. package/templates/roles/coder/openai.md +59 -0
  99. package/templates/roles/coder/opencode.md +49 -0
  100. package/templates/roles/planner/anthropic.md +50 -0
  101. package/templates/roles/planner/default.md +48 -0
  102. package/templates/roles/planner/google.md +66 -0
  103. package/templates/roles/planner/openai.md +46 -0
  104. package/templates/roles/planner/opencode.md +53 -0
  105. package/templates/roles/reviewer/anthropic.md +61 -0
  106. package/templates/roles/reviewer/default.md +62 -0
  107. package/templates/roles/reviewer/google.md +63 -0
  108. package/templates/roles/reviewer/openai.md +57 -0
  109. package/templates/roles/reviewer/opencode.md +42 -0
  110. package/templates/roles/solomon/anthropic.md +71 -0
  111. package/templates/roles/solomon/default.md +83 -0
  112. package/templates/roles/solomon/google.md +61 -0
  113. package/templates/roles/solomon/openai.md +53 -0
  114. package/templates/roles/solomon/opencode.md +55 -0
  115. package/src/utils/display.js +0 -669
@@ -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
+ }