karajan-code 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.0.1",
3
+ "version": "2.1.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",
@@ -116,18 +116,45 @@ function extractTextFromStreamJson(raw) {
116
116
  * Create a wrapping onOutput that parses stream-json lines and forwards
117
117
  * meaningful content (assistant text, tool usage) to the original callback.
118
118
  */
119
+ function summarizeToolCall(name, input = {}) {
120
+ // Produce concise human-readable action line from tool_use block
121
+ const rel = (p) => String(p || "").replace(process.cwd() + "/", "");
122
+ switch (name) {
123
+ case "Read": return `Read ${rel(input.file_path)}`;
124
+ case "Write": return `Write ${rel(input.file_path)}`;
125
+ case "Edit": return `Edit ${rel(input.file_path)}`;
126
+ case "MultiEdit": return `MultiEdit ${rel(input.file_path)} (${(input.edits || []).length} edits)`;
127
+ case "NotebookEdit": return `NotebookEdit ${rel(input.notebook_path)}`;
128
+ case "Glob": return `Glob ${input.pattern || ""}`.trim();
129
+ case "Grep": return `Grep "${(input.pattern || "").slice(0, 60)}"${input.path ? ` in ${rel(input.path)}` : ""}`;
130
+ case "Bash": {
131
+ const cmd = String(input.command || "").replace(/\s+/g, " ").slice(0, 100);
132
+ return `Bash $ ${cmd}`;
133
+ }
134
+ case "TodoWrite": return `Todo ${(input.todos || []).length} items`;
135
+ case "WebFetch": return `WebFetch ${input.url || ""}`;
136
+ case "WebSearch": return `WebSearch "${(input.query || "").slice(0, 60)}"`;
137
+ case "Task": return `Agent: ${input.subagent_type || "?"} — ${(input.description || "").slice(0, 60)}`;
138
+ default: return `${name}${Object.keys(input).length > 0 ? " (…)" : ""}`;
139
+ }
140
+ }
141
+
119
142
  function createStreamJsonFilter(onOutput) {
120
143
  if (!onOutput) return null;
121
144
  return ({ stream, line }) => {
122
145
  try {
123
146
  const obj = JSON.parse(line);
124
- // Forward assistant text messages
147
+ // Forward assistant content
125
148
  if (obj.type === "assistant" && obj.message?.content) {
126
149
  for (const block of obj.message.content) {
127
150
  if (block.type === "text" && block.text) {
128
- onOutput({ stream, line: block.text.slice(0, 200) });
151
+ // Only emit first line of text for brevity
152
+ const firstLine = block.text.split("\n")[0].trim().slice(0, 120);
153
+ if (firstLine) onOutput({ stream, line: firstLine, kind: "text" });
129
154
  } else if (block.type === "tool_use") {
130
- onOutput({ stream, line: `[tool: ${block.name}]` });
155
+ onOutput({ stream, line: summarizeToolCall(block.name, block.input), kind: "tool" });
156
+ } else if (block.type === "thinking" && block.thinking) {
157
+ onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replace(/\s+/g, " ")}…`, kind: "thinking" });
131
158
  }
132
159
  }
133
160
  return;
@@ -135,9 +162,9 @@ function createStreamJsonFilter(onOutput) {
135
162
  // Forward result
136
163
  if (obj.type === "result") {
137
164
  const summary = typeof obj.result === "string"
138
- ? obj.result.slice(0, 200)
165
+ ? obj.result.split("\n")[0].slice(0, 120)
139
166
  : "result received";
140
- onOutput({ stream, line: `[result] ${summary}` });
167
+ onOutput({ stream, line: `done: ${summary}` });
141
168
  return;
142
169
  }
143
170
  } catch { /* not JSON, forward raw */ }
package/src/config.js CHANGED
@@ -45,6 +45,7 @@ const DEFAULTS = {
45
45
  },
46
46
  review_mode: "standard",
47
47
  max_iterations: 5,
48
+ hu_max_iterations: 3,
48
49
  max_budget_usd: null,
49
50
  review_rules: "./.karajan/review-rules.md",
50
51
  coder_rules: "./.karajan/coder-rules.md",
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Per-HU git automation: branch, commit, push, PR per HU story.
3
+ * Called from the HU sub-pipeline wrapper in orchestrator.js.
4
+ */
5
+ import { commitAll, pushBranch, createPullRequest, hasChanges } from "../utils/git.js";
6
+ import { runCommand } from "../utils/process.js";
7
+
8
+ /**
9
+ * Build a git-safe branch name for an HU story.
10
+ * @param {string} prefix - e.g. "feat/"
11
+ * @param {object} story - HU story {id, title}
12
+ * @returns {string}
13
+ */
14
+ export function buildHuBranchName(prefix, story) {
15
+ const baseSlug = String(story.title || story.id || "hu")
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, 40);
20
+ return `${prefix}${story.id}-${baseSlug}`;
21
+ }
22
+
23
+ /**
24
+ * Resolve the base branch for a given HU based on its dependencies.
25
+ * If it has no blocked_by, uses config.base_branch.
26
+ * If it has parents, uses the last-created parent branch (assumes parents ran first).
27
+ *
28
+ * @param {object} story - the HU story
29
+ * @param {Map<string,string>} huBranches - map of huId → branchName (already created)
30
+ * @param {string} baseBranch - config.base_branch fallback
31
+ * @returns {string} base branch name
32
+ */
33
+ export function resolveHuBase(story, huBranches, baseBranch) {
34
+ const parents = story.blocked_by || [];
35
+ if (parents.length === 0) return baseBranch;
36
+ // Walk parents in reverse order of declaration to find the most recent one
37
+ for (let i = parents.length - 1; i >= 0; i--) {
38
+ const parentBranch = huBranches.get(parents[i]);
39
+ if (parentBranch) return parentBranch;
40
+ }
41
+ return baseBranch;
42
+ }
43
+
44
+ /**
45
+ * Create a branch for an HU starting from its resolved base.
46
+ * Returns the branch name created (or null if git automation is disabled).
47
+ *
48
+ * @param {object} params
49
+ * @param {object} params.story
50
+ * @param {Map} params.huBranches
51
+ * @param {object} params.config
52
+ * @param {object} params.logger
53
+ * @returns {Promise<string|null>}
54
+ */
55
+ export async function prepareHuBranch({ story, huBranches, config, logger }) {
56
+ if (!config.git?.auto_commit && !config.git?.auto_push && !config.git?.auto_pr) {
57
+ return null;
58
+ }
59
+ const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
60
+ const prefix = config.git?.branch_prefix || "feat/";
61
+ const branchName = buildHuBranchName(prefix, story);
62
+
63
+ // Checkout from the resolved base. Use `git checkout -B` to overwrite if rerun.
64
+ const res = await runCommand("git", ["checkout", "-B", branchName, baseBranch], {});
65
+ if (res.exitCode !== 0) {
66
+ logger.warn(`HU git: failed to create branch ${branchName} from ${baseBranch}: ${res.stderr}`);
67
+ return null;
68
+ }
69
+ huBranches.set(story.id, branchName);
70
+ logger.info(`HU ${story.id}: branch '${branchName}' from '${baseBranch}'`);
71
+ return branchName;
72
+ }
73
+
74
+ /**
75
+ * After an HU is approved, commit its changes and optionally push + create PR.
76
+ *
77
+ * @param {object} params
78
+ * @param {object} params.story
79
+ * @param {string} params.branchName
80
+ * @param {object} params.config
81
+ * @param {object} params.logger
82
+ * @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null}>}
83
+ */
84
+ export async function finalizeHuCommit({ story, branchName, config, logger }) {
85
+ const result = { committed: false, pushed: false, prUrl: null };
86
+ if (!branchName) return result;
87
+
88
+ const changed = await hasChanges();
89
+ if (!changed) {
90
+ logger.info(`HU ${story.id}: no changes to commit`);
91
+ return result;
92
+ }
93
+
94
+ const title = story.title || story.id;
95
+ const commitMsg = `feat(${story.id}): ${title}`;
96
+ if (config.git?.auto_commit) {
97
+ const commitRes = await commitAll(commitMsg);
98
+ if (commitRes) {
99
+ result.committed = true;
100
+ logger.info(`HU ${story.id}: committed on '${branchName}'`);
101
+ }
102
+ }
103
+
104
+ if (config.git?.auto_push && result.committed) {
105
+ try {
106
+ await pushBranch(branchName);
107
+ result.pushed = true;
108
+ logger.info(`HU ${story.id}: pushed '${branchName}'`);
109
+ } catch (err) {
110
+ logger.warn(`HU ${story.id}: push failed: ${err.message}`);
111
+ }
112
+ }
113
+
114
+ if (config.git?.auto_pr && result.pushed) {
115
+ try {
116
+ const prBody = [
117
+ `## HU ${story.id}: ${title}`,
118
+ "",
119
+ story.certified?.text || "",
120
+ "",
121
+ "### Acceptance criteria",
122
+ ...(story.acceptance_criteria || []).map(c => `- ${c}`)
123
+ ].join("\n");
124
+ const url = await createPullRequest({
125
+ baseBranch: config.base_branch || "main",
126
+ branch: branchName,
127
+ title: commitMsg,
128
+ body: prBody
129
+ });
130
+ result.prUrl = url;
131
+ logger.info(`HU ${story.id}: PR created ${url}`);
132
+ } catch (err) {
133
+ logger.warn(`HU ${story.id}: PR creation failed: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ return result;
138
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * HU Auto-Generator — converts triage subtasks (+ researcher/architect context)
3
+ * into a certified HU batch ready for hu-sub-pipeline execution.
4
+ *
5
+ * Input: original task, triage subtasks, detected stack, researcher/architect context.
6
+ * Output: HU batch with setup HU (when needed), task HUs with per-HU task_type,
7
+ * and a dependency graph (setup blocks everything; remaining linear by default).
8
+ */
9
+
10
+ /**
11
+ * Classify a subtask into a Karajan task_type.
12
+ * Maps free-text subtask descriptions to {infra|sw|add-tests|doc|refactor|nocode}.
13
+ */
14
+ export function classifyTaskType(text) {
15
+ if (!text || typeof text !== "string") return "sw";
16
+ const t = text.toLowerCase();
17
+ // Order matters: no-code beats infra (Zapier/Notion setups are no-code, not infra)
18
+ if (/\b(no-code|nocode|zapier|make\.com|airtable|notion)\b/.test(t)) return "nocode";
19
+ if (/\b(setup|install|init(?:ialize|iate)?|configure|scaffold|bootstrap)\b/.test(t)) return "infra";
20
+ if (/\b(docker|ci\/cd|pipeline|deploy|workflow\.yml|github actions?)\b/.test(t)) return "infra";
21
+ if (/\b(tests?|coverage|spec|vitest|jest|mocha|playwright)\b/.test(t) && !/\b(component|feature|endpoint)\b/.test(t)) return "add-tests";
22
+ if (/\b(readme|docs?|documentation|guide|tutorial)\b/.test(t)) return "doc";
23
+ if (/\b(refactor|cleanup|reorganiz|restructure|extract)\b/.test(t)) return "refactor";
24
+ return "sw";
25
+ }
26
+
27
+ /**
28
+ * Decide whether a setup HU is needed.
29
+ * Needed when: project is new OR stack hints suggest new dependencies.
30
+ */
31
+ export function needsSetupHu({ isNewProject = false, stackHints = [], subtasks = [] }) {
32
+ if (isNewProject) return true;
33
+ if (stackHints.length > 0) return true;
34
+ // Subtasks mentioning a framework/tool suggest fresh setup
35
+ const setupKeywords = /\b(npm init|package\.json|workspace|monorepo|vite|vitest|express|astro|next\.js|nestjs)\b/i;
36
+ return subtasks.some(s => setupKeywords.test(s));
37
+ }
38
+
39
+ /**
40
+ * Build the setup HU story from stack hints + subtasks.
41
+ */
42
+ function buildSetupHu({ stackHints, subtasks, originalTask }) {
43
+ const hintList = stackHints.length > 0
44
+ ? stackHints.map(h => `- ${h}`).join("\n")
45
+ : "- Detect required dependencies from task and install them";
46
+ const certifiedText = [
47
+ `**Setup project infrastructure and dependencies.**`,
48
+ ``,
49
+ `Original goal: ${originalTask}`,
50
+ ``,
51
+ `**Scope:**`,
52
+ `- Initialize project structure (package.json, workspaces if monorepo)`,
53
+ `- Install all dependencies required by the task`,
54
+ `- Configure tooling (test framework, linter, build tool)`,
55
+ `- Create .env.example with all required env vars`,
56
+ `- Verify install works (npm install, npm run test --run)`,
57
+ ``,
58
+ `**Stack hints:**`,
59
+ hintList
60
+ ].join("\n");
61
+ return {
62
+ id: "HU-01",
63
+ title: "Setup project infrastructure",
64
+ task_type: "infra",
65
+ status: "certified",
66
+ blocked_by: [],
67
+ certified: { text: certifiedText },
68
+ acceptance_criteria: [
69
+ "Project builds without errors (npm install succeeds)",
70
+ "Test framework is installed and 'npm test' runs (even with 0 tests)",
71
+ "All declared dependencies match what the task requires",
72
+ ".env.example exists with documented variables"
73
+ ]
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Build a task HU story from a subtask description.
79
+ */
80
+ function buildTaskHu({ id, subtask, originalTask, blockedBy }) {
81
+ const taskType = classifyTaskType(subtask);
82
+ const certifiedText = [
83
+ `**${subtask}**`,
84
+ ``,
85
+ `Part of: ${originalTask}`,
86
+ ``,
87
+ `**Scope:** implement this subtask only. Do not touch unrelated subtasks.`
88
+ ].join("\n");
89
+ return {
90
+ id,
91
+ title: subtask.length > 80 ? subtask.slice(0, 77) + "..." : subtask,
92
+ task_type: taskType,
93
+ status: "certified",
94
+ blocked_by: blockedBy,
95
+ certified: { text: certifiedText },
96
+ acceptance_criteria: [
97
+ `Subtask '${subtask}' is implemented`,
98
+ `Unit tests cover the new code (where applicable)`,
99
+ `No regressions in existing functionality`
100
+ ]
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Main entry point: generate a certified HU batch from triage output.
106
+ *
107
+ * @param {object} input
108
+ * @param {string} input.originalTask - the user's raw task
109
+ * @param {string[]} input.subtasks - triage.subtasks array
110
+ * @param {string[]} [input.stackHints] - detected stack keywords (e.g. ["nodejs", "vitest"])
111
+ * @param {boolean} [input.isNewProject] - true when projectDir is empty/fresh
112
+ * @param {string} [input.researcherContext] - researcher output (optional, used for better HU text)
113
+ * @param {string} [input.architectContext] - architect output (optional, used for dep graph)
114
+ * @returns {{ stories: object[], total: number, certified: number, generated: true }}
115
+ */
116
+ export function generateHuBatch({
117
+ originalTask,
118
+ subtasks = [],
119
+ stackHints = [],
120
+ isNewProject = false,
121
+ researcherContext = null,
122
+ architectContext = null
123
+ }) {
124
+ if (!originalTask || typeof originalTask !== "string") {
125
+ throw new Error("generateHuBatch: originalTask is required");
126
+ }
127
+ if (!Array.isArray(subtasks) || subtasks.length === 0) {
128
+ throw new Error("generateHuBatch: subtasks array is required");
129
+ }
130
+
131
+ const stories = [];
132
+ const needsSetup = needsSetupHu({ isNewProject, stackHints, subtasks });
133
+ let nextId = 1;
134
+
135
+ if (needsSetup) {
136
+ stories.push(buildSetupHu({ stackHints, subtasks, originalTask }));
137
+ nextId = 2;
138
+ }
139
+
140
+ // Task HUs: linear dependency chain after setup (conservative default).
141
+ // Architect context could later inform parallel-safe groupings.
142
+ const setupId = needsSetup ? "HU-01" : null;
143
+ let previousId = setupId;
144
+ for (const subtask of subtasks) {
145
+ const id = `HU-${String(nextId).padStart(2, "0")}`;
146
+ const blockedBy = [];
147
+ if (setupId) blockedBy.push(setupId);
148
+ // Conservative: also depend on previous task HU to enforce linear execution.
149
+ // Later phases can relax this with architect-informed graph.
150
+ if (previousId && previousId !== setupId) blockedBy.push(previousId);
151
+ stories.push(buildTaskHu({ id, subtask, originalTask, blockedBy }));
152
+ previousId = id;
153
+ nextId += 1;
154
+ }
155
+
156
+ return {
157
+ stories,
158
+ total: stories.length,
159
+ certified: stories.length,
160
+ generated: true,
161
+ source: { triage_subtasks: subtasks.length, researcher: Boolean(researcherContext), architect: Boolean(architectContext) }
162
+ };
163
+ }
@@ -120,7 +120,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
120
120
  }));
121
121
 
122
122
  try {
123
- const iterResult = await runIterationFn(huTask);
123
+ const iterResult = await runIterationFn(huTask, story);
124
124
  const approved = Boolean(iterResult?.approved);
125
125
 
126
126
  // --- Transition to reviewing (post-coder, pre-reviewer evaluation) ---
@@ -7,7 +7,7 @@ import { ArchitectRole } from "../../roles/architect-role.js";
7
7
  import { createAgent } from "../../agents/index.js";
8
8
  import { createArchitectADRs } from "../../planning-game/architect-adrs.js";
9
9
  import { addCheckpoint } from "../../session-store.js";
10
- import { emitProgress, makeEvent } from "../../utils/events.js";
10
+ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
11
11
  import { createStallDetector } from "../../utils/stall-detector.js";
12
12
 
13
13
  async function handleArchitectClarification({ architectOutput, askQuestion, config, logger, emitter, eventBase, session, architectOnOutput, architectProvider, coderRole, researchContext, discoverResult, triageLevel, trackBudget }) {
@@ -77,12 +77,7 @@ export async function runArchitectStage({ config, logger, emitter, eventBase, se
77
77
  detail: { architect: architectProvider, provider: architectProvider, executorType: "agent" }
78
78
  })
79
79
  );
80
- const architectOnOutput = ({ stream, line }) => {
81
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "architect" }, {
82
- message: line,
83
- detail: { stream, agent: architectProvider }
84
- }));
85
- };
80
+ const architectOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "architect", architectProvider, payload);
86
81
  const architectStall = createStallDetector({
87
82
  onOutput: architectOnOutput, emitter, eventBase, stage: "architect", provider: architectProvider
88
83
  });
@@ -10,7 +10,7 @@ import { addCheckpoint, markSessionStatus, saveSession } from "../../session-sto
10
10
  import { generateDiff, getUntrackedFiles } from "../../review/diff-generator.js";
11
11
  import { evaluateTddPolicy } from "../../review/tdd-policy.js";
12
12
  import { buildDeferredContext } from "../../review/scope-filter.js";
13
- import { emitProgress, makeEvent } from "../../utils/events.js";
13
+ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
14
14
  import { runCoderWithFallback } from "../agent-fallback.js";
15
15
  import { invokeSolomon } from "../solomon-escalation.js";
16
16
  import { detectRateLimit } from "../../utils/rate-limit-detector.js";
@@ -37,12 +37,7 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
37
37
  }
38
38
  }
39
39
 
40
- const coderOnOutput = ({ stream, line }) => {
41
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "coder" }, {
42
- message: line,
43
- detail: { stream, agent: coderRole.provider }
44
- }));
45
- };
40
+ const coderOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "coder", coderRole.provider, payload);
46
41
  const coderStall = createStallDetector({
47
42
  onOutput: coderOnOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
48
43
  });
@@ -187,12 +182,7 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
187
182
  detail: { refactorer: refactorerRole.provider, provider: refactorerRole.provider, executorType: "agent" }
188
183
  })
189
184
  );
190
- const refactorerOnOutput = ({ stream, line }) => {
191
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "refactorer" }, {
192
- message: line,
193
- detail: { stream, agent: refactorerRole.provider }
194
- }));
195
- };
185
+ const refactorerOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "refactorer", refactorerRole.provider, payload);
196
186
  const refactorerStall = createStallDetector({
197
187
  onOutput: refactorerOnOutput, emitter, eventBase, stage: "refactorer", provider: refactorerRole.provider
198
188
  });
@@ -6,7 +6,7 @@
6
6
  import { PlannerRole } from "../../roles/planner-role.js";
7
7
  import { createAgent } from "../../agents/index.js";
8
8
  import { addCheckpoint, markSessionStatus } from "../../session-store.js";
9
- import { emitProgress, makeEvent } from "../../utils/events.js";
9
+ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
10
10
  import { parsePlannerOutput } from "../../prompts/planner.js";
11
11
  import { createStallDetector } from "../../utils/stall-detector.js";
12
12
 
@@ -21,12 +21,7 @@ export async function runPlannerStage({ config, logger, emitter, eventBase, sess
21
21
  })
22
22
  );
23
23
 
24
- const plannerOnOutput = ({ stream, line }) => {
25
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "planner" }, {
26
- message: line,
27
- detail: { stream, agent: plannerRole.provider }
28
- }));
29
- };
24
+ const plannerOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "planner", plannerRole.provider, payload);
30
25
  const plannerStall = createStallDetector({
31
26
  onOutput: plannerOnOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
32
27
  });
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { ResearcherRole } from "../../roles/researcher-role.js";
7
7
  import { addCheckpoint } from "../../session-store.js";
8
- import { emitProgress, makeEvent } from "../../utils/events.js";
8
+ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
9
9
  import { createStallDetector } from "../../utils/stall-detector.js";
10
10
 
11
11
  export async function runResearcherStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
@@ -20,12 +20,7 @@ export async function runResearcherStage({ config, logger, emitter, eventBase, s
20
20
  })
21
21
  );
22
22
 
23
- const researcherOnOutput = ({ stream, line }) => {
24
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "researcher" }, {
25
- message: line,
26
- detail: { stream, agent: researcherProvider }
27
- }));
28
- };
23
+ const researcherOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "researcher", researcherProvider, payload);
29
24
  const researcherStall = createStallDetector({
30
25
  onOutput: researcherOnOutput, emitter, eventBase, stage: "researcher", provider: researcherProvider
31
26
  });
@@ -7,7 +7,7 @@ import { addCheckpoint, markSessionStatus, saveSession } from "../../session-sto
7
7
  import { generateDiff } from "../../review/diff-generator.js";
8
8
  import { validateReviewResult } from "../../review/schema.js";
9
9
  import { filterReviewScope, buildDeferredContext } from "../../review/scope-filter.js";
10
- import { emitProgress, makeEvent } from "../../utils/events.js";
10
+ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
11
11
  import { runReviewerWithFallback } from "../reviewer-fallback.js";
12
12
  import { invokeSolomon } from "../solomon-escalation.js";
13
13
  import { detectRateLimit } from "../../utils/rate-limit-detector.js";
@@ -226,12 +226,7 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
226
226
  };
227
227
  }
228
228
 
229
- const reviewerOnOutput = ({ stream, line }) => {
230
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "reviewer" }, {
231
- message: line,
232
- detail: { stream, agent: reviewerRole.provider }
233
- }));
234
- };
229
+ const reviewerOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "reviewer", reviewerRole.provider, payload);
235
230
  const reviewerStall = createStallDetector({
236
231
  onOutput: reviewerOnOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
237
232
  });
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { TriageRole } from "../../roles/triage-role.js";
7
7
  import { addCheckpoint } from "../../session-store.js";
8
- import { emitProgress, makeEvent } from "../../utils/events.js";
8
+ import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
9
9
  import { selectModelsForRoles } from "../../utils/model-selector.js";
10
10
  import { createStallDetector } from "../../utils/stall-detector.js";
11
11
 
@@ -53,12 +53,7 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
53
53
  })
54
54
  );
55
55
 
56
- const triageOnOutput = ({ stream, line }) => {
57
- emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "triage" }, {
58
- message: line,
59
- detail: { stream, agent: triageProvider }
60
- }));
61
- };
56
+ const triageOnOutput = (payload) => emitAgentOutput(emitter, eventBase, "triage", triageProvider, payload);
62
57
  const triageStall = createStallDetector({
63
58
  onOutput: triageOnOutput, emitter, eventBase, stage: "triage", provider: triageProvider
64
59
  });
@@ -71,14 +71,20 @@ export const parseCheckpointAnswer = _parseCheckpointAnswer;
71
71
 
72
72
  // PG card "In Progress" logic moved to src/planning-game/pipeline-adapter.js → initPgAdapter()
73
73
 
74
- async function runPlanningPhases({ config, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion }) {
74
+ async function runPlanningPhases({ config, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion, brainCtx }) {
75
75
  let researchContext = null;
76
76
  let plannedTask = task;
77
77
 
78
+ // Brain: track compression across pre-loop roles
79
+ const brainCompress = brainCtx?.enabled
80
+ ? (await import("./orchestrator/brain-coordinator.js")).processRoleOutput
81
+ : null;
82
+
78
83
  if (pipelineFlags.researcherEnabled) {
79
84
  const researcherResult = await runResearcherStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget });
80
85
  researchContext = researcherResult.researchContext;
81
86
  stageResults.researcher = researcherResult.stageResult;
87
+ if (brainCompress) brainCompress(brainCtx, { roleName: "researcher", output: researcherResult.stageResult, iteration: 0 });
82
88
  }
83
89
 
84
90
  // --- Architect (between researcher and planner) ---
@@ -93,6 +99,7 @@ async function runPlanningPhases({ config, logger, emitter, eventBase, session,
93
99
  });
94
100
  architectContext = architectResult.architectContext;
95
101
  stageResults.architect = architectResult.stageResult;
102
+ if (brainCompress) brainCompress(brainCtx, { roleName: "architect", output: architectResult.stageResult, iteration: 0 });
96
103
  }
97
104
 
98
105
  const triageDecomposition = stageResults.triage?.shouldDecompose ? stageResults.triage.subtasks : null;
@@ -101,6 +108,7 @@ async function runPlanningPhases({ config, logger, emitter, eventBase, session,
101
108
  const plannerResult = await runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, architectContext, triageDecomposition, trackBudget });
102
109
  plannedTask = plannerResult.plannedTask;
103
110
  stageResults.planner = plannerResult.stageResult;
111
+ if (brainCompress) brainCompress(brainCtx, { roleName: "planner", output: plannerResult.stageResult, iteration: 0 });
104
112
 
105
113
  await tryCiComment({
106
114
  config, session, logger,
@@ -201,12 +209,12 @@ async function handleStandbyResult({ stageResult, session, emitter, eventBase, i
201
209
 
202
210
  function emitSolomonAlerts(alerts, emitter, eventBase, logger) {
203
211
  for (const alert of alerts) {
204
- emitProgress(emitter, makeEvent("solomon:alert", { ...eventBase, stage: "solomon" }, {
212
+ emitProgress(emitter, makeEvent("brain:rules-alert", { ...eventBase, stage: "brain" }, {
205
213
  status: alert.severity === "critical" ? "fail" : "warn",
206
214
  message: alert.message,
207
215
  detail: alert.detail
208
216
  }));
209
- logger.warn(`Solomon alert [${alert.rule}]: ${alert.message}`);
217
+ logger.warn(`Rules alert [${alert.rule}]: ${alert.message}`);
210
218
  }
211
219
  }
212
220
 
@@ -313,7 +321,7 @@ async function checkSolomonCriticalAlerts({ rulesResult, askQuestion, session, i
313
321
  }
314
322
 
315
323
 
316
- async function handlePostLoopStages({ config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults, ciEnabled, testerEnabled, securityEnabled, askQuestion, logger }) {
324
+ async function handlePostLoopStages({ config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults, ciEnabled, testerEnabled, securityEnabled, askQuestion, logger, brainCtx }) {
317
325
  const postLoopDiff = await generateDiff({ baseRef: session.session_start_sha });
318
326
 
319
327
  if (testerEnabled) {
@@ -327,6 +335,11 @@ async function handlePostLoopStages({ config, session, emitter, eventBase, coder
327
335
  session.last_reviewer_feedback = `Tester FAILED — fix these issues:\n${summary}`;
328
336
  await saveSession(session);
329
337
  if (testerResult.stageResult) stageResults.tester = testerResult.stageResult;
338
+ // Brain: push tester failure into feedback queue + compress for next coder iteration
339
+ if (brainCtx?.enabled) {
340
+ const { processRoleOutput } = await import("./orchestrator/brain-coordinator.js");
341
+ processRoleOutput(brainCtx, { roleName: "tester", output: testerResult.stageResult, iteration: i });
342
+ }
330
343
  return { action: "continue" };
331
344
  }
332
345
  if (testerResult.stageResult) {
@@ -346,6 +359,11 @@ async function handlePostLoopStages({ config, session, emitter, eventBase, coder
346
359
  session.last_reviewer_feedback = `Security FAILED — fix these issues:\n${summary}`;
347
360
  await saveSession(session);
348
361
  if (securityResult.stageResult) stageResults.security = securityResult.stageResult;
362
+ // Brain: push security findings into feedback queue + compress for next coder iteration
363
+ if (brainCtx?.enabled) {
364
+ const { processRoleOutput } = await import("./orchestrator/brain-coordinator.js");
365
+ processRoleOutput(brainCtx, { roleName: "security", output: securityResult.stageResult, iteration: i });
366
+ }
349
367
  return { action: "continue" };
350
368
  }
351
369
  if (securityResult.stageResult) {
@@ -494,7 +512,7 @@ async function handleReviewerRetryAndSolomon({ config, session, emitter, eventBa
494
512
  }
495
513
 
496
514
 
497
- async function runPreLoopStages({ config, logger, emitter, eventBase, session, flags, pipelineFlags, coderRole, trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults }) {
515
+ async function runPreLoopStages({ config, logger, emitter, eventBase, session, flags, pipelineFlags, coderRole, trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults, brainCtx }) {
498
516
  // --- HU Reviewer (first stage, before everything else, opt-in) ---
499
517
  const huFile = flags.huFile || null;
500
518
  if (flags.enableHuReviewer !== undefined) pipelineFlags.huReviewerEnabled = Boolean(flags.enableHuReviewer);
@@ -663,12 +681,17 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
663
681
  }
664
682
 
665
683
  // --- Researcher → Planner ---
666
- const { plannedTask } = await runPlanningPhases({ config: updatedConfig, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion });
684
+ const { plannedTask } = await runPlanningPhases({ config: updatedConfig, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion, brainCtx });
667
685
 
668
686
  // --- Update .gitignore with stack-specific entries based on planner/architect output ---
669
687
  const projectDir = updatedConfig.projectDir || process.cwd();
670
688
  await updateGitignoreForStack(projectDir, { stageResults, task, logger });
671
689
 
690
+ // --- Auto-HU: when triage recommended decomposition and no manual huFile, generate HU batch ---
691
+ await maybeGenerateAutoHuBatch({
692
+ flags, stageResults, task, plannedTask, logger, emitter, eventBase, projectDir, session
693
+ });
694
+
672
695
  // --- Auto-install skills based on task + planner output + project detection ---
673
696
  // Runs AFTER triage and planner so that the planned task text (which includes
674
697
  // planner output like implementation steps) is available for keyword detection.
@@ -698,6 +721,87 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
698
721
  return { plannedTask, updatedConfig };
699
722
  }
700
723
 
724
+ /**
725
+ * Auto-generate HU batch from triage decomposition when no manual huFile is present.
726
+ * Runs after researcher/architect/planner so that context is available for better HUs.
727
+ * Sets stageResults.huReviewer so needsSubPipeline picks it up later.
728
+ */
729
+ async function maybeGenerateAutoHuBatch({ flags, stageResults, task, plannedTask, logger, emitter, eventBase, projectDir, session }) {
730
+ // Skip if user passed a manual hu-file
731
+ if (flags?.huFile) return;
732
+ // Skip if hu-reviewer already produced a batch (manual enable + PG stories)
733
+ if (stageResults.huReviewer) return;
734
+ // Need triage decomposition recommendation
735
+ const shouldDecompose = stageResults.triage?.shouldDecompose;
736
+ const subtasks = stageResults.triage?.subtasks;
737
+ if (!shouldDecompose || !Array.isArray(subtasks) || subtasks.length < 2) return;
738
+
739
+ const { generateHuBatch } = await import("./hu/auto-generator.js");
740
+
741
+ // Detect if project is new: empty dir or only .git/.karajan/.gitignore
742
+ let isNewProject = false;
743
+ try {
744
+ const fs = await import("node:fs/promises");
745
+ const entries = await fs.readdir(projectDir);
746
+ const relevant = entries.filter(e => !e.startsWith(".git") && e !== ".karajan" && e !== ".gitignore");
747
+ isNewProject = relevant.length === 0;
748
+ } catch { /* ignore */ }
749
+
750
+ // Extract stack hints from planner + architect output
751
+ const stackHints = [];
752
+ const combined = `${stageResults.planner?.plan || ""} ${stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : ""} ${task}`.toLowerCase();
753
+ const stackKeywords = ["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "fastapi", "django", "spring", "gin", "nestjs", "monorepo", "workspaces"];
754
+ for (const kw of stackKeywords) {
755
+ if (combined.includes(kw)) stackHints.push(kw);
756
+ }
757
+
758
+ const batch = generateHuBatch({
759
+ originalTask: task,
760
+ subtasks,
761
+ stackHints,
762
+ isNewProject,
763
+ researcherContext: stageResults.researcher?.summary || null,
764
+ architectContext: stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : null
765
+ });
766
+
767
+ // Persist batch to HU store so hu-sub-pipeline can update story status via saveHuBatch.
768
+ // Use session.id as batchSessionId.
769
+ const batchSessionId = `auto-${session.id}`;
770
+ try {
771
+ const fs = await import("node:fs/promises");
772
+ const path = await import("node:path");
773
+ const { getKarajanHome } = await import("./utils/paths.js");
774
+ const huDir = path.join(getKarajanHome(), "hu", batchSessionId);
775
+ await fs.mkdir(huDir, { recursive: true });
776
+ const persistBatch = {
777
+ session_id: batchSessionId,
778
+ created_at: new Date().toISOString(),
779
+ updated_at: new Date().toISOString(),
780
+ stories: batch.stories
781
+ };
782
+ await fs.writeFile(path.join(huDir, "batch.json"), JSON.stringify(persistBatch, null, 2));
783
+ } catch (err) {
784
+ logger.warn(`Auto-HU: failed to persist batch (${err.message}) — sub-pipeline will use in-memory fallback`);
785
+ }
786
+
787
+ // Wrap in format compatible with needsSubPipeline + runHuSubPipeline
788
+ stageResults.huReviewer = {
789
+ ok: true,
790
+ stories: batch.stories,
791
+ total: batch.total,
792
+ certified: batch.certified,
793
+ batchSessionId,
794
+ auto_generated: true,
795
+ source: batch.source
796
+ };
797
+
798
+ logger.info(`Auto-HU: generated ${batch.total} stories (${batch.source.triage_subtasks} subtasks${isNewProject ? ", new project" : ""}${stackHints.length ? `, stack: ${stackHints.join(",")}` : ""})`);
799
+ emitProgress(emitter, makeEvent("hu:auto-generated", { ...eventBase, stage: "hu-auto-gen" }, {
800
+ message: `Auto-generated ${batch.total} HU(s) from triage decomposition`,
801
+ detail: { total: batch.total, subtasks: batch.source.triage_subtasks, isNewProject, stackHints }
802
+ }));
803
+ }
804
+
701
805
  async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole, refactorerRole, pipelineFlags, config, logger, emitter, eventBase, session, plannedTask, trackBudget, i, brainCtx }) {
702
806
  const coderResult = await runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i, brainCtx });
703
807
  if (coderResult?.action === "pause") return { action: "return", result: coderResult.result };
@@ -870,11 +974,11 @@ async function runReviewerGateStage({ pipelineFlags, reviewerRole, config, logge
870
974
  return { action: "ok", review: reviewerResult.review };
871
975
  }
872
976
 
873
- async function handleApprovedReview({ config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults, pipelineFlags, askQuestion, logger, gitCtx, budgetSummary, pgCard, pgProject, review, rtkTracker }) {
977
+ async function handleApprovedReview({ config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults, pipelineFlags, askQuestion, logger, gitCtx, budgetSummary, pgCard, pgProject, review, rtkTracker, brainCtx }) {
874
978
  session.reviewer_retry_count = 0;
875
979
  const postLoopResult = await handlePostLoopStages({
876
980
  config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults,
877
- ciEnabled: Boolean(config.ci?.enabled), testerEnabled: pipelineFlags.testerEnabled, securityEnabled: pipelineFlags.securityEnabled, askQuestion, logger
981
+ ciEnabled: Boolean(config.ci?.enabled), testerEnabled: pipelineFlags.testerEnabled, securityEnabled: pipelineFlags.securityEnabled, askQuestion, logger, brainCtx
878
982
  });
879
983
  if (postLoopResult.action === "return") return { action: "return", result: postLoopResult.result };
880
984
  if (postLoopResult.action === "continue") return { action: "continue" };
@@ -883,8 +987,72 @@ async function handleApprovedReview({ config, session, emitter, eventBase, coder
883
987
  return { action: "return", result };
884
988
  }
885
989
 
886
- async function handleMaxIterationsReached({ session, budgetSummary, emitter, eventBase, config, stageResults, logger, askQuestion, task, rtkTracker }) {
990
+ async function handleMaxIterationsReached({ session, budgetSummary, emitter, eventBase, config, stageResults, logger, askQuestion, task, rtkTracker, brainCtx }) {
887
991
  const budget = budgetSummary();
992
+
993
+ // Brain-owned decision: max_iterations is guidance, not a hard rule.
994
+ // Brain evaluates the feedback queue state to decide extend / finalize / escalate.
995
+ // Solomon is only consulted if Brain cannot decide on its own.
996
+ if (brainCtx?.enabled) {
997
+ const entries = brainCtx.feedbackQueue?.entries || [];
998
+ const pending = entries.map(e => ({ source: e.source, category: e.category, severity: e.severity, description: e.description }));
999
+ const hasSecurity = entries.some(e => e.category === "security" || e.source === "security");
1000
+ const hasCorrectness = entries.some(e => ["correctness", "tests"].includes(e.category));
1001
+ const hasStyleOnly = entries.length > 0 && !hasSecurity && !hasCorrectness;
1002
+
1003
+ if (hasSecurity) {
1004
+ // Brain: security issues unresolved → cannot finalize, escalate
1005
+ logger.warn(`Brain: max_iterations reached with ${entries.filter(e => e.category === "security" || e.source === "security").length} unresolved security issue(s) — cannot finalize`);
1006
+ return { paused: true, sessionId: session.id, question: "Brain: unresolved security issues at max_iterations. Review manually or extend pipeline.", context: "brain_security_block", pending };
1007
+ }
1008
+
1009
+ if (hasCorrectness) {
1010
+ // Brain: correctness/test issues pending → extend iterations (Brain's decision, not a rule)
1011
+ logger.info(`Brain: max_iterations reached with ${entries.filter(e => ["correctness", "tests"].includes(e.category)).length} correctness issue(s) pending — extending iterations`);
1012
+ session.reviewer_retry_count = 0;
1013
+ await saveSession(session);
1014
+ return { approved: false, sessionId: session.id, reason: "max_iterations_extended", extraIterations: Math.ceil(config.max_iterations / 2) };
1015
+ }
1016
+
1017
+ if (entries.length === 0) {
1018
+ // Brain: no pending feedback → last reviewer approved, finalize
1019
+ logger.info("Brain: max_iterations reached with clean feedback queue — finalizing as approved");
1020
+ return { approved: true, sessionId: session.id, reason: "brain_approved" };
1021
+ }
1022
+
1023
+ // hasStyleOnly: genuine dilemma → Brain consults Solomon
1024
+ logger.info(`Brain: max_iterations with ${entries.length} style-only issue(s) — consulting Solomon on dilemma`);
1025
+ const { invokeSolomon: invokeSolomonAI } = await import("./orchestrator/solomon-escalation.js");
1026
+ const solomonResult = await invokeSolomonAI({
1027
+ config, logger, emitter, eventBase, stage: "max_iterations", askQuestion, session,
1028
+ iteration: config.max_iterations,
1029
+ conflict: {
1030
+ stage: "brain-max-iterations",
1031
+ task,
1032
+ iterationCount: config.max_iterations,
1033
+ maxIterations: config.max_iterations,
1034
+ budget_usd: budget?.total_cost_usd || 0,
1035
+ dilemma: `Max iterations reached with ${entries.length} style-only issue(s) pending. Accept as-is or request more work?`,
1036
+ pendingIssues: pending,
1037
+ history: [{ agent: "pipeline", feedback: session.last_reviewer_feedback || "Max iterations reached" }]
1038
+ }
1039
+ });
1040
+ // Brain applies Solomon's decision
1041
+ if (solomonResult.action === "approve") {
1042
+ logger.info("Brain: Solomon advised approve for style-only pending — finalizing");
1043
+ return { approved: true, sessionId: session.id, reason: "brain_solomon_approved" };
1044
+ }
1045
+ if (solomonResult.action === "continue") {
1046
+ return { approved: false, sessionId: session.id, reason: "max_iterations_extended", extraIterations: solomonResult.extraIterations || Math.ceil(config.max_iterations / 2) };
1047
+ }
1048
+ if (solomonResult.action === "pause") {
1049
+ return { paused: true, sessionId: session.id, question: solomonResult.question, context: "brain_solomon_dilemma" };
1050
+ }
1051
+ // Fallback: escalate to human
1052
+ return { paused: true, sessionId: session.id, question: `Brain+Solomon cannot resolve: ${entries.length} pending issue(s) at max_iterations`, context: "max_iterations" };
1053
+ }
1054
+
1055
+ // Legacy path (Brain disabled): original Solomon-driven flow
888
1056
  const solomonResult = await invokeSolomon({
889
1057
  config, logger, emitter, eventBase, stage: "max_iterations", askQuestion, session,
890
1058
  iteration: config.max_iterations,
@@ -898,13 +1066,11 @@ async function handleMaxIterationsReached({ session, budgetSummary, emitter, eve
898
1066
  }
899
1067
  });
900
1068
 
901
- // Solomon approved the work — treat as successful completion
902
1069
  if (solomonResult.action === "approve") {
903
1070
  logger.info("Solomon approved coder's work at max_iterations checkpoint");
904
1071
  return { approved: true, sessionId: session.id, reason: "solomon_approved" };
905
1072
  }
906
1073
 
907
- // Solomon says continue — extend iterations
908
1074
  if (solomonResult.action === "continue") {
909
1075
  if (solomonResult.humanGuidance) {
910
1076
  session.last_reviewer_feedback = `Solomon guidance: ${solomonResult.humanGuidance}`;
@@ -1074,7 +1240,7 @@ async function initFlowContext({ task, config, logger, emitter, askQuestion, pgT
1074
1240
  ctx.stageResults = {};
1075
1241
  ctx.sonarState = { issuesInitial: null, issuesFinal: null };
1076
1242
 
1077
- const preLoopResult = await runPreLoopStages({ config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session, flags, pipelineFlags: ctx.pipelineFlags, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults: ctx.stageResults });
1243
+ const preLoopResult = await runPreLoopStages({ config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session, flags, pipelineFlags: ctx.pipelineFlags, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults: ctx.stageResults, brainCtx: ctx.brainCtx });
1078
1244
  ctx.plannedTask = preLoopResult.plannedTask;
1079
1245
  ctx.config = preLoopResult.updatedConfig;
1080
1246
 
@@ -1199,7 +1365,7 @@ async function runSingleIteration(ctx) {
1199
1365
  config, session, emitter, eventBase, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, i, task,
1200
1366
  stageResults: ctx.stageResults, pipelineFlags: ctx.pipelineFlags, askQuestion: ctx.askQuestion, logger,
1201
1367
  gitCtx: ctx.gitCtx, budgetSummary: ctx.budgetSummary, pgCard: ctx.pgCard, pgProject: ctx.pgProject, review,
1202
- rtkTracker: ctx.rtkTracker
1368
+ rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx
1203
1369
  });
1204
1370
  if (approvedResult.action === "return" || approvedResult.action === "continue") return approvedResult;
1205
1371
  }
@@ -1320,7 +1486,7 @@ async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitter, log
1320
1486
  }
1321
1487
 
1322
1488
  // Solomon decides whether to extend iterations or stop
1323
- const maxIterResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker });
1489
+ const maxIterResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx });
1324
1490
 
1325
1491
  // Solomon said "continue" — extend iterations and keep going
1326
1492
  if (maxIterResult.reason === "max_iterations_extended") {
@@ -1358,7 +1524,7 @@ async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitter, log
1358
1524
  }
1359
1525
 
1360
1526
  // Extended iterations also exhausted — final Solomon call
1361
- const finalResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker });
1527
+ const finalResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx });
1362
1528
  return finalResult;
1363
1529
  }
1364
1530
 
@@ -1452,9 +1618,33 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1452
1618
  detail: { total: ctx.stageResults.huReviewer.total, certified: ctx.stageResults.huReviewer.certified }
1453
1619
  }));
1454
1620
 
1621
+ // Per-HU pipeline: focused max_iterations, fresh Brain state, own git branch.
1622
+ const originalMaxIterations = ctx.config.max_iterations;
1623
+ const huMaxIterations = ctx.config.hu_max_iterations ?? 3;
1624
+ const huBranches = new Map();
1625
+ const { prepareHuBranch, finalizeHuCommit } = await import("./git/hu-automation.js");
1455
1626
  const subPipelineResult = await runHuSubPipeline({
1456
1627
  huReviewerResult: ctx.stageResults.huReviewer,
1457
- runIterationFn: async (huTask) => runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger }),
1628
+ runIterationFn: async (huTask, story) => {
1629
+ ctx.config.max_iterations = huMaxIterations;
1630
+ if (ctx.brainCtx?.enabled) {
1631
+ ctx.brainCtx.extensionCount = 0;
1632
+ const { createBrainContext } = await import("./orchestrator/brain-coordinator.js");
1633
+ const fresh = createBrainContext({ enabled: true });
1634
+ ctx.brainCtx.feedbackQueue = fresh.feedbackQueue;
1635
+ ctx.brainCtx.verificationTracker = fresh.verificationTracker;
1636
+ }
1637
+ const branchName = await prepareHuBranch({ story, huBranches, config: ctx.config, logger });
1638
+ try {
1639
+ const result = await runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger });
1640
+ if (result?.approved) {
1641
+ await finalizeHuCommit({ story, branchName, config: ctx.config, logger });
1642
+ }
1643
+ return result;
1644
+ } finally {
1645
+ ctx.config.max_iterations = originalMaxIterations;
1646
+ }
1647
+ },
1458
1648
  emitter,
1459
1649
  eventBase: ctx.eventBase,
1460
1650
  logger,
@@ -45,6 +45,7 @@ const ICONS = {
45
45
  "solomon:escalate": "\u26a0\ufe0f",
46
46
  "coder:standby": "\u23f3",
47
47
  "coder:standby_heartbeat": "\u23f3",
48
+ "agent:heartbeat": "\u23f3",
48
49
  "coder:standby_resume": "\u25b6\ufe0f",
49
50
  "budget:update": "\ud83d\udcb8",
50
51
  "iteration:end": "\u23f1\ufe0f",
@@ -84,7 +85,8 @@ const BAR = `${ANSI.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u
84
85
 
85
86
  export function printHeader({ task, config }) {
86
87
  const version = DISPLAY_VERSION;
87
- printBanner(version);
88
+ // Force banner: caller already gates on !jsonMode, so if we're here it's a human-readable run
89
+ printBanner(version, { force: true });
88
90
  console.log(`${ANSI.bold}Task:${ANSI.reset} ${task}`);
89
91
  console.log(
90
92
  `${ANSI.bold}Coder:${ANSI.reset} ${config.roles?.coder?.provider || config.coder} ${ANSI.dim}|${ANSI.reset} ${ANSI.bold}Reviewer:${ANSI.reset} ${config.roles?.reviewer?.provider || config.reviewer}`
@@ -437,6 +439,15 @@ const EVENT_HANDLERS = {
437
439
  console.log(` \u251c\u2500 ${ANSI.yellow}${icon} Standby: ${remaining}s remaining${ANSI.reset}`);
438
440
  },
439
441
 
442
+ "agent:heartbeat": (event, icon) => {
443
+ const d = event.detail || {};
444
+ const provider = d.provider || "?";
445
+ const elapsed = d.elapsedMs ? Math.round(d.elapsedMs / 1000) : 0;
446
+ const silent = d.silenceMs ? Math.round(d.silenceMs / 1000) : 0;
447
+ const status = d.status === "waiting" ? `${ANSI.yellow}waiting (silent ${silent}s)` : `${ANSI.green}working`;
448
+ console.log(` \u251c\u2500 ${ANSI.dim}${icon} ${provider} ${status}${ANSI.dim} — ${elapsed}s elapsed${ANSI.reset}`);
449
+ },
450
+
440
451
  "coder:standby_resume": (event, icon) => {
441
452
  console.log(` \u251c\u2500 ${ANSI.green}${icon} Cooldown expired \u2014 resuming with ${event.detail?.coder || event.detail?.provider || "?"}${ANSI.reset}`);
442
453
  },
@@ -593,12 +604,16 @@ const EVENT_HANDLERS = {
593
604
  console.log(` \u251c\u2500 ${ANSI.green}\ud83d\ude80 CI PR created: ${url}${ANSI.reset}`);
594
605
  },
595
606
 
596
- "solomon:alert": (event) => {
597
- console.log(` \u251c\u2500 ${ANSI.yellow}\u2696\ufe0f Solomon alert: ${event.message || "conflict detected"}${ANSI.reset}`);
607
+ "brain:rules-alert": (event) => {
608
+ console.log(` \u251c\u2500 ${ANSI.yellow}\u26a0\ufe0f Rules alert: ${event.message || "anomaly detected"}${ANSI.reset}`);
598
609
  },
599
610
 
600
611
  "agent:output": (event) => {
601
612
  console.log(` \u2502 ${ANSI.dim}${event.message}${ANSI.reset}`);
613
+ },
614
+
615
+ "agent:action": (event) => {
616
+ console.log(` \u2502 ${ANSI.dim}\u2937 ${event.message}${ANSI.reset}`);
602
617
  }
603
618
  };
604
619
 
@@ -608,7 +623,6 @@ const EVENT_HANDLERS = {
608
623
  const QUIET_SUPPRESSED = new Set([
609
624
  "agent:output",
610
625
  "agent:stall",
611
- "agent:heartbeat",
612
626
  "pipeline:simplify",
613
627
  "pipeline:analysis-only",
614
628
  "policies:resolved",
@@ -21,3 +21,15 @@ export function makeEvent(type, base, extra = {}) {
21
21
  timestamp: new Date().toISOString()
22
22
  };
23
23
  }
24
+
25
+ /**
26
+ * Standard agent output emitter. Routes tool invocations to agent:action
27
+ * (visible in quiet mode) and everything else to agent:output (verbose only).
28
+ */
29
+ export function emitAgentOutput(emitter, eventBase, stage, provider, { stream, line, kind }) {
30
+ const eventType = kind === "tool" ? "agent:action" : "agent:output";
31
+ emitProgress(emitter, makeEvent(eventType, { ...eventBase, stage }, {
32
+ message: line,
33
+ detail: { stream, agent: provider, kind }
34
+ }));
35
+ }