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
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
 
@@ -107,7 +107,23 @@ kj run "Create a utility function that validates Spanish DNI numbers, with tests
107
107
  kj code "Add input validation to the signup form" # Coder only
108
108
  kj review "Check the authentication changes" # Review current diff
109
109
  kj audit "Full health analysis of this codebase" # Read-only audit
110
- kj plan "Refactor the database layer" # Plan without coding
110
+
111
+ # Planning workflow (v2.5+)
112
+ kj plan "Refactor the database layer" # Generate plan + HUs
113
+ kj plan list # List plans for this project
114
+ kj plan show <planId> # Show plan details + HU table
115
+ kj plan validate <planId> # Check structure and deps
116
+ kj plan ready <planId> # Certify all HUs, mark ready
117
+ kj plan add-hu <planId> --title "..." --type feat # Add HU to plan
118
+ kj plan remove-hu <planId> <huId> # Remove HU from plan
119
+ kj plan delete <planId> # Delete plan from disk
120
+ kj run --plan <planId> "task" # Execute an approved plan
121
+
122
+ # HU Board dashboard (v1.34.0+)
123
+ kj board start # Start web dashboard (port 4000)
124
+ kj board open # Start + open in browser
125
+ kj board status # Check if running
126
+ kj board stop # Stop the board
111
127
  ```
112
128
 
113
129
  ### 2. MCP: inside your AI agent
@@ -218,7 +234,7 @@ After `npm install -g karajan-code`, the MCP server auto-registers in Claude and
218
234
  # command = "karajan-mcp"
219
235
  ```
