karajan-code 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +39 -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 +171 -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 +55 -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/agent-role.js +9 -0
  56. package/src/roles/architect-role.js +3 -1
  57. package/src/roles/base-role.js +40 -2
  58. package/src/roles/coder-role.js +3 -1
  59. package/src/roles/security-role.js +12 -0
  60. package/src/roles/tester-role.js +11 -0
  61. package/src/session/journal/decisions-writer.js +200 -0
  62. package/src/session/journal/iteration-logger.js +194 -0
  63. package/src/session/journal/summary-writer.js +176 -0
  64. package/src/session/journal/tree-writer.js +164 -0
  65. package/src/skills/semantic-detector.js +147 -0
  66. package/src/skills/skill-detector.js +186 -8
  67. package/src/skills/skill-loader.js +131 -1
  68. package/src/skills/skills-cache.js +128 -0
  69. package/src/types/agent.js +55 -0
  70. package/src/types/config.js +114 -0
  71. package/src/types/hu.js +47 -0
  72. package/src/types/index.js +38 -0
  73. package/src/types/policy.js +43 -0
  74. package/src/types/session.js +74 -0
  75. package/src/types/solomon.js +87 -0
  76. package/src/types/stage.js +68 -0
  77. package/src/utils/display/event-handlers.js +389 -0
  78. package/src/utils/display/formatters.js +132 -0
  79. package/src/utils/display/header.js +48 -0
  80. package/src/utils/display/session-summary.js +108 -0
  81. package/src/utils/display/solomon.js +42 -0
  82. package/src/utils/port-check.js +45 -0
  83. package/src/utils/port-occupant.js +95 -0
  84. package/src/utils/provider-env.js +116 -0
  85. package/src/utils/with-timeout.js +42 -0
  86. package/templates/roles/architect/anthropic.md +65 -0
  87. package/templates/roles/architect/default.md +63 -0
  88. package/templates/roles/architect/google.md +52 -0
  89. package/templates/roles/architect/openai.md +51 -0
  90. package/templates/roles/architect/opencode.md +50 -0
  91. package/templates/roles/coder/anthropic.md +72 -0
  92. package/templates/roles/coder/default.md +69 -0
  93. package/templates/roles/coder/google.md +78 -0
  94. package/templates/roles/coder/openai.md +59 -0
  95. package/templates/roles/coder/opencode.md +49 -0
  96. package/templates/roles/planner/anthropic.md +50 -0
  97. package/templates/roles/planner/default.md +48 -0
  98. package/templates/roles/planner/google.md +66 -0
  99. package/templates/roles/planner/openai.md +46 -0
  100. package/templates/roles/planner/opencode.md +53 -0
  101. package/templates/roles/reviewer/anthropic.md +61 -0
  102. package/templates/roles/reviewer/default.md +62 -0
  103. package/templates/roles/reviewer/google.md +63 -0
  104. package/templates/roles/reviewer/openai.md +57 -0
  105. package/templates/roles/reviewer/opencode.md +42 -0
  106. package/templates/roles/solomon/anthropic.md +71 -0
  107. package/templates/roles/solomon/default.md +83 -0
  108. package/templates/roles/solomon/google.md +61 -0
  109. package/templates/roles/solomon/openai.md +53 -0
  110. package/templates/roles/solomon/opencode.md +55 -0
  111. package/src/utils/display.js +0 -669
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/karajan-code-logo.svg" alt="Karajan Code" width="180">
2
+ <img src="docs/karajan-orbit.svg" alt="Karajan Code" width="220">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Karajan Code</h1>
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.4.0 released** — Executable acceptance tests for HUs: each HU runs shell commands after every coder iteration. All pass → approved. Any fail → Brain diagnoses with exact error output. First version where the full demo completes end-to-end: 6 HUs, 280 tests, 97% coverage, 0 vulnerabilities. Also includes security audit fixes and auto-HU decomposition (v2.1+). See [CHANGELOG.md](./CHANGELOG.md) for full history and [MIGRATION-v2.md](./MIGRATION-v2.md) for v1→v2 breaking changes.
26
+ > **v2.6.0 released** — Modular orchestrator + infrastructure DI + Valibot config validation. `src/orchestrator.js` split into a thin barrel + `flow-runner.js` + new `StageExecutor` contract under `src/orchestrator/stages/`. `FileSystemService` and `CommandRunner` adapters let tests swap in mocks instead of spawning real subprocesses. Config now validated with Valibot (catches `review_mode` typos, `max_iterations: 0`, bad ports at load time). HU Board auto-start gate simplified to `hu_board.auto_start` alone with a prominent cyan URL banner at pipeline init. Plus: budget comparison (With-KJ vs Without-KJ), rich session journal (decisions / iterations / summary / tree), Brain decisor with intent-driven routing, and Karajan-wide JSDoc typedefs. See [CHANGELOG.md](./CHANGELOG.md) for full history and [MIGRATION-v2.md](./MIGRATION-v2.md) for v1→v2 breaking changes.
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
@@ -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):
@@ -107,7 +126,23 @@ kj run "Create a utility function that validates Spanish DNI numbers, with tests
107
126
  kj code "Add input validation to the signup form" # Coder only
