karajan-code 1.37.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hu/store.js CHANGED
@@ -4,6 +4,21 @@ import { getKarajanHome } from "../utils/paths.js";
4
4
 
5
5
  // FUTURE: hu-storage adapter for PG/Trello/etc — currently local files only
6
6
 
7
+ /**
8
+ * Valid HU story status values.
9
+ * @type {Readonly<{PENDING: "pending", CODING: "coding", REVIEWING: "reviewing", DONE: "done", FAILED: "failed", BLOCKED: "blocked", CERTIFIED: "certified", NEEDS_CONTEXT: "needs_context"}>}
10
+ */
11
+ export const HU_STATUS = Object.freeze({
12
+ PENDING: "pending",
13
+ CODING: "coding",
14
+ REVIEWING: "reviewing",
15
+ DONE: "done",
16
+ FAILED: "failed",
17
+ BLOCKED: "blocked",
18
+ CERTIFIED: "certified",
19
+ NEEDS_CONTEXT: "needs_context"
20
+ });
21
+
7
22
  /** @returns {string} Path to the hu-stories directory (evaluated at call time). */
8
23
  function getHuDir() {
9
24
  return path.join(getKarajanHome(), "hu-stories");
@@ -151,3 +166,45 @@ export function answerContextRequest(batch, storyId, answer) {
151
166
  story.updated_at = new Date().toISOString();
152
167
  return story;
153
168
  }
169
+
170
+ /**
171
+ * Create a lightweight history record for a pipeline run.
172
+ * Stores a minimal single-HU batch in the same hu-stories directory
173
+ * so the HU Board can pick it up alongside full batches.
174
+ * @param {string} sessionId - The session identifier.
175
+ * @param {{task: string, result: string, approved: boolean, summary?: string, timestamp?: string}} data
176
+ * @returns {Promise<object>} The created batch object.
177
+ */
178
+ export async function createHistoryRecord(sessionId, { task, result, approved, summary, timestamp }) {
179
+ const ts = timestamp || new Date().toISOString();
180
+ const dir = path.join(getHuDir(), sessionId);
181
+ await fs.mkdir(dir, { recursive: true });
182
+
183
+ const batch = {
184
+ session_id: sessionId,
185
+ created_at: ts,
186
+ stories: [
187
+ {
188
+ id: `HU-hist-${sessionId}`,
189
+ status: approved ? "certified" : "failed",
190
+ original: { text: task },
191
+ blocked_by: [],
192
+ certified: approved ? { summary: summary || result } : null,
193
+ quality: null,
194
+ context_requests: [],
195
+ created_at: ts,
196
+ updated_at: ts
197
+ }
198
+ ],
199
+ history: {
200
+ task,
201
+ result,
202
+ approved,
203
+ summary: summary || null,
204
+ timestamp: ts
205
+ }
206
+ };
207
+
208
+ await fs.writeFile(path.join(dir, "batch.json"), JSON.stringify(batch, null, 2));
209
+ return batch;
210
+ }
@@ -482,7 +482,9 @@ export async function handleCodeDirect(a, server, extra) {
482
482
  if (config.coder_rules) {
483
483
  try {
484
484
  coderRules = await fs.readFile(config.coder_rules, "utf8");
485
- } catch { /* no coder rules file */ }
485
+ } catch {
486
+ try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
487
+ }
486
488
  }
487
489
  const prompt = buildCoderPrompt({ task: a.task, coderRules, methodology: config.development?.methodology || "tdd" });
488
490
  sendTrackerLog(server, "coder", "running", coderRole.provider);
@@ -0,0 +1,166 @@
1
+ /**
2
+ * HU Sub-Pipeline — executes each certified HU as an independent sub-pipeline
3
+ * with per-HU state tracking, topological ordering, and dependency-aware blocking.
4
+ */
5
+ import { topologicalSort } from "../hu/graph.js";
6
+ import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/store.js";
7
+ import { emitProgress, makeEvent } from "../utils/events.js";
8
+
9
+ /**
10
+ * Determine whether the HU reviewer result requires a sub-pipeline
11
+ * (more than one certified story).
12
+ * @param {object|null} huReviewerResult - stageResults.huReviewer
13
+ * @returns {boolean}
14
+ */
15
+ export function needsSubPipeline(huReviewerResult) {
16
+ if (!huReviewerResult?.ok) return false;
17
+ const certified = (huReviewerResult.stories || []).filter(
18
+ s => s.status === "certified"
19
+ );
20
+ return certified.length > 1;
21
+ }
22
+
23
+ /**
24
+ * Mark all stories that depend (directly or transitively) on a failed story as "blocked".
25
+ * @param {object} batch - The HU batch object.
26
+ * @param {string} failedId - The ID of the failed story.
27
+ * @returns {string[]} IDs of newly blocked stories.
28
+ */
29
+ export function blockDependents(batch, failedId) {
30
+ const blocked = [];
31
+ const failedSet = new Set([failedId]);
32
+ // Iterate until no new blocked stories are found (handles transitive deps)
33
+ let changed = true;
34
+ while (changed) {
35
+ changed = false;
36
+ for (const story of batch.stories) {
37
+ if (failedSet.has(story.id)) continue;
38
+ if (story.status === HU_STATUS.BLOCKED) continue;
39
+ const deps = story.blocked_by || [];
40
+ if (deps.some(dep => failedSet.has(dep))) {
41
+ updateStoryStatus(batch, story.id, HU_STATUS.BLOCKED);
42
+ failedSet.add(story.id);
43
+ blocked.push(story.id);
44
+ changed = true;
45
+ }
46
+ }
47
+ }
48
+ return blocked;
49
+ }
50
+
51
+ /**
52
+ * Build task description for the coder from a certified HU story.
53
+ * @param {object} story - A story object from the batch.
54
+ * @returns {string}
55
+ */
56
+ function buildHuTask(story) {
57
+ if (story.certified?.text) return story.certified.text;
58
+ if (story.original?.text) return story.original.text;
59
+ return `Implement HU ${story.id}`;
60
+ }
61
+
62
+ /**
63
+ * Run all certified HUs as sub-pipelines in topological order.
64
+ *
65
+ * @param {object} params
66
+ * @param {object} params.huReviewerResult - stageResults.huReviewer (from pre-loop)
67
+ * @param {Function} params.runIterationFn - async (task) => { approved, result, ... }
68
+ * Callback that runs the full coder→sonar→reviewer iteration loop for a given task.
69
+ * Returns an object with at least { approved: boolean }.
70
+ * @param {object} params.emitter - Event emitter
71
+ * @param {object} params.eventBase - Base event fields
72
+ * @param {object} params.logger - Logger instance
73
+ * @returns {Promise<{approved: boolean, results: object[], blockedIds: string[]}>}
74
+ */
75
+ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger }) {
76
+ const batchSessionId = huReviewerResult.batchSessionId;
77
+ let batch;
78
+ try {
79
+ batch = await loadHuBatch(batchSessionId);
80
+ } catch {
81
+ logger.warn("HU sub-pipeline: could not load batch, falling back to in-memory stories");
82
+ // Build a minimal batch from the stage result
83
+ batch = {
84
+ session_id: batchSessionId,
85
+ stories: huReviewerResult.stories || []
86
+ };
87
+ }
88
+
89
+ const certifiedStories = batch.stories.filter(s => s.status === HU_STATUS.CERTIFIED);
90
+ let orderedIds;
91
+ try {
92
+ orderedIds = topologicalSort(certifiedStories);
93
+ } catch {
94
+ orderedIds = certifiedStories.map(s => s.id);
95
+ }
96
+
97
+ const results = [];
98
+ const blockedIds = [];
99
+ let allApproved = true;
100
+
101
+ for (const storyId of orderedIds) {
102
+ const story = batch.stories.find(s => s.id === storyId);
103
+ if (!story) continue;
104
+
105
+ // Skip if already blocked by a failed dependency
106
+ if (story.status === HU_STATUS.BLOCKED) {
107
+ blockedIds.push(story.id);
108
+ continue;
109
+ }
110
+
111
+ const huTask = buildHuTask(story);
112
+
113
+ // --- hu:start ---
114
+ emitProgress(emitter, makeEvent("hu:start", { ...eventBase, stage: "hu-sub-pipeline" }, {
115
+ message: `Starting HU ${story.id}`,
116
+ detail: { huId: story.id, title: story.certified?.title || story.id }
117
+ }));
118
+
119
+ // Update status to coding
120
+ updateStoryStatus(batch, story.id, HU_STATUS.CODING);
121
+ await saveHuBatch(batchSessionId, batch);
122
+
123
+ try {
124
+ const iterResult = await runIterationFn(huTask);
125
+ const approved = Boolean(iterResult?.approved);
126
+
127
+ if (approved) {
128
+ updateStoryStatus(batch, story.id, HU_STATUS.DONE);
129
+ emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
130
+ status: "ok",
131
+ message: `HU ${story.id} completed successfully`,
132
+ detail: { huId: story.id, approved: true }
133
+ }));
134
+ results.push({ huId: story.id, approved: true, result: iterResult });
135
+ } else {
136
+ updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
137
+ const newlyBlocked = blockDependents(batch, story.id);
138
+ blockedIds.push(...newlyBlocked);
139
+ allApproved = false;
140
+
141
+ emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
142
+ status: "fail",
143
+ message: `HU ${story.id} failed — ${newlyBlocked.length} dependent(s) blocked`,
144
+ detail: { huId: story.id, approved: false, blockedDependents: newlyBlocked, reason: iterResult?.reason }
145
+ }));
146
+ results.push({ huId: story.id, approved: false, result: iterResult, blockedDependents: newlyBlocked });
147
+ }
148
+ } catch (err) {
149
+ updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
150
+ const newlyBlocked = blockDependents(batch, story.id);
151
+ blockedIds.push(...newlyBlocked);
152
+ allApproved = false;
153
+
154
+ emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
155
+ status: "fail",
156
+ message: `HU ${story.id} threw: ${err.message}`,
157
+ detail: { huId: story.id, approved: false, error: err.message, blockedDependents: newlyBlocked }
158
+ }));
159
+ results.push({ huId: story.id, approved: false, error: err.message, blockedDependents: newlyBlocked });
160
+ }
161
+
162
+ await saveHuBatch(batchSessionId, batch);
163
+ }
164
+
165
+ return { approved: allApproved, results, blockedIds };
166
+ }
@@ -9,6 +9,7 @@ import { createArchitectADRs } from "../planning-game/architect-adrs.js";
9
9
  import { addCheckpoint, markSessionStatus } from "../session-store.js";
