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/README.md +7 -8
- package/docs/README.es.md +245 -34
- package/package.json +1 -1
- package/src/agents/resolve-bin.js +26 -14
- package/src/commands/code.js +3 -1
- package/src/commands/init.js +6 -4
- package/src/config.js +2 -2
- package/src/hu/store.js +57 -0
- package/src/mcp/server-handlers.js +3 -1
- package/src/orchestrator/hu-sub-pipeline.js +166 -0
- package/src/orchestrator/pre-loop-stages.js +120 -24
- package/src/orchestrator.js +94 -10
- package/src/planning-game/pipeline-adapter.js +37 -0
- package/src/prompts/hu-reviewer.js +55 -0
- package/src/prompts/triage.js +4 -2
- package/src/utils/os-detect.js +17 -8
- package/src/utils/process.js +7 -4
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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) {
|
package/src/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
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")
|
|
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")
|
|
1371
|
+
if (iterResult.action === "return") {
|
|
1372
|
+
return iterResult.result;
|
|
1373
|
+
}
|
|
1340
1374
|
if (iterResult.action === "retry") { i -= 1; }
|
|
1341
1375
|
}
|
|
1342
1376
|
|
|
1343
|
-
|
|
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.
|