108
127
  kj review "Check the authentication changes" # Review current diff
109
128
  kj audit "Full health analysis of this codebase" # Read-only audit
110
- kj plan "Refactor the database layer" # Plan without coding
129
+
130
+ # Planning workflow (v2.5+)
131
+ kj plan "Refactor the database layer" # Generate plan + HUs
132
+ kj plan list # List plans for this project
133
+ kj plan show <planId> # Show plan details + HU table
134
+ kj plan validate <planId> # Check structure and deps
135
+ kj plan ready <planId> # Certify all HUs, mark ready
136
+ kj plan add-hu <planId> --title "..." --type feat # Add HU to plan
137
+ kj plan remove-hu <planId> <huId> # Remove HU from plan
138
+ kj plan delete <planId> # Delete plan from disk
139
+ kj run --plan <planId> "task" # Execute an approved plan
140
+
141
+ # HU Board dashboard (v1.34.0+)
142
+ kj board start # Start web dashboard (port 4000)
143
+ kj board open # Start + open in browser
144
+ kj board status # Check if running
145
+ kj board stop # Stop the board
111
146
  ```
112
147
 
113
148
  ### 2. MCP: inside your AI agent
@@ -218,7 +253,7 @@ After `npm install -g karajan-code`, the MCP server auto-registers in Claude and
218
253
  # command = "karajan-mcp"
219
254
  ```
220
255
 