10
10
  import { emitProgress, makeEvent } from "../utils/events.js";
11
11
  import { parsePlannerOutput } from "../prompts/planner.js";
12
+ import { buildDecompositionPrompt, parseDecompositionOutput } from "../prompts/hu-reviewer.js";
12
13
  import { selectModelsForRoles } from "../utils/model-selector.js";
13
14
  import { createStallDetector } from "../utils/stall-detector.js";
14
15
  import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../hu/store.js";
@@ -519,11 +520,81 @@ export async function runDiscoverStage({ config, logger, emitter, eventBase, ses
519
520
  }
520
521
 
521
522
  /**
522
- * Run the HU Reviewer stage: load stories from YAML, evaluate, certify, and return in topological order.
523
+ * Use an AI agent to decompose a complex task into multiple formal HUs.
524
+ * Returns null if decomposition fails or the task is too simple.
525
+ * @param {object} params
526
+ * @returns {Promise<Array|null>} Array of decomposed HUs or null.
527
+ */
528
+ async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
529
+ const provider = config?.roles?.hu_reviewer?.provider || coderRole.provider;
530
+ const prompt = buildDecompositionPrompt(session.task);
531
+
532
+ emitProgress(
533
+ emitter,
534
+ makeEvent("hu-reviewer:decompose-start", { ...eventBase, stage: "hu-reviewer" }, {
535
+ message: "Decomposing complex task into formal HUs",
536
+ detail: { provider }
537
+ })
538
+ );
539
+
540
+ const onOutput = ({ stream, line }) => {
541
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "hu-reviewer" }, {
542
+ message: line,
543
+ detail: { stream, agent: provider }
544
+ }));
545
+ };
546
+ const stall = createStallDetector({
547
+ onOutput, emitter, eventBase, stage: "hu-reviewer", provider
548
+ });
549
+
550
+ const agent = createAgent(provider, config, logger);
551
+ const decompStart = Date.now();
552
+ let result;
553
+ try {
554
+ result = await agent.runTask({ prompt, role: "hu-reviewer", onOutput: stall.onOutput });
555
+ } catch (err) {
556
+ logger.warn(`HU decomposition threw: ${err.message}`);
557
+ return null;
558
+ } finally {
559
+ stall.stop();
560
+ }
561
+
562
+ trackBudget({
563
+ role: "hu-reviewer-decompose",
564
+ provider,
565
+ model: config?.roles?.hu_reviewer?.model || coderRole.model,
566
+ result,
567
+ duration_ms: Date.now() - decompStart
568
+ });
569
+
570
+ if (!result.ok) {
571
+ logger.warn(`HU decomposition failed: ${result.error || "unknown"}`);
572
+ return null;
573
+ }
574
+
575
+ const stories = parseDecompositionOutput(result.output);
576
+ if (!stories || stories.length < 2) {
577
+ logger.info("HU decomposition returned < 2 stories, falling back to single auto-story");
578
+ return null;
579
+ }
580
+
581
+ emitProgress(
582
+ emitter,
583
+ makeEvent("hu-reviewer:decompose-end", { ...eventBase, stage: "hu-reviewer" }, {
584
+ message: `Task decomposed into ${stories.length} HUs`,
585
+ detail: { count: stories.length, ids: stories.map(s => s.id) }
586
+ })
587
+ );
588
+
589
+ return stories;
590
+ }
591
+
592
+ /**
593
+ * Run the HU Reviewer stage: load stories from YAML or generate from task, evaluate, certify, and return in topological order.
523
594
  * @param {object} params
524
595
  * @returns {Promise<{stageResult: object}>}
525
596
  */
