karajan-code 2.6.0 → 2.7.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 +19 -0
- package/package.json +1 -1
- package/src/brain/solomon-consult.js +9 -1
- package/src/cli.js +1 -1
- package/src/commands/skills.js +41 -4
- package/src/config/schema.js +25 -0
- package/src/config.js +9 -0
- package/src/orchestrator/flow-runner.js +90 -0
- package/src/orchestrator/solomon-escalation.js +34 -0
- package/src/roles/agent-role.js +9 -0
- package/src/roles/base-role.js +17 -0
- package/src/skills/addyosmani-catalog.js +293 -0
- package/src/skills/addyosmani-role-map.js +133 -0
- package/src/types/index.js +4 -0
- package/src/types/solomon.js +87 -0
package/README.md
CHANGED
|
@@ -53,6 +53,25 @@ Use Karajan when you want:
|
|
|
53
53
|
|
|
54
54
|
If Claude Code is a smart pair programmer, Karajan is the CI/CD pipeline for AI-assisted development. They work great together: Karajan is designed to be used as an MCP server inside Claude Code.
|
|
55
55
|
|
|
56
|
+
## How Karajan differs from AI frameworks
|
|
57
|
+
|
|
58
|
+
While Genkit, Mastra, LangChain and Vercel AI SDK call `/v1/messages`, Karajan orchestrates the AI CLIs your developers already use in their terminals.
|
|
59
|
+
|
|
60
|
+
| Axis | Karajan | Genkit / Mastra / LangChain / Vercel AI SDK |
|
|
61
|
+
|------|---------|---------------------------------------------|
|
|
62
|
+
| Calls provider HTTP API (`/v1/messages`, etc.) | ❌ Delegates to CLIs | ✅ |
|
|
63
|
+
| Orchestrates existing AI CLIs (claude, codex, gemini, aider, opencode) as subprocesses | ✅ | ❌ |
|
|
64
|
+
| Depends on cloud infrastructure | ❌ Fully local | ⚠️ Varies |
|
|
65
|
+
| Vanilla JS (no TypeScript required) | ✅ | ⚠️ TS-first |
|
|
66
|
+
| Token billing | **Uses your existing CLI subscriptions** | Pay per API call |
|
|
67
|
+
|
|
68
|
+
Two technical facts worth keeping straight:
|
|
69
|
+
|
|
70
|
+
1. **Subprocess, not PTY.** Karajan spawns each CLI via `execa` / `child_process` with plain `stdin` / `stdout` / `stderr` — see `src/infrastructure/command-runner.js` and `src/agents/*.js`. There is no PTY emulation.
|
|
71
|
+
2. **Fresh subprocess per invocation + state on disk.** Every coder run is a new process; the state lives in `~/.karajan/sessions/` (see `src/session-store.js`) and the per-session journal under `.reviews/<session-id>/`. This is what makes pipelines **reproducible** and **resumable** with `kj resume`.
|
|
72
|
+
|
|
73
|
+
Full write-up with mental mapping for Genkit / Mastra / LangChain / Vercel AI SDK developers: **[docs/COMPARISON.md](./docs/COMPARISON.md)**.
|
|
74
|
+
|
|
56
75
|
## Install
|
|
57
76
|
|
|
58
77
|
**npm** (recommended):
|
package/package.json
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This is intentionally conservative. A future iteration can make Solomon a
|
|
10
10
|
* binding arbiter for routing (like it is for pipeline-control decisions).
|
|
11
|
+
*
|
|
12
|
+
* Architectural invariant: this module is the ONLY sanctioned path from
|
|
13
|
+
* Brain to Solomon for routing concerns. Stages / rules / agents must not
|
|
14
|
+
* bypass Brain to reach Solomon directly — see the note on `invokeSolomon`
|
|
15
|
+
* in `src/orchestrator/solomon-escalation.js` and docs/ARCHITECTURE.md.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {import("../types/solomon.js").Conflict} Conflict
|
|
18
|
+
* @typedef {import("../types/solomon.js").SolomonResult} SolomonResult
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
/**
|
|
@@ -17,7 +25,7 @@
|
|
|
17
25
|
* @param {import("./decisor.js").Decision} args.decision
|
|
18
26
|
* @param {Object} args.triage
|
|
19
27
|
* @param {string} args.task
|
|
20
|
-
* @returns {
|
|
28
|
+
* @returns {Conflict} conflict
|
|
21
29
|
*/
|
|
22
30
|
export function buildRoutingConflict({ decision, triage, task }) {
|
|
23
31
|
return {
|
package/src/cli.js
CHANGED
|
@@ -217,7 +217,7 @@ program
|
|
|
217
217
|
|
|
218
218
|
program
|
|
219
219
|
.command("skills [action]")
|
|
220
|
-
.description("Manage the
|
|
220
|
+
.description("Manage the skill cache (actions: list | clear-cache | sync-addyosmani | list-addyosmani)")
|
|
221
221
|
.action(async (action) => {
|
|
222
222
|
const { skillsCommand } = await import("./commands/skills.js");
|
|
223
223
|
const exitCode = await skillsCommand({ action: action || "list" });
|
package/src/commands/skills.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `kj skills` — manage the local OpenSkills cache.
|
|
2
|
+
* `kj skills` — manage the local OpenSkills cache and the addyosmani catalog.
|
|
3
3
|
*
|
|
4
4
|
* Subcommands:
|
|
5
|
-
* - list
|
|
6
|
-
* - clear-cache
|
|
5
|
+
* - list : print cached OpenSkills with their age and freshness.
|
|
6
|
+
* - clear-cache : delete all cached metadata (forces re-install on next session).
|
|
7
|
+
* - sync-addyosmani : force a git pull of addyosmani/agent-skills.
|
|
8
|
+
* - list-addyosmani : enumerate slugs available in the cached catalog.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { listCached, clearCache, getCacheRoot, DEFAULT_TTL_MS } from "../skills/skills-cache.js";
|
|
12
|
+
import { refreshIfStale, listAvailableSlugs, getCatalogRoot, loadSkillBySlug } from "../skills/addyosmani-catalog.js";
|
|
10
13
|
|
|
11
14
|
function humanAge(cachedAt) {
|
|
12
15
|
const ms = Date.now() - Date.parse(cachedAt);
|
|
@@ -22,8 +25,12 @@ export async function skillsCommand({ action = "list" } = {}) {
|
|
|
22
25
|
return listAction();
|
|
23
26
|
case "clear-cache":
|
|
24
27
|
return clearAction();
|
|
28
|
+
case "sync-addyosmani":
|
|
29
|
+
return syncAddyosmaniAction();
|
|
30
|
+
case "list-addyosmani":
|
|
31
|
+
return listAddyosmaniAction();
|
|
25
32
|
default:
|
|
26
|
-
console.error(`Unknown action: ${action}. Use: list | clear-cache`);
|
|
33
|
+
console.error(`Unknown action: ${action}. Use: list | clear-cache | sync-addyosmani | list-addyosmani`);
|
|
27
34
|
return 1;
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -47,3 +54,33 @@ async function clearAction() {
|
|
|
47
54
|
console.log(`Cleared ${removed} cached skill${removed === 1 ? "" : "s"} from ${getCacheRoot()}`);
|
|
48
55
|
return 0;
|
|
49
56
|
}
|
|
57
|
+
|
|
58
|
+
async function syncAddyosmaniAction() {
|
|
59
|
+
console.log("Syncing addyosmani/agent-skills catalog...");
|
|
60
|
+
const logger = { warn: (m) => console.warn(` warn: ${m}`), info: (m) => console.log(` ${m}`) };
|
|
61
|
+
const result = await refreshIfStale({ force: true, logger });
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
console.error(`Sync failed: ${result.error || result.action}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const slugs = await listAvailableSlugs();
|
|
67
|
+
console.log(`${result.action === "cloned" ? "Cloned" : "Pulled"} at ${getCatalogRoot()}`);
|
|
68
|
+
console.log(`Available slugs: ${slugs.length}`);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function listAddyosmaniAction() {
|
|
73
|
+
const slugs = await listAvailableSlugs();
|
|
74
|
+
if (slugs.length === 0) {
|
|
75
|
+
console.log(`No addyosmani catalog found at ${getCatalogRoot()}`);
|
|
76
|
+
console.log("Run `kj skills sync-addyosmani` to bootstrap it.");
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
console.log(`addyosmani/agent-skills catalog at ${getCatalogRoot()}\n${slugs.length} slug(s):\n`);
|
|
80
|
+
for (const slug of slugs) {
|
|
81
|
+
const skill = await loadSkillBySlug(slug);
|
|
82
|
+
const desc = skill?.description ? ` — ${skill.description.slice(0, 100)}${skill.description.length > 100 ? "..." : ""}` : "";
|
|
83
|
+
console.log(` ${slug}${desc}`);
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
package/src/config/schema.js
CHANGED
|
@@ -151,6 +151,30 @@ const Budget = v.optional(v.looseObject({
|
|
|
151
151
|
pricing: v.optional(v.unknown()),
|
|
152
152
|
}));
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* skills.addyosmani subtree (KJC-TSK-0327). Governs the addyosmani/agent-skills
|
|
156
|
+
* catalog provider: enabled flag, refresh TTL and repo URL.
|
|
157
|
+
*/
|
|
158
|
+
const SkillsAddyosmani = v.looseObject({
|
|
159
|
+
enabled: v.optional(v.boolean()),
|
|
160
|
+
refreshDays: v.optional(v.pipe(
|
|
161
|
+
v.number(),
|
|
162
|
+
v.minValue(0, "skills.addyosmani.refreshDays must be >= 0")
|
|
163
|
+
)),
|
|
164
|
+
repoUrl: v.optional(v.string()),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* skills.* subtree. `sources` controls which providers Karajan consults and in
|
|
169
|
+
* what order (addyosmani → openskills → local by default). `mode` is the
|
|
170
|
+
* existing regex/semantic/auto/none knob from semantic-detector.js.
|
|
171
|
+
*/
|
|
172
|
+
const Skills = v.optional(v.looseObject({
|
|
173
|
+
mode: v.optional(v.picklist(["auto", "regex", "semantic", "none"], "skills.mode must be one of: auto | regex | semantic | none")),
|
|
174
|
+
sources: v.optional(v.array(v.string())),
|
|
175
|
+
addyosmani: v.optional(SkillsAddyosmani),
|
|
176
|
+
}));
|
|
177
|
+
|
|
154
178
|
/**
|
|
155
179
|
* Main config schema. `looseObject` lets every consumer that reads an
|
|
156
180
|
* undeclared key keep working while the schema catches the bugs we care
|
|
@@ -181,6 +205,7 @@ export const ConfigSchema = v.looseObject({
|
|
|
181
205
|
hu_language: v.optional(v.string()),
|
|
182
206
|
policies: v.optional(v.record(v.string(), v.unknown())),
|
|
183
207
|
telemetry: v.optional(v.boolean()),
|
|
208
|
+
skills: Skills,
|
|
184
209
|
});
|
|
185
210
|
|
|
186
211
|
/**
|
package/src/config.js
CHANGED
|
@@ -141,6 +141,15 @@ const DEFAULTS = {
|
|
|
141
141
|
policies: {},
|
|
142
142
|
serena: { enabled: false },
|
|
143
143
|
planning_game: { enabled: false, project_id: null, codeveloper: null },
|
|
144
|
+
skills: {
|
|
145
|
+
mode: "auto",
|
|
146
|
+
sources: ["addyosmani", "openskills", "local"],
|
|
147
|
+
addyosmani: {
|
|
148
|
+
enabled: true,
|
|
149
|
+
refreshDays: 7,
|
|
150
|
+
repoUrl: "https://github.com/addyosmani/agent-skills.git"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
144
153
|
ci: { enabled: false, review_event: "kj-review", comment_event: "kj-comment", comment_prefix: true },
|
|
145
154
|
git: { auto_commit: false, auto_push: false, auto_pr: false, auto_rebase: true, branch_prefix: "feat/" },
|
|
146
155
|
output: { report_dir: "./.reviews", log_level: "info", quiet: true },
|
|
@@ -39,6 +39,8 @@ import { setRunner as setGitRunner } from "../utils/git.js";
|
|
|
39
39
|
import { detectNeededSkills, autoInstallSkills, cleanupAutoInstalledSkills } from "../skills/skill-detector.js";
|
|
40
40
|
import { isOpenSkillsAvailable } from "../skills/openskills-client.js";
|
|
41
41
|
import { refineSkillsSemantically, resolveSkillsMode } from "../skills/semantic-detector.js";
|
|
42
|
+
import { refreshIfStale as refreshAddyosmaniCatalog, listAvailableSlugs as listAddyosmaniSlugs } from "../skills/addyosmani-catalog.js";
|
|
43
|
+
import { resolveAddyosmaniSlugs } from "../skills/addyosmani-role-map.js";
|
|
42
44
|
|
|
43
45
|
// Extracted modules
|
|
44
46
|
import {
|
|
@@ -832,6 +834,20 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
|
|
|
832
834
|
// User explicitly opted out. Skip detection entirely.
|
|
833
835
|
return { plannedTask, updatedConfig };
|
|
834
836
|
}
|
|
837
|
+
|
|
838
|
+
// FIRST source: addyosmani/agent-skills (process skills per role).
|
|
839
|
+
// This runs BEFORE OpenSkills detection because process skills are the
|
|
840
|
+
// canonical workflows (TDD, code-review, security, performance...) that
|
|
841
|
+
// should shape every role's prompt; stack skills come second.
|
|
842
|
+
await ensureAddyosmaniSkills({
|
|
843
|
+
task: plannedTask,
|
|
844
|
+
config: updatedConfig,
|
|
845
|
+
logger,
|
|
846
|
+
session,
|
|
847
|
+
emitter,
|
|
848
|
+
eventBase,
|
|
849
|
+
});
|
|
850
|
+
|
|
835
851
|
let neededSkills = skillsMode === "semantic"
|
|
836
852
|
? []
|
|
837
853
|
: await detectNeededSkills(plannedTask, skillProjectDir);
|
|
@@ -879,6 +895,80 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
|
|
|
879
895
|
return { plannedTask, updatedConfig };
|
|
880
896
|
}
|
|
881
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Ensure the addyosmani/agent-skills catalog is present and fresh, then
|
|
900
|
+
* resolve which upstream slugs are relevant for the current task. The
|
|
901
|
+
* resolved slugs are stored on `session.addyosmaniSkills` so downstream
|
|
902
|
+
* prompt builders can inject them ahead of OpenSkills-detected stack skills.
|
|
903
|
+
*
|
|
904
|
+
* Config surface (all optional, sensible defaults):
|
|
905
|
+
* skills:
|
|
906
|
+
* sources: ["addyosmani", "openskills", "local"] # default
|
|
907
|
+
* addyosmani:
|
|
908
|
+
* enabled: true
|
|
909
|
+
* refreshDays: 7
|
|
910
|
+
* repoUrl: "https://github.com/addyosmani/agent-skills.git"
|
|
911
|
+
*
|
|
912
|
+
* Setting skills.addyosmani.enabled = false, or removing "addyosmani" from
|
|
913
|
+
* skills.sources, disables this step entirely (reverts to OpenSkills-only).
|
|
914
|
+
*/
|
|
915
|
+
async function ensureAddyosmaniSkills({ task, config, logger, session, emitter, eventBase }) {
|
|
916
|
+
const skillsConfig = config?.skills || {};
|
|
917
|
+
const sources = Array.isArray(skillsConfig.sources) ? skillsConfig.sources : ["addyosmani", "openskills", "local"];
|
|
918
|
+
const addyConfig = skillsConfig.addyosmani || {};
|
|
919
|
+
// Test harness override: tests/setup.js sets __KJ_DEFAULT_ADDYOSMANI_ENABLED
|
|
920
|
+
// to false so orchestrator tests don't spawn git. Tests that need the real
|
|
921
|
+
// catalog opt in by setting config.skills.addyosmani.enabled = true.
|
|
922
|
+
const globalDefault = globalThis.__KJ_DEFAULT_ADDYOSMANI_ENABLED;
|
|
923
|
+
const enabledFromConfig = addyConfig.enabled === true
|
|
924
|
+
|| (addyConfig.enabled !== false && globalDefault !== false);
|
|
925
|
+
const enabled = enabledFromConfig && sources.includes("addyosmani");
|
|
926
|
+
if (!enabled) return;
|
|
927
|
+
|
|
928
|
+
const refreshDays = Number.isFinite(addyConfig.refreshDays) ? addyConfig.refreshDays : 7;
|
|
929
|
+
const refreshMs = Math.max(0, refreshDays) * 24 * 60 * 60 * 1000;
|
|
930
|
+
|
|
931
|
+
try {
|
|
932
|
+
const refreshResult = await refreshAddyosmaniCatalog({
|
|
933
|
+
refreshMs,
|
|
934
|
+
repoUrl: addyConfig.repoUrl,
|
|
935
|
+
logger,
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
if (!refreshResult.ok) {
|
|
939
|
+
session.addyosmaniSkills = { available: false, reason: refreshResult.error || "refresh failed" };
|
|
940
|
+
emitProgress(emitter, makeEvent("skills:addyosmani-unavailable", { ...eventBase, stage: "skills" }, {
|
|
941
|
+
message: `addyosmani/agent-skills catalog unavailable: ${refreshResult.error || refreshResult.action}`,
|
|
942
|
+
detail: { action: refreshResult.action, hint: "Install git to enable process skills from addyosmani/agent-skills" },
|
|
943
|
+
}));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const available = new Set(await listAddyosmaniSlugs());
|
|
948
|
+
const resolved = resolveAddyosmaniSlugs({ role: null, task }); // role resolution happens per-stage later
|
|
949
|
+
const valid = resolved.filter((slug) => available.has(slug));
|
|
950
|
+
|
|
951
|
+
session.addyosmaniSkills = {
|
|
952
|
+
available: true,
|
|
953
|
+
action: refreshResult.action,
|
|
954
|
+
resolvedSlugs: valid,
|
|
955
|
+
allAvailable: Array.from(available),
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
emitProgress(emitter, makeEvent("skills:addyosmani-ready", { ...eventBase, stage: "skills" }, {
|
|
959
|
+
message: `addyosmani/agent-skills ${refreshResult.action} — ${available.size} slug(s) available, ${valid.length} relevant to task`,
|
|
960
|
+
detail: {
|
|
961
|
+
action: refreshResult.action,
|
|
962
|
+
relevantSlugs: valid,
|
|
963
|
+
availableCount: available.size,
|
|
964
|
+
},
|
|
965
|
+
}));
|
|
966
|
+
} catch (err) {
|
|
967
|
+
logger.warn(`addyosmani catalog step failed (non-blocking): ${err.message}`);
|
|
968
|
+
session.addyosmaniSkills = { available: false, reason: err.message };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
882
972
|
/**
|
|
883
973
|
* Auto-generate HU batch from triage decomposition when no manual huFile is present.
|
|
884
974
|
* Runs after researcher/architect/planner so that context is available for better HUs.
|
|
@@ -3,6 +3,23 @@ import { addCheckpoint, pauseSession, saveSession } from "../session-store.js";
|
|
|
3
3
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
4
4
|
import { msg, getLang } from "../utils/messages.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import("../types/solomon.js").Conflict} Conflict
|
|
8
|
+
* @typedef {import("../types/solomon.js").SolomonResult} SolomonResult
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Architectural invariant (DO NOT BREAK):
|
|
13
|
+
* `invokeSolomon` MUST NOT be called directly from stages, rules or agent
|
|
14
|
+
* code. The only callers should be:
|
|
15
|
+
* - Karajan Brain (via `src/brain/solomon-consult.js`)
|
|
16
|
+
* - The orchestrator's escalation path (`src/orchestrator/flow-runner.js`)
|
|
17
|
+
* on behalf of Brain.
|
|
18
|
+
* The v2.3.0 audit fixed 21 violations of this rule. Keeping Solomon behind
|
|
19
|
+
* Brain is what the "Brain-as-gateway, Solomon-as-judge" architecture
|
|
20
|
+
* depends on — see docs/ARCHITECTURE.md §Brain/Solomon.
|
|
21
|
+
*/
|
|
22
|
+
|
|
6
23
|
/**
|
|
7
24
|
* Build a human-readable escalation message from raw Solomon conflict data.
|
|
8
25
|
* Replaces the raw JSON dump with structured, plain-text output.
|
|
@@ -81,6 +98,23 @@ function extractSolomonHistory(session) {
|
|
|
81
98
|
}));
|
|
82
99
|
}
|
|
83
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Consult Solomon (or fall back to human escalation) for a given conflict.
|
|
103
|
+
* See the Conflict typedef for the shape of the `conflict` argument and
|
|
104
|
+
* the invariant above for who is allowed to call this.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {Object} params.config
|
|
108
|
+
* @param {Object} params.logger
|
|
109
|
+
* @param {Object} [params.emitter]
|
|
110
|
+
* @param {Object} [params.eventBase]
|
|
111
|
+
* @param {string} params.stage
|
|
112
|
+
* @param {Conflict} params.conflict
|
|
113
|
+
* @param {Function} [params.askQuestion]
|
|
114
|
+
* @param {Object} [params.session]
|
|
115
|
+
* @param {number} [params.iteration]
|
|
116
|
+
* @returns {Promise<SolomonResult>}
|
|
117
|
+
*/
|
|
84
118
|
export async function invokeSolomon({ config, logger, emitter, eventBase, stage, conflict, askQuestion, session, iteration }) {
|
|
85
119
|
const solomonEnabled = Boolean(config.pipeline?.solomon?.enabled);
|
|
86
120
|
|
package/src/roles/agent-role.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
* Handles the common execute() flow: resolve provider → create agent → call LLM → parse output.
|
|
4
4
|
* Subclasses override hooks (buildPrompt, parseOutput, buildSuccessResult, buildSummary)
|
|
5
5
|
* instead of reimplementing the full execute() boilerplate.
|
|
6
|
+
*
|
|
7
|
+
* Conceptual mapping: `AgentRole` is a Karajan Role that delegates to an AI CLI
|
|
8
|
+
* subprocess (not to a provider HTTP API). From the outside it plays the same
|
|
9
|
+
* role as a Genkit Flow — named unit of work with a prompt and a parsed output —
|
|
10
|
+
* but every invocation spawns a fresh `execa` subprocess (claude, codex, gemini,
|
|
11
|
+
* aider, opencode) and the state lives in Karajan's session store, not in a
|
|
12
|
+
* provider-side conversation. See `docs/COMPARISON.md`.
|
|
13
|
+
*
|
|
14
|
+
* @alias Flow — similar to Genkit Flow but executed over subprocess CLIs, not API calls.
|
|
6
15
|
*/
|
|
7
16
|
import { BaseRole } from "./base-role.js";
|
|
8
17
|
import { createAgent as defaultCreateAgent } from "../agents/index.js";
|
package/src/roles/base-role.js
CHANGED
|
@@ -49,6 +49,23 @@ async function loadFirstExisting(paths) {
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Base class for every pipeline role (coder, reviewer, planner, refactorer,
|
|
54
|
+
* researcher, architect, triage, discover, tester, security, impeccable,
|
|
55
|
+
* hu-reviewer, audit, solomon, commiter, karajan-brain, domain-curator).
|
|
56
|
+
*
|
|
57
|
+
* Conceptual mapping for developers coming from other ecosystems:
|
|
58
|
+
* `BaseRole` (and every subclass) is Karajan's equivalent of a **Flow** in the
|
|
59
|
+
* Genkit sense — a named, composable unit of work with a prompt, an invocation
|
|
60
|
+
* and a parsed output. The important difference: a Karajan Role executes over
|
|
61
|
+
* a **subprocess CLI** (`claude`, `codex`, `gemini`, `aider`, `opencode`), not
|
|
62
|
+
* by calling an HTTP provider API. No PTY, no long-lived session — each run
|
|
63
|
+
* is a fresh subprocess with state on disk under `~/.karajan/sessions/`.
|
|
64
|
+
*
|
|
65
|
+
* See `docs/COMPARISON.md` for the full positioning.
|
|
66
|
+
*
|
|
67
|
+
* @alias Flow — similar to Genkit Flow but executed over subprocess CLIs, not API calls.
|
|
68
|
+
*/
|
|
52
69
|
export class BaseRole {
|
|
53
70
|
constructor({ name, config, logger, emitter = null }) {
|
|
54
71
|
if (!name) throw new Error("Role name is required");
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* addyosmani/agent-skills catalog provider (KJC-TSK-0327).
|
|
3
|
+
*
|
|
4
|
+
* Fetches and caches the addyosmani/agent-skills repository (20+ process
|
|
5
|
+
* skills: TDD, code-review, security, performance, git-workflow, debugging,
|
|
6
|
+
* CI/CD, docs, spec-driven, planning...) and exposes them as a first-class
|
|
7
|
+
* skill source for Karajan's pipeline.
|
|
8
|
+
*
|
|
9
|
+
* This catalog is ORTHOGONAL to the OpenSkills marketplace (which hosts stack
|
|
10
|
+
* skills like astro, react, prisma, python-data). Together they cover:
|
|
11
|
+
* - addyosmani → lifecycle/process skills mapped per role
|
|
12
|
+
* - OpenSkills → stack/framework skills detected from task + deps
|
|
13
|
+
* - local skills → project-specific skills under .claude/skills
|
|
14
|
+
*
|
|
15
|
+
* Lifecycle:
|
|
16
|
+
* 1. ensureCatalog() – clone (depth=1) on first use
|
|
17
|
+
* 2. refreshIfStale() – git pull when older than TTL (default 7d)
|
|
18
|
+
* 3. loadSkillBySlug() – read /skills/<slug>/SKILL.md from disk
|
|
19
|
+
* 4. listAvailableSlugs() – enumerate the /skills/ directory
|
|
20
|
+
*
|
|
21
|
+
* Degradation contract: when git is missing, the network is unreachable, or
|
|
22
|
+
* the catalog directory is malformed, every exported function returns safe
|
|
23
|
+
* empty values and logs a warning. The pipeline NEVER blocks on this module.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { runCommand } from "../utils/process.js";
|
|
29
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
30
|
+
|
|
31
|
+
const REPO_URL = "https://github.com/addyosmani/agent-skills.git";
|
|
32
|
+
const CATALOG_DIR_NAME = "agent-skills";
|
|
33
|
+
const SKILLS_SUBDIR = "skills";
|
|
34
|
+
const SKILL_FILE = "SKILL.md";
|
|
35
|
+
const DEFAULT_REFRESH_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
36
|
+
const META_FILENAME = ".karajan-catalog-meta.json";
|
|
37
|
+
|
|
38
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Absolute path to the cached catalog inside ~/.karajan/.
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function getCatalogRoot() {
|
|
45
|
+
return path.join(getKarajanHome(), CATALOG_DIR_NAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getSkillsRoot() {
|
|
49
|
+
return path.join(getCatalogRoot(), SKILLS_SUBDIR);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getMetaPath() {
|
|
53
|
+
return path.join(getCatalogRoot(), META_FILENAME);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function pathExists(p) {
|
|
57
|
+
try {
|
|
58
|
+
await fs.access(p);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readMeta() {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(getMetaPath(), "utf8");
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function writeMeta(meta) {
|
|
75
|
+
await fs.mkdir(getCatalogRoot(), { recursive: true });
|
|
76
|
+
await fs.writeFile(getMetaPath(), JSON.stringify(meta, null, 2), "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check whether `git` is on PATH. Cached per-process.
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
82
|
+
*/
|
|
83
|
+
let _gitAvailable = null;
|
|
84
|
+
export async function isGitAvailable() {
|
|
85
|
+
if (_gitAvailable !== null) return _gitAvailable;
|
|
86
|
+
try {
|
|
87
|
+
const result = await runCommand("git", ["--version"], { timeout: 5_000 });
|
|
88
|
+
_gitAvailable = result.exitCode === 0;
|
|
89
|
+
} catch {
|
|
90
|
+
_gitAvailable = false;
|
|
91
|
+
}
|
|
92
|
+
return _gitAvailable;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Internal: reset the cached probe (tests use this to re-evaluate git presence).
|
|
96
|
+
export function __resetGitProbe() {
|
|
97
|
+
_gitAvailable = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clone the addyosmani/agent-skills repo (shallow) if it does not already
|
|
102
|
+
* exist locally. No-op when it is already present.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ repoUrl?: string, logger?: object }} [opts]
|
|
105
|
+
* @returns {Promise<{ok: boolean, path?: string, error?: string, skipped?: boolean}>}
|
|
106
|
+
*/
|
|
107
|
+
export async function ensureCatalog(opts = {}) {
|
|
108
|
+
const { repoUrl = REPO_URL, logger } = opts;
|
|
109
|
+
const root = getCatalogRoot();
|
|
110
|
+
|
|
111
|
+
if (await pathExists(path.join(root, ".git"))) {
|
|
112
|
+
return { ok: true, path: root, skipped: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!(await isGitAvailable())) {
|
|
116
|
+
logger?.warn?.("addyosmani-catalog: git not available — skipping catalog bootstrap");
|
|
117
|
+
return { ok: false, error: "git not available" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await fs.mkdir(path.dirname(root), { recursive: true });
|
|
121
|
+
|
|
122
|
+
const result = await runCommand(
|
|
123
|
+
"git",
|
|
124
|
+
["clone", "--depth", "1", repoUrl, root],
|
|
125
|
+
{ timeout: 120_000 }
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (result.exitCode !== 0) {
|
|
129
|
+
const stderr = (result.stderr || "").trim();
|
|
130
|
+
logger?.warn?.(`addyosmani-catalog: clone failed — ${stderr}`);
|
|
131
|
+
return { ok: false, error: stderr || `clone failed (exit ${result.exitCode})` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await writeMeta({
|
|
135
|
+
repoUrl,
|
|
136
|
+
clonedAt: new Date().toISOString(),
|
|
137
|
+
lastPullAt: new Date().toISOString(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { ok: true, path: root };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Refresh the catalog via `git pull` when the cached copy is older than
|
|
145
|
+
* `refreshMs`. When the catalog does not exist yet, delegates to
|
|
146
|
+
* ensureCatalog().
|
|
147
|
+
*
|
|
148
|
+
* @param {{ refreshMs?: number, repoUrl?: string, force?: boolean, logger?: object }} [opts]
|
|
149
|
+
* @returns {Promise<{ok: boolean, action: "cloned"|"pulled"|"fresh"|"skipped", error?: string}>}
|
|
150
|
+
*/
|
|
151
|
+
export async function refreshIfStale(opts = {}) {
|
|
152
|
+
const { refreshMs = DEFAULT_REFRESH_MS, repoUrl = REPO_URL, force = false, logger } = opts;
|
|
153
|
+
const root = getCatalogRoot();
|
|
154
|
+
|
|
155
|
+
if (!(await pathExists(path.join(root, ".git")))) {
|
|
156
|
+
const cloneResult = await ensureCatalog({ repoUrl, logger });
|
|
157
|
+
return cloneResult.ok
|
|
158
|
+
? { ok: true, action: "cloned" }
|
|
159
|
+
: { ok: false, action: "skipped", error: cloneResult.error };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const meta = await readMeta();
|
|
163
|
+
const lastPull = meta?.lastPullAt ? Date.parse(meta.lastPullAt) : 0;
|
|
164
|
+
const age = Date.now() - (Number.isFinite(lastPull) ? lastPull : 0);
|
|
165
|
+
if (!force && age < refreshMs) {
|
|
166
|
+
return { ok: true, action: "fresh" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!(await isGitAvailable())) {
|
|
170
|
+
logger?.warn?.("addyosmani-catalog: git unavailable — using stale cache");
|
|
171
|
+
return { ok: true, action: "skipped" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = await runCommand(
|
|
175
|
+
"git",
|
|
176
|
+
["-C", root, "pull", "--ff-only", "--depth", "1"],
|
|
177
|
+
{ timeout: 60_000 }
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (result.exitCode !== 0) {
|
|
181
|
+
const stderr = (result.stderr || "").trim();
|
|
182
|
+
logger?.warn?.(`addyosmani-catalog: git pull failed — ${stderr} (keeping stale cache)`);
|
|
183
|
+
return { ok: false, action: "skipped", error: stderr };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await writeMeta({
|
|
187
|
+
...(meta || {}),
|
|
188
|
+
repoUrl,
|
|
189
|
+
lastPullAt: new Date().toISOString(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return { ok: true, action: "pulled" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse YAML frontmatter for name + description (we intentionally avoid a
|
|
197
|
+
* YAML dep — a tiny regex is enough for the fields we care about and stays
|
|
198
|
+
* compatible with addyosmani's SKILL.md format).
|
|
199
|
+
*
|
|
200
|
+
* @param {string} raw
|
|
201
|
+
* @returns {{name: string|null, description: string|null, body: string}}
|
|
202
|
+
*/
|
|
203
|
+
export function parseFrontmatter(raw) {
|
|
204
|
+
if (!raw) return { name: null, description: null, body: "" };
|
|
205
|
+
const match = raw.match(FRONTMATTER_RE);
|
|
206
|
+
if (!match) {
|
|
207
|
+
return { name: null, description: null, body: raw };
|
|
208
|
+
}
|
|
209
|
+
const frontmatter = match[1];
|
|
210
|
+
const body = match[2] || "";
|
|
211
|
+
|
|
212
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
213
|
+
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
name: nameMatch ? stripQuotes(nameMatch[1]) : null,
|
|
217
|
+
description: descMatch ? stripQuotes(descMatch[1]) : null,
|
|
218
|
+
body,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stripQuotes(value) {
|
|
223
|
+
const trimmed = value.trim();
|
|
224
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
225
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
226
|
+
return trimmed.slice(1, -1);
|
|
227
|
+
}
|
|
228
|
+
return trimmed;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Enumerate available skill slugs under /skills/.
|
|
233
|
+
*
|
|
234
|
+
* @returns {Promise<string[]>}
|
|
235
|
+
*/
|
|
236
|
+
export async function listAvailableSlugs() {
|
|
237
|
+
const root = getSkillsRoot();
|
|
238
|
+
let entries;
|
|
239
|
+
try {
|
|
240
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
return entries
|
|
245
|
+
.filter((e) => e.isDirectory())
|
|
246
|
+
.map((e) => e.name)
|
|
247
|
+
.filter((name) => !name.startsWith("."))
|
|
248
|
+
.sort();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Load a skill by its slug. Returns null when the slug is not present or the
|
|
253
|
+
* SKILL.md file is missing/unreadable.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} slug
|
|
256
|
+
* @returns {Promise<{slug: string, name: string, description: string|null, content: string, path: string}|null>}
|
|
257
|
+
*/
|
|
258
|
+
export async function loadSkillBySlug(slug) {
|
|
259
|
+
if (!slug || typeof slug !== "string") return null;
|
|
260
|
+
const safeSlug = slug.trim().replace(/[^A-Za-z0-9._-]/g, "");
|
|
261
|
+
if (!safeSlug) return null;
|
|
262
|
+
|
|
263
|
+
const skillPath = path.join(getSkillsRoot(), safeSlug, SKILL_FILE);
|
|
264
|
+
let raw;
|
|
265
|
+
try {
|
|
266
|
+
raw = await fs.readFile(skillPath, "utf8");
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const parsed = parseFrontmatter(raw);
|
|
272
|
+
return {
|
|
273
|
+
slug: safeSlug,
|
|
274
|
+
name: parsed.name || safeSlug,
|
|
275
|
+
description: parsed.description,
|
|
276
|
+
content: raw,
|
|
277
|
+
path: skillPath,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Load multiple skills by slug in parallel. Silently drops missing entries.
|
|
283
|
+
*
|
|
284
|
+
* @param {string[]} slugs
|
|
285
|
+
* @returns {Promise<Array<{slug: string, name: string, description: string|null, content: string, path: string}>>}
|
|
286
|
+
*/
|
|
287
|
+
export async function loadSkillsBySlugs(slugs = []) {
|
|
288
|
+
if (!Array.isArray(slugs) || slugs.length === 0) return [];
|
|
289
|
+
const results = await Promise.all(slugs.map((s) => loadSkillBySlug(s)));
|
|
290
|
+
return results.filter(Boolean);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export { DEFAULT_REFRESH_MS, REPO_URL };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role → addyosmani/agent-skills slug mapping (KJC-TSK-0327).
|
|
3
|
+
*
|
|
4
|
+
* The addyosmani catalog is process-oriented (not stack-oriented), so the
|
|
5
|
+
* natural keying is by Karajan role: reviewer wants code-review workflows,
|
|
6
|
+
* tester wants TDD, security wants hardening, etc.
|
|
7
|
+
*
|
|
8
|
+
* Task-text triggers complement the role map: e.g. a task that mentions
|
|
9
|
+
* "performance" or "Core Web Vitals" pulls in performance-optimization,
|
|
10
|
+
* regardless of which role is running.
|
|
11
|
+
*
|
|
12
|
+
* Every slug listed here MUST exist in the upstream repo at:
|
|
13
|
+
* https://github.com/addyosmani/agent-skills/tree/main/skills/<slug>
|
|
14
|
+
* If upstream renames or removes a slug, loadSkillBySlug() degrades silently
|
|
15
|
+
* (returns null) and the pipeline continues without blocking.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Role → array of upstream slugs.
|
|
20
|
+
* Keep slugs lowercase-hyphenated, matching addyosmani's directory names.
|
|
21
|
+
*/
|
|
22
|
+
export const ROLE_TO_ADDYOSMANI_SLUGS = {
|
|
23
|
+
// The coder role benefits from broad process context: how to iterate,
|
|
24
|
+
// how to engineer context, and how to debug when things break.
|
|
25
|
+
coder: [
|
|
26
|
+
"incremental-implementation",
|
|
27
|
+
"source-driven-development",
|
|
28
|
+
"context-engineering",
|
|
29
|
+
"debugging-and-error-recovery",
|
|
30
|
+
],
|
|
31
|
+
|
|
32
|
+
// Reviewer wants the review + simplification workflows.
|
|
33
|
+
reviewer: [
|
|
34
|
+
"code-review-and-quality",
|
|
35
|
+
"code-simplification",
|
|
36
|
+
],
|
|
37
|
+
|
|
38
|
+
// Tester focuses on TDD and browser verification.
|
|
39
|
+
tester: [
|
|
40
|
+
"test-driven-development",
|
|
41
|
+
"browser-testing-with-devtools",
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
// Security role uses the dedicated hardening workflow.
|
|
45
|
+
security: [
|
|
46
|
+
"security-and-hardening",
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
// Architect covers spec-driven, API design and planning skills.
|
|
50
|
+
architect: [
|
|
51
|
+
"spec-driven-development",
|
|
52
|
+
"api-and-interface-design",
|
|
53
|
+
"planning-and-task-breakdown",
|
|
54
|
+
],
|
|
55
|
+
|
|
56
|
+
// Planner overlaps with architect but leans on task breakdown and spec.
|
|
57
|
+
planner: [
|
|
58
|
+
"planning-and-task-breakdown",
|
|
59
|
+
"spec-driven-development",
|
|
60
|
+
],
|
|
61
|
+
|
|
62
|
+
// Researcher / discover benefit from the idea-refine workflow.
|
|
63
|
+
researcher: [
|
|
64
|
+
"idea-refine",
|
|
65
|
+
],
|
|
66
|
+
discover: [
|
|
67
|
+
"idea-refine",
|
|
68
|
+
],
|
|
69
|
+
|
|
70
|
+
// Impeccable (UI/UX audit) uses the frontend engineering workflow.
|
|
71
|
+
impeccable: [
|
|
72
|
+
"frontend-ui-engineering",
|
|
73
|
+
],
|
|
74
|
+
|
|
75
|
+
// Refactorer uses simplification and debugging workflows.
|
|
76
|
+
refactorer: [
|
|
77
|
+
"code-simplification",
|
|
78
|
+
"debugging-and-error-recovery",
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Task-text patterns → addyosmani slug. These are applied in addition to the
|
|
84
|
+
* role map, so a reviewer task that mentions "performance" also pulls in the
|
|
85
|
+
* performance-optimization skill.
|
|
86
|
+
*/
|
|
87
|
+
export const TASK_PATTERN_TO_SLUG = [
|
|
88
|
+
{ pattern: /\bperformance\b|\bcore\s+web\s+vitals\b|\blcp\b|\bcls\b|\binp\b/i, slug: "performance-optimization" },
|
|
89
|
+
{ pattern: /\bsecurity\b|\bhardening\b|\bauth(entication|orization)?\b|\bxss\b|\bcsrf\b|\binjection\b/i, slug: "security-and-hardening" },
|
|
90
|
+
{ pattern: /\btdd\b|\btest[- ]driven\b/i, slug: "test-driven-development" },
|
|
91
|
+
{ pattern: /\bfrontend\b|\bui\b|\bux\b|\bcss\b|\bresponsive\b/i, slug: "frontend-ui-engineering" },
|
|
92
|
+
{ pattern: /\bapi\b|\bendpoint\b|\binterface\s+design\b|\bopenapi\b|\brest\b|\bgraphql\b/i, slug: "api-and-interface-design" },
|
|
93
|
+
{ pattern: /\bci\b|\bcd\b|\bci\/cd\b|\bpipeline\b|\bgithub\s+actions\b|\bworkflow\b/i, slug: "ci-cd-and-automation" },
|
|
94
|
+
{ pattern: /\bgit\b|\bcommit\b|\bbranch\b|\bmerge\b|\bversioning\b|\bsemver\b/i, slug: "git-workflow-and-versioning" },
|
|
95
|
+
{ pattern: /\bdebug\b|\berror\s+recovery\b|\bstack\s+trace\b/i, slug: "debugging-and-error-recovery" },
|
|
96
|
+
{ pattern: /\bdeprecat|\bmigrat/i, slug: "deprecation-and-migration" },
|
|
97
|
+
{ pattern: /\bdocs?\b|\badr\b|\breadme\b|\bdocumentation\b/i, slug: "documentation-and-adrs" },
|
|
98
|
+
{ pattern: /\brelease\b|\blaunch\b|\bship\b/i, slug: "shipping-and-launch" },
|
|
99
|
+
{ pattern: /\bspec\b|\bspecification\b|\bprd\b/i, slug: "spec-driven-development" },
|
|
100
|
+
{ pattern: /\bidea\b|\brefine\b|\bdiscover/i, slug: "idea-refine" },
|
|
101
|
+
{ pattern: /\bbrowser\s+test|\bdevtools\b|\bchrome\s+devtools\b/i, slug: "browser-testing-with-devtools" },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the set of addyosmani slugs that apply to a given (role, task)
|
|
106
|
+
* tuple. Returns a deduped array preserving role-first ordering (so role
|
|
107
|
+
* skills appear before task-inferred ones in the prompt).
|
|
108
|
+
*
|
|
109
|
+
* @param {{role?: string|null, task?: string|null}} args
|
|
110
|
+
* @returns {string[]}
|
|
111
|
+
*/
|
|
112
|
+
export function resolveAddyosmaniSlugs({ role = null, task = null } = {}) {
|
|
113
|
+
const out = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
|
|
116
|
+
const push = (slug) => {
|
|
117
|
+
if (!slug || seen.has(slug)) return;
|
|
118
|
+
seen.add(slug);
|
|
119
|
+
out.push(slug);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (role && ROLE_TO_ADDYOSMANI_SLUGS[role]) {
|
|
123
|
+
for (const slug of ROLE_TO_ADDYOSMANI_SLUGS[role]) push(slug);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (task && typeof task === "string") {
|
|
127
|
+
for (const { pattern, slug } of TASK_PATTERN_TO_SLUG) {
|
|
128
|
+
if (pattern.test(task)) push(slug);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|
package/src/types/index.js
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
*
|
|
30
30
|
* @typedef {import("./policy.js").ResolvedPolicies} ResolvedPolicies
|
|
31
31
|
* @typedef {import("./policy.js").QualityGateResult} QualityGateResult
|
|
32
|
+
*
|
|
33
|
+
* @typedef {import("./solomon.js").Conflict} SolomonConflict
|
|
34
|
+
* @typedef {import("./solomon.js").ConflictHistoryEntry} SolomonConflictHistoryEntry
|
|
35
|
+
* @typedef {import("./solomon.js").SolomonResult} SolomonResult
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
38
|
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typedefs for Solomon — the AI judge consulted by Brain on genuine dilemmas.
|
|
3
|
+
*
|
|
4
|
+
* Architectural invariant (enforced by convention, see docs/ARCHITECTURE.md):
|
|
5
|
+
* Solomon is **NEVER** invoked directly from stages, rules or roles.
|
|
6
|
+
* The only legitimate entry points are:
|
|
7
|
+
* - `src/brain/solomon-consult.js#consultSolomonForRouting`
|
|
8
|
+
* - `src/orchestrator/solomon-escalation.js#invokeSolomon`
|
|
9
|
+
* Both are called by Brain (or by the orchestrator on Brain's behalf).
|
|
10
|
+
* The v2.3.0 audit fixed 21 violations where stages bypassed Brain and
|
|
11
|
+
* called Solomon directly. Don't reintroduce that pattern.
|
|
12
|
+
*
|
|
13
|
+
* This module is runtime-empty — it only carries JSDoc annotations.
|
|
14
|
+
*
|
|
15
|
+
* @module types/solomon
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A single turn of feedback attributed to a pipeline agent. Used to build
|
|
20
|
+
* `Conflict.history` when Brain escalates a decision to Solomon.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {Object} ConflictHistoryEntry
|
|
23
|
+
* @property {string} agent - slug of the agent that produced the feedback
|
|
24
|
+
* (e.g. "reviewer", "sonar", "tester",
|
|
25
|
+
* "coder_rate_limit").
|
|
26
|
+
* @property {string} feedback - raw textual feedback to be weighted by Solomon.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The bundle of context Brain hands to Solomon when it cannot decide on its
|
|
31
|
+
* own. Solomon reads only this object + the stage + task; it never sees
|
|
32
|
+
* session internals beyond what is packed here.
|
|
33
|
+
*
|
|
34
|
+
* Historical shape: this matches the argument already used by
|
|
35
|
+
* `invokeSolomon({ ..., conflict })` — the typedef formalizes the contract
|
|
36
|
+
* so future drift is caught by `npm run typecheck`.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {Object} Conflict
|
|
39
|
+
* @property {string} stage
|
|
40
|
+
* Pipeline stage that triggered escalation (e.g. "reviewer",
|
|
41
|
+
* "sonar", "tester", "init_error", "coder_rate_limit").
|
|
42
|
+
* @property {string} task
|
|
43
|
+
* Original user task (denormalized so Solomon sees what the user
|
|
44
|
+
* actually asked for, not just the stage context).
|
|
45
|
+
* @property {number} iterationCount
|
|
46
|
+
* Current iteration number inside the escalating sub-loop.
|
|
47
|
+
* @property {number} maxIterations
|
|
48
|
+
* Configured cap for that sub-loop (reviewer retries, tester
|
|
49
|
+
* retries, full pipeline iterations, etc.). Hitting this cap is
|
|
50
|
+
* the canonical trigger for Solomon consultation.
|
|
51
|
+
* @property {string|null} [cooldownUntil]
|
|
52
|
+
* ISO timestamp for rate-limit / standby escalations. Null when
|
|
53
|
+
* the conflict is not a rate-limit.
|
|
54
|
+
* @property {number|null} [cooldownMs]
|
|
55
|
+
* Remaining cooldown in milliseconds. Null outside rate-limit
|
|
56
|
+
* escalations.
|
|
57
|
+
* @property {ConflictHistoryEntry[]} history
|
|
58
|
+
* Ordered list of agent feedback Solomon should weigh. Most
|
|
59
|
+
* recent last. Must be non-empty — Solomon should always see at
|
|
60
|
+
* least one piece of evidence.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Solomon's ruling on a `Conflict`. Returned by `invokeSolomon()` and
|
|
65
|
+
* consumed by the orchestrator to decide what happens next.
|
|
66
|
+
*
|
|
67
|
+
* @typedef {Object} SolomonResult
|
|
68
|
+
* @property {"continue"|"pause"|"subtask"} action
|
|
69
|
+
* - `continue`: retry the sub-loop with optional `humanGuidance`
|
|
70
|
+
* appended to the next feedback round.
|
|
71
|
+
* - `pause`: stop the pipeline and surface a question to the
|
|
72
|
+
* human operator (`SolomonResult.question`).
|
|
73
|
+
* - `subtask`: bifurcate into a named sub-task defined in
|
|
74
|
+
* `SolomonResult.subtask`.
|
|
75
|
+
* @property {string} [rationale] - human-readable reasoning for the
|
|
76
|
+
* chosen action. Logged to the
|
|
77
|
+
* decisions journal.
|
|
78
|
+
* @property {string} [humanGuidance] - text to merge into the next
|
|
79
|
+
* coder/reviewer prompt when
|
|
80
|
+
* action === "continue".
|
|
81
|
+
* @property {string} [question] - prompt shown to the human when
|
|
82
|
+
* action === "pause".
|
|
83
|
+
* @property {Object} [subtask] - sub-task descriptor when
|
|
84
|
+
* action === "subtask".
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
export {};
|