karajan-code 1.57.2 → 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 +18 -5
- package/docs/README.es.md +18 -5
- package/package.json +1 -1
- package/src/cli.js +7 -0
- 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/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/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
package/README.md
CHANGED
|
@@ -63,12 +63,24 @@ npm install -g karajan-code
|
|
|
63
63
|
brew install manufosela/tap/karajan-code
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
**Standalone binary** (no Node.js needed):
|
|
67
|
+
```bash
|
|
68
|
+
# macOS (Apple Silicon)
|
|
69
|
+
curl -L https://github.com/manufosela/karajan-code/releases/latest/download/kj-darwin-arm64 -o kj && chmod +x kj
|
|
70
|
+
|
|
71
|
+
# Linux x64
|
|
72
|
+
curl -L https://github.com/manufosela/karajan-code/releases/latest/download/kj-linux-x64 -o kj && chmod +x kj
|
|
73
|
+
|
|
74
|
+
# Windows
|
|
75
|
+
curl -L https://github.com/manufosela/karajan-code/releases/latest/download/kj-win-x64.exe -o kj.exe
|
|
76
|
+
```
|
|
77
|
+
|
|
66
78
|
**One-liner** (detects OS, installs via npm):
|
|
67
79
|
```bash
|
|
68
80
|
curl -fsSL https://raw.githubusercontent.com/manufosela/karajan-code/main/scripts/install-kj.sh | sh
|
|
69
81
|
```
|
|
70
82
|
|
|
71
|
-
**Docker
|
|
83
|
+
**Docker**:
|
|
72
84
|
```bash
|
|
73
85
|
docker run --rm -v $(pwd):/workspace karajan-code kj --version
|
|
74
86
|
```
|
|
@@ -156,15 +168,16 @@ $ claude $ kj-tail
|
|
|
156
168
|
## The pipeline
|
|
157
169
|
|
|
158
170
|
```
|
|
159
|
-
hu-reviewer? → triage → discover? → architect? → planner? → coder → sonar? → impeccable? → reviewer → tester? → security? → solomon → commiter?
|
|
171
|
+
hu-reviewer? → triage → domain-curator? → discover? → architect? → planner? → coder → sonar? → impeccable? → reviewer → tester? → security? → solomon → commiter?
|
|
160
172
|
```
|
|
161
173
|
|
|
162
|
-
**
|
|
174
|
+
**16 roles**, each executed by the AI agent you choose:
|
|
163
175
|
|
|
164
176
|
| Role | What it does | Default |
|
|
165
177
|
|------|-------------|---------|
|
|
166
178
|
| **hu-reviewer** | Certifies user stories before coding (6 dimensions, 7 antipatterns) | Auto (medium/complex) |
|
|
167
|
-
| **triage** | Classifies complexity, activates roles,
|
|
179
|
+
| **triage** | Classifies complexity, activates roles, detects domain hints | **On** |
|
|
180
|
+
| **domain-curator** | Discovers, proposes and synthesizes business-domain knowledge for the pipeline | Auto (when domains exist) |
|
|
168
181
|
| **discover** | Detects gaps in requirements (Mom Test, Wendel, JTBD) | Off |
|
|
169
182
|
| **architect** | Designs solution architecture before planning | Off |
|
|
170
183
|
| **planner** | Generates structured implementation plans | Off |
|
|
@@ -228,7 +241,7 @@ Karajan auto-detects and auto-configures everything it can:
|
|
|
228
241
|
- **TDD**: Detects test framework for 12 languages (vitest, jest, JUnit, pytest, go test, cargo test, and more). Auto-enables TDD for code tasks, skips for doc/infra
|
|
229
242
|
- **Bootstrap gate**: Validates all prerequisites (git repo, remote, config, agents, SonarQube) before any tool runs. Fails hard with actionable fix instructions, never silently degrades
|
|
230
243
|
- **Injection guard**: Scans diffs for prompt injection before AI review. Detects directive overrides, invisible Unicode, oversized comment payloads. Also runs as a GitHub Action on every PR
|
|
231
|
-
- **SonarQube**: Auto-starts Docker container, generates config if missing
|
|
244
|
+
- **SonarQube**: Auto-starts Docker container, waits up to 60s for startup, generates config if missing
|
|
232
245
|
- **Pipeline complexity**: Triage classifies task → trivial tasks skip reviewer loop
|
|
233
246
|
- **Provider outages**: Retries on 500/502/503/504 with backoff (same as rate limits)
|
|
234
247
|
- **Coverage**: Coverage-only quality gate failures treated as advisory
|
package/docs/README.es.md
CHANGED
|
@@ -63,12 +63,24 @@ npm install -g karajan-code
|
|
|
63
63
|
brew install manufosela/tap/karajan-code
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
**Binario standalone** (sin Node.js):
|
|
67
|
+
```bash
|
|
68
|
+
# macOS (Apple Silicon)
|
|
69
|
+
curl -L https://github.com/manufosela/karajan-code/releases/latest/download/kj-darwin-arm64 -o kj && chmod +x kj
|
|
70
|
+
|
|
71
|
+
# Linux x64
|
|
72
|
+
curl -L https://github.com/manufosela/karajan-code/releases/latest/download/kj-linux-x64 -o kj && chmod +x kj
|
|
73
|
+
|
|
74
|
+
# Windows
|
|
75
|
+
curl -L https://github.com/manufosela/karajan-code/releases/latest/download/kj-win-x64.exe -o kj.exe
|
|
76
|
+
```
|
|
77
|
+
|
|
66
78
|
**One-liner** (detecta SO, instala via npm):
|
|
67
79
|
```bash
|
|
68
80
|
curl -fsSL https://raw.githubusercontent.com/manufosela/karajan-code/main/scripts/install-kj.sh | sh
|
|
69
81
|
```
|
|
70
82
|
|
|
71
|
-
**Docker
|
|
83
|
+
**Docker**:
|
|
72
84
|
```bash
|
|
73
85
|
docker run --rm -v $(pwd):/workspace karajan-code kj --version
|
|
74
86
|
```
|
|
@@ -129,15 +141,16 @@ kj-tail --help # Todas las opciones
|
|
|
129
141
|
## El pipeline
|
|
130
142
|
|
|
131
143
|
```
|
|
132
|
-
hu-reviewer? → triage → discover? → architect? → planner? → coder → sonar? → impeccable? → reviewer → tester? → security? → solomon → commiter?
|
|
144
|
+
hu-reviewer? → triage → domain-curator? → discover? → architect? → planner? → coder → sonar? → impeccable? → reviewer → tester? → security? → solomon → commiter?
|
|
133
145
|
```
|
|
134
146
|
|
|
135
|
-
**
|
|
147
|
+
**16 roles**, cada uno ejecutado por el agente de IA que elijas:
|
|
136
148
|
|
|
137
149
|
| Rol | Que hace | Por defecto |
|
|
138
150
|
|-----|----------|-------------|
|
|
139
151
|
| **hu-reviewer** | Certifica historias de usuario antes de codificar (6 dimensiones, 7 antipatrones) | Auto (media/compleja) |
|
|
140
|
-
| **triage** | Clasifica complejidad, activa roles,
|
|
152
|
+
| **triage** | Clasifica complejidad, activa roles, detecta domain hints | **On** |
|
|
153
|
+
| **domain-curator** | Descubre, propone y sintetiza conocimiento de dominio de negocio para el pipeline | Auto (cuando existen dominios) |
|
|
141
154
|
| **discover** | Detecta huecos en requisitos (Mom Test, Wendel, JTBD) | Off |
|
|
142
155
|
| **architect** | Diseña la arquitectura de la solucion antes de planificar | Off |
|
|
143
156
|
| **planner** | Genera planes de implementacion estructurados | Off |
|
|
@@ -201,7 +214,7 @@ Karajan auto-detecta y auto-configura todo lo que puede:
|
|
|
201
214
|
- **TDD**: Detecta framework de tests para 12 lenguajes (vitest, jest, JUnit, pytest, go test, cargo test, y mas). Auto-activa TDD para tareas de codigo, lo salta para doc/infra
|
|
202
215
|
- **Bootstrap gate**: Valida todos los prerequisitos (repo git, remote, config, agentes, SonarQube) antes de ejecutar. Falla con instrucciones claras, nunca degrada silenciosamente
|
|
203
216
|
- **Injection guard**: Escanea diffs en busca de prompt injection antes del review de IA. Detecta directivas de override, Unicode invisible, payloads en comentarios sobredimensionados. Tambien como GitHub Action en cada PR
|
|
204
|
-
- **SonarQube**: Auto-arranca contenedor Docker, genera config si falta
|
|
217
|
+
- **SonarQube**: Auto-arranca contenedor Docker, espera hasta 60s al arranque, genera config si falta
|
|
205
218
|
- **Complejidad del pipeline**: Triage clasifica la tarea, las triviales saltan el loop del reviewer
|
|
206
219
|
- **Caidas de proveedor**: Reintentos en 500/502/503/504 con backoff (igual que rate limits)
|
|
207
220
|
- **Cobertura**: Fallos de quality gate solo por cobertura se tratan como advisory
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ import { undoCommand } from "./commands/undo.js";
|
|
|
28
28
|
import { statusCommand } from "./commands/status.js";
|
|
29
29
|
|
|
30
30
|
import { printUpdateNotice } from "./utils/update-check.js";
|
|
31
|
+
import { printWelcomeScreen } from "./utils/welcome.js";
|
|
31
32
|
|
|
32
33
|
const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
33
34
|
const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
|
|
@@ -369,6 +370,12 @@ sonar
|
|
|
369
370
|
console.log(`Opened ${result.url}`);
|
|
370
371
|
});
|
|
371
372
|
|
|
373
|
+
// Show welcome screen when invoked with no subcommand
|
|
374
|
+
program.action(async () => {
|
|
375
|
+
const { config } = await loadConfig().catch(() => ({ config: null }));
|
|
376
|
+
printWelcomeScreen({ version: PKG_VERSION, config });
|
|
377
|
+
});
|
|
378
|
+
|
|
372
379
|
try {
|
|
373
380
|
await program.parseAsync();
|
|
374
381
|
} catch (error) {
|
|
@@ -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
|
+
}
|