526
- export async function runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile, askQuestion }) {
597
+ export async function runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile, askQuestion, pgStories = null }) {
527
598
  logger.setContext({ iteration: 0, stage: "hu-reviewer" });
528
599
  const huReviewerProvider = config?.roles?.hu_reviewer?.provider || coderRole.provider;
529
600
  emitProgress(
@@ -534,30 +605,55 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
534
605
  })
535
606
  );
536
607
 
537
- // --- Load YAML file ---
538
- const yaml = await import("js-yaml");
539
- const fs = await import("node:fs/promises");
540
- let rawYaml;
541
- try {
542
- rawYaml = await fs.readFile(huFile, "utf8");
543
- } catch (err) {
544
- const stageResult = { ok: false, error: `Could not read HU file: ${err.message}` };
545
- emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
546
- status: "fail", message: stageResult.error
547
- }));
548
- return { stageResult };
549
- }
550
-
551
608
  let stories;
552
- try {
553
- const parsed = yaml.load(rawYaml);
554
- stories = Array.isArray(parsed) ? parsed : (parsed?.stories || []);
555
- } catch (err) {
556
- const stageResult = { ok: false, error: `Invalid YAML in HU file: ${err.message}` };
557
- emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
558
- status: "fail", message: stageResult.error
609
+
610
+ if (huFile) {
611
+ // --- Load YAML file ---
612
+ const yaml = await import("js-yaml");
613
+ const fs = await import("node:fs/promises");
614
+ let rawYaml;
615
+ try {
616
+ rawYaml = await fs.readFile(huFile, "utf8");
617
+ } catch (err) {
618
+ const stageResult = { ok: false, error: `Could not read HU file: ${err.message}` };
619
+ emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
620
+ status: "fail", message: stageResult.error
621
+ }));
622
+ return { stageResult };
623
+ }
624
+
625
+ try {
626
+ const parsed = yaml.load(rawYaml);
627
+ stories = Array.isArray(parsed) ? parsed : (parsed?.stories || []);
628
+ } catch (err) {
629
+ const stageResult = { ok: false, error: `Invalid YAML in HU file: ${err.message}` };
630
+ emitProgress(emitter, makeEvent("hu-reviewer:end", { ...eventBase, stage: "hu-reviewer" }, {
631
+ status: "fail", message: stageResult.error
632
+ }));
633
+ return { stageResult };
634
+ }
635
+ } else if (pgStories && pgStories.length > 0) {
636
+ // --- Use pre-built stories from Planning Game card structured data ---
637
+ stories = pgStories;
638
+ logger.info(`HU Reviewer: using ${pgStories.length} story(ies) from PG card`);
639
+ emitProgress(emitter, makeEvent("hu-reviewer:pg-feed", { ...eventBase, stage: "hu-reviewer" }, {
640
+ message: `Using ${pgStories.length} story(ies) from Planning Game card`,
641
+ detail: { source: "pg-card", storyIds: pgStories.map(s => s.id) }
559
642
  }));
560
- return { stageResult };
643
+ } else {
644
+ // --- Decompose task into formal HUs via AI, or fall back to single auto-story ---
645
+ const decomposed = await decomposeTaskIntoHUs({
646
+ config, logger, emitter, eventBase, session, coderRole, trackBudget
647
+ });
648
+ if (decomposed && decomposed.length > 1) {
649
+ stories = decomposed.map(hu => ({
650
+ id: hu.id,
651
+ text: `As a ${hu.role}, I want to ${hu.goal}, so that ${hu.benefit}\n\nTitle: ${hu.title}\nAcceptance Criteria:\n${hu.acceptanceCriteria.map(ac => `- ${ac}`).join("\n")}`,
652
+ blocked_by: hu.dependsOn || []
653
+ }));
654
+ } else {
655
+ stories = [{ id: "HU-AUTO-001", text: session.task }];
656
+ }
561
657
  }
