karajan-code 2.4.1 → 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 (115) 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 +90 -7
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/plan.js +221 -37
  32. package/src/commands/resume.js +1 -1
  33. package/src/commands/run.js +2 -1
  34. package/src/commands/skills.js +49 -0
  35. package/src/config/schema.js +222 -0
  36. package/src/config.js +32 -3
  37. package/src/guards/policy-resolver.js +8 -0
  38. package/src/hu/store.js +9 -0
  39. package/src/infrastructure/command-runner.js +44 -0
  40. package/src/infrastructure/environment.js +38 -0
  41. package/src/infrastructure/file-system-service.js +63 -0
  42. package/src/infrastructure/mocks.js +182 -0
  43. package/src/mcp/handlers/management-handlers.js +7 -2
  44. package/src/mcp/tools.js +10 -3
  45. package/src/orchestrator/config-init.js +16 -1
  46. package/src/orchestrator/flow-runner.js +2085 -0
  47. package/src/orchestrator/pipeline-context.js +17 -0
  48. package/src/orchestrator/preflight-checks.js +78 -4
  49. package/src/orchestrator/solomon-escalation.js +21 -0
  50. package/src/orchestrator/stages/stage-executor.js +155 -0
  51. package/src/orchestrator.js +19 -1884
  52. package/src/plan/plan-executor.js +69 -0
  53. package/src/plan/plan-hu-ops.js +134 -0
  54. package/src/plan/plan-id.js +25 -0
  55. package/src/plan/plan-schema.js +139 -0
  56. package/src/plan/plan-store.js +58 -25
  57. package/src/prompts/architect.js +2 -2
  58. package/src/prompts/coder.js +2 -2
  59. package/src/prompts/prompt-resolver.js +127 -0
  60. package/src/prompts/reviewer.js +2 -2
  61. package/src/roles/architect-role.js +3 -1
  62. package/src/roles/base-role.js +23 -2
  63. package/src/roles/coder-role.js +3 -1
  64. package/src/roles/security-role.js +12 -0
  65. package/src/roles/tester-role.js +11 -0
  66. package/src/session/journal/decisions-writer.js +200 -0
  67. package/src/session/journal/iteration-logger.js +194 -0
  68. package/src/session/journal/summary-writer.js +176 -0
  69. package/src/session/journal/tree-writer.js +164 -0
  70. package/src/skills/semantic-detector.js +147 -0
  71. package/src/skills/skill-detector.js +186 -8
  72. package/src/skills/skill-loader.js +131 -1
  73. package/src/skills/skills-cache.js +128 -0
  74. package/src/types/agent.js +55 -0
  75. package/src/types/config.js +114 -0
  76. package/src/types/hu.js +47 -0
  77. package/src/types/index.js +34 -0
  78. package/src/types/policy.js +43 -0
  79. package/src/types/session.js +74 -0
  80. package/src/types/stage.js +68 -0
  81. package/src/utils/display/event-handlers.js +389 -0
  82. package/src/utils/display/formatters.js +132 -0
  83. package/src/utils/display/header.js +48 -0
  84. package/src/utils/display/session-summary.js +108 -0
  85. package/src/utils/display/solomon.js +42 -0
  86. package/src/utils/port-check.js +45 -0
  87. package/src/utils/port-occupant.js +95 -0
  88. package/src/utils/provider-env.js +116 -0
  89. package/src/utils/with-timeout.js +42 -0
  90. package/templates/roles/architect/anthropic.md +65 -0
  91. package/templates/roles/architect/default.md +63 -0
  92. package/templates/roles/architect/google.md +52 -0
  93. package/templates/roles/architect/openai.md +51 -0
  94. package/templates/roles/architect/opencode.md +50 -0
  95. package/templates/roles/coder/anthropic.md +72 -0
  96. package/templates/roles/coder/default.md +69 -0
  97. package/templates/roles/coder/google.md +78 -0
  98. package/templates/roles/coder/openai.md +59 -0
  99. package/templates/roles/coder/opencode.md +49 -0
  100. package/templates/roles/planner/anthropic.md +50 -0
  101. package/templates/roles/planner/default.md +48 -0
  102. package/templates/roles/planner/google.md +66 -0
  103. package/templates/roles/planner/openai.md +46 -0
  104. package/templates/roles/planner/opencode.md +53 -0
  105. package/templates/roles/reviewer/anthropic.md +61 -0
  106. package/templates/roles/reviewer/default.md +62 -0
  107. package/templates/roles/reviewer/google.md +63 -0
  108. package/templates/roles/reviewer/openai.md +57 -0
  109. package/templates/roles/reviewer/opencode.md +42 -0
  110. package/templates/roles/solomon/anthropic.md +71 -0
  111. package/templates/roles/solomon/default.md +83 -0
  112. package/templates/roles/solomon/google.md +61 -0
  113. package/templates/roles/solomon/openai.md +53 -0
  114. package/templates/roles/solomon/opencode.md +55 -0
  115. package/src/utils/display.js +0 -669
