karajan-code 1.29.1 → 1.31.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.
@@ -1,10 +1,32 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import readline from "node:readline";
2
3
  import { resumeFlow } from "../orchestrator.js";
3
4
  import { createActivityLog } from "../activity-log.js";
4
5
  import { printEvent } from "../utils/display.js";
5
6
 
7
+ function createCliAskQuestion() {
8
+ return async (question, context) => {
9
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
+ return new Promise((resolve) => {
11
+ console.log(`\n\u2753 ${question}`);
12
+ if (context?.detail) {
13
+ console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
14
+ }
15
+ rl.question("\n> Your response (or 'stop' to exit): ", (answer) => {
16
+ rl.close();
17
+ if (answer.trim().toLowerCase() === "stop") {
18
+ resolve(null);
19
+ } else {
20
+ resolve(answer.trim());
21
+ }
22
+ });
23
+ });
24
+ };
25
+ }
26
+
6
27
  export async function resumeCommand({ sessionId, answer, config, logger, flags }) {
7
28
  const jsonMode = flags?.json;
29
+ const quietMode = config.output?.quiet !== false;
8
30
 
9
31
  const emitter = new EventEmitter();
10
32
  let activityLog = null;
@@ -20,17 +42,19 @@ export async function resumeCommand({ sessionId, answer, config, logger, flags }
20
42
  }
21
43
 
22
44
  if (!jsonMode) {
23
- printEvent(event);
45
+ printEvent(event, { quiet: quietMode });
24
46
  }
25
47
  });
26
48
 
49
+ const askQuestion = createCliAskQuestion();
27
50
  const result = await resumeFlow({
28
51
  sessionId,
29
52
  answer: answer || null,
30
53
  config,
31
54
  logger,
32
55
  flags: flags || {},
33
- emitter
56
+ emitter,
57
+ askQuestion
34
58
  });
35
59
 