562
658
 
563
659
  if (stories.length === 0) {
@@ -35,6 +35,7 @@ import { PipelineContext } from "./orchestrator/pipeline-context.js";
35
35
  import { runTriageStage, runResearcherStage, runArchitectStage, runPlannerStage, runDiscoverStage, runHuReviewerStage } from "./orchestrator/pre-loop-stages.js";
36
36
  import { runCoderStage, runRefactorerStage, runTddCheckStage, runSonarStage, runSonarCloudStage, runReviewerStage } from "./orchestrator/iteration-stages.js";
37
37
  import { runTesterStage, runSecurityStage, runImpeccableStage, runFinalAuditStage } from "./orchestrator/post-loop-stages.js";
38
+ import { needsSubPipeline, runHuSubPipeline } from "./orchestrator/hu-sub-pipeline.js";
38
39
  import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js";
39
40
  import { detectTestFramework } from "./utils/project-detect.js";
40
41
  import { runPreflightChecks } from "./orchestrator/preflight-checks.js";
@@ -816,7 +817,7 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
816
817
  const huFile = flags.huFile || null;
817
818
  if (flags.enableHuReviewer !== undefined) pipelineFlags.huReviewerEnabled = Boolean(flags.enableHuReviewer);
818
819
  if (pipelineFlags.huReviewerEnabled && huFile) {
819
- const huResult = await runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile, askQuestion });
820
+ const huResult = await runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile, askQuestion, pgStories: null });
820
821
  stageResults.huReviewer = huResult.stageResult;