@@ -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
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Per-iteration journal entries for `.reviews/{sessionId}/iterations.md`.
3
+ *
4
+ * The orchestrator calls `recordIteration(session, data)` after every
5
+ * iteration of the coder/reviewer loop completes. Entries are appended to
6
+ * `session._journalIterations` and rendered as Markdown at session end.
7
+ *
8
+ * Acceptance criterion (KJC-TSK-0287):
9
+ * - Given a kj run with multiple iterations → iterations.md exists with
10
+ * one section per iteration containing: number, coder summary, reviewer
11
+ * blocking issues, sonar issues (if any), Solomon ruling (if any),
12
+ * duration.
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} IterationData
17
+ * @property {number} iteration - 1-based index
18
+ * @property {number} durationMs
19
+ * @property {string} [coderSummary]
20
+ * @property {string} [reviewerSummary]
21
+ * @property {Array<{id?:string, severity?:string, file?:string, line?:number, description?:string}>} [blockingIssues]
22
+ * @property {string} [sonarSummary]
23
+ * @property {number} [sonarIssuesFound]
24
+ * @property {number} [sonarIssuesResolved]
25
+ * @property {string} [testerSummary]
26
+ * @property {string} [securitySummary]
27
+ * @property {{ ruling?:string, reasoning?:string }} [solomon] - only present when Solomon ran in this iteration
28
+ */
29
+
30
+ function esc(text) {
31
+ return String(text ?? "").replace(/\|/g, "\\|");
32
+ }
33
+
34
+ function formatDuration(ms) {
35
+ if (!Number.isFinite(ms) || ms < 0) return "—";
36
+ if (ms < 1000) return `${ms}ms`;
37
+ const s = Math.round(ms / 1000);
38
+ if (s < 60) return `${s}s`;
39
+ const m = Math.floor(s / 60);
40
+ const rs = s % 60;
41
+ return `${m}m ${rs}s`;
42
+ }
43
+
44
+ function formatBlockingIssues(issues) {
45
+ if (!Array.isArray(issues) || issues.length === 0) return null;
46
+ const lines = ["**Blocking issues**:"];
47
+ for (const issue of issues) {
48
+ const sev = issue.severity ? `[${issue.severity}] ` : "";
49
+ const loc = issue.file ? ` — \`${esc(issue.file)}${issue.line ? `:${issue.line}` : ""}\`` : "";
50
+ const id = issue.id ? `${issue.id}: ` : "";
51
+ const desc = esc(issue.description || issue.message || "(no description)");
52
+ lines.push(`- ${sev}${id}${desc}${loc}`);
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+
57
+ function formatSonarBlock(data) {
58
+ const parts = [];
59
+ if (data.sonarSummary) parts.push(esc(data.sonarSummary));
60
+ if (Number.isFinite(data.sonarIssuesFound)) {
61
+ const resolved = Number.isFinite(data.sonarIssuesResolved) ? ` (${data.sonarIssuesResolved} resolved)` : "";
62
+ parts.push(`Issues: ${data.sonarIssuesFound}${resolved}`);
63
+ }
64
+ return parts.length > 0 ? parts.join(" · ") : null;
65
+ }
66
+
67
+ function formatSolomonBlock(solomon) {
68
+ if (!solomon || !solomon.ruling) return null;
69
+ const lines = [`**Ruling**: \`${esc(solomon.ruling)}\``];
70
+ if (solomon.reasoning) lines.push(`**Reasoning**: ${esc(solomon.reasoning)}`);
71
+ return lines.join(" \n");
72
+ }
73
+
74
+ /**
75
+ * Render a single iteration as a Markdown section.
76
+ *
77
+ * @param {IterationData} data
78
+ * @returns {string}
79
+ */
80
+ export function renderIterationEntry(data) {
81
+ const lines = [`## Iteration ${data.iteration}`, ""];
82
+ lines.push(`**Duration**: ${formatDuration(data.durationMs)}`);
83
+
84
+ if (data.coderSummary) {
85
+ lines.push("", "### Coder", data.coderSummary);
86
+ }
87
+
88
+ if (data.reviewerSummary || (data.blockingIssues && data.blockingIssues.length > 0)) {
89
+ lines.push("", "### Reviewer");
90
+ if (data.reviewerSummary) lines.push(data.reviewerSummary);
91
+ const blocks = formatBlockingIssues(data.blockingIssues);
92
+ if (blocks) {
93
+ lines.push("", blocks);
94
+ }
95
+ }
96
+
97
+ const sonarLine = formatSonarBlock(data);
98
+ if (sonarLine) {
99
+ lines.push("", "### Sonar", sonarLine);
100
+ }
101
+
102
+ if (data.testerSummary) lines.push("", "### Tester", data.testerSummary);
103
+ if (data.securitySummary) lines.push("", "### Security", data.securitySummary);
104
+
105
+ const solomonBlock = formatSolomonBlock(data.solomon);
106
+ if (solomonBlock) {
107
+ lines.push("", "### Solomon", solomonBlock);
108
+ }
109
+
110
+ lines.push("");
111
+ return lines.join("\n");
112
+ }
113
+
114
+ /**
115
+ * Append an iteration record to session._journalIterations.
116
+ * Creates the array if absent. Callers pass structured IterationData.
117
+ *
118
+ * @param {Object} session
119
+ * @param {IterationData} data
120
+ */
121
+ export function recordIteration(session, data) {
122
+ if (!session || !data) return;
123
+ if (!Array.isArray(session._journalIterations)) session._journalIterations = [];
124
+ session._journalIterations.push(renderIterationEntry(data));
125
+ }
126
+
127
+ /**
128
+ * Build the full iterations.md content from recorded entries.
129
+ * Returns null when there are no iterations to render.
130
+ *
131
+ * @param {Object} session
132
+ * @returns {string|null}
133
+ */
134
+ export function buildIterationsMarkdown(session) {
135
+ const entries = Array.isArray(session?._journalIterations) ? session._journalIterations : [];
136
+ if (entries.length === 0) return null;
137
+ const header = [
138
+ `# Session iterations — ${session.id || "(unknown)"}`,
139
+ "",
140
+ `_Generated at ${new Date().toISOString()}._`,
141
+ "",
142
+ `Total iterations: **${entries.length}**`,
143
+ "",
144
+ "---",
145
+ "",
146
+ ].join("\n");
147
+ return header + entries.join("---\n\n") + "\n";
148
+ }
149
+
150
+ /**
151
+ * Extract structured iteration data from a stageResults bundle + session state.
152
+ * The orchestrator produces stage results per role; this helper pulls the
153
+ * relevant fields into a single object suitable for recordIteration().
154
+ *
155
+ * @param {Object} args
156
+ * @param {number} args.iteration
157
+ * @param {number} args.durationMs
158
+ * @param {Object} args.stageResults
159
+ * @param {Object} [args.session]
160
+ * @returns {IterationData}
161
+ */
162
+ export function extractIterationData({ iteration, durationMs, stageResults = {}, session = {} }) {
163
+ const coderSummary = stageResults.coder?.summary || stageResults.coder?.result?.summary;
164
+ const reviewerSummary = stageResults.reviewer?.summary;
165
+ const blockingIssues = stageResults.reviewer?.result?.blocking_issues
166
+ || stageResults.reviewer?.blocking_issues
167
+ || [];
168
+ const testerSummary = stageResults.tester?.summary;
169
+ const securitySummary = stageResults.security?.summary;
170
+ const sonarSummary = stageResults.sonar?.summary
171
+ || (stageResults.sonar?.openIssues != null ? `${stageResults.sonar.openIssues} open issue(s)` : undefined);
172
+ const sonarIssuesFound = stageResults.sonar?.issuesInitial ?? stageResults.sonar?.issuesFound;
173
+ const sonarIssuesResolved = stageResults.sonar?.issuesResolved;
174
+
175
+ // Solomon ruling for THIS iteration: match by iteration if the session
176
+ // tracks it; otherwise just take the latest ruling.
177
+ const rulings = Array.isArray(session.solomonRulings) ? session.solomonRulings : [];
178
+ const solomonRecord = rulings.find((r) => r.iteration === iteration) || rulings.at(-1);
179
+ const solomon = solomonRecord ? { ruling: solomonRecord.ruling, reasoning: solomonRecord.reasoning } : undefined;
180
+
181
+ return {
182
+ iteration,
183
+ durationMs,
184
+ coderSummary,
185
+ reviewerSummary,
186
+ blockingIssues,
187
+ sonarSummary,
188
+ sonarIssuesFound,
189
+ sonarIssuesResolved,
190
+ testerSummary,
191
+ securitySummary,
192
+ solomon,
193
+ };
194
+ }