220
236
 
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`.
237
+ **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
238
 
223
239
  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
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",
@@ -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
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Brain decisor — intent-driven pipeline routing.
3
+ *
4
+ * Takes the triage output + task text + config and produces a Decision that
5
+ * the orchestrator applies as pipelineFlags overrides. The existing stage
6
+ * if/else in runFlow continues to drive execution; Brain just tells it which
7
+ * roles are relevant for this particular task.
8
+ *
9
+ * This is an MVP that keeps the current architecture. A future iteration
10
+ * (Pipeline Sovereignty / KJC-PCS-0022) can promote the Decision into a
11
+ * true DAG with parallel stage execution.
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} TriageOutput
16
+ * @property {"trivial"|"simple"|"medium"|"complex"} level
17
+ * @property {string[]} roles - roles triage suggests running
18
+ * @property {string} [taskType] - sw | infra | doc | add-tests | refactor | no-code | audit
19
+ * @property {string} [reasoning]
20
+ * @property {number} [confidence] - 0..1, often absent
21
+ * @property {string[]} [domainHints]
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} Decision
26
+ * @property {string[]} rolesOn - roles to enable in pipelineFlags
27
+ * @property {string[]} rolesOff - roles to explicitly disable
28
+ * @property {"low"|"medium"|"high"} confidence
29
+ * @property {string} rationale - human-readable reasoning
30
+ * @property {boolean} consultSolomon - true if the Brain is unsure
31
+ * @property {string} [solomonReason] - why Solomon is needed
32
+ * @property {string[]} appliedOverrides - names of manual overrides respected
33
+ */
34
+
35
+ const VALID_ROLES = new Set([
36
+ "planner", "researcher", "architect", "coder", "reviewer", "refactorer",
37
+ "tester", "security", "impeccable", "triage", "discover", "hu_reviewer", "solomon",
38
+ ]);
39
+
40
+ /**
41
+ * Translate triage level → base set of roles. Conservative baseline that the
42
+ * decisor then adjusts via taskType and domain hints.
43
+ */
44
+ function baselineForLevel(level) {
45
+ switch (level) {
46
+ case "trivial": return new Set(["coder"]);
47
+ case "simple": return new Set(["coder", "reviewer"]);
48
+ case "medium": return new Set(["coder", "reviewer", "tester"]);
49
+ case "complex": return new Set(["researcher", "architect", "planner", "coder", "reviewer", "tester", "security"]);
50
+ default: return new Set(["coder", "reviewer"]);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Apply taskType adjustments on top of the baseline set (mutates in place).
56
+ */
57
+ function applyTaskTypeAdjustments(roles, taskType) {
58
+ switch (taskType) {
59
+ case "doc":
60
+ // Documentation tasks: skip testing and security, keep reviewer for tone/clarity.
61
+ roles.delete("tester");
62
+ roles.delete("security");
63
+ roles.delete("refactorer");
64
+ break;
65
+ case "add-tests":
66
+ // Adding tests: tester is primary, security less relevant.
67
+ roles.add("tester");
68
+ roles.delete("security");
69
+ break;
70
+ case "refactor":
71
+ // Refactor: no new behavior; tester verifies nothing broke; reviewer checks structure.
72
+ roles.add("refactorer");
73
+ roles.add("tester");
74
+ break;
75
+ case "infra":
76
+ // Infra: architect helps; security critical.
77
+ roles.add("architect");
78
+ roles.add("security");
79
+ break;
80
+ case "audit":
81
+ // Audit: no coder; reviewer + security.
82
+ roles.delete("coder");
83
+ roles.add("reviewer");
84
+ roles.add("security");
85
+ break;
86
+ case "no-code":
87
+ // No-code tasks (e.g. running SQL queries): drop coder+reviewer+tester.
88
+ roles.delete("coder");
89
+ roles.delete("reviewer");
90
+ roles.delete("tester");
91
+ break;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Apply explicit overrides (CLI flags / config) on top of the decision.
97
+ * force = add; skip = remove. Tracks what was applied.
98
+ *
99
+ * @param {Set<string>} roles
100
+ * @param {{ forceRoles?: string[], skipRoles?: string[] }} overrides
101
+ * @returns {string[]} applied override names
102
+ */
103
+ function applyOverrides(roles, overrides) {
104
+ const applied = [];
105
+ for (const r of overrides.forceRoles || []) {
106
+ if (!VALID_ROLES.has(r)) continue;
107
+ roles.add(r);
108
+ applied.push(`force:${r}`);
109
+ }
110
+ for (const r of overrides.skipRoles || []) {
111
+ if (!VALID_ROLES.has(r)) continue;
112
+ roles.delete(r);
113
+ applied.push(`skip:${r}`);
114
+ }
115
+ return applied;
116
+ }
117
+
118
+ /**
119
+ * Build a routing Decision from triage output + task text + config.
120
+ *
121
+ * @param {Object} args
122
+ * @param {TriageOutput} args.triage
123
+ * @param {string} [args.task]
124
+ * @param {Object} [args.config] - resolved config (for rolesEnabled in pipeline)
125
+ * @param {{ forceRoles?: string[], skipRoles?: string[] }} [args.overrides]
126
+ * @returns {Decision}
127
+ */
128
+ export function buildDecision({ triage, task, config = {}, overrides = {} } = {}) {
129
+ const level = triage?.level || "simple";
130
+ const taskType = triage?.taskType || "sw";
131
+
132
+ // 1. Baseline by complexity.
133
+ const roles = baselineForLevel(level);
134
+
135
+ // 2. Include triage's own suggested roles (triage already factors domain hints).
136
+ if (Array.isArray(triage?.roles)) {
137
+ for (const r of triage.roles) {
138
+ if (VALID_ROLES.has(r)) roles.add(r);
139
+ }
140
+ }
141
+
142
+ // 3. Task-type-specific adjustments.
143
+ applyTaskTypeAdjustments(roles, taskType);
144
+
145
+ // 4. Config pipeline.X.enabled=false forces a role off (respect user config).
146
+ const pipeline = config?.pipeline || {};
147
+ for (const r of Array.from(roles)) {
148
+ if (pipeline[r]?.enabled === false) {
149
+ roles.delete(r);
150
+ }
151
+ }
152
+
153
+ // 5. Manual CLI/flag overrides (highest priority).
154
+ const appliedOverrides = applyOverrides(roles, overrides);
155
+
156
+ // 6. Invariants: coder is always on unless explicitly audit/no-code AND not force-added.
157
+ if (taskType !== "audit" && taskType !== "no-code" && !overrides.skipRoles?.includes("coder")) {
158
+ roles.add("coder");
159
+ }
160
+
161
+ // 7. Confidence + Solomon consultation.
162
+ const explicitConfidence = typeof triage?.confidence === "number" ? triage.confidence : null;
163
+ let confidence = "medium";
164
+ let consultSolomon = false;
165
+ let solomonReason;
166
+ if (explicitConfidence !== null) {
167
+ if (explicitConfidence < 0.6) {
168
+ confidence = "low";
169
+ consultSolomon = true;
170
+ solomonReason = `triage confidence ${explicitConfidence.toFixed(2)} < 0.6`;
171
+ } else if (explicitConfidence >= 0.85) {
172
+ confidence = "high";
173
+ }
174
+ }
175
+ // Ambiguous level (absent or off-vocabulary) → low confidence regardless.
176
+ if (!triage?.level) {
177
+ confidence = "low";
178
+ consultSolomon = true;
179
+ solomonReason = "triage returned no level";
180
+ }
181
+
182
+ const rolesOn = Array.from(roles).sort();
183
+ const rolesOff = Array.from(VALID_ROLES).filter((r) => !roles.has(r)).sort();
184
+
185
+ const rationale = buildRationale({ level, taskType, rolesOn, appliedOverrides, triage });
186
+
187
+ return {
188
+ rolesOn,
189
+ rolesOff,
190
+ confidence,
191
+ rationale,
192
+ consultSolomon,
193
+ solomonReason,
194
+ appliedOverrides,
195
+ };
196
+ }
197
+
198
+ function buildRationale({ level, taskType, rolesOn, appliedOverrides, triage }) {
199
+ const parts = [
200
+ `level=${level}`,
201
+ `taskType=${taskType}`,
202
+ `roles=[${rolesOn.join(",")}]`,
203
+ ];
204
+ if (triage?.domainHints?.length) {
205
+ parts.push(`hints=[${triage.domainHints.slice(0, 3).join(",")}]`);
206
+ }
207
+ if (appliedOverrides.length) {
208
+ parts.push(`overrides=[${appliedOverrides.join(",")}]`);
209
+ }
210
+ return parts.join(" | ");
211
+ }
212
+
213
+ /**
214
+ * Apply a Decision onto an existing pipelineFlags object. Returns a NEW
215
+ * object — the input is not mutated. Setting roleEnabled flags via a
216
+ * consistent naming pattern: `${role}Enabled`.
217
+ *
218
+ * Unknown roles are silently ignored; the orchestrator only consumes a
219
+ * fixed set of *Enabled flags.
220
+ *
221
+ * @param {Decision} decision
222
+ * @param {Object} pipelineFlags
223
+ * @returns {Object} new flags object
224
+ */
225
+ export function applyDecisionToFlags(decision, pipelineFlags = {}) {
226
+ const next = { ...pipelineFlags };
227
+ const flagFor = (role) => {
228
+ if (role === "hu_reviewer") return "huReviewerEnabled";
229
+ return `${role}Enabled`;
230
+ };
231
+ for (const role of decision.rolesOn) {
232
+ next[flagFor(role)] = true;
233
+ }
234
+ for (const role of decision.rolesOff) {
235
+ next[flagFor(role)] = false;
236
+ }
237
+ // Coder/security/reviewer aren't always toggled via flags in the current
238
+ // orchestrator (coder is implicit). Don't touch fields the orchestrator
239
+ // doesn't know; next simply carries them.
240
+ return next;
241
+ }
242
+
243
+ export { VALID_ROLES };