821
822
  }
822
823
 
@@ -844,6 +845,20 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
844
845
  applyTriageOverrides(pipelineFlags, triageResult.roleOverrides);
845
846
  stageResults.triage = triageResult.stageResult;
846
847
 
848
+ // --- HU Reviewer auto-activation from triage (post-triage, no huFile needed) ---
849
+ const triageRoles = new Set(triageResult.stageResult?.roles || []);
850
+ if (triageRoles.has("hu-reviewer") && !stageResults.huReviewer) {
851
+ pipelineFlags.huReviewerEnabled = true;
852
+ // Feed PG card structured data to hu-reviewer when available
853
+ let pgStories = null;
854
+ if (pgTaskId && pgProject && session.pg_card) {
855
+ const { buildHuStoriesFromPgCard } = await import("./planning-game/pipeline-adapter.js");
856
+ pgStories = buildHuStoriesFromPgCard(session.pg_card);
857
+ }
858
+ const huResult = await runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile: null, askQuestion, pgStories });
859
+ stageResults.huReviewer = huResult.stageResult;
860
+ }
861
+
847
862
  // --- Auto-simplify pipeline for simple tasks (before explicit flag overrides) ---
848
863
  const simplified = applyAutoSimplify({
849
864
  pipelineFlags,
@@ -1301,14 +1316,29 @@ async function runSingleIteration(ctx) {
1301
1316
  return { action: "next" };
1302
1317
  }
1303
1318
 
1304
- export async function runFlow({ task, config, logger, flags = {}, emitter = null, askQuestion = null, pgTaskId = null, pgProject = null }) {
1305
- const pipelineFlags = resolvePipelineFlags(config);
1306
-
1307
- if (flags.dryRun) {
1308
- return handleDryRun({ task, config, flags, emitter, pipelineFlags });
1319
+ async function writeHistoryRecord({ sessionId, task, result, logger }) {
1320
+ try {
1321
+ const { createHistoryRecord } = await import("./hu/store.js");
1322
+ const approved = Boolean(result?.approved);
1323
+ const summary = result?.review?.summary || result?.reason || null;
1324
+ await createHistoryRecord(sessionId, {
1325
+ task,
1326
+ result: JSON.stringify(result),
1327
+ approved,
1328
+ summary
1329
+ });
1330
+ } catch (err) {
1331
+ logger.warn(`HU history record failed (non-blocking): ${err.message}`);
1309
1332
  }
1333
+ }
1310
1334
 
1311
- const ctx = await initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags });
1335
+ /**
1336
+ * Run the standard iteration loop for a given task using the pipeline context.
1337
+ * Returns a result object with at least { approved: boolean }.
1338
+ * Used both as the main loop and as the per-HU callback in sub-pipeline mode.
1339
+ */
1340
+ async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitter, logger }) {
1341
+ ctx.plannedTask = loopTask;
1312
1342
 
1313
1343
  const checkpointIntervalMs = (ctx.config.session.checkpoint_interval_minutes ?? 5) * 60 * 1000;
1314
1344
  let lastCheckpointAt = Date.now();
@@ -1324,7 +1354,9 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1324
1354
  checkpointDisabled, askQuestion, lastCheckpointAt, checkpointIntervalMs, elapsedMinutes,
1325
1355
  i, config: ctx.config, budgetTracker: ctx.budgetTracker, stageResults: ctx.stageResults, emitter, eventBase: ctx.eventBase, session: ctx.session, budgetSummary: ctx.budgetSummary, lastCheckpointSnapshot
1326
1356
  });
