karajan-code 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +29 -3
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/resume.js +1 -1
  32. package/src/commands/run.js +2 -1
  33. package/src/commands/skills.js +49 -0
  34. package/src/config/schema.js +222 -0
  35. package/src/config.js +32 -3
  36. package/src/guards/policy-resolver.js +8 -0
  37. package/src/hu/store.js +9 -0
  38. package/src/infrastructure/command-runner.js +44 -0
  39. package/src/infrastructure/environment.js +38 -0
  40. package/src/infrastructure/file-system-service.js +63 -0
  41. package/src/infrastructure/mocks.js +182 -0
  42. package/src/mcp/handlers/management-handlers.js +7 -2
  43. package/src/mcp/tools.js +10 -3
  44. package/src/orchestrator/config-init.js +16 -1
  45. package/src/orchestrator/flow-runner.js +2085 -0
  46. package/src/orchestrator/pipeline-context.js +17 -0
  47. package/src/orchestrator/preflight-checks.js +78 -4
  48. package/src/orchestrator/solomon-escalation.js +21 -0
  49. package/src/orchestrator/stages/stage-executor.js +155 -0
  50. package/src/orchestrator.js +19 -1923
  51. package/src/prompts/architect.js +2 -2
  52. package/src/prompts/coder.js +2 -2
  53. package/src/prompts/prompt-resolver.js +127 -0
  54. package/src/prompts/reviewer.js +2 -2
  55. package/src/roles/architect-role.js +3 -1
  56. package/src/roles/base-role.js +23 -2
  57. package/src/roles/coder-role.js +3 -1
  58. package/src/roles/security-role.js +12 -0
  59. package/src/roles/tester-role.js +11 -0
  60. package/src/session/journal/decisions-writer.js +200 -0
  61. package/src/session/journal/iteration-logger.js +194 -0
  62. package/src/session/journal/summary-writer.js +176 -0
  63. package/src/session/journal/tree-writer.js +164 -0
  64. package/src/skills/semantic-detector.js +147 -0
  65. package/src/skills/skill-detector.js +186 -8
  66. package/src/skills/skill-loader.js +131 -1
  67. package/src/skills/skills-cache.js +128 -0
  68. package/src/types/agent.js +55 -0
  69. package/src/types/config.js +114 -0
  70. package/src/types/hu.js +47 -0
  71. package/src/types/index.js +34 -0
  72. package/src/types/policy.js +43 -0
  73. package/src/types/session.js +74 -0
  74. package/src/types/stage.js +68 -0
  75. package/src/utils/display/event-handlers.js +389 -0
  76. package/src/utils/display/formatters.js +132 -0
  77. package/src/utils/display/header.js +48 -0
  78. package/src/utils/display/session-summary.js +108 -0
  79. package/src/utils/display/solomon.js +42 -0
  80. package/src/utils/port-check.js +45 -0
  81. package/src/utils/port-occupant.js +95 -0
  82. package/src/utils/provider-env.js +116 -0
  83. package/src/utils/with-timeout.js +42 -0
  84. package/templates/roles/architect/anthropic.md +65 -0
  85. package/templates/roles/architect/default.md +63 -0
  86. package/templates/roles/architect/google.md +52 -0
  87. package/templates/roles/architect/openai.md +51 -0
  88. package/templates/roles/architect/opencode.md +50 -0
  89. package/templates/roles/coder/anthropic.md +72 -0
  90. package/templates/roles/coder/default.md +69 -0
  91. package/templates/roles/coder/google.md +78 -0
  92. package/templates/roles/coder/openai.md +59 -0
  93. package/templates/roles/coder/opencode.md +49 -0
  94. package/templates/roles/planner/anthropic.md +50 -0
  95. package/templates/roles/planner/default.md +48 -0
  96. package/templates/roles/planner/google.md +66 -0
  97. package/templates/roles/planner/openai.md +46 -0
  98. package/templates/roles/planner/opencode.md +53 -0
  99. package/templates/roles/reviewer/anthropic.md +61 -0
  100. package/templates/roles/reviewer/default.md +62 -0
  101. package/templates/roles/reviewer/google.md +63 -0
  102. package/templates/roles/reviewer/openai.md +57 -0
  103. package/templates/roles/reviewer/opencode.md +42 -0
  104. package/templates/roles/solomon/anthropic.md +71 -0
  105. package/templates/roles/solomon/default.md +83 -0
  106. package/templates/roles/solomon/google.md +61 -0
  107. package/templates/roles/solomon/openai.md +53 -0
  108. package/templates/roles/solomon/opencode.md +55 -0
  109. package/src/utils/display.js +0 -669
