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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -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 {Object} conflict
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 local OpenSkills cache (actions: list | clear-cache)")
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" });
@@ -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 : print cached skills with their age and freshness.
6
- * - clear-cache : delete all cached metadata (forces re-install on next session).
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
+ }
@@ -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
 
@@ -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";
@@ -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
+ }
@@ -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 {};