1327
- if (cpResult.action === "stop") return cpResult.result;
1357
+ if (cpResult.action === "stop") {
1358
+ return cpResult.result;
1359
+ }
1328
1360
  checkpointDisabled = cpResult.checkpointDisabled;
1329
1361
  lastCheckpointAt = cpResult.lastCheckpointAt;
1330
1362
  if (cpResult.lastCheckpointSnapshot !== undefined) lastCheckpointSnapshot = cpResult.lastCheckpointSnapshot;
@@ -1336,11 +1368,63 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1336
1368
  ctx.iteration = i;
1337
1369
 
1338
1370
  const iterResult = await runSingleIteration(ctx);
1339
- if (iterResult.action === "return") return iterResult.result;
1371
+ if (iterResult.action === "return") {
1372
+ return iterResult.result;
1373
+ }
1340
1374
  if (iterResult.action === "retry") { i -= 1; }
1341
1375
  }
1342
1376
 
1343
- return handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task });
1377
+ const maxIterResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask });
1378
+ return maxIterResult;
1379
+ }
1380
+
1381
+ export async function runFlow({ task, config, logger, flags = {}, emitter = null, askQuestion = null, pgTaskId = null, pgProject = null }) {
1382
+ const pipelineFlags = resolvePipelineFlags(config);
1383
+
1384
+ if (flags.dryRun) {
1385
+ return handleDryRun({ task, config, flags, emitter, pipelineFlags });
1386
+ }
1387
+
1388
+ const ctx = await initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags });
1389
+
1390
+ // --- HU Sub-Pipeline: run each certified HU as an independent iteration loop ---
1391
+ if (needsSubPipeline(ctx.stageResults.huReviewer)) {
1392
+ logger.info(`HU sub-pipeline: ${ctx.stageResults.huReviewer.certified} certified stories — running each as a sub-pipeline`);
1393
+ emitProgress(emitter, makeEvent("hu:sub-pipeline:start", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
1394
+ message: `Running ${ctx.stageResults.huReviewer.certified} HUs as sub-pipelines`,
1395
+ detail: { total: ctx.stageResults.huReviewer.total, certified: ctx.stageResults.huReviewer.certified }
1396
+ }));
1397
+
1398
+ const subPipelineResult = await runHuSubPipeline({
1399
+ huReviewerResult: ctx.stageResults.huReviewer,
1400
+ runIterationFn: async (huTask) => runIterationLoop(ctx, { task: huTask, askQuestion, emitter, logger }),
1401
+ emitter,
1402
+ eventBase: ctx.eventBase,
1403
+ logger
1404
+ });
1405
+
1406
+ emitProgress(emitter, makeEvent("hu:sub-pipeline:end", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {
1407
+ status: subPipelineResult.approved ? "ok" : "fail",
1408
+ message: subPipelineResult.approved
1409
+ ? "All HUs completed successfully"
1410
+ : `Sub-pipeline finished with failures (${subPipelineResult.blockedIds.length} blocked)`,
1411
+ detail: { results: subPipelineResult.results, blockedIds: subPipelineResult.blockedIds }
1412
+ }));
1413
+
1414
+ const finalResult = {
1415
+ approved: subPipelineResult.approved,
1416
+ sessionId: ctx.session.id,
1417
+ huResults: subPipelineResult.results,
1418
+ blockedIds: subPipelineResult.blockedIds
1419
+ };
1420
+ await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
1421
+ return finalResult;
1422
+ }
1423
+
1424
+ // --- Standard single-task pipeline (1 HU or no HU reviewer) ---
1425
+ const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
1426
+ await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
1427
+ return result;
1344
1428
  }