221
- **23 tools** available: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`, `kj_skills`, `kj_suggest`.
256
+ **24 tools** available: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_board`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`, `kj_skills`, `kj_suggest`.
222
257
 
223
258
  Use `kj-tail` in a separate terminal to see what the pipeline is doing in real time (see [Three ways to use Karajan](#three-ways-to-use-karajan)).
224
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
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",
@@ -47,6 +47,7 @@
47
47
  "test": "vitest run",
48
48
  "test:watch": "vitest",
49
49
  "lint": "((command -v rg >/dev/null 2>&1 && rg --files src tests | rg '\\.js$') || find src tests -type f -name '*.js') | while IFS= read -r f; do node --check \"$f\"; done",
50
+ "typecheck": "npx -p typescript@5 tsc --noEmit -p tsconfig.json",
50
51
  "validate": "npm run lint && npm test",
51
52
  "mcp": "node src/mcp/server.js"
52
53
  },
@@ -54,9 +55,11 @@
54
55
  "@modelcontextprotocol/sdk": "^1.26.0",
55
56
  "commander": "^14.0.0",
56
57
  "execa": "^9.6.0",
57
- "js-yaml": "^4.1.0"
58
+ "js-yaml": "^4.1.0",
59
+ "valibot": "^1.3.1"
58
60
  },
59
61
  "devDependencies": {
62
+ "@changesets/cli": "^2.30.0",
60
63
  "@vitest/coverage-v8": "^4.0.18",
61
64
  "vitest": "^4.0.0"
62
65
  }
@@ -1,5 +1,4 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
- import { runCommand } from "../utils/process.js";
3
2
  import { resolveBin } from "./resolve-bin.js";
4
3
 
5
4
  export class AiderAgent extends BaseAgent {
@@ -28,7 +27,7 @@ export class AiderAgent extends BaseAgent {
28
27
  async _exec(task, model) {
29
28
  const args = ["--yes", "--message", task.prompt];
30
29
  if (model) args.push("--model", model);
31
- const res = await runCommand(resolveBin("aider"), args, {
30
+ const res = await this.runCommand(resolveBin("aider"), args, {
32
31
  onOutput: task.onOutput,
33
32
  silenceTimeoutMs: task.silenceTimeoutMs,
34
33
  timeout: task.timeoutMs
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Base class for provider-specific agent adapters (claude, codex, gemini, ...).
3
+ * Every concrete agent must implement runTask() and may override reviewTask().
4
+ *
5
+ * @typedef {import("../types/config.js").KarajanConfig} KarajanConfig
6
+ * @typedef {import("../types/stage.js").Logger} Logger
7
+ * @typedef {import("../types/agent.js").AgentResult} AgentResult
8
+ * @typedef {import("../infrastructure/environment.js").Environment} Environment
9
+ * @typedef {import("../infrastructure/command-runner.js").CommandRunResult} CommandRunResult
10
+ * @typedef {import("../infrastructure/command-runner.js").CommandRunOptions} CommandRunOptions
11
+ */
12
+
13
+ import { defaultEnvironment } from "../infrastructure/environment.js";
14
+
1
15
  const MODEL_NOT_SUPPORTED_PATTERNS = [
2
16
  /model.{0,30}is not supported/i,
3
17
  /model.{0,30}not available/i,
@@ -8,20 +22,58 @@ const MODEL_NOT_SUPPORTED_PATTERNS = [
8
22
  ];
9
23
 
10
24
  export class BaseAgent {
11
- constructor(name, config, logger) {
25
+ /**
26
+ * @param {string} name - agent slug (claude|codex|gemini|aider|opencode)
27
+ * @param {KarajanConfig} config
28
+ * @param {Logger} logger
29
+ * @param {Environment} [environment] - DI-friendly fs + runner bundle. Defaults
30
+ * to the native filesystem and execa-based
31
+ * command runner. Tests pass a mock env to
32
+ * unit-test agent paths without spawning
33
+ * real processes.
34
+ */
35
+ constructor(name, config, logger, environment = defaultEnvironment) {
12
36
  this.name = name;
13
37
  this.config = config;
14
38
  this.logger = logger;
39
+ this.environment = environment;
40
+ }
41
+
42
+ /**
43
+ * Run a shell command through the injected CommandRunner. Subclasses should
44
+ * call this instead of importing runCommand directly so tests can swap in
45
+ * MockCommandRunner.
46
+ * @param {string} command
47
+ * @param {string[]} [args]
48
+ * @param {CommandRunOptions} [options]
49
+ * @returns {Promise<CommandRunResult>}
50
+ */
51
+ async runCommand(command, args = [], options = {}) {
52
+ return this.environment.runner.run(command, args, options);
15
53
  }
16
54
 
55
+ /**
56
+ * Execute a coding/agentic task. Subclasses MUST override.
57
+ * @param {string|Object} _task
58
+ * @returns {Promise<AgentResult>}
59
+ */
17
60
  async runTask(_task) {
18
61
  throw new Error("runTask not implemented");
19
62
  }
20
63
 
64
+ /**
65
+ * Execute a review task. Subclasses MAY override for different behaviour.
66
+ * @param {string|Object} _task
67
+ * @returns {Promise<AgentResult>}
68
+ */
21
69
  async reviewTask(_task) {
22
70
  throw new Error("reviewTask not implemented");
23
71
  }
24
72
 
73
+ /**
74
+ * @param {string} role
75
+ * @returns {string|null}
76
+ */
25
77
  getRoleModel(role) {
26
78
  const roleModel = this.config?.roles?.[role]?.model;
27
79
  if (roleModel) return roleModel;
@@ -29,11 +81,21 @@ export class BaseAgent {
29
81
  return this.config?.coder_options?.model || null;
30
82
  }
31
83
 
84
+ /**
85
+ * @param {string} role
86
+ * @returns {boolean}
87
+ */
32
88
  isAutoApproveEnabled(role) {
33
89
  if (role === "reviewer") return false;
34
90
  return Boolean(this.config?.coder_options?.auto_approve);
35
91
  }
36
92
 
93
+ /**
94
+ * Heuristic: does the agent's error look like "model not supported"?
95
+ * Used by the retry/fallback path.
96
+ * @param {Partial<AgentResult>} result
97
+ * @returns {boolean}
98
+ */
37
99
  isModelNotSupportedError(result) {
38
100
  const text = [result?.error, result?.output, result?.stderr, result?.stdout]
39
101
  .filter(Boolean).join("\n");
@@ -1,5 +1,4 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
- import { runCommand } from "../utils/process.js";
3
2
  import { resolveBin } from "./resolve-bin.js";
4
3
 
5
4
  /**
@@ -244,7 +243,7 @@ export class ClaudeAgent extends BaseAgent {
244
243
  if (task.onOutput) {
245
244
  args.push("--output-format", "stream-json", "--verbose");
246
245
  const streamFilter = createStreamJsonFilter(task.onOutput);
247
- const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts({
246
+ const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({
248
247
  onOutput: streamFilter,
249
248
  silenceTimeoutMs: task.silenceTimeoutMs,
250
249
  timeout: task.timeoutMs
@@ -257,7 +256,7 @@ export class ClaudeAgent extends BaseAgent {
257
256
 
258
257
  // Without streaming, use json output to get structured response via stderr
259
258
  args.push("--output-format", "json");
260
- const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts());
259
+ const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts());
261
260
  const raw = pickOutput(res);
262
261
  const output = extractTextFromStreamJson(raw);
263
262
  const usage = extractUsageFromStreamJson(raw);
@@ -267,7 +266,7 @@ export class ClaudeAgent extends BaseAgent {
267
266
  async _reviewTaskExec(task, model) {
268
267
  const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS, "--output-format", "stream-json", "--verbose"];
269
268
  if (model) args.push("--model", model);
270
- const res = await runCommand(resolveBin("claude"), args, cleanExecaOpts({
269
+ const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({
271
270
  onOutput: task.onOutput,
272
271
  silenceTimeoutMs: task.silenceTimeoutMs,
273
272
  timeout: task.timeoutMs
@@ -1,5 +1,4 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
- import { runCommand } from "../utils/process.js";
3
2
  import { resolveBin } from "./resolve-bin.js";
4
3
 
5
4
  /**
@@ -45,7 +44,7 @@ export class CodexAgent extends BaseAgent {
45
44
  if (model) args.push("--model", model);
46
45
  if (role !== "reviewer" && this.isAutoApproveEnabled(role)) args.push("--full-auto");
47
46
  args.push("-");
48
- const res = await runCommand(resolveBin("codex"), args, {
47
+ const res = await this.runCommand(resolveBin("codex"), args, {
49
48
  onOutput: task.onOutput,
50
49
  silenceTimeoutMs: task.silenceTimeoutMs,
51
50
  timeout: task.timeoutMs,
@@ -1,5 +1,4 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
- import { runCommand } from "../utils/process.js";
3
2
  import { resolveBin } from "./resolve-bin.js";
4
3
 
5
4
  export class GeminiAgent extends BaseAgent {
@@ -29,7 +28,7 @@ export class GeminiAgent extends BaseAgent {
29
28
  const args = ["-p", task.prompt];
30
29
  if (mode === "review") args.push("--output-format", "json");
31
30
  if (model) args.push("--model", model);
32
- const res = await runCommand(resolveBin("gemini"), args, {
31
+ const res = await this.runCommand(resolveBin("gemini"), args, {
33
32
  onOutput: task.onOutput,
34
33
  silenceTimeoutMs: task.silenceTimeoutMs,
35
34
  timeout: task.timeoutMs
@@ -26,12 +26,21 @@ export function getAgentMeta(name) {
26
26
  return entry ? { ...entry.meta } : null;
27
27
  }
28
28
 
29
- export function createAgent(agentName, config, logger) {
29
+ /**
30
+ * @param {string} agentName
31
+ * @param {import("../types/config.js").KarajanConfig} config
32
+ * @param {import("../types/stage.js").Logger} logger
33
+ * @param {import("../infrastructure/environment.js").Environment} [environment]
34
+ * Optional DI environment. Defaults to native fs + execa. Tests pass a mock
35
+ * environment built via `buildMockEnvironment()` to exercise agent paths
36
+ * without spawning real processes.
37
+ */
38
+ export function createAgent(agentName, config, logger, environment) {
30
39
  const entry = agentRegistry.get(agentName);
31
40
  if (!entry) {
32
41
  throw new Error(`Unsupported agent: ${agentName}`);
33
42
  }
34
- return new entry.AgentClass(agentName, config, logger);
43
+ return new entry.AgentClass(agentName, config, logger, environment);
35
44
  }
36
45
 
37
46
  // Auto-register built-in agents with CLI metadata
@@ -1,5 +1,4 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
- import { runCommand } from "../utils/process.js";
3
2
  import { resolveBin } from "./resolve-bin.js";
4
3
 
5
4
  export class OpenCodeAgent extends BaseAgent {
@@ -30,7 +29,7 @@ export class OpenCodeAgent extends BaseAgent {
30
29
  if (jsonFormat) args.push("--format", "json");
31
30
  if (model) args.push("--model", model);
32
31
  args.push(task.prompt);
33
- const res = await runCommand(resolveBin("opencode"), args, {
32
+ const res = await this.runCommand(resolveBin("opencode"), args, {
34
33
  onOutput: task.onOutput,
35
34
  silenceTimeoutMs: task.silenceTimeoutMs,
36
35
  timeout: task.timeoutMs
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Tracks every routing decision the Brain makes during a session and enforces
3
+ * safety limits (max decisions per session, indecision-loop detection).
4
+ *
5
+ * Decisions are appended to `session.brainDecisions`. Consumers call
6
+ * `recordDecision(tracker, decision, context)` after a buildDecision() call
7
+ * and `checkLimits(tracker)` before making another decision.
8
+ *
9
+ * The limits are soft guards, not hard failures: hitting a limit surfaces
10
+ * a status that the orchestrator can use to escalate to Solomon or pause
11
+ * the session for human input.
12
+ */
13
+
14
+ export const DEFAULT_MAX_DECISIONS = 20;
15
+ export const INDECISION_REPEAT_THRESHOLD = 3; // 3 identical classifications in a row
16
+
17
+ /**
18
+ * @typedef {Object} RecordedDecision
19
+ * @property {number} seq
20
+ * @property {string} at - ISO timestamp
21
+ * @property {string} intent - e.g. "sw:medium" (taskType:level)
22
+ * @property {string[]} rolesOn
23
+ * @property {"low"|"medium"|"high"} confidence
24
+ * @property {string} rationale
25
+ * @property {string[]} appliedOverrides
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} TrackerStatus
30
+ * @property {"ok"|"max-reached"|"indecision"} status
31
+ * @property {number} decisionCount
32
+ * @property {string} [detail]
33
+ */
34
+
35
+ /**
36
+ * Initialize a tracker bound to a session. If the session already has a
37
+ * `brainDecisions` array (e.g. from a resumed session), keep it.
38
+ *
39
+ * @param {Object} session
40
+ * @param {{ max?: number }} [options]
41
+ * @returns {{ session: Object, max: number }}
42
+ */
43
+ export function createTracker(session, { max = DEFAULT_MAX_DECISIONS } = {}) {
44
+ if (!Array.isArray(session.brainDecisions)) {
45
+ session.brainDecisions = [];
46
+ }
47
+ return { session, max };
48
+ }
49
+
50
+ /**
51
+ * Append a decision to the tracker.
52
+ *
53
+ * @param {{ session: Object, max: number }} tracker
54
+ * @param {Object} decision - output of buildDecision()
55
+ * @param {{ taskType?: string, level?: string }} intent
56
+ * @returns {RecordedDecision}
57
+ */
58
+ export function recordDecision(tracker, decision, intent = {}) {
59
+ const seq = tracker.session.brainDecisions.length + 1;
60
+ const entry = {
61
+ seq,
62
+ at: new Date().toISOString(),
63
+ intent: `${intent.taskType || "?"}:${intent.level || "?"}`,
64
+ rolesOn: Array.isArray(decision.rolesOn) ? [...decision.rolesOn] : [],
65
+ confidence: decision.confidence || "medium",
66
+ rationale: decision.rationale || "",
67
+ appliedOverrides: Array.isArray(decision.appliedOverrides) ? [...decision.appliedOverrides] : [],
68
+ };
69
+ tracker.session.brainDecisions.push(entry);
70
+ return entry;
71
+ }
72
+
73
+ /**
74
+ * Compute status of the tracker. Called BEFORE recording a new decision so
75
+ * the orchestrator can decide to escalate instead of calling the Brain again.
76
+ *
77
+ * @param {{ session: Object, max: number }} tracker
78
+ * @returns {TrackerStatus}
79
+ */
80
+ export function checkLimits(tracker) {
81
+ const decisions = tracker.session.brainDecisions || [];
82
+ const decisionCount = decisions.length;
83
+
84
+ if (decisionCount >= tracker.max) {
85
+ return {
86
+ status: "max-reached",
87
+ decisionCount,
88
+ detail: `Brain made ${decisionCount} decisions (limit ${tracker.max}). Escalate to Solomon or human.`,
89
+ };
90
+ }
91
+
92
+ // Indecision loop: last N decisions share the exact same intent.
93
+ const window = decisions.slice(-INDECISION_REPEAT_THRESHOLD);
94
+ if (window.length >= INDECISION_REPEAT_THRESHOLD && window.every((d) => d.intent === window[0].intent)) {
95
+ return {
96
+ status: "indecision",
97
+ decisionCount,
98
+ detail: `Brain classified the same intent (${window[0].intent}) ${INDECISION_REPEAT_THRESHOLD} times in a row — loop detected.`,
99
+ };
100
+ }
101
+
102
+ return { status: "ok", decisionCount };
103
+ }
104
+
105
+ /**
106
+ * Build a short summary of all decisions made in the session. Useful for the
107
+ * final report and for debugging.
108
+ *
109
+ * @param {{ session: Object }} tracker
110
+ * @returns {{ total: number, byIntent: Record<string,number>, lastRationale: string }}
111
+ */
112
+ export function summarize(tracker) {
113
+ const decisions = tracker.session.brainDecisions || [];
114
+ const byIntent = {};
115
+ for (const d of decisions) {
116
+ byIntent[d.intent] = (byIntent[d.intent] || 0) + 1;
117
+ }
118
+ return {
119
+ total: decisions.length,
120
+ byIntent,
121
+ lastRationale: decisions.at(-1)?.rationale ?? "",
122
+ };
123
+ }