@@ -9,7 +9,7 @@ const SUBAGENT_PREAMBLE = [
9
9
 
10
10
  export const VALID_VERDICTS = new Set(["ready", "needs_clarification"]);
11
11
 
12
- export async function buildArchitectPrompt({ task, instructions, researchContext = null, productContext = null, domainContext = null, projectDir = null, language = "en" }) {
12
+ export async function buildArchitectPrompt({ task, instructions, researchContext = null, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null }) {
13
13
  const langInstruction = getLanguageInstruction(language);
14
14
  const sections = [SUBAGENT_PREAMBLE, ...(langInstruction ? [langInstruction] : [])];
15
15
 
@@ -51,7 +51,7 @@ export async function buildArchitectPrompt({ task, instructions, researchContext
51
51
 
52
52
  if (projectDir) {
53
53
  const skills = await loadAvailableSkills(projectDir);
54
- const skillSection = buildSkillSection(skills);
54
+ const skillSection = buildSkillSection(skills, { provider, role: "architect" });
55
55
  if (skillSection) {
56
56
  sections.push(skillSection);
57
57
  }
@@ -33,7 +33,7 @@ const SERENA_INSTRUCTIONS = [
33
33
  "Fall back to reading files only when Serena tools are not sufficient."
34
34
  ].join("\n");
35
35
 
36
- export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en" }) {
36
+ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en", provider = null }) {
37
37
  const langInstruction = getLanguageInstruction(language);
38
38
  const sections = [
39
39
  serenaEnabled ? SUBAGENT_PREAMBLE_SERENA : SUBAGENT_PREAMBLE,
@@ -108,7 +108,7 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
108
108
 
109
109
  if (projectDir) {
110
110
  const skills = await loadAvailableSkills(projectDir);
111
- const skillSection = buildSkillSection(skills);
111
+ const skillSection = buildSkillSection(skills, { provider });
112
112
  if (skillSection) {
113
113
  sections.push(skillSection);
114
114
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Provider-aware role template resolver.
3
+ *
4
+ * Extends the legacy `resolveRoleMdPath()` contract from base-role.js to
5
+ * support per-provider variants without breaking existing consumers.
6
+ *
7
+ * Lookup order (first match wins):
8
+ *
9
+ * 1. <projectDir>/.karajan/roles/{role}/{provider}.md project override, per provider
10
+ * 2. <projectDir>/.karajan/roles/{role}/default.md project override, default
11
+ * 3. <projectDir>/.karajan/roles/{role}.md project override, legacy flat
12
+ * 4. <karajanHome>/roles/{role}/{provider}.md user home, per provider
13
+ * 5. <karajanHome>/roles/{role}/default.md user home, default
14
+ * 6. <karajanHome>/roles/{role}.md user home, legacy flat
15
+ * 7. <built-in>/templates/roles/{role}/{provider}.md built-in, per provider
16
+ * 8. <built-in>/templates/roles/{role}/default.md built-in, default
17
+ * 9. <built-in>/templates/roles/{role}.md built-in, legacy flat (back-compat)
18
+ *
19
+ * When `provider` is undefined/null the per-provider entries are skipped and
20
+ * only the default + legacy tiers are tried.
21
+ *
22
+ * If a provider is supplied but no per-provider file exists, we emit a
23
+ * one-time `prompt-resolver:fallback` event/log entry so the user knows their
24
+ * config asked for a variant that isn't present.
25
+ */
26
+
27
+ import fs from "node:fs/promises";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { getKarajanHome } from "../utils/paths.js";
31
+ import { normalizeProvider } from "../utils/provider-env.js";
32
+
33
+ /**
34
+ * Compute the full list of candidate file paths for a role template.
35
+ *
36
+ * @param {string} role - role slug (e.g. "coder")
37
+ * @param {string|null|undefined} provider - provider slug or CLI name;
38
+ * normalized internally
39
+ * @param {string|null|undefined} projectDir
40
+ * @returns {{ perProvider: string[], default: string[], legacy: string[], all: string[], provider: string|null }}
41
+ */
42
+ export function resolveRolePromptPaths(role, provider, projectDir) {
43
+ if (!role || typeof role !== "string") {
44
+ throw new Error("resolveRolePromptPaths: role name is required");
45
+ }
46
+ const normalized = provider ? normalizeProvider(provider) : null;
47
+ const canonical = normalized && normalized.length > 0 ? normalized : null;
48
+
49
+ const homes = [];
50
+ if (projectDir) homes.push(path.join(projectDir, ".karajan", "roles"));
51
+ homes.push(path.join(getKarajanHome(), "roles"));
52
+
53
+ const builtInRoot = path.resolve(
54
+ path.dirname(fileURLToPath(import.meta.url)),
55
+ "..",
56
+ "..",
57
+ "templates",
58
+ "roles"
59
+ );
60
+ homes.push(builtInRoot);
61
+
62
+ const perProvider = [];
63
+ const defaults = [];
64
+ const legacy = [];
65
+
66
+ for (const home of homes) {
67
+ if (canonical) {
68
+ perProvider.push(path.join(home, role, `${canonical}.md`));
69
+ }
70
+ defaults.push(path.join(home, role, "default.md"));
71
+ legacy.push(path.join(home, `${role}.md`));
72
+ }
73
+
74
+ // Interleave: for each home, try per-provider first, then default, then
75
+ // legacy. This gives the user's project override precedence over the
76
+ // built-in default even when they only supplied a legacy flat file.
77
+ const all = [];
78
+ for (const home of homes) {
79
+ if (canonical) all.push(path.join(home, role, `${canonical}.md`));
80
+ all.push(path.join(home, role, "default.md"));
81
+ all.push(path.join(home, `${role}.md`));
82
+ }
83
+
84
+ return { perProvider, default: defaults, legacy, all, provider: canonical };
85
+ }
86
+
87
+ /**
88
+ * Return the contents of the first existing candidate path. Returns null if
89
+ * no template is found.
90
+ *
91
+ * @param {string[]} paths
92
+ * @returns {Promise<{content: string, path: string}|null>}
93
+ */
94
+ export async function readFirstExisting(paths) {
95
+ for (const p of paths) {
96
+ try {
97
+ const content = await fs.readFile(p, "utf8");
98
+ return { content, path: p };
99
+ } catch { /* file not found, keep trying */ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Load role instructions resolved for a specific provider.
106
+ *
107
+ * @param {Object} args
108
+ * @param {string} args.role
109
+ * @param {string|null|undefined} args.provider
110
+ * @param {string|null|undefined} args.projectDir
111
+ * @param {{ warn?: Function, debug?: Function }} [args.logger]
112
+ * @returns {Promise<{ instructions: string|null, path: string|null, resolvedProvider: string|null, fellBack: boolean }>}
113
+ */
114
+ export async function loadRoleInstructions({ role, provider, projectDir, logger } = {}) {
115
+ const { all, provider: canonical } = resolveRolePromptPaths(role, provider, projectDir);
116
+ const hit = await readFirstExisting(all);
117
+ if (!hit) {
118
+ return { instructions: null, path: null, resolvedProvider: canonical, fellBack: !!canonical };
119
+ }
120
+ // Detect fallback: if the user asked for a provider but we matched a path
121
+ // that is NOT a per-provider file, we consider it a fallback and warn.
122
+ const fellBack = !!canonical && !hit.path.endsWith(`${canonical}.md`);
123
+ if (fellBack && logger?.debug) {
124
+ logger.debug(`prompt-resolver: role "${role}" has no "${canonical}" variant, using ${path.basename(hit.path)}`);
125
+ }
126
+ return { instructions: hit.content, path: hit.path, resolvedProvider: canonical, fellBack };
127
+ }
@@ -24,7 +24,7 @@ const SERENA_INSTRUCTIONS = [
24
24
  "Fall back to reading files only when Serena tools are not sufficient."
25
25
  ].join("\n");
26
26
 
27
- export async function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en" }) {
27
+ export async function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null }) {
28
28
  const truncatedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n[TRUNCATED]` : diff;
29
29
 
30
30
  const langInstruction = getLanguageInstruction(language);
@@ -64,7 +64,7 @@ export async function buildReviewerPrompt({ task, diff, reviewRules, mode, seren
64
64
 
65
65
  if (projectDir) {
66
66
  const skills = await loadAvailableSkills(projectDir);
67
- const skillSection = buildSkillSection(skills);
67
+ const skillSection = buildSkillSection(skills, { provider, role: "reviewer" });
68
68
  if (skillSection) {
69
69
  sections.push(skillSection);
70
70
  }
@@ -24,7 +24,9 @@ export class ArchitectRole extends AgentRole {
24
24
  const prompt = await buildArchitectPrompt({
25
25
  task, instructions: this.instructions, researchContext,
26
26
  productContext: this.config?.productContext || null,
27
- domainContext: this.config?.domainContext || null
27
+ domainContext: this.config?.domainContext || null,
28
+ projectDir: this.config?.projectDir || null,
29
+ provider: this._resolvedProvider || (typeof this.resolveProvider === "function" ? this.resolveProvider() : null)
28
30
  });
29
31
  return { prompt };
30
32
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { getKarajanHome } from "../utils/paths.js";
4
+ import { loadRoleInstructions } from "../prompts/prompt-resolver.js";
4
5
 
5
6
  const ROLE_EVENTS = {
6
7
  START: "role:start",
@@ -8,6 +9,12 @@ const ROLE_EVENTS = {
8
9
  ERROR: "role:error"
9
10
  };
10
11
 
12
+ /**
13
+ * Legacy flat resolver. Kept for back-compat with external callers and
14
+ * existing tests that assert the 3-entry candidate list. New code should use
15
+ * `loadRoleInstructions()` from src/prompts/prompt-resolver.js, which
16
+ * additionally tries per-provider and per-role subdirectories.
17
+ */
11
18
  function resolveRoleMdPath(roleName, projectDir) {
12
19
  const fileName = `${roleName}.md`;
13
20
  const candidates = [];
@@ -58,8 +65,22 @@ export class BaseRole {
58
65
  async init(context = {}) {
59
66
  this.context = context;
60
67
  const projectDir = this.config.projectDir || process.cwd();
61
- const paths = resolveRoleMdPath(this.name, projectDir);
62
- this.instructions = await loadFirstExisting(paths);
68
+ // Subclasses (AgentRole) can override resolveProvider to expose the
69
+ // provider slug for the active role, which lets the prompt resolver
70
+ // pick a per-provider template variant when available.
71
+ const provider = typeof this.resolveProvider === "function"
72
+ ? this.resolveProvider()
73
+ : null;
74
+ const { instructions, path: templatePath, resolvedProvider, fellBack } = await loadRoleInstructions({
75
+ role: this.name,
76
+ provider,
77
+ projectDir,
78
+ logger: this.logger,
79
+ });
80
+ this.instructions = instructions;
81
+ this._templatePath = templatePath;
82
+ this._resolvedProvider = resolvedProvider;
83
+ this._promptFellBack = fellBack;
63
84
  this._initialized = true;
64
85
  }
65
86
 
@@ -40,7 +40,9 @@ export class CoderRole extends AgentRole {
40
40
  serenaEnabled: Boolean(this.config?.serena?.enabled),
41
41
  rtkAvailable: Boolean(this.config?.rtk?.available),
42
42
  productContext: this.config?.productContext || null,
43
- domainContext: this.config?.domainContext || null
43
+ domainContext: this.config?.domainContext || null,
44
+ projectDir: this.config?.projectDir || null,
45
+ provider: this._resolvedProvider || (typeof this.resolveProvider === "function" ? this.resolveProvider() : null)
44
46
  });
45
47
  return { prompt };
46
48
  }
@@ -1,5 +1,6 @@
1
1
  import { AgentRole } from "./agent-role.js";
2
2
  import { extractFirstJson } from "../utils/json-extract.js";
3
+ import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
3
4
 
4
5
  const SUBAGENT_PREAMBLE = [
5
6
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -23,6 +24,17 @@ export class SecurityRole extends AgentRole {
23
24
  `## Task\n${task}`
24
25
  );
25
26
  if (diff) sections.push(`## Git diff to audit\n${diff}`);
27
+
28
+ // Inject security-relevant skills (owasp-*, security-*, sast-*) filtered
29
+ // by role=security.
30
+ const projectDir = this.config?.projectDir || process.cwd();
31
+ const skills = await loadAvailableSkills(projectDir);
32
+ const skillSection = buildSkillSection(skills, {
33
+ provider: this._resolvedProvider || (typeof this.resolveProvider === "function" ? this.resolveProvider() : null),
34
+ role: "security",
35
+ });
36
+ if (skillSection) sections.push(skillSection);
37
+
26
38
  return { prompt: sections.join("\n\n") };
27
39
  }
28
40
 
@@ -1,6 +1,7 @@
1
1
  import { AgentRole } from "./agent-role.js";
2
2
  import { extractFirstJson } from "../utils/json-extract.js";
3
3
  import { detectTestFramework } from "../utils/project-detect.js";
4
+ import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
4
5
 
5
6
  const SUBAGENT_PREAMBLE = [
6
7
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -108,6 +109,16 @@ export class TesterRole extends AgentRole {
108
109
  );
109
110
  if (diff) sections.push(`## Git diff\n${diff}`);
110
111
  if (sonarIssues) sections.push(`## Sonar test issues\n${sonarIssues}`);
112
+
113
+ // Inject test-relevant skills (filtered by role=tester to only include
114
+ // testing patterns like pytest-patterns, vitest-patterns, etc.)
115
+ const skills = await loadAvailableSkills(projectDir);
116
+ const skillSection = buildSkillSection(skills, {
117
+ provider: this._resolvedProvider || (typeof this.resolveProvider === "function" ? this.resolveProvider() : null),
118
+ role: "tester",
119
+ });
120
+ if (skillSection) sections.push(skillSection);
121
+
111
122
  return { prompt: sections.join("\n\n") };
112
123
  }
113
124
 
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Build a human-readable `decisions.md` journal file summarizing every
3
+ * Solomon ruling and Brain decision made during a session.
4
+ *
5
+ * Acceptance criterion (KJC-TSK-0287):
6
+ * - Given Solomon was invoked at least once → decisions.md is written with
7
+ * timestamp, trigger, conflict context, ruling (action + reasoning),
8
+ * and resulting actions for every invocation.
9
+ * - Given Solomon was NOT invoked → decisions.md is NOT created.
10
+ *
11
+ * Brain routing decisions (from KJC-TSK-0322) are appended as a secondary
12
+ * section when present, since they share the same "decisions" semantics.
13
+ */
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { getSessionRoot } from "../../utils/paths.js";
18
+
19
+ /**
20
+ * @typedef {Object} SolomonRuling
21
+ * @property {string} at - ISO timestamp
22
+ * @property {string} stage - conflict stage (reviewer, brain-routing, ...)
23
+ * @property {string} trigger - short reason the invocation was triggered
24
+ * @property {Object} conflict - conflict context (task, reason, summary)
25
+ * @property {string} ruling - approve / continue / escalate_human / create_subtask / ...
26
+ * @property {string} [reasoning]
27
+ * @property {string[]} [conditions]
28
+ * @property {string} [alternativeAgent]
29
+ * @property {string} [result] - what the orchestrator did with the ruling
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} BrainDecisionRecord
34
+ * @property {number} seq
35
+ * @property {string} at
36
+ * @property {string} intent
37
+ * @property {string[]} rolesOn
38
+ * @property {string} confidence
39
+ * @property {string} rationale
40
+ * @property {string[]} appliedOverrides
41
+ */
42
+
43
+ function esc(text) {
44
+ return String(text ?? "").replace(/\|/g, "\\|");
45
+ }
46
+
47
+ function formatConditions(conditions) {
48
+ if (!Array.isArray(conditions) || conditions.length === 0) return "—";
49
+ return conditions.map((c) => `- ${esc(c)}`).join("\n");
50
+ }
51
+
52
+ function formatConflict(conflict) {
53
+ if (!conflict) return "—";
54
+ const parts = [];
55
+ if (conflict.task) parts.push(`**Task**: ${esc(conflict.task.slice(0, 200))}${conflict.task.length > 200 ? "…" : ""}`);
56
+ if (conflict.reason) parts.push(`**Reason**: ${esc(conflict.reason)}`);
57
+ if (conflict.summary) parts.push(`**Summary**: ${esc(conflict.summary)}`);
58
+ return parts.length > 0 ? parts.join("\n") : "—";
59
+ }
60
+
61
+ /**
62
+ * Render a Solomon ruling as a Markdown subsection.
63
+ * @param {SolomonRuling} ruling
64
+ * @param {number} index
65
+ * @returns {string}
66
+ */
67
+ function renderRuling(ruling, index) {
68
+ const at = ruling.at || "(unknown timestamp)";
69
+ const header = `### ${index + 1}. Solomon @ ${at} — ${esc(ruling.ruling || "unknown")}`;
70
+ const lines = [
71
+ header,
72
+ "",
73
+ `- **Stage**: ${esc(ruling.stage || "?")}`,
74
+ `- **Trigger**: ${esc(ruling.trigger || "?")}`,
75
+ `- **Ruling**: \`${esc(ruling.ruling || "?")}\``,
76
+ ];
77
+ if (ruling.alternativeAgent) lines.push(`- **Alternative agent**: ${esc(ruling.alternativeAgent)}`);
78
+ if (ruling.result) lines.push(`- **Applied result**: ${esc(ruling.result)}`);
79
+ lines.push("");
80
+ if (ruling.reasoning) {
81
+ lines.push("**Reasoning:**", "");
82
+ lines.push(ruling.reasoning.trim(), "");
83
+ }
84
+ if (ruling.conditions?.length) {
85
+ lines.push("**Conditions:**", "");
86
+ lines.push(formatConditions(ruling.conditions), "");
87
+ }
88
+ lines.push("**Conflict context:**", "");
89
+ lines.push(formatConflict(ruling.conflict), "");
90
+ return lines.join("\n");
91
+ }
92
+
93
+ function renderBrainDecision(decision, index) {
94
+ return [
95
+ `### ${index + 1}. Brain @ ${decision.at} — ${esc(decision.intent)}`,
96
+ "",
97
+ `- **Confidence**: ${esc(decision.confidence)}`,
98
+ `- **Roles on**: ${(decision.rolesOn || []).map(esc).join(", ") || "—"}`,
99
+ `- **Overrides applied**: ${(decision.appliedOverrides || []).map(esc).join(", ") || "—"}`,
100
+ "",
101
+ `> ${esc(decision.rationale || "")}`,
102
+ "",
103
+ ].join("\n");
104
+ }
105
+
106
+ /**
107
+ * Build the Markdown content for a session. Returns `null` if no Solomon
108
+ * rulings are present (per acceptance criteria — no Solomon, no file).
109
+ *
110
+ * @param {Object} session
111
+ * @returns {string|null}
112
+ */
113
+ export function buildDecisionsMarkdown(session) {
114
+ const rulings = Array.isArray(session?.solomonRulings) ? session.solomonRulings : [];
115
+ const brainDecisions = Array.isArray(session?.brainDecisions) ? session.brainDecisions : [];
116
+
117
+ // AC: no Solomon invocation → do not generate the file.
118
+ if (rulings.length === 0) return null;
119
+
120
+ const sessionId = session?.id || "(unknown)";
121
+ const header = [
122
+ `# Session decisions — ${sessionId}`,
123
+ "",
124
+ `_Generated at ${new Date().toISOString()}._`,
125
+ "",
126
+ `This journal records every decision the pipeline delegated to Solomon, plus Brain routing decisions for cross-reference.`,
127
+ "",
128
+ "## Summary",
129
+ "",
130
+ `- Solomon invocations: **${rulings.length}**`,
131
+ `- Brain routing decisions: **${brainDecisions.length}**`,
132
+ "",
133
+ "---",
134
+ "",
135
+ "## Solomon rulings",
136
+ "",
137
+ ].join("\n");
138
+
139
+ const solomonSection = rulings.map((r, i) => renderRuling(r, i)).join("\n---\n\n");
140
+
141
+ const brainSection = brainDecisions.length > 0
142
+ ? "\n---\n\n## Brain routing decisions\n\n" + brainDecisions.map((d, i) => renderBrainDecision(d, i)).join("\n")
143
+ : "";
144
+
145
+ return header + solomonSection + brainSection + "\n";
146
+ }
147
+
148
+ /**
149
+ * Persist decisions.md to the session's journal directory. No-op when there
150
+ * are no Solomon rulings (per acceptance criteria).
151
+ *
152
+ * Path resolution (first match wins):
153
+ * 1. options.journalDir — direct path to the session journal directory
154
+ * 2. options.sessionRoot + id — combines with session.id
155
+ * 3. getSessionRoot() + id — default Karajan session root
156
+ *
157
+ * @param {Object} session
158
+ * @param {{ journalDir?: string, sessionRoot?: string, logger?: Object }} [options]
159
+ * @returns {Promise<{ written: boolean, path: string|null }>}
160
+ */
161
+ export async function writeDecisionsJournal(session, options = {}) {
162
+ const content = buildDecisionsMarkdown(session);
163
+ if (content === null) return { written: false, path: null };
164
+
165
+ const sessionDir = options.journalDir
166
+ || path.join(options.sessionRoot || getSessionRoot(), session.id);
167
+ const filePath = path.join(sessionDir, "decisions.md");
168
+ try {
169
+ await fs.mkdir(sessionDir, { recursive: true });
170
+ await fs.writeFile(filePath, content, "utf8");
171
+ options.logger?.info?.(`Wrote decisions journal: ${filePath}`);
172
+ return { written: true, path: filePath };
173
+ } catch (err) {
174
+ options.logger?.warn?.(`Failed to write decisions journal (non-blocking): ${err.message}`);
175
+ return { written: false, path: null };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Append a Solomon ruling to session.solomonRulings. Creates the array if
181
+ * absent. Callers (invokeSolomon) pass the structured record.
182
+ *
183
+ * @param {Object} session
184
+ * @param {SolomonRuling} record
185
+ */
186
+ export function recordSolomonRuling(session, record) {
187
+ if (!session) return;
188
+ if (!Array.isArray(session.solomonRulings)) session.solomonRulings = [];
189
+ session.solomonRulings.push({
190
+ at: record.at || new Date().toISOString(),
191
+ stage: record.stage || "unknown",
192
+ trigger: record.trigger || "",
193
+ conflict: record.conflict || {},
194
+ ruling: record.ruling || "unknown",
195
+ reasoning: record.reasoning || "",
196
+ conditions: Array.isArray(record.conditions) ? [...record.conditions] : [],
197
+ alternativeAgent: record.alternativeAgent || null,
198
+ result: record.result || "",
199
+ });
200
+ }