karajan-code 1.57.0 → 1.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/docs/README.es.md +19 -7
- package/package.json +1 -1
- package/scripts/ai-attribution-guard.yml +77 -0
- package/scripts/esbuild-sea.config.mjs +83 -15
- package/scripts/install-guard-all-repos.sh +85 -0
- package/scripts/protect-all-repos.sh +88 -0
- package/src/bootstrap.js +8 -4
- package/src/cli.js +7 -0
- package/src/commands/init.js +30 -0
- package/src/config.js +19 -6
- package/src/domains/domain-loader.js +164 -0
- package/src/domains/domain-registry.js +140 -0
- package/src/domains/domain-synthesizer.js +129 -0
- package/src/mcp/shared-helpers.js +132 -4
- package/src/orchestrator/pipeline-context.js +3 -0
- package/src/orchestrator/preflight-checks.js +16 -5
- package/src/orchestrator/stages/domain-curator-stage.js +82 -0
- package/src/orchestrator/stages/triage-stage.js +4 -1
- package/src/orchestrator.js +18 -0
- package/src/prompts/architect.js +5 -1
- package/src/prompts/coder.js +5 -1
- package/src/prompts/hu-reviewer.js +5 -1
- package/src/prompts/planner.js +5 -1
- package/src/prompts/reviewer.js +5 -1
- package/src/roles/architect-role.js +1 -1
- package/src/roles/coder-role.js +2 -1
- package/src/roles/domain-curator-role.js +141 -0
- package/src/roles/hu-reviewer-role.js +1 -1
- package/src/roles/planner-role.js +6 -2
- package/src/roles/reviewer-role.js +7 -2
- package/src/roles/triage-role.js +10 -0
- package/src/skills/skill-loader.js +35 -4
- package/src/utils/process.js +4 -0
- package/src/utils/welcome.js +52 -0
- package/templates/domains/example-dental.md +57 -0
- package/templates/roles/domain-curator.md +52 -0
- package/templates/roles/triage.md +15 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain Knowledge loader.
|
|
3
|
+
* Scans .karajan/domains/ (project-local) and ~/.karajan/domains/ (user-global)
|
|
4
|
+
* for DOMAIN.md files with YAML frontmatter.
|
|
5
|
+
* Project-local domains override user-global domains by directory name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import yaml from "js-yaml";
|
|
11
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
12
|
+
|
|
13
|
+
const DOMAIN_FILE = "DOMAIN.md";
|
|
14
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a DOMAIN.md file into a structured DomainFile object.
|
|
18
|
+
* @param {string} filePath — absolute path to the DOMAIN.md file
|
|
19
|
+
* @returns {Promise<DomainFile|null>} parsed domain or null on error
|
|
20
|
+
*
|
|
21
|
+
* @typedef {Object} DomainSection
|
|
22
|
+
* @property {string} heading — section heading (e.g. "Core Concepts")
|
|
23
|
+
* @property {string} content — section body (markdown)
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} DomainFile
|
|
26
|
+
* @property {string} name
|
|
27
|
+
* @property {string} description
|
|
28
|
+
* @property {string[]} tags
|
|
29
|
+
* @property {string} version
|
|
30
|
+
* @property {string} author
|
|
31
|
+
* @property {string} visibility — "private" | "public"
|
|
32
|
+
* @property {Array<{type: string, note?: string}>} sources
|
|
33
|
+
* @property {string} content — full markdown body (after frontmatter)
|
|
34
|
+
* @property {DomainSection[]} sections — parsed markdown sections
|
|
35
|
+
* @property {string} filePath — original file path
|
|
36
|
+
* @property {string} [origin] — "project" | "user" (set by loadDomains)
|
|
37
|
+
*/
|
|
38
|
+
export async function parseDomainFile(filePath) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(filePath, "utf-8");
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const match = raw.match(FRONTMATTER_RE);
|
|
47
|
+
if (!match) return null;
|
|
48
|
+
|
|
49
|
+
let meta;
|
|
50
|
+
try {
|
|
51
|
+
meta = yaml.load(match[1]);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!meta || typeof meta !== "object" || !meta.name) return null;
|
|
57
|
+
|
|
58
|
+
const content = match[2].trim();
|
|
59
|
+
const sections = parseSections(content);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: meta.name,
|
|
63
|
+
description: meta.description || "",
|
|
64
|
+
tags: Array.isArray(meta.tags) ? meta.tags : [],
|
|
65
|
+
version: meta.version || "0.0.0",
|
|
66
|
+
author: meta.author || "",
|
|
67
|
+
visibility: meta.visibility || "private",
|
|
68
|
+
sources: Array.isArray(meta.sources) ? meta.sources : [],
|
|
69
|
+
content,
|
|
70
|
+
sections,
|
|
71
|
+
filePath
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split markdown content into sections by ## headings.
|
|
77
|
+
* @param {string} content
|
|
78
|
+
* @returns {DomainSection[]}
|
|
79
|
+
*/
|
|
80
|
+
function parseSections(content) {
|
|
81
|
+
const sections = [];
|
|
82
|
+
const lines = content.split("\n");
|
|
83
|
+
let current = null;
|
|
84
|
+
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const headingMatch = line.match(/^##\s+(.+)/);
|
|
87
|
+
if (headingMatch) {
|
|
88
|
+
if (current) {
|
|
89
|
+
current.content = current.content.trim();
|
|
90
|
+
sections.push(current);
|
|
91
|
+
}
|
|
92
|
+
current = { heading: headingMatch[1].trim(), content: "" };
|
|
93
|
+
} else if (current) {
|
|
94
|
+
current.content += line + "\n";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (current) {
|
|
99
|
+
current.content = current.content.trim();
|
|
100
|
+
sections.push(current);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return sections;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load all domains from project-local and user-global directories.
|
|
108
|
+
* Project-local domains override user-global domains when directory names match.
|
|
109
|
+
*
|
|
110
|
+
* @param {string|null} projectDir — absolute path to project root (null = user-global only)
|
|
111
|
+
* @returns {Promise<DomainFile[]>}
|
|
112
|
+
*/
|
|
113
|
+
export async function loadDomains(projectDir) {
|
|
114
|
+
const userDir = join(getKarajanHome(), "domains");
|
|
115
|
+
const projectDomainDir = projectDir ? join(projectDir, ".karajan", "domains") : null;
|
|
116
|
+
|
|
117
|
+
// Load user-global first, then project-local overrides
|
|
118
|
+
const userDomains = await scanDomainDir(userDir, "user");
|
|
119
|
+
const projectDomains = projectDomainDir ? await scanDomainDir(projectDomainDir, "project") : [];
|
|
120
|
+
|
|
121
|
+
// Build a map keyed by directory name for merge.
|
|
122
|
+
// We track by directory name (not domain name) because the override is by directory.
|
|
123
|
+
const merged = new Map();
|
|
124
|
+
|
|
125
|
+
for (const { dirName, domain } of userDomains) {
|
|
126
|
+
merged.set(dirName, domain);
|
|
127
|
+
}
|
|
128
|
+
for (const { dirName, domain } of projectDomains) {
|
|
129
|
+
merged.set(dirName, domain); // project overrides user
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Array.from(merged.values());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Scan a single domains directory for DOMAIN.md files.
|
|
137
|
+
* @param {string} dir — absolute path to a domains/ directory
|
|
138
|
+
* @param {string} origin — "project" | "user"
|
|
139
|
+
* @returns {Promise<Array<{dirName: string, domain: DomainFile}>>}
|
|
140
|
+
*/
|
|
141
|
+
async function scanDomainDir(dir, origin) {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const results = [];
|
|
150
|
+
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!entry.isDirectory()) continue;
|
|
153
|
+
|
|
154
|
+
const domainPath = join(dir, entry.name, DOMAIN_FILE);
|
|
155
|
+
const domain = await parseDomainFile(domainPath);
|
|
156
|
+
|
|
157
|
+
if (domain) {
|
|
158
|
+
domain.origin = origin;
|
|
159
|
+
results.push({ dirName: entry.name, domain });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return results;
|
|
164
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local domain registry.
|
|
3
|
+
* Tracks installed domains in ~/.karajan/domain-registry.json.
|
|
4
|
+
* Provides search and filtering by tags/hints.
|
|
5
|
+
* Interface prepared for future remote registry extension.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
11
|
+
|
|
12
|
+
const REGISTRY_FILE = "domain-registry.json";
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} RegistryEntry
|
|
17
|
+
* @property {string} name
|
|
18
|
+
* @property {string} [description]
|
|
19
|
+
* @property {string} version
|
|
20
|
+
* @property {string} source — "local" (future: "remote")
|
|
21
|
+
* @property {string} installedAt — ISO timestamp
|
|
22
|
+
* @property {string} filePath — absolute path to DOMAIN.md
|
|
23
|
+
* @property {string[]} tags
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export class DomainRegistry {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Record<string, RegistryEntry>} domains
|
|
29
|
+
*/
|
|
30
|
+
constructor(domains = {}) {
|
|
31
|
+
this._domains = domains;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load registry from disk. Creates empty registry if file is missing or corrupt.
|
|
36
|
+
* @returns {Promise<DomainRegistry>}
|
|
37
|
+
*/
|
|
38
|
+
static async load() {
|
|
39
|
+
const filePath = join(getKarajanHome(), REGISTRY_FILE);
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(filePath, "utf-8");
|
|
42
|
+
const data = JSON.parse(raw);
|
|
43
|
+
if (data?.schemaVersion === SCHEMA_VERSION && data.domains) {
|
|
44
|
+
return new DomainRegistry(data.domains);
|
|
45
|
+
}
|
|
46
|
+
} catch { /* file missing, corrupt, or wrong schema */ }
|
|
47
|
+
return new DomainRegistry();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Persist registry to disk.
|
|
52
|
+
*/
|
|
53
|
+
async save() {
|
|
54
|
+
const home = getKarajanHome();
|
|
55
|
+
await mkdir(home, { recursive: true });
|
|
56
|
+
const filePath = join(home, REGISTRY_FILE);
|
|
57
|
+
const data = {
|
|
58
|
+
schemaVersion: SCHEMA_VERSION,
|
|
59
|
+
domains: this._domains
|
|
60
|
+
};
|
|
61
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register or update a domain in the registry.
|
|
66
|
+
* @param {{name: string, version?: string, tags?: string[], filePath?: string, description?: string}} domainFile
|
|
67
|
+
*/
|
|
68
|
+
register(domainFile) {
|
|
69
|
+
this._domains[domainFile.name] = {
|
|
70
|
+
name: domainFile.name,
|
|
71
|
+
description: domainFile.description || "",
|
|
72
|
+
version: domainFile.version || "0.0.0",
|
|
73
|
+
source: "local",
|
|
74
|
+
installedAt: new Date().toISOString(),
|
|
75
|
+
filePath: domainFile.filePath || "",
|
|
76
|
+
tags: domainFile.tags || []
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* List domains, optionally filtered by tags and/or free-text query.
|
|
82
|
+
* @param {{tags?: string[], query?: string}} [filters]
|
|
83
|
+
* @returns {RegistryEntry[]}
|
|
84
|
+
*/
|
|
85
|
+
list(filters = {}) {
|
|
86
|
+
let entries = Object.values(this._domains);
|
|
87
|
+
|
|
88
|
+
if (filters.tags?.length) {
|
|
89
|
+
const tagSet = new Set(filters.tags.map(t => t.toLowerCase()));
|
|
90
|
+
entries = entries.filter(e =>
|
|
91
|
+
e.tags?.some(t => tagSet.has(t.toLowerCase()))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (filters.query) {
|
|
96
|
+
const q = filters.query.toLowerCase();
|
|
97
|
+
entries = entries.filter(e =>
|
|
98
|
+
(e.name || "").toLowerCase().includes(q) ||
|
|
99
|
+
(e.description || "").toLowerCase().includes(q)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Search domains by triage hints.
|
|
108
|
+
* Matches hints against tags, name and description.
|
|
109
|
+
* Returns results ranked by number of matching hints.
|
|
110
|
+
* @param {string[]} hints — keywords from triage domainHints
|
|
111
|
+
* @returns {RegistryEntry[]}
|
|
112
|
+
*/
|
|
113
|
+
search(hints) {
|
|
114
|
+
if (!hints?.length) return [];
|
|
115
|
+
|
|
116
|
+
const lowerHints = hints.map(h => h.toLowerCase());
|
|
117
|
+
const entries = Object.values(this._domains);
|
|
118
|
+
const scored = [];
|
|
119
|
+
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const searchable = [
|
|
122
|
+
entry.name || "",
|
|
123
|
+
entry.description || "",
|
|
124
|
+
...(entry.tags || [])
|
|
125
|
+
].join(" ").toLowerCase();
|
|
126
|
+
|
|
127
|
+
let score = 0;
|
|
128
|
+
for (const hint of lowerHints) {
|
|
129
|
+
if (searchable.includes(hint)) score++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (score > 0) {
|
|
133
|
+
scored.push({ entry, score });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
scored.sort((a, b) => b.score - a.score);
|
|
138
|
+
return scored.map(s => s.entry);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain context synthesizer.
|
|
3
|
+
* Filters relevant sections from domain files based on task + hints,
|
|
4
|
+
* and compacts the output to stay within a token budget.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_TOKENS = 4000;
|
|
8
|
+
const CHARS_PER_TOKEN = 4;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Synthesize a domain context string from selected domains,
|
|
12
|
+
* filtered by relevance to the task and domain hints.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} params
|
|
15
|
+
* @param {string} params.task — task description
|
|
16
|
+
* @param {string[]} params.domainHints — keywords from triage
|
|
17
|
+
* @param {import('./domain-loader.js').DomainFile[]|null} params.selectedDomains
|
|
18
|
+
* @param {number} [params.maxTokens=4000] — token budget (0 = unlimited)
|
|
19
|
+
* @returns {string} synthesized markdown context or empty string
|
|
20
|
+
*/
|
|
21
|
+
export function synthesizeDomainContext({ task, domainHints = [], selectedDomains, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
22
|
+
if (!selectedDomains?.length) return "";
|
|
23
|
+
|
|
24
|
+
const keywords = buildKeywords(task, domainHints);
|
|
25
|
+
const domainBlocks = [];
|
|
26
|
+
|
|
27
|
+
for (const domain of selectedDomains) {
|
|
28
|
+
const sections = domain.sections || [];
|
|
29
|
+
if (!sections.length) continue;
|
|
30
|
+
|
|
31
|
+
// Score all sections but include them all (domain was explicitly selected).
|
|
32
|
+
// Sort by relevance so highest-scoring sections survive token truncation.
|
|
33
|
+
const scored = sections
|
|
34
|
+
.map(section => ({
|
|
35
|
+
section,
|
|
36
|
+
score: scoreSection(section, keywords)
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => b.score - a.score);
|
|
39
|
+
|
|
40
|
+
const selected = scored.map(s => s.section);
|
|
41
|
+
|
|
42
|
+
const sectionMd = selected
|
|
43
|
+
.map(s => `#### ${s.heading}\n${s.content}`)
|
|
44
|
+
.join("\n\n");
|
|
45
|
+
|
|
46
|
+
domainBlocks.push(`### ${domain.name}\n\n${sectionMd}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!domainBlocks.length) return "";
|
|
50
|
+
|
|
51
|
+
let result = domainBlocks.join("\n\n---\n\n");
|
|
52
|
+
|
|
53
|
+
// Truncate by token budget if needed
|
|
54
|
+
if (maxTokens > 0) {
|
|
55
|
+
const maxChars = maxTokens * CHARS_PER_TOKEN;
|
|
56
|
+
if (result.length > maxChars) {
|
|
57
|
+
result = truncateBySection(result, maxChars);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a set of lowercase keywords from task text and domain hints.
|
|
66
|
+
* @param {string} task
|
|
67
|
+
* @param {string[]} hints
|
|
68
|
+
* @returns {string[]}
|
|
69
|
+
*/
|
|
70
|
+
function buildKeywords(task, hints) {
|
|
71
|
+
const words = new Set();
|
|
72
|
+
|
|
73
|
+
// Add domain hints
|
|
74
|
+
for (const h of hints) {
|
|
75
|
+
words.add(h.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Extract meaningful words from task (>3 chars, skip stop words)
|
|
79
|
+
const stopWords = new Set(["the", "this", "that", "with", "from", "have", "will", "should", "could", "would", "been", "being", "their", "there", "where", "when", "what", "which", "about", "into", "some", "than", "then", "them", "they", "also", "each", "more", "most", "other"]);
|
|
80
|
+
|
|
81
|
+
const taskWords = task
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
84
|
+
.split(/\s+/)
|
|
85
|
+
.filter(w => w.length > 3 && !stopWords.has(w));
|
|
86
|
+
|
|
87
|
+
for (const w of taskWords) {
|
|
88
|
+
words.add(w);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Array.from(words);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Score a section by keyword overlap.
|
|
96
|
+
* @param {import('./domain-loader.js').DomainSection} section
|
|
97
|
+
* @param {string[]} keywords
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
function scoreSection(section, keywords) {
|
|
101
|
+
const text = `${section.heading} ${section.content}`.toLowerCase();
|
|
102
|
+
let score = 0;
|
|
103
|
+
for (const kw of keywords) {
|
|
104
|
+
if (text.includes(kw)) score++;
|
|
105
|
+
}
|
|
106
|
+
return score;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Truncate result by dropping trailing sections to fit within maxChars.
|
|
111
|
+
* Preserves complete sections rather than cutting mid-sentence.
|
|
112
|
+
* @param {string} text
|
|
113
|
+
* @param {number} maxChars
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function truncateBySection(text, maxChars) {
|
|
117
|
+
// Split by section markers (#### or ###)
|
|
118
|
+
const parts = text.split(/(?=^#{3,4}\s)/m);
|
|
119
|
+
let result = "";
|
|
120
|
+
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
if (result.length + part.length > maxChars && result.length > 0) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
result += part;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result.trim();
|
|
129
|
+
}
|
|
@@ -104,11 +104,125 @@ export async function buildConfig(options, commandName = "run") {
|
|
|
104
104
|
return merged;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Parse a structured response from free-text input.
|
|
109
|
+
* Exported for testing.
|
|
110
|
+
* @param {string} raw — user's raw text answer
|
|
111
|
+
* @param {string} type — question type: "multi-select" | "select" | "confirm" | "text"
|
|
112
|
+
* @param {Array<{id: string, label: string}>} [options] — available options (for select types)
|
|
113
|
+
* @returns {string[]|string|boolean|null} parsed response
|
|
114
|
+
*/
|
|
115
|
+
export function parseStructuredResponse(raw, type, options = []) {
|
|
116
|
+
const trimmed = (raw || "").trim();
|
|
117
|
+
const lower = trimmed.toLowerCase();
|
|
118
|
+
|
|
119
|
+
if (type === "confirm") {
|
|
120
|
+
return ["yes", "y", "si", "sí", "1", "true"].includes(lower) ? true : false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (type === "text") {
|
|
124
|
+
return trimmed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (type === "select") {
|
|
128
|
+
const num = parseInt(trimmed, 10);
|
|
129
|
+
if (!isNaN(num) && num >= 1 && num <= options.length) {
|
|
130
|
+
return options[num - 1].id;
|
|
131
|
+
}
|
|
132
|
+
// Try matching by id directly
|
|
133
|
+
const match = options.find(o => o.id.toLowerCase() === lower);
|
|
134
|
+
return match ? match.id : (options[0]?.id || null);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (type === "multi-select") {
|
|
138
|
+
if (lower === "all") return options.map(o => o.id);
|
|
139
|
+
if (lower === "none" || lower === "0") return [];
|
|
140
|
+
|
|
141
|
+
// Parse comma-separated numbers or ids
|
|
142
|
+
const parts = trimmed.split(/[,;\s]+/).map(p => p.trim()).filter(Boolean);
|
|
143
|
+
const selected = [];
|
|
144
|
+
|
|
145
|
+
for (const part of parts) {
|
|
146
|
+
const num = parseInt(part, 10);
|
|
147
|
+
if (!isNaN(num) && num >= 1 && num <= options.length) {
|
|
148
|
+
selected.push(options[num - 1].id);
|
|
149
|
+
} else {
|
|
150
|
+
const match = options.find(o => o.id.toLowerCase() === part.toLowerCase());
|
|
151
|
+
if (match) selected.push(match.id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return selected;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return trimmed || null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Format a structured question into a text message for elicitInput.
|
|
163
|
+
* @param {Object} question
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function formatStructuredQuestion(question) {
|
|
167
|
+
const lines = [question.message, ""];
|
|
168
|
+
|
|
169
|
+
if (question.options?.length) {
|
|
170
|
+
for (let i = 0; i < question.options.length; i++) {
|
|
171
|
+
const opt = question.options[i];
|
|
172
|
+
const marker = opt.default ? "*" : " ";
|
|
173
|
+
lines.push(` ${i + 1}.${marker} ${opt.label}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push("");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (question.type === "multi-select") {
|
|
179
|
+
lines.push("(Enter numbers separated by commas, 'all', or 'none')");
|
|
180
|
+
} else if (question.type === "select") {
|
|
181
|
+
lines.push("(Enter a number)");
|
|
182
|
+
} else if (question.type === "confirm") {
|
|
183
|
+
lines.push("(yes/no)");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build an askQuestion function from an MCP server.
|
|
191
|
+
* Detects host capabilities and adapts behavior accordingly.
|
|
192
|
+
*
|
|
193
|
+
* The returned function has an `.interactive` boolean property indicating
|
|
194
|
+
* whether the host supports elicitation.
|
|
195
|
+
*
|
|
196
|
+
* Accepts both plain string questions (backward compatible) and structured
|
|
197
|
+
* question objects ({message, type, options, defaults}).
|
|
198
|
+
*
|
|
199
|
+
* @param {Object} server — MCP server instance
|
|
200
|
+
* @returns {Function & {interactive: boolean}}
|
|
201
|
+
*/
|
|
107
202
|
export function buildAskQuestion(server) {
|
|
108
|
-
|
|
203
|
+
const canElicit = Boolean(server.getClientCapabilities?.()?.elicitation);
|
|
204
|
+
|
|
205
|
+
const askQuestion = async (question) => {
|
|
206
|
+
const isStructured = question && typeof question === "object" && question.message;
|
|
207
|
+
|
|
208
|
+
// Non-interactive: return defaults or null
|
|
209
|
+
if (!canElicit) {
|
|
210
|
+
if (!isStructured) return null;
|
|
211
|
+
if (question.defaults) return question.defaults;
|
|
212
|
+
if (question.type === "confirm") return false;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build the message text
|
|
217
|
+
const message = isStructured
|
|
218
|
+
? formatStructuredQuestion(question)
|
|
219
|
+
: question;
|
|
220
|
+
|
|
221
|
+
// Call elicitInput
|
|
222
|
+
let rawAnswer;
|
|
109
223
|
try {
|
|
110
224
|
const result = await server.elicitInput({
|
|
111
|
-
message
|
|
225
|
+
message,
|
|
112
226
|
requestedSchema: {
|
|
113
227
|
type: "object",
|
|
114
228
|
properties: {
|
|
@@ -117,11 +231,25 @@ export function buildAskQuestion(server) {
|
|
|
117
231
|
required: ["answer"]
|
|
118
232
|
}
|
|
119
233
|
});
|
|
120
|
-
|
|
234
|
+
rawAnswer = result.action === "accept" ? result.content?.answer || null : null;
|
|
121
235
|
} catch {
|
|
122
|
-
return null;
|
|
236
|
+
return isStructured && question.defaults ? question.defaults : null;
|
|
123
237
|
}
|
|
238
|
+
|
|
239
|
+
if (rawAnswer === null) {
|
|
240
|
+
return isStructured && question.defaults ? question.defaults : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Parse structured response
|
|
244
|
+
if (isStructured && question.type) {
|
|
245
|
+
return parseStructuredResponse(rawAnswer, question.type, question.options);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return rawAnswer;
|
|
124
249
|
};
|
|
250
|
+
|
|
251
|
+
askQuestion.interactive = canElicit;
|
|
252
|
+
return askQuestion;
|
|
125
253
|
}
|
|
126
254
|
|
|
127
255
|
const EVENT_LOG_LEVELS = {
|
|
@@ -40,6 +40,9 @@ export class PipelineContext {
|
|
|
40
40
|
// Product context (loaded from .karajan/context.md or product-vision.md)
|
|
41
41
|
this.productContext = null;
|
|
42
42
|
|
|
43
|
+
// Domain context (synthesized by Domain Curator from ~/.karajan/domains/ and .karajan/domains/)
|
|
44
|
+
this.domainContext = null;
|
|
45
|
+
|
|
43
46
|
// Planned task (may differ from original task after planner)
|
|
44
47
|
this.plannedTask = null;
|
|
45
48
|
|
|
@@ -38,19 +38,30 @@ async function checkDocker() {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const SONAR_STARTUP_POLL_MS = 5000;
|
|
42
|
+
const SONAR_STARTUP_MAX_WAIT_MS = 60000;
|
|
43
|
+
|
|
44
|
+
async function waitForSonar(host, maxWaitMs = SONAR_STARTUP_MAX_WAIT_MS) {
|
|
45
|
+
const deadline = Date.now() + maxWaitMs;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
if (await isSonarReachable(host)) return true;
|
|
48
|
+
await new Promise(r => setTimeout(r, SONAR_STARTUP_POLL_MS));
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
async function checkSonarReachable(host) {
|
|
42
54
|
const reachable = await isSonarReachable(host);
|
|
43
55
|
if (reachable) {
|
|
44
56
|
return { name: "sonar-reachable", ok: true, detail: `SonarQube reachable at ${host}`, remediated: false };
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
// Auto-remediation:
|
|
59
|
+
// Auto-remediation: start SonarQube and wait for it to be ready
|
|
48
60
|
try {
|
|
49
61
|
const upResult = await sonarUp(host);
|
|
50
62
|
if (upResult.exitCode === 0) {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
if (reachableAfter) {
|
|
63
|
+
// SonarQube needs 20-40s to start. Poll until ready.
|
|
64
|
+
if (await waitForSonar(host)) {
|
|
54
65
|
return { name: "sonar-reachable", ok: true, detail: `SonarQube started and reachable at ${host}`, remediated: true };
|
|
55
66
|
}
|
|
56
67
|
}
|
|
@@ -58,7 +69,7 @@ async function checkSonarReachable(host) {
|
|
|
58
69
|
// sonarUp failed, fall through
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
return { name: "sonar-reachable", ok: false, detail: `SonarQube not reachable at ${host} (auto-start failed)` };
|
|
72
|
+
return { name: "sonar-reachable", ok: false, detail: `SonarQube not reachable at ${host} (auto-start failed after ${SONAR_STARTUP_MAX_WAIT_MS / 1000}s)` };
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
async function checkSonarAuth(config) {
|