36
60
  if (jsonMode || !answer) {
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import readline from "node:readline";
2
3
  import { runFlow } from "../orchestrator.js";
3
4
  import { assertAgentsAvailable } from "../agents/availability.js";
4
5
  import { createActivityLog } from "../activity-log.js";
@@ -6,6 +7,26 @@ import { printHeader, printEvent } from "../utils/display.js";
6
7
  import { resolveRole } from "../config.js";
7
8
  import { parseCardId } from "../planning-game/adapter.js";
8
9
 
10
+ function createCliAskQuestion() {
11
+ return async (question, context) => {
12
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise((resolve) => {
14
+ console.log(`\n\u2753 ${question}`);
15
+ if (context?.detail) {
16
+ console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
17
+ }
18
+ rl.question("\n> Your response (or 'stop' to exit): ", (answer) => {
19
+ rl.close();
20
+ if (answer.trim().toLowerCase() === "stop") {
21
+ resolve(null);
22
+ } else {
23
+ resolve(answer.trim());
24
+ }
25
+ });
26
+ });
27
+ };
28
+ }
29
+
9
30
  export async function runCommandHandler({ task, config, logger, flags }) {
10
31
  // Best-effort session cleanup before starting
11
32
  try {
@@ -33,6 +54,8 @@ export async function runCommandHandler({ task, config, logger, flags }) {
33
54
  const pgProject = flags?.pgProject || config.planning_game?.project_id || null;
34
55
 
35
56
  const jsonMode = flags?.json;
57
+ // Quiet mode is the default; --verbose disables it
58
+ const quietMode = config.output?.quiet !== false;
36
59
 
37
60
  const emitter = new EventEmitter();
38
61
  let activityLog = null;
@@ -48,7 +71,7 @@ export async function runCommandHandler({ task, config, logger, flags }) {
48
71
  }
49
72
 
50
73
  if (!jsonMode) {
51
- printEvent(event);
74
+ printEvent(event, { quiet: quietMode });
52
75
  }
53
76
  });
54
77
 
@@ -56,7 +79,8 @@ export async function runCommandHandler({ task, config, logger, flags }) {
56
79
  printHeader({ task: task, config });
57
80
  }
58
81
 
59
- const result = await runFlow({ task: task, config, logger, flags, emitter, pgTaskId: pgCardId || null, pgProject: pgProject || null });
82
+ const askQuestion = createCliAskQuestion();
83
+ const result = await runFlow({ task: task, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
60
84
 
61
85
  if (jsonMode) {
62
86
  console.log(JSON.stringify(result, null, 2));
package/src/config.js CHANGED
@@ -19,7 +19,8 @@ const DEFAULTS = {
19
19
  impeccable: { provider: null, model: null },
20
20
  triage: { provider: null, model: null },
21
21
  discover: { provider: null, model: null },
22
- architect: { provider: null, model: null }
22
+ architect: { provider: null, model: null },
23
+ hu_reviewer: { provider: null, model: null }
23
24
  },
24
25
  pipeline: {
25
26
  planner: { enabled: false },
@@ -32,6 +33,7 @@ const DEFAULTS = {
32
33
  triage: { enabled: true },
33
34
  discover: { enabled: false },
34
35
  architect: { enabled: false },
36
+ hu_reviewer: { enabled: false },
35
37
  auto_simplify: true
36
38
  },
37
39
  review_mode: "standard",
@@ -121,7 +123,7 @@ const DEFAULTS = {
121
123
  planning_game: { enabled: false, project_id: null, codeveloper: null },
122
124
  becaria: { enabled: false, review_event: "becaria-review", comment_event: "becaria-comment", comment_prefix: true },
123
125
  git: { auto_commit: false, auto_push: false, auto_pr: false, auto_rebase: true, branch_prefix: "feat/" },
124
- output: { report_dir: "./.reviews", log_level: "info" },
126
+ output: { report_dir: "./.reviews", log_level: "info", quiet: true },
125
127
  budget: {
126
128
  warn_threshold_pct: 80,
127
129
  currency: "usd",
@@ -281,7 +283,8 @@ const PIPELINE_ENABLE_FLAGS = [
281
283
  ["enableSolomon", "solomon"], ["enableResearcher", "researcher"],
282
284
  ["enableTester", "tester"], ["enableSecurity", "security"], ["enableImpeccable", "impeccable"],
283
285
  ["enableTriage", "triage"], ["enableDiscover", "discover"],
284
- ["enableArchitect", "architect"]
286
+ ["enableArchitect", "architect"],
287
+ ["enableHuReviewer", "hu_reviewer"]
285
288
  ];
286
289
 
287
290
  const AUTO_SIMPLIFY_FLAG = "autoSimplify";
@@ -363,6 +366,17 @@ function applyBecariaOverride(out, flags) {
363
366
  }
364
367
  }
365
368
 
369
+ function applyOutputModeOverrides(out, flags) {
370
+ out.output = out.output || {};
371
+ // --verbose explicitly overrides quiet
372
+ if (flags.verbose === true) {
373
+ out.output.quiet = false;
374
+ } else if (flags.quiet === true) {
375
+ out.output.quiet = true;
376
+ }
377
+ // quiet defaults to true (set in DEFAULTS)
378
+ }
379
+
366
380
  function applyMiscOverrides(out, flags) {
367
381
  if (flags[AUTO_SIMPLIFY_FLAG] !== undefined) out.pipeline.auto_simplify = Boolean(flags[AUTO_SIMPLIFY_FLAG]);
368
382
  if (flags.noSonar || flags.sonar === false) out.sonarqube.enabled = false;
@@ -401,6 +415,7 @@ export function applyRunOverrides(config, flags) {
401
415
  applyMethodologyOverride(out, flags);
402
416
  applyBecariaOverride(out, flags);
403
417
  applyMiscOverrides(out, flags);
418
+ applyOutputModeOverrides(out, flags);
404
419
 
405
420
  return out;
406
421
  }
@@ -414,14 +429,14 @@ export function resolveRole(config, role) {
414
429
  let provider = roleConfig.provider ?? null;
415
430
  if (!provider && role === "coder") provider = legacyCoder;
416
431
  if (!provider && role === "reviewer") provider = legacyReviewer;
417
- if (!provider && (role === "planner" || role === "refactorer" || role === "solomon" || role === "researcher" || role === "tester" || role === "security" || role === "impeccable" || role === "triage" || role === "discover" || role === "architect" || role === "audit")) {
432
+ if (!provider && (role === "planner" || role === "refactorer" || role === "solomon" || role === "researcher" || role === "tester" || role === "security" || role === "impeccable" || role === "triage" || role === "discover" || role === "architect" || role === "audit" || role === "hu_reviewer" || role === "hu-reviewer")) {
418
433
  provider = roles.coder?.provider || legacyCoder;
419
434
  }
420
435
 
421
436
  let model = roleConfig.model ?? null;
422
437
  if (!model && role === "coder") model = config?.coder_options?.model ?? null;
423
438
  if (!model && role === "reviewer") model = config?.reviewer_options?.model ?? null;
424
- if (!model && (role === "planner" || role === "refactorer" || role === "solomon" || role === "researcher" || role === "tester" || role === "security" || role === "impeccable" || role === "triage" || role === "discover" || role === "architect")) {
439
+ if (!model && (role === "planner" || role === "refactorer" || role === "solomon" || role === "researcher" || role === "tester" || role === "security" || role === "impeccable" || role === "triage" || role === "discover" || role === "architect" || role === "hu_reviewer" || role === "hu-reviewer")) {
425
440
  model = config?.coder_options?.model ?? null;
426
441
  }
427
442
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Topological sort of HU stories respecting blocked_by dependencies.
3
+ * Returns ordered array of story IDs (dependencies first).
4
+ * Throws if circular dependency detected.
5
+ * @param {Array<{id: string, blocked_by?: string[]}>} stories
6
+ * @returns {string[]} Sorted story IDs.
7
+ */
8
+ export function topologicalSort(stories) {
9
+ const ids = new Set(stories.map(s => s.id));
10
+ const adj = new Map(); // id -> [dependents]
11
+ const inDegree = new Map();
12
+
13
+ for (const s of stories) {
14
+ adj.set(s.id, []);
15
+ inDegree.set(s.id, 0);
16
+ }
17
+
18
+ for (const s of stories) {
19
+ for (const dep of (s.blocked_by || [])) {
20
+ if (!ids.has(dep)) throw new Error(`Dependency ${dep} not found in batch`);
21
+ adj.get(dep).push(s.id);
22
+ inDegree.set(s.id, (inDegree.get(s.id) || 0) + 1);
23
+ }
24
+ }
25
+
26
+ const queue = [];
27
+ for (const [id, degree] of inDegree) {
28
+ if (degree === 0) queue.push(id);
29
+ }
30
+
31
+ const sorted = [];
32
+ while (queue.length > 0) {
33
+ const id = queue.shift();
34
+ sorted.push(id);
35
+ for (const dependent of adj.get(id)) {
36
+ inDegree.set(dependent, inDegree.get(dependent) - 1);
37
+ if (inDegree.get(dependent) === 0) queue.push(dependent);
38
+ }
39
+ }
40
+
41
+ if (sorted.length !== stories.length) {
42
+ throw new Error("Circular dependency detected in HU batch");
43
+ }
44
+
45
+ return sorted;
46
+ }
47
+
48
+ /**
49
+ * Check if a story is ready to execute (all its dependencies are done).
50
+ * @param {{blocked_by?: string[]}} story
51
+ * @param {{stories: Array<{id: string, status: string}>}} batch
52
+ * @returns {boolean}
53
+ */
54
+ export function isStoryReady(story, batch) {
55
+ if (!story.blocked_by || story.blocked_by.length === 0) return true;
56
+ return story.blocked_by.every(depId => {
57
+ const dep = batch.stories.find(s => s.id === depId);
58
+ return dep && dep.status === "done";
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Get next stories ready for execution (certified + all deps done).
64
+ * @param {{stories: Array<{id: string, status: string, blocked_by?: string[]}>}} batch
65
+ * @returns {Array<object>} Stories that are certified and whose deps are all done.
66
+ */
67
+ export function getNextReadyStories(batch) {
68
+ return batch.stories.filter(s =>
69
+ s.status === "certified" && isStoryReady(s, batch)
70
+ );
71
+ }
@@ -0,0 +1,153 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getKarajanHome } from "../utils/paths.js";
4
+
5
+ // FUTURE: hu-storage adapter for PG/Trello/etc — currently local files only
6
+
7
+ /** @returns {string} Path to the hu-stories directory (evaluated at call time). */
8
+ function getHuDir() {
9
+ return path.join(getKarajanHome(), "hu-stories");
10
+ }
11
+
12
+ /**
13
+ * Create a new HU batch from an array of story definitions.
14
+ * @param {string} sessionId - The session identifier.
15
+ * @param {Array<{id?: string, text: string, blocked_by?: string[]}>} stories - Raw story inputs.
16
+ * @returns {Promise<object>} The created batch object.
17
+ */
18
+ export async function createHuBatch(sessionId, stories) {
19
+ const dir = path.join(getHuDir(), sessionId);
20
+ await fs.mkdir(dir, { recursive: true });
21
+
22
+ const batch = {
23
+ session_id: sessionId,
24
+ created_at: new Date().toISOString(),
25
+ stories: stories.map((s, i) => ({
26
+ id: s.id || `HU-${Date.now()}-${i}`,
27
+ status: "pending",
28
+ original: { text: s.text },
29
+ blocked_by: s.blocked_by || [],
30
+ certified: null,
31
+ quality: null,
32
+ context_requests: [],
33
+ created_at: new Date().toISOString(),
34
+ updated_at: new Date().toISOString()
35
+ }))
36
+ };
37
+
38
+ await fs.writeFile(path.join(dir, "batch.json"), JSON.stringify(batch, null, 2));
39
+ return batch;
40
+ }
41
+
42
+ /**
43
+ * Load an existing HU batch from disk.
44
+ * @param {string} sessionId - The session identifier.
45
+ * @returns {Promise<object>} The loaded batch object.
46
+ */
47
+ export async function loadHuBatch(sessionId) {
48
+ const file = path.join(getHuDir(), sessionId, "batch.json");
49
+ const raw = await fs.readFile(file, "utf8");
50
+ return JSON.parse(raw);
51
+ }
52
+
53
+ /**
54
+ * Save a batch back to disk.
55
+ * @param {string} sessionId - The session identifier.
56
+ * @param {object} batch - The batch object to persist.
57
+ * @returns {Promise<void>}
58
+ */
59
+ export async function saveHuBatch(sessionId, batch) {
60
+ const dir = path.join(getHuDir(), sessionId);
61
+ batch.updated_at = new Date().toISOString();
62
+ await fs.writeFile(path.join(dir, "batch.json"), JSON.stringify(batch, null, 2));
63
+ }
64
+
65
+ /**
66
+ * Update the status of a single story within a batch.
67
+ * @param {object} batch - The batch object.
68
+ * @param {string} storyId - Story ID to update.
69
+ * @param {string} status - New status value.
70
+ * @param {object} [extra={}] - Additional fields to merge.
71
+ * @returns {object} The updated story.
72
+ */
73
+ export function updateStoryStatus(batch, storyId, status, extra = {}) {
74
+ const story = batch.stories.find(s => s.id === storyId);
75
+ if (!story) throw new Error(`Story ${storyId} not found`);
76
+ story.status = status;
77
+ story.updated_at = new Date().toISOString();
78
+ Object.assign(story, extra);
79
+ return story;
80
+ }
81
+
82
+ /**
83
+ * Store quality scores on a story.
84
+ * @param {object} batch - The batch object.
85
+ * @param {string} storyId - Story ID.
86
+ * @param {object} quality - Quality scores object.
87
+ * @returns {object} The updated story.
88
+ */
89
+ export function updateStoryQuality(batch, storyId, quality) {
90
+ const story = batch.stories.find(s => s.id === storyId);
91
+ if (!story) throw new Error(`Story ${storyId} not found`);
92
+ story.quality = { ...quality, evaluated_at: new Date().toISOString() };
93
+ story.updated_at = new Date().toISOString();
94
+ return story;
95
+ }
96
+
97
+ /**
98
+ * Mark a story as certified with the provided certified data.
99
+ * @param {object} batch - The batch object.
100
+ * @param {string} storyId - Story ID.
101
+ * @param {object} certified - Certified HU data.
102
+ * @returns {object} The updated story.
103
+ */
104
+ export function updateStoryCertified(batch, storyId, certified) {
105
+ const story = batch.stories.find(s => s.id === storyId);
106
+ if (!story) throw new Error(`Story ${storyId} not found`);
107
+ story.certified = certified;
108
+ story.status = "certified";
109
+ story.updated_at = new Date().toISOString();
110
+ return story;
111
+ }
112
+
113
+ /**
114
+ * Add a context request to a story and set its status to needs_context.
115
+ * @param {object} batch - The batch object.
116
+ * @param {string} storyId - Story ID.
117
+ * @param {{fields_needed: string[], question: string}} request - Context request.
118
+ * @returns {object} The updated story.
119
+ */
120
+ export function addContextRequest(batch, storyId, request) {
121
+ const story = batch.stories.find(s => s.id === storyId);
122
+ if (!story) throw new Error(`Story ${storyId} not found`);
123
+ story.context_requests.push({
124
+ requested_at: new Date().toISOString(),
125
+ fields_needed: request.fields_needed,
126
+ question_to_fde: request.question,
127
+ answered_at: null,
128
+ answer: null
129
+ });
130
+ story.status = "needs_context";
131
+ story.updated_at = new Date().toISOString();
132
+ return story;
133
+ }
134
+
135
+ /**
136
+ * Answer the most recent pending context request and reset status to pending.
137
+ * @param {object} batch - The batch object.
138
+ * @param {string} storyId - Story ID.
139
+ * @param {string} answer - The FDE's answer.
140
+ * @returns {object} The updated story.
141
+ */
142
+ export function answerContextRequest(batch, storyId, answer) {
143
+ const story = batch.stories.find(s => s.id === storyId);
144
+ if (!story) throw new Error(`Story ${storyId} not found`);
145
+ const pending = story.context_requests.find(r => !r.answered_at);
146
+ if (pending) {
147
+ pending.answered_at = new Date().toISOString();
148
+ pending.answer = answer;
149
+ }
150
+ story.status = "pending"; // back to pending for re-evaluation
151
+ story.updated_at = new Date().toISOString();
152
+ return story;
153
+ }
package/src/mcp/run-kj.js CHANGED
@@ -46,6 +46,8 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
46
46
  normalizeBoolFlag(options.enableTriage, "--enable-triage", args);
47
47
  normalizeBoolFlag(options.enableDiscover, "--enable-discover", args);
48
48
  normalizeBoolFlag(options.enableArchitect, "--enable-architect", args);
49
+ normalizeBoolFlag(options.enableHuReviewer, "--enable-hu-reviewer", args);
50
+ addOptionalValue(args, "--hu-file", options.huFile);
49
51
  normalizeBoolFlag(options.enableSerena, "--enable-serena", args);
50
52
  normalizeBoolFlag(options.autoCommit, "--auto-commit", args);
51
53
  normalizeBoolFlag(options.autoPush, "--auto-push", args);
@@ -60,6 +62,8 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
60
62
  addOptionalValue(args, "--checkpoint-interval", options.checkpointInterval);
61
63
  addOptionalValue(args, "--pg-task", options.pgTask);
62
64
  addOptionalValue(args, "--pg-project", options.pgProject);
65
+ if (options.quiet === true) args.push("--quiet");
66
+ if (options.quiet === false) args.push("--verbose");
63
67
 
64
68
  const runEnv = {
65
69
  ...process.env,
package/src/mcp/tools.js CHANGED
@@ -74,7 +74,9 @@ export const tools = [
74
74
  enableTriage: { type: "boolean" },
75
75
  enableDiscover: { type: "boolean" },
76
76
  enableArchitect: { type: "boolean" },
77
+ enableHuReviewer: { type: "boolean" },
77
78
  architectModel: { type: "string" },
79
+ huFile: { type: "string", description: "Path to YAML file with HU stories to certify before coding" },
78
80
  enableSerena: { type: "boolean" },
79
81
  enableBecaria: { type: "boolean", description: "Enable BecarIA Gateway (early PR + dispatch comments/reviews)" },
80
82
  reviewerFallback: { type: "string" },
@@ -95,6 +97,7 @@ export const tools = [
95
97
  smartModels: { type: "boolean", description: "Enable/disable smart model selection based on triage complexity" },
96
98
  checkpointInterval: { type: "number", description: "Minutes between interactive checkpoints (default: 5). Set 0 to disable." },
97
99
  taskType: { type: "string", enum: ["sw", "infra", "doc", "add-tests", "refactor"], description: "Explicit task type for policy resolution. Overrides triage classification." },
100
+ quiet: { type: "boolean", description: "Suppress raw agent output lines, show only stage status (default: true). Set false for verbose output." },
98
101
  noSonar: { type: "boolean" },
99
102
  enableSonarcloud: { type: "boolean", description: "Enable SonarCloud scan (complementary to SonarQube)" },
100
103
  kjHome: { type: "string" },
@@ -3,6 +3,7 @@ import { ResearcherRole } from "../roles/researcher-role.js";
3
3
  import { PlannerRole } from "../roles/planner-role.js";
4
4
  import { DiscoverRole } from "../roles/discover-role.js";
5
5
  import { ArchitectRole } from "../roles/architect-role.js";
6
+ import { HuReviewerRole } from "../roles/hu-reviewer-role.js";
6
7
  import { createAgent } from "../agents/index.js";
7
8
  import { createArchitectADRs } from "../planning-game/architect-adrs.js";
8
9
  import { addCheckpoint, markSessionStatus } from "../session-store.js";
@@ -10,6 +11,8 @@ import { emitProgress, makeEvent } from "../utils/events.js";
10
11
  import { parsePlannerOutput } from "../prompts/planner.js";
11
12
  import { selectModelsForRoles } from "../utils/model-selector.js";
12
13
  import { createStallDetector } from "../utils/stall-detector.js";
14
+ import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../hu/store.js";
15
+ import { topologicalSort } from "../hu/graph.js";
13
16
 
14
17
  const ROLE_NAMES = ["planner", "researcher", "architect", "refactorer", "reviewer", "tester", "security", "impeccable"];
15
18
 
@@ -507,3 +510,210 @@ export async function runDiscoverStage({ config, logger, emitter, eventBase, ses
507
510
 
508
511
  return { stageResult };
509
512
  }
513
+
514
+ /**
515
+ * Run the HU Reviewer stage: load stories from YAML, evaluate, certify, and return in topological order.
516
+ * @param {object} params
517
+ * @returns {Promise<{stageResult: object}>}
518
+ */
519
+ export async function runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile, askQuestion }) {
520
+ logger.setContext({ iteration: 0, stage: "hu-reviewer" });
521
+ emitProgress(
522
+ emitter,
523
+ makeEvent("hu-reviewer:start", { ...eventBase, stage: "hu-reviewer" }, {
524
+ message: "HU Reviewer certifying user stories"
525
+ })
526
+ );
527
+
528
+ // --- Load YAML file ---
529
+ const yaml = await import("js-yaml");
530
+ const fs = await import("node:fs/promises");
531
+ let rawYaml;
532
+ try {
533
+ rawYaml = await fs.readFile(huFile, "utf8");
534
+ } catch (err) {
535
+ const stageResult = { ok: false, error: `Could not read HU file: ${err.message}` };
536
+ emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
537
+ status: "fail", message: stageResult.error
538
+ }));
539
+ return { stageResult };
540
+ }
541
+
542
+ let stories;
543
+ try {
544
+ const parsed = yaml.load(rawYaml);
545
+ stories = Array.isArray(parsed) ? parsed : (parsed?.stories || []);
546
+ } catch (err) {
547
+ const stageResult = { ok: false, error: `Invalid YAML in HU file: ${err.message}` };
548
+ emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
549
+ status: "fail", message: stageResult.error
550
+ }));
551
+ return { stageResult };
552
+ }
553
+
554
+ if (stories.length === 0) {
555
+ const stageResult = { ok: true, certified: 0, stories: [] };
556
+ emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
557
+ status: "ok", message: "No stories to evaluate"
558
+ }));
559
+ return { stageResult };
560
+ }
561
+
562
+ // --- Create or load batch ---
563
+ const batchSessionId = `hu-${session.id}`;
564
+ let batch;
565
+ try {
566
+ batch = await loadHuBatch(batchSessionId);
567
+ } catch {
568
+ batch = await createHuBatch(batchSessionId, stories);
569
+ }
570
+
571
+ // --- Evaluate loop (re-evaluate entire batch until all certified or needs_context with no askQuestion) ---
572
+ const huReviewerProvider = config?.roles?.hu_reviewer?.provider || coderRole.provider;
573
+ const huReviewerOnOutput = ({ stream, line }) => {
574
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "hu-reviewer" }, {
575
+ message: line,
576
+ detail: { stream, agent: huReviewerProvider }
577
+ }));
578
+ };
579
+
580
+ let maxRounds = 5;
581
+ let round = 0;
582
+
583
+ while (round < maxRounds) {
584
+ round += 1;
585
+
586
+ const pendingStories = batch.stories.filter(s => s.status === "pending" || s.status === "needs_context");
587
+ if (pendingStories.length === 0) break;
588
+
589
+ const storiesToEvaluate = pendingStories.map(s => ({ id: s.id, text: s.original.text }));
590
+
591
+ const stall = createStallDetector({
592
+ onOutput: huReviewerOnOutput, emitter, eventBase, stage: "hu-reviewer", provider: huReviewerProvider
593
+ });
594
+
595
+ const huReviewer = new HuReviewerRole({ config, logger, emitter, createAgentFn: createAgent });
596
+ await huReviewer.init({ task: session.task, sessionId: session.id, iteration: 0 });
597
+ const reviewStart = Date.now();
598
+ let reviewOutput;
599
+ try {
600
+ reviewOutput = await huReviewer.run({ stories: storiesToEvaluate, onOutput: stall.onOutput });
601
+ } catch (err) {
602
+ logger.warn(`HU Reviewer threw: ${err.message}`);
603
+ reviewOutput = { ok: false, summary: `HU Reviewer error: ${err.message}`, result: { error: err.message } };
604
+ } finally {
605
+ stall.stop();
606
+ }
607
+
608
+ trackBudget({
609
+ role: "hu-reviewer",
610
+ provider: huReviewerProvider,
611
+ model: config?.roles?.hu_reviewer?.model || coderRole.model,
612
+ result: reviewOutput,
613
+ duration_ms: Date.now() - reviewStart
614
+ });
615
+
616
+ if (!reviewOutput.ok || !reviewOutput.result?.evaluations) {
617
+ break;
618
+ }
619
+
620
+ // --- Process evaluations ---
621
+ for (const evaluation of reviewOutput.result.evaluations) {
622
+ const storyId = evaluation.story_id;
623
+ try {
624
+ updateStoryQuality(batch, storyId, evaluation.scores);
625
+ } catch {
626
+ continue; // story not found in batch, skip
627
+ }
628
+
629
+ if (evaluation.verdict === "certified") {
630
+ updateStoryCertified(batch, storyId, evaluation.certified_hu);
631
+ } else if (evaluation.verdict === "needs_context" && evaluation.context_needed) {
632
+ addContextRequest(batch, storyId, {
633
+ fields_needed: evaluation.context_needed.fields_needed || [],
634
+ question: evaluation.context_needed.question_to_fde || ""
635
+ });
636
+ } else if (evaluation.verdict === "needs_rewrite" && evaluation.rewritten) {
637
+ // Accept the rewrite and re-certify
638
+ updateStoryCertified(batch, storyId, evaluation.rewritten);
639
+ } else {
640
+ updateStoryStatus(batch, storyId, "pending");
641
+ }
642
+ }
643
+
644
+ await saveHuBatch(batchSessionId, batch);
645
+
646
+ // --- Check if any need context ---
647
+ const needsContext = batch.stories.filter(s => s.status === "needs_context");
648
+ if (needsContext.length > 0) {
649
+ const consolidatedQuestions = reviewOutput.result.batch_summary?.consolidated_questions
650
+ || needsContext.map(s => {
651
+ const pending = s.context_requests.find(r => !r.answered_at);
652
+ return pending ? `[${s.id}] ${pending.question_to_fde}` : null;
653
+ }).filter(Boolean).join("\n");
654
+
655
+ if (!askQuestion) {
656
+ // No interactive input — pause session
657
+ break;
658
+ }
659
+
660
+ emitProgress(emitter, makeEvent("hu-reviewer:needs-context", { ...eventBase, stage: "hu-reviewer" }, {
661
+ message: `${needsContext.length} story(ies) need context from FDE`,
662
+ detail: { questions: consolidatedQuestions }
663
+ }));
664
+
665
+ const answer = await askQuestion(
666
+ `The HU Reviewer needs additional context:\n\n${consolidatedQuestions}\n\nPlease provide your answers:`,
667
+ { iteration: 0, stage: "hu-reviewer" }
668
+ );
669
+
670
+ if (!answer) break;
671
+
672
+ // --- Incorporate FDE answers and re-evaluate ---
673
+ for (const s of needsContext) {
674
+ answerContextRequest(batch, s.id, answer);
675
+ }
676
+ await saveHuBatch(batchSessionId, batch);
677
+ // Loop will re-evaluate entire batch
678
+ }
679
+ }
680
+
681
+ await addCheckpoint(session, {
682
+ stage: "hu-reviewer",
683
+ iteration: 0,
684
+ ok: true,
685
+ certified: batch.stories.filter(s => s.status === "certified").length,
686
+ total: batch.stories.length
687
+ });
688
+
689
+ // --- Return certified stories in topological order ---
690
+ const certifiedStories = batch.stories.filter(s => s.status === "certified");
691
+ let orderedIds;
692
+ try {
693
+ orderedIds = topologicalSort(certifiedStories);
694
+ } catch {
695
+ orderedIds = certifiedStories.map(s => s.id);
696
+ }
697
+
698
+ const orderedStories = orderedIds.map(id => batch.stories.find(s => s.id === id)).filter(Boolean);
699
+
700
+ const stageResult = {
701
+ ok: true,
702
+ certified: certifiedStories.length,
703
+ total: batch.stories.length,
704
+ needsContext: batch.stories.filter(s => s.status === "needs_context").length,
705
+ stories: orderedStories,
706
+ batchSessionId
707
+ };
708
+
709
+ emitProgress(
710
+ emitter,
711
+ makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
712
+ status: "ok",
713
+ message: `HU Review complete: ${certifiedStories.length}/${batch.stories.length} certified`,
714
+ detail: stageResult
715
+ })
716
+ );
717
+
718
+ return { stageResult };
719
+ }