1345
1429
 
1346
1430
  export async function resumeFlow({ sessionId, answer, config, logger, flags = {}, emitter = null, askQuestion = null }) {
@@ -119,6 +119,43 @@ export async function markPgCardToValidate({ pgCard, pgProject, config, session,
119
119
  }
120
120
  }
121
121
 
122
+ /**
123
+ * Convert a PG card's structured data into HU stories for the hu-reviewer stage.
124
+ * Returns an array of story objects ({id, text}) or null if the card lacks structured data.
125
+ *
126
+ * @param {object} pgCard - The PG card object (from fetchCard)
127
+ * @returns {Array<{id: string, text: string}>|null}
128
+ */
129
+ export function buildHuStoriesFromPgCard(pgCard) {
130
+ if (!pgCard?.descriptionStructured?.length) return null;
131
+
132
+ const parts = [];
133
+
134
+ // User stories (Como/Quiero/Para → As/I want/So that)
135
+ for (const s of pgCard.descriptionStructured) {
136
+ parts.push(`As ${s.role}, I want ${s.goal}, so that ${s.benefit}.`);
137
+ }
138
+
139
+ // Acceptance criteria
140
+ if (pgCard.acceptanceCriteriaStructured?.length) {
141
+ parts.push("", "Acceptance Criteria:");
142
+ for (const ac of pgCard.acceptanceCriteriaStructured) {
143
+ if (ac.given && ac.when && ac.then) {
144
+ parts.push(`- Given ${ac.given}, When ${ac.when}, Then ${ac.then}`);
145
+ } else if (ac.raw) {
146
+ parts.push(`- ${ac.raw}`);
147
+ }
148
+ }
149
+ } else if (pgCard.acceptanceCriteria) {
150
+ parts.push("", "Acceptance Criteria:", pgCard.acceptanceCriteria);
151
+ }
152
+
153
+ const storyId = pgCard.cardId || "HU-PG-001";
154
+ const storyText = parts.join("\n");
155
+
156
+ return [{ id: storyId, text: storyText }];
157
+ }
158
+
122
159
  // --- Internal helpers ---
123
160
 
124
161
  async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
@@ -131,6 +131,61 @@ export function normalizeAcceptanceCriteria(criteria) {
131
131
  return criteria.map(detectAcFormat);
132
132
  }
133
133
 
134
+ /**
135
+ * Build a prompt that asks the AI to decompose a complex task into 2-5 formal HUs.
136
+ * Each HU follows the role/goal/benefit format with acceptance criteria and optional dependencies.
137
+ * @param {string} task - The complex task description to decompose.
138
+ * @returns {string} The assembled decomposition prompt.
139
+ */
140
+ export function buildDecompositionPrompt(task) {
141
+ const sections = [SUBAGENT_PREAMBLE];
142
+
143
+ sections.push("## Task Decomposition");
144
+ sections.push(
145
+ "You are a senior product owner. Decompose the following complex task into 2-5 independent, implementable user stories (HUs).",
146
+ "Each HU must be small enough to implement in a single iteration and must follow the role/goal/benefit format."
147
+ );
148
+
149
+ sections.push(`## Task to Decompose\n${task}`);
150
+
151
+ sections.push(
152
+ "Return a single valid JSON object and nothing else.",
153
+ `JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string]}]}`,
154
+ "Rules:",
155
+ "- Generate between 2 and 5 HUs",
156
+ "- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
157
+ "- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
158
+ "- acceptanceCriteria should be concrete, testable statements",
159
+ "- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)"
160
+ );
161
+
162
+ return sections.join("\n\n");
163
+ }
164
+
165
+ /**
166
+ * Parse the output from a decomposition prompt.
167
+ * @param {string} raw - Raw text output from the agent.
168
+ * @returns {Array<{id: string, title: string, role: string, goal: string, benefit: string, acceptanceCriteria: string[], dependsOn: string[]}>|null}
169
+ */
170
+ export function parseDecompositionOutput(raw) {
171
+ const parsed = extractFirstJson(raw);
172
+ if (!parsed || !Array.isArray(parsed.stories)) return null;
173
+
174
+ const stories = parsed.stories
175
+ .filter(s => s && s.id && s.title)
176
+ .map(s => ({
177
+ id: s.id,
178
+ title: s.title,
179
+ role: s.role || "developer",
180
+ goal: s.goal || s.title,
181
+ benefit: s.benefit || "",
182
+ acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
183
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : []
184
+ }));
185
+
186
+ return stories.length > 0 ? stories : null;
187
+ }
188
+
134
189
  /**
135
190
  * Parse the raw output from the HU reviewer agent.
136
191
  * @param {string} raw - Raw text output from the agent.