karajan-code 1.45.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/docs/README.es.md +4 -4
- package/package.json +1 -1
- package/scripts/build-sea.mjs +122 -0
- package/scripts/esbuild-sea.config.mjs +33 -0
- package/src/hu/parallel-executor.js +94 -0
- package/src/hu/split-validator.js +143 -0
- package/src/hu/splitting-detector.js +158 -0
- package/src/hu/splitting-generator.js +170 -0
- package/src/hu/store.js +20 -0
- package/src/mcp/server-handlers.js +26 -1
- package/src/orchestrator/hu-sub-pipeline.js +154 -84
- package/src/orchestrator/pre-loop-stages.js +80 -0
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ This is the primary use case. Karajan runs as an MCP server inside Claude Code,
|
|
|
90
90
|
You → Claude Code → kj_run (via MCP) → triage → coder → sonar → reviewer → tester → security
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
The MCP server auto-registers during `npm install`. Your AI agent sees
|
|
93
|
+
The MCP server auto-registers during `npm install`. Your AI agent sees 23 tools (`kj_run`, `kj_code`, `kj_review`, etc.) and uses them as needed.
|
|
94
94
|
|
|
95
95
|
**The problem**: when Karajan runs inside an AI agent, you lose visibility. The agent shows you the final result, but not the pipeline stages, iterations, or Solomon decisions happening in real time.
|
|
96
96
|
|
|
@@ -177,7 +177,7 @@ hu-reviewer? → triage → discover? → architect? → planner? → coder →
|
|
|
177
177
|
|
|
178
178
|
Mix and match. Use Claude as coder and Codex as reviewer. Karajan auto-detects installed agents during `kj init`.
|
|
179
179
|
|
|
180
|
-
## MCP server (
|
|
180
|
+
## MCP server (23 tools)
|
|
181
181
|
|
|
182
182
|
After `npm install -g karajan-code`, the MCP server auto-registers in Claude and Codex. Manual config if needed:
|
|
183
183
|
|
|
@@ -189,7 +189,7 @@ After `npm install -g karajan-code`, the MCP server auto-registers in Claude and
|
|
|
189
189
|
# command = "karajan-mcp"
|
|
190
190
|
```
|
|
191
191
|
|
|
192
|
-
**
|
|
192
|
+
**23 tools** available: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`, `kj_skills`, `kj_suggest`.
|
|
193
193
|
|
|
194
194
|
Use `kj-tail` in a separate terminal to see what the pipeline is doing in real time (see [Three ways to use Karajan](#three-ways-to-use-karajan)).
|
|
195
195
|
|
|
@@ -243,7 +243,7 @@ Not nostalgia, not stubbornness. I've been using JavaScript since 1997, when Bre
|
|
|
243
243
|
git clone https://github.com/manufosela/karajan-code.git
|
|
244
244
|
cd karajan-code
|
|
245
245
|
npm install
|
|
246
|
-
npm test # Run
|
|
246
|
+
npm test # Run 2318 tests with Vitest
|
|
247
247
|
npm run validate # Lint + test
|
|
248
248
|
```
|
|
249
249
|
|
package/docs/README.es.md
CHANGED
|
@@ -82,7 +82,7 @@ Este es el caso de uso principal. Karajan corre como servidor MCP dentro de Clau
|
|
|
82
82
|
Tu → Claude Code → kj_run (via MCP) → triage → coder → sonar → reviewer → tester → security
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
El servidor MCP se auto-registra durante `npm install`. Tu agente de IA ve
|
|
85
|
+
El servidor MCP se auto-registra durante `npm install`. Tu agente de IA ve 23 herramientas (`kj_run`, `kj_code`, `kj_review`, `kj_hu`, etc.) y las usa segun necesite.
|
|
86
86
|
|
|
87
87
|
**El problema**: cuando Karajan corre dentro de un agente de IA, pierdes visibilidad. El agente te muestra el resultado final, pero no las etapas del pipeline, iteraciones o decisiones de Solomon en tiempo real.
|
|
88
88
|
|
|
@@ -147,7 +147,7 @@ hu-reviewer? → triage → discover? → architect? → planner? → coder →
|
|
|
147
147
|
|
|
148
148
|
Mezcla y combina. Usa Claude como coder y Codex como reviewer. Karajan auto-detecta agentes instalados durante `kj init`.
|
|
149
149
|
|
|
150
|
-
## Servidor MCP (
|
|
150
|
+
## Servidor MCP (23 herramientas)
|
|
151
151
|
|
|
152
152
|
Tras `npm install -g karajan-code`, el servidor MCP se auto-registra en Claude y Codex. Config manual si es necesario:
|
|
153
153
|
|
|
@@ -159,7 +159,7 @@ Tras `npm install -g karajan-code`, el servidor MCP se auto-registra en Claude y
|
|
|
159
159
|
# command = "karajan-mcp"
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
**
|
|
162
|
+
**23 herramientas** disponibles: `kj_run`, `kj_code`, `kj_review`, `kj_plan`, `kj_audit`, `kj_scan`, `kj_doctor`, `kj_config`, `kj_report`, `kj_resume`, `kj_roles`, `kj_agents`, `kj_preflight`, `kj_status`, `kj_init`, `kj_discover`, `kj_triage`, `kj_researcher`, `kj_architect`, `kj_impeccable`, `kj_hu`, `kj_skills`, `kj_suggest`.
|
|
163
163
|
|
|
164
164
|
Usa `kj-tail` en un terminal separado para ver lo que el pipeline esta haciendo en tiempo real (ver [Tres formas de usar Karajan](#tres-formas-de-usar-karajan)).
|
|
165
165
|
|
|
@@ -213,7 +213,7 @@ No es nostalgia ni cabezoneria. Es que llevo usando JavaScript desde 1997, cuand
|
|
|
213
213
|
git clone https://github.com/manufosela/karajan-code.git
|
|
214
214
|
cd karajan-code
|
|
215
215
|
npm install
|
|
216
|
-
npm test # Ejecutar
|
|
216
|
+
npm test # Ejecutar 2318 tests con Vitest
|
|
217
217
|
npm run validate # Lint + test
|
|
218
218
|
```
|
|
219
219
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* build-sea.mjs — Build a Node.js Single Executable Application (SEA) binary.
|
|
5
|
+
*
|
|
6
|
+
* Steps:
|
|
7
|
+
* 1. esbuild bundles src/cli.js -> dist/kj-bundle.cjs (CJS, all deps inlined)
|
|
8
|
+
* 2. Generate SEA preparation blob via node --experimental-sea-config
|
|
9
|
+
* 3. Copy the current node binary to dist/kj (or dist/kj.exe on Windows)
|
|
10
|
+
* 4. Inject the blob into the binary with postject
|
|
11
|
+
* 5. (macOS only) Re-sign the binary with ad-hoc signature
|
|
12
|
+
*
|
|
13
|
+
* Requirements (installed globally or via npx):
|
|
14
|
+
* - esbuild
|
|
15
|
+
* - postject
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/build-sea.mjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import { cpSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
const __dirname = path.dirname(__filename);
|
|
28
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
29
|
+
const DIST = path.join(ROOT, "dist");
|
|
30
|
+
|
|
31
|
+
const isWindows = process.platform === "win32";
|
|
32
|
+
const isMac = process.platform === "darwin";
|
|
33
|
+
const binaryName = isWindows ? "kj.exe" : "kj";
|
|
34
|
+
const binaryPath = path.join(DIST, binaryName);
|
|
35
|
+
|
|
36
|
+
function run(cmd, opts = {}) {
|
|
37
|
+
console.log(` $ ${cmd}`);
|
|
38
|
+
execSync(cmd, { cwd: ROOT, stdio: "inherit", ...opts });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatBytes(bytes) {
|
|
42
|
+
const mb = bytes / (1024 * 1024);
|
|
43
|
+
return `${mb.toFixed(1)} MB`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
try {
|
|
48
|
+
console.log("\n=== Karajan Code — SEA Build ===\n");
|
|
49
|
+
|
|
50
|
+
// ── Step 0: Prepare dist directory ──────────────────────────────
|
|
51
|
+
mkdirSync(DIST, { recursive: true });
|
|
52
|
+
|
|
53
|
+
// ── Step 1: Bundle with esbuild ─────────────────────────────────
|
|
54
|
+
console.log("[1/5] Bundling with esbuild...");
|
|
55
|
+
const { seaBuildOptions } = await import("./esbuild-sea.config.mjs");
|
|
56
|
+
const esbuild = await import("esbuild");
|
|
57
|
+
await esbuild.build(seaBuildOptions);
|
|
58
|
+
console.log(" -> dist/kj-bundle.cjs created.\n");
|
|
59
|
+
|
|
60
|
+
// ── Step 2: Generate SEA blob ───────────────────────────────────
|
|
61
|
+
console.log("[2/5] Generating SEA preparation blob...");
|
|
62
|
+
const seaConfig = {
|
|
63
|
+
main: "dist/kj-bundle.cjs",
|
|
64
|
+
output: "dist/sea-prep.blob",
|
|
65
|
+
disableExperimentalSEAWarning: true,
|
|
66
|
+
};
|
|
67
|
+
writeFileSync(
|
|
68
|
+
path.join(DIST, "sea-config.json"),
|
|
69
|
+
JSON.stringify(seaConfig, null, 2),
|
|
70
|
+
);
|
|
71
|
+
run("node --experimental-sea-config dist/sea-config.json");
|
|
72
|
+
console.log(" -> dist/sea-prep.blob created.\n");
|
|
73
|
+
|
|
74
|
+
// ── Step 3: Copy node binary ────────────────────────────────────
|
|
75
|
+
console.log("[3/5] Copying node binary...");
|
|
76
|
+
cpSync(process.execPath, binaryPath);
|
|
77
|
+
console.log(` -> ${binaryName} copied.\n`);
|
|
78
|
+
|
|
79
|
+
// ── Step 3.5: Remove signature on macOS (required before postject)
|
|
80
|
+
if (isMac) {
|
|
81
|
+
console.log("[3.5] Removing macOS code signature...");
|
|
82
|
+
run(`codesign --remove-signature ${binaryPath}`);
|
|
83
|
+
console.log(" -> Signature removed.\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Step 4: Inject blob with postject ───────────────────────────
|
|
87
|
+
console.log("[4/5] Injecting SEA blob with postject...");
|
|
88
|
+
const postjectCmd = [
|
|
89
|
+
`npx postject ${binaryPath}`,
|
|
90
|
+
"NODE_SEA_BLOB dist/sea-prep.blob",
|
|
91
|
+
"--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2",
|
|
92
|
+
].join(" ");
|
|
93
|
+
run(postjectCmd);
|
|
94
|
+
console.log(" -> Blob injected.\n");
|
|
95
|
+
|
|
96
|
+
// ── Step 4.5: Re-sign on macOS ──────────────────────────────────
|
|
97
|
+
if (isMac) {
|
|
98
|
+
console.log("[4.5] Re-signing binary (ad-hoc)...");
|
|
99
|
+
run(`codesign --sign - ${binaryPath}`);
|
|
100
|
+
console.log(" -> Binary signed.\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Done ────────────────────────────────────────────────────────
|
|
104
|
+
const stat = statSync(binaryPath);
|
|
105
|
+
console.log("[5/5] Done!");
|
|
106
|
+
console.log(` Binary: ${binaryPath}`);
|
|
107
|
+
console.log(` Size: ${formatBytes(stat.size)}`);
|
|
108
|
+
console.log("");
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error("\n=== SEA Build FAILED ===\n");
|
|
111
|
+
console.error(err.message || err);
|
|
112
|
+
console.error(
|
|
113
|
+
"\nNote: esbuild may fail to bundle certain ESM-only dependencies",
|
|
114
|
+
);
|
|
115
|
+
console.error(
|
|
116
|
+
"or native modules. Check the error above for details.\n",
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* esbuild configuration for Node.js SEA (Single Executable Application) bundle.
|
|
3
|
+
*
|
|
4
|
+
* Bundles the entire CLI into a single CJS file suitable for SEA injection.
|
|
5
|
+
* SEA requires CJS format — ESM entry points are transpiled by esbuild.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @type {import('esbuild').BuildOptions} */
|
|
9
|
+
export const seaBuildOptions = {
|
|
10
|
+
entryPoints: ["src/cli.js"],
|
|
11
|
+
outfile: "dist/kj-bundle.cjs",
|
|
12
|
+
format: "cjs",
|
|
13
|
+
platform: "node",
|
|
14
|
+
target: "node20",
|
|
15
|
+
bundle: true,
|
|
16
|
+
minify: false,
|
|
17
|
+
// Bundle everything — no externals. The binary must be fully self-contained.
|
|
18
|
+
external: [],
|
|
19
|
+
// Shim for CJS compatibility in the SEA context.
|
|
20
|
+
banner: {
|
|
21
|
+
js: [
|
|
22
|
+
"/* Karajan Code — SEA bundle */",
|
|
23
|
+
"/* eslint-disable */",
|
|
24
|
+
'const __importMetaUrl = require("node:url").pathToFileURL(__filename).href;',
|
|
25
|
+
].join("\n"),
|
|
26
|
+
},
|
|
27
|
+
// Replace import.meta.url references with the CJS-compatible shim variable.
|
|
28
|
+
define: {
|
|
29
|
+
"import.meta.url": "__importMetaUrl",
|
|
30
|
+
},
|
|
31
|
+
// Log level for debugging build issues.
|
|
32
|
+
logLevel: "info",
|
|
33
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel HU execution using git worktrees.
|
|
3
|
+
*
|
|
4
|
+
* Analyses the dependency graph to find groups of HUs that can run
|
|
5
|
+
* concurrently, creates isolated git worktrees for each parallel HU,
|
|
6
|
+
* and merges results back sequentially.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
/** Directory inside the project where worktrees are created. */
|
|
15
|
+
const WORKTREE_DIR = ".kj/worktrees";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Given stories and their topological order, return groups (batches) of HUs
|
|
19
|
+
* that can execute in parallel. Two HUs can run in parallel when neither
|
|
20
|
+
* depends (directly or transitively) on the other.
|
|
21
|
+
*
|
|
22
|
+
* The algorithm walks the topological order and assigns each HU to the
|
|
23
|
+
* earliest batch where all its dependencies have already been placed in
|
|
24
|
+
* a previous batch.
|
|
25
|
+
*
|
|
26
|
+
* @param {Array<{id: string, blocked_by?: string[]}>} stories
|
|
27
|
+
* @param {string[]} order - topologically sorted story IDs
|
|
28
|
+
* @returns {string[][]} Array of batches; each batch is an array of story IDs.
|
|
29
|
+
*/
|
|
30
|
+
export function findParallelGroups(stories, order) {
|
|
31
|
+
if (!order.length) return [];
|
|
32
|
+
|
|
33
|
+
const storyMap = new Map(stories.map(s => [s.id, s]));
|
|
34
|
+
// batchIndex[id] = index of the batch the story was assigned to
|
|
35
|
+
const batchIndex = new Map();
|
|
36
|
+
const batches = [];
|
|
37
|
+
|
|
38
|
+
for (const id of order) {
|
|
39
|
+
const story = storyMap.get(id);
|
|
40
|
+
const deps = story?.blocked_by || [];
|
|
41
|
+
// This HU must go into the batch *after* the latest dependency batch
|
|
42
|
+
let earliest = 0;
|
|
43
|
+
for (const dep of deps) {
|
|
44
|
+
if (batchIndex.has(dep)) {
|
|
45
|
+
earliest = Math.max(earliest, batchIndex.get(dep) + 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!batches[earliest]) batches[earliest] = [];
|
|
49
|
+
batches[earliest].push(id);
|
|
50
|
+
batchIndex.set(id, earliest);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return batches;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a git worktree for a given HU.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} projectDir - Root directory of the git repo.
|
|
60
|
+
* @param {string} huId - Story identifier (used for branch and directory name).
|
|
61
|
+
* @returns {Promise<string>} Absolute path to the created worktree.
|
|
62
|
+
*/
|
|
63
|
+
export async function createWorktree(projectDir, huId) {
|
|
64
|
+
const worktreePath = path.join(projectDir, WORKTREE_DIR, huId);
|
|
65
|
+
const branchName = `kj-hu-${huId}`;
|
|
66
|
+
await execFileAsync("git", ["worktree", "add", worktreePath, "-b", branchName], { cwd: projectDir });
|
|
67
|
+
return worktreePath;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove a git worktree (forcefully).
|
|
72
|
+
*
|
|
73
|
+
* @param {string} projectDir - Root directory of the git repo.
|
|
74
|
+
* @param {string} huId - Story identifier.
|
|
75
|
+
* @returns {Promise<void>}
|
|
76
|
+
*/
|
|
77
|
+
export async function removeWorktree(projectDir, huId) {
|
|
78
|
+
const worktreePath = path.join(projectDir, WORKTREE_DIR, huId);
|
|
79
|
+
await execFileAsync("git", ["worktree", "remove", worktreePath, "--force"], { cwd: projectDir });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Merge a worktree branch back into the current branch and clean up.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} projectDir - Root directory of the git repo.
|
|
86
|
+
* @param {string} huId - Story identifier.
|
|
87
|
+
* @returns {Promise<void>}
|
|
88
|
+
*/
|
|
89
|
+
export async function mergeWorktree(projectDir, huId) {
|
|
90
|
+
const branchName = `kj-hu-${huId}`;
|
|
91
|
+
await execFileAsync("git", ["merge", branchName, "--no-edit"], { cwd: projectDir });
|
|
92
|
+
await removeWorktree(projectDir, huId);
|
|
93
|
+
await execFileAsync("git", ["branch", "-d", branchName], { cwd: projectDir });
|
|
94
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates sub-HUs produced by the splitting generator against 4 mandatory criteria.
|
|
3
|
+
* Uses heuristic checks (no AI calls).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Maximum devPoints for a sub-HU to be considered completable in 3 days. */
|
|
7
|
+
const MAX_DEV_POINTS = 3;
|
|
8
|
+
|
|
9
|
+
/** Text length threshold — stories longer than this are likely too big for 3 days. */
|
|
10
|
+
const TEXT_LENGTH_THRESHOLD = 2000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regex patterns that indicate a horizontal (layer-only) split — both EN and ES.
|
|
14
|
+
* @type {RegExp[]}
|
|
15
|
+
*/
|
|
16
|
+
export const HORIZONTAL_PATTERNS = [
|
|
17
|
+
/\bonly the (api|backend|frontend|database|ui)\b/i,
|
|
18
|
+
/\bjust the (endpoint|interface|model|schema)\b/i,
|
|
19
|
+
/\bsolo (el endpoint|la interfaz|el modelo|la base de datos|el backend|el frontend)\b/i,
|
|
20
|
+
/\bimplementar (el|la|los) \w+ sin\b/i
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Patterns that indicate a sub-HU is not independently deployable.
|
|
25
|
+
* @type {RegExp[]}
|
|
26
|
+
*/
|
|
27
|
+
const DEPENDENCY_PATTERNS = [
|
|
28
|
+
/\bpart \d+ of \d+\b/i,
|
|
29
|
+
/\bdepends on .+ being deployed first\b/i,
|
|
30
|
+
/\brequires .+ to be completed before\b/i,
|
|
31
|
+
/\bparte \d+ de \d+\b/i
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check whether the text has a role/goal/benefit structure (independently valuable).
|
|
36
|
+
* Looks for patterns like "As a <role>, I want <goal> so that <benefit>".
|
|
37
|
+
* Also accepts structured objects with role/goal/benefit fields.
|
|
38
|
+
* @param {object} subHU
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function checkIndependentlyValuable(subHU) {
|
|
42
|
+
// Check structured fields first
|
|
43
|
+
const desc = subHU.descriptionStructured || subHU.description_structured;
|
|
44
|
+
if (Array.isArray(desc) && desc.length > 0) {
|
|
45
|
+
const entry = desc[0];
|
|
46
|
+
if (entry.role && entry.goal && entry.benefit) return true;
|
|
47
|
+
}
|
|
48
|
+
if (subHU.role && subHU.goal && subHU.benefit) return true;
|
|
49
|
+
|
|
50
|
+
// Check text for As a/I want/so that pattern
|
|
51
|
+
const text = extractText(subHU);
|
|
52
|
+
const hasRole = /\b(as an?|como)\b/i.test(text);
|
|
53
|
+
const hasGoal = /\b(i want|quiero|i need|necesito)\b/i.test(text);
|
|
54
|
+
const hasBenefit = /\b(so that|para que|in order to|de modo que)\b/i.test(text);
|
|
55
|
+
return hasRole && hasGoal && hasBenefit;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check that the sub-HU does not reference sequential dependencies.
|
|
60
|
+
* @param {object} subHU
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function checkDeployableAlone(subHU) {
|
|
64
|
+
const text = extractText(subHU);
|
|
65
|
+
return !DEPENDENCY_PATTERNS.some(p => p.test(text));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check whether the sub-HU is completable within 3 days.
|
|
70
|
+
* Uses devPoints if available, otherwise falls back to text length heuristic.
|
|
71
|
+
* @param {object} subHU
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function checkCompletableIn3Days(subHU) {
|
|
75
|
+
if (typeof subHU.devPoints === "number") {
|
|
76
|
+
return subHU.devPoints <= MAX_DEV_POINTS;
|
|
77
|
+
}
|
|
78
|
+
const text = extractText(subHU);
|
|
79
|
+
return text.length <= TEXT_LENGTH_THRESHOLD;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check the sub-HU is a vertical slice (not a horizontal layer-only story).
|
|
84
|
+
* @param {object} subHU
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function checkIsVertical(subHU) {
|
|
88
|
+
const text = extractText(subHU);
|
|
89
|
+
return !HORIZONTAL_PATTERNS.some(p => p.test(text));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract all relevant text from a sub-HU for pattern matching.
|
|
94
|
+
* @param {object} subHU
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function extractText(subHU) {
|
|
98
|
+
const parts = [
|
|
99
|
+
subHU.title,
|
|
100
|
+
subHU.text,
|
|
101
|
+
subHU.description,
|
|
102
|
+
subHU.acceptanceCriteria
|
|
103
|
+
].filter(Boolean);
|
|
104
|
+
|
|
105
|
+
// Include structured description text
|
|
106
|
+
const desc = subHU.descriptionStructured || subHU.description_structured;
|
|
107
|
+
if (Array.isArray(desc)) {
|
|
108
|
+
for (const entry of desc) {
|
|
109
|
+
if (entry.role) parts.push(entry.role);
|
|
110
|
+
if (entry.goal) parts.push(entry.goal);
|
|
111
|
+
if (entry.benefit) parts.push(entry.benefit);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parts.join(" ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate a sub-HU against 4 mandatory split criteria.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} subHU - The sub-HU object to validate.
|
|
122
|
+
* @returns {{ valid: boolean, criteria: { independently_valuable: boolean, deployable_alone: boolean, completable_in_3_days: boolean, is_vertical: boolean }, failures: string[] }}
|
|
123
|
+
*/
|
|
124
|
+
export function validateSplitCriteria(subHU) {
|
|
125
|
+
const criteria = {
|
|
126
|
+
independently_valuable: checkIndependentlyValuable(subHU),
|
|
127
|
+
deployable_alone: checkDeployableAlone(subHU),
|
|
128
|
+
completable_in_3_days: checkCompletableIn3Days(subHU),
|
|
129
|
+
is_vertical: checkIsVertical(subHU)
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const failures = [];
|
|
133
|
+
if (!criteria.independently_valuable) failures.push("Sub-HU does not describe independent user-facing value (missing role/goal/benefit)");
|
|
134
|
+
if (!criteria.deployable_alone) failures.push("Sub-HU references sequential deployment dependencies");
|
|
135
|
+
if (!criteria.completable_in_3_days) failures.push("Sub-HU appears too large to complete in 3 days");
|
|
136
|
+
if (!criteria.is_vertical) failures.push("Sub-HU describes a horizontal slice instead of a vertical one");
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
valid: failures.length === 0,
|
|
140
|
+
criteria,
|
|
141
|
+
failures
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic-based HU splitting detection.
|
|
3
|
+
* Analyzes user story text for indicators that suggest the HU should be split.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} SplitIndicator
|
|
8
|
+
* @property {string} type - Indicator type (e.g. "multiple_and", "multiple_roles")
|
|
9
|
+
* @property {string} detail - Human-readable description of what was detected
|
|
10
|
+
* @property {number} weight - How strongly this indicator suggests splitting (1-3)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @type {Record<string, string>} */
|
|
14
|
+
export const HEURISTIC_DESCRIPTIONS = Object.freeze({
|
|
15
|
+
workflow_steps: "Split by workflow steps: each step of the user journey becomes its own HU.",
|
|
16
|
+
data_entity: "Split by data entity: each distinct entity (model, resource) gets its own HU.",
|
|
17
|
+
user_role: "Split by user role: each actor/persona gets their own HU.",
|
|
18
|
+
crud_operations: "Split by CRUD operations: separate create, read, update, delete into individual HUs.",
|
|
19
|
+
happy_sad_path: "Split by happy/sad path: main flow in one HU, error/edge cases in another.",
|
|
20
|
+
interface_boundary: "Split by interface boundary: frontend, backend, integration each get their own HU."
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const HEURISTIC_KEYS = Object.keys(HEURISTIC_DESCRIPTIONS);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detect indicators in HU text that suggest the story should be split.
|
|
27
|
+
* @param {string} text - The HU text to analyze.
|
|
28
|
+
* @returns {SplitIndicator[]} Array of detected indicators (empty if no splitting needed).
|
|
29
|
+
*/
|
|
30
|
+
export function detectIndicators(text) {
|
|
31
|
+
if (!text || typeof text !== "string") return [];
|
|
32
|
+
|
|
33
|
+
const indicators = [];
|
|
34
|
+
const lower = text.toLowerCase();
|
|
35
|
+
|
|
36
|
+
// Multiple "and" connectors suggest compound story
|
|
37
|
+
const andMatches = lower.match(/\band\b/g);
|
|
38
|
+
if (andMatches && andMatches.length >= 2) {
|
|
39
|
+
indicators.push({
|
|
40
|
+
type: "multiple_and",
|
|
41
|
+
detail: `Found ${andMatches.length} "and" connectors suggesting compound story`,
|
|
42
|
+
weight: 2
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Multiple roles mentioned
|
|
47
|
+
const rolePatterns = /\bas (?:a |an )?(\w+)/gi;
|
|
48
|
+
const roles = [...text.matchAll(rolePatterns)].map(m => m[1].toLowerCase());
|
|
49
|
+
const uniqueRoles = new Set(roles);
|
|
50
|
+
if (uniqueRoles.size > 1) {
|
|
51
|
+
indicators.push({
|
|
52
|
+
type: "multiple_roles",
|
|
53
|
+
detail: `Multiple roles detected: ${[...uniqueRoles].join(", ")}`,
|
|
54
|
+
weight: 3
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Multiple acceptance criteria groups or numbered lists
|
|
59
|
+
const acCount = (text.match(/^[-*\d+.]\s/gm) || []).length;
|
|
60
|
+
if (acCount > 5) {
|
|
61
|
+
indicators.push({
|
|
62
|
+
type: "many_acceptance_criteria",
|
|
63
|
+
detail: `${acCount} acceptance criteria items detected`,
|
|
64
|
+
weight: 2
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// CRUD keywords
|
|
69
|
+
const crudKeywords = ["create", "read", "update", "delete", "edit", "remove", "list", "view"];
|
|
70
|
+
const foundCrud = crudKeywords.filter(kw => lower.includes(kw));
|
|
71
|
+
if (foundCrud.length >= 3) {
|
|
72
|
+
indicators.push({
|
|
73
|
+
type: "crud_operations",
|
|
74
|
+
detail: `Multiple CRUD operations: ${foundCrud.join(", ")}`,
|
|
75
|
+
weight: 2
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Multiple "so that" / "in order to" benefits
|
|
80
|
+
const benefitMatches = lower.match(/\b(so that|in order to)\b/g);
|
|
81
|
+
if (benefitMatches && benefitMatches.length > 1) {
|
|
82
|
+
indicators.push({
|
|
83
|
+
type: "multiple_benefits",
|
|
84
|
+
detail: `${benefitMatches.length} benefit clauses detected`,
|
|
85
|
+
weight: 2
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Workflow/step keywords
|
|
90
|
+
const stepKeywords = ["first", "then", "after that", "finally", "next", "step"];
|
|
91
|
+
const foundSteps = stepKeywords.filter(kw => lower.includes(kw));
|
|
92
|
+
if (foundSteps.length >= 2) {
|
|
93
|
+
indicators.push({
|
|
94
|
+
type: "workflow_steps",
|
|
95
|
+
detail: `Sequential workflow indicators: ${foundSteps.join(", ")}`,
|
|
96
|
+
weight: 2
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return indicators;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Select the best splitting heuristic based on detected indicators.
|
|
105
|
+
* @param {SplitIndicator[]} indicators - Detected split indicators.
|
|
106
|
+
* @param {string[]} [excludeHeuristics=[]] - Heuristics to skip (already tried).
|
|
107
|
+
* @returns {string|null} The selected heuristic key, or null if none applicable.
|
|
108
|
+
*/
|
|
109
|
+
export function selectHeuristic(indicators, excludeHeuristics = []) {
|
|
110
|
+
if (!indicators || indicators.length === 0) return null;
|
|
111
|
+
|
|
112
|
+
const excluded = new Set(excludeHeuristics);
|
|
113
|
+
|
|
114
|
+
// Map indicator types to preferred heuristics
|
|
115
|
+
const heuristicScores = {};
|
|
116
|
+
for (const key of HEURISTIC_KEYS) {
|
|
117
|
+
if (!excluded.has(key)) {
|
|
118
|
+
heuristicScores[key] = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const ind of indicators) {
|
|
123
|
+
switch (ind.type) {
|
|
124
|
+
case "multiple_roles":
|
|
125
|
+
if (!excluded.has("user_role")) heuristicScores.user_role = (heuristicScores.user_role || 0) + ind.weight;
|
|
126
|
+
break;
|
|
127
|
+
case "crud_operations":
|
|
128
|
+
if (!excluded.has("crud_operations")) heuristicScores.crud_operations = (heuristicScores.crud_operations || 0) + ind.weight;
|
|
129
|
+
break;
|
|
130
|
+
case "workflow_steps":
|
|
131
|
+
if (!excluded.has("workflow_steps")) heuristicScores.workflow_steps = (heuristicScores.workflow_steps || 0) + ind.weight;
|
|
132
|
+
break;
|
|
133
|
+
case "multiple_and":
|
|
134
|
+
case "many_acceptance_criteria":
|
|
135
|
+
if (!excluded.has("workflow_steps")) heuristicScores.workflow_steps = (heuristicScores.workflow_steps || 0) + ind.weight;
|
|
136
|
+
if (!excluded.has("data_entity")) heuristicScores.data_entity = (heuristicScores.data_entity || 0) + ind.weight;
|
|
137
|
+
break;
|
|
138
|
+
case "multiple_benefits":
|
|
139
|
+
if (!excluded.has("happy_sad_path")) heuristicScores.happy_sad_path = (heuristicScores.happy_sad_path || 0) + ind.weight;
|
|
140
|
+
if (!excluded.has("interface_boundary")) heuristicScores.interface_boundary = (heuristicScores.interface_boundary || 0) + ind.weight;
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Pick highest scoring non-excluded heuristic
|
|
148
|
+
let best = null;
|
|
149
|
+
let bestScore = 0;
|
|
150
|
+
for (const [key, score] of Object.entries(heuristicScores)) {
|
|
151
|
+
if (score > bestScore) {
|
|
152
|
+
bestScore = score;
|
|
153
|
+
best = key;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return best;
|
|
158
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HU Splitting Generator.
|
|
3
|
+
* Uses an AI agent to split a HU based on a selected heuristic,
|
|
4
|
+
* then formats proposals for FDE confirmation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createAgent } from "../agents/index.js";
|
|
8
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
9
|
+
import { HEURISTIC_DESCRIPTIONS } from "./splitting-detector.js";
|
|
10
|
+
|
|
11
|
+
const SUBAGENT_PREAMBLE = [
|
|
12
|
+
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
13
|
+
"Do NOT ask about using Karajan, do NOT mention Karajan, do NOT suggest orchestration.",
|
|
14
|
+
"Do NOT use any MCP tools. Focus only on splitting this HU."
|
|
15
|
+
].join(" ");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the prompt for the AI agent that will split the HU.
|
|
19
|
+
* @param {{id: string, text: string}} hu - The HU to split.
|
|
20
|
+
* @param {string} heuristic - The heuristic key to apply.
|
|
21
|
+
* @returns {string} The assembled prompt.
|
|
22
|
+
*/
|
|
23
|
+
export function buildSplitPrompt(hu, heuristic) {
|
|
24
|
+
const heuristicDesc = HEURISTIC_DESCRIPTIONS[heuristic] || heuristic;
|
|
25
|
+
const baseId = hu.id || "HU-XXX";
|
|
26
|
+
|
|
27
|
+
const sections = [SUBAGENT_PREAMBLE];
|
|
28
|
+
|
|
29
|
+
sections.push("## Task: Split User Story");
|
|
30
|
+
sections.push(
|
|
31
|
+
"You are a senior product owner. Split the following user story into smaller, independent sub-HUs.",
|
|
32
|
+
`Apply this splitting heuristic: **${heuristicDesc}**`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
sections.push(`## Original User Story (${baseId})\n${hu.text}`);
|
|
36
|
+
|
|
37
|
+
sections.push(
|
|
38
|
+
"## Output Requirements",
|
|
39
|
+
"Return a single valid JSON object and nothing else.",
|
|
40
|
+
`JSON schema: {"subHUs":[{"id":string,"title":string,"text":string,"acceptanceCriteria":[string],"blocked_by":[string]}],"heuristic":string,"reason":string}`,
|
|
41
|
+
"Rules:",
|
|
42
|
+
`- Each sub-HU id must follow the pattern: ${baseId}-A, ${baseId}-B, ${baseId}-C, etc.`,
|
|
43
|
+
"- text must follow role/goal/benefit format: 'As a [role], I want to [goal], so that [benefit]'",
|
|
44
|
+
"- acceptanceCriteria must be concrete, testable statements",
|
|
45
|
+
"- blocked_by: first sub-HU has empty array, each subsequent one is blocked by the previous (sequential chain)",
|
|
46
|
+
"- Generate between 2 and 5 sub-HUs",
|
|
47
|
+
`- heuristic must be "${heuristic}"`,
|
|
48
|
+
"- reason must explain why this heuristic was applied"
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return sections.join("\n\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse the AI agent's split output into a structured proposal.
|
|
56
|
+
* @param {string} raw - Raw agent output text.
|
|
57
|
+
* @returns {{subHUs: Array, heuristic: string, reason: string}|null}
|
|
58
|
+
*/
|
|
59
|
+
export function parseSplitOutput(raw) {
|
|
60
|
+
const parsed = extractFirstJson(raw);
|
|
61
|
+
if (!parsed || !Array.isArray(parsed.subHUs) || parsed.subHUs.length < 2) return null;
|
|
62
|
+
|
|
63
|
+
const subHUs = parsed.subHUs
|
|
64
|
+
.filter(s => s && s.id && s.title && s.text)
|
|
65
|
+
.map(s => ({
|
|
66
|
+
id: s.id,
|
|
67
|
+
title: s.title,
|
|
68
|
+
text: s.text,
|
|
69
|
+
acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
|
|
70
|
+
blocked_by: Array.isArray(s.blocked_by) ? s.blocked_by : []
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
if (subHUs.length < 2) return null;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
subHUs,
|
|
77
|
+
heuristic: parsed.heuristic || "unknown",
|
|
78
|
+
reason: parsed.reason || ""
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Use an AI agent to generate a split proposal for a HU based on a heuristic.
|
|
84
|
+
* @param {{id: string, text: string}} hu - The HU to split.
|
|
85
|
+
* @param {string} heuristic - The heuristic key to apply.
|
|
86
|
+
* @param {object} config - Karajan config object.
|
|
87
|
+
* @param {object} logger - Logger instance.
|
|
88
|
+
* @returns {Promise<{subHUs: Array, heuristic: string, reason: string}|null>}
|
|
89
|
+
*/
|
|
90
|
+
export async function generateSplitProposal(hu, heuristic, config, logger) {
|
|
91
|
+
const provider = config?.roles?.hu_reviewer?.provider
|
|
92
|
+
|| config?.roles?.coder?.provider
|
|
93
|
+
|| "claude";
|
|
94
|
+
|
|
95
|
+
const agent = createAgent(provider, config, logger);
|
|
96
|
+
const prompt = buildSplitPrompt(hu, heuristic);
|
|
97
|
+
|
|
98
|
+
let result;
|
|
99
|
+
try {
|
|
100
|
+
result = await agent.runTask({ prompt, role: "hu-splitter" });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.warn(`HU split generation threw: ${err.message}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
logger.warn(`HU split generation failed: ${result.error || "unknown"}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const proposal = parseSplitOutput(result.output);
|
|
112
|
+
if (!proposal) {
|
|
113
|
+
logger.warn("HU split generation returned unparseable output");
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return proposal;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format a split proposal as human-readable text for the FDE to review.
|
|
122
|
+
* @param {{subHUs: Array<{id: string, title: string, text: string, acceptanceCriteria?: string[], blocked_by?: string[]}>, heuristic: string, reason: string}} proposal
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
export function formatSplitProposalForFDE(proposal) {
|
|
126
|
+
const lines = [];
|
|
127
|
+
const heuristicDesc = HEURISTIC_DESCRIPTIONS[proposal.heuristic] || proposal.heuristic;
|
|
128
|
+
|
|
129
|
+
lines.push(`Heuristic: ${heuristicDesc}`);
|
|
130
|
+
lines.push(`Reason: ${proposal.reason}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
|
|
133
|
+
for (const sub of proposal.subHUs) {
|
|
134
|
+
lines.push(`--- ${sub.id}: ${sub.title} ---`);
|
|
135
|
+
lines.push(sub.text);
|
|
136
|
+
if (sub.acceptanceCriteria && sub.acceptanceCriteria.length > 0) {
|
|
137
|
+
lines.push("Acceptance Criteria:");
|
|
138
|
+
for (const ac of sub.acceptanceCriteria) {
|
|
139
|
+
lines.push(` - ${ac}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (sub.blocked_by && sub.blocked_by.length > 0) {
|
|
143
|
+
lines.push(`Depends on: ${sub.blocked_by.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
lines.push("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Set blocked_by for each sub-HU: first inherits original's dependencies,
|
|
153
|
+
* each subsequent is blocked by its predecessor (sequential chain).
|
|
154
|
+
* @param {Array<{id: string, blocked_by?: string[]}>} subHUs - The sub-HUs to update.
|
|
155
|
+
* @param {{blocked_by?: string[]}} originalHu - The original HU with its dependencies.
|
|
156
|
+
* @returns {Array<{id: string, blocked_by: string[]}>} Updated sub-HUs with correct dependencies.
|
|
157
|
+
*/
|
|
158
|
+
export function buildSplitDependencies(subHUs, originalHu) {
|
|
159
|
+
if (!subHUs || subHUs.length === 0) return [];
|
|
160
|
+
|
|
161
|
+
const originalDeps = originalHu?.blocked_by || [];
|
|
162
|
+
|
|
163
|
+
return subHUs.map((sub, idx) => {
|
|
164
|
+
const blockedBy = idx === 0
|
|
165
|
+
? [...originalDeps]
|
|
166
|
+
: [subHUs[idx - 1].id];
|
|
167
|
+
|
|
168
|
+
return { ...sub, blocked_by: blockedBy };
|
|
169
|
+
});
|
|
170
|
+
}
|
package/src/hu/store.js
CHANGED
|
@@ -210,6 +210,26 @@ export async function createHistoryRecord(sessionId, { task, result, approved, s
|
|
|
210
210
|
return batch;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/* ── Splitting metadata ──────────────────────────────────────────── */
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Add splitting metadata to a story within a batch.
|
|
217
|
+
* Used to track the relationship between an original HU and its sub-HUs
|
|
218
|
+
* after a split operation.
|
|
219
|
+
*
|
|
220
|
+
* @param {object} batch - The batch object.
|
|
221
|
+
* @param {string} huId - Story ID to annotate.
|
|
222
|
+
* @param {{ original_hu_id?: string, split_children?: string[], indicators_detected?: string[], heuristic_applied?: string, split_confirmed_by_fde?: boolean, validation?: object }} metadata - Splitting metadata.
|
|
223
|
+
* @returns {object} The updated story.
|
|
224
|
+
*/
|
|
225
|
+
export function addSplittingMetadata(batch, huId, metadata) {
|
|
226
|
+
const story = batch.stories.find(s => s.id === huId);
|
|
227
|
+
if (!story) throw new Error(`Story ${huId} not found`);
|
|
228
|
+
story.splitting = { ...(story.splitting || {}), ...metadata };
|
|
229
|
+
story.updated_at = new Date().toISOString();
|
|
230
|
+
return story;
|
|
231
|
+
}
|
|
232
|
+
|
|
213
233
|
/* ── Manual HU management ─────────────────────────────────────────── */
|
|
214
234
|
|
|
215
235
|
/**
|
|
@@ -918,7 +918,32 @@ async function handleArchitect(a, server, extra) {
|
|
|
918
918
|
|
|
919
919
|
async function handleAudit(a, server, extra) {
|
|
920
920
|
await runBootstrapGate(server, a);
|
|
921
|
-
|
|
921
|
+
const result = await handleAuditDirect(a, server, extra);
|
|
922
|
+
|
|
923
|
+
// Return compact summary for MCP (full details in session log)
|
|
924
|
+
if (result?.ok && result?.summary) {
|
|
925
|
+
const compact = {
|
|
926
|
+
ok: true,
|
|
927
|
+
overallHealth: result.summary?.overallHealth || result.summary,
|
|
928
|
+
totalFindings: result.summary?.totalFindings,
|
|
929
|
+
critical: result.summary?.critical,
|
|
930
|
+
high: result.summary?.high,
|
|
931
|
+
topRecommendations: (result.topRecommendations || []).slice(0, 5).map(r => ({
|
|
932
|
+
priority: r.priority,
|
|
933
|
+
dimension: r.dimension,
|
|
934
|
+
action: r.action,
|
|
935
|
+
impact: r.impact
|
|
936
|
+
})),
|
|
937
|
+
textSummary: result.textSummary || result.summary,
|
|
938
|
+
basalCost: result.basalCost ? {
|
|
939
|
+
totalLines: result.basalCost.totalLines,
|
|
940
|
+
totalFiles: result.basalCost.totalFiles,
|
|
941
|
+
dependencies: result.basalCost.dependencies
|
|
942
|
+
} : undefined
|
|
943
|
+
};
|
|
944
|
+
return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] };
|
|
945
|
+
}
|
|
946
|
+
return result;
|
|
922
947
|
}
|
|
923
948
|
|
|
924
949
|
async function handleHu(a, server) {
|
|
@@ -6,6 +6,7 @@ import { topologicalSort } from "../hu/graph.js";
|
|
|
6
6
|
import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/store.js";
|
|
7
7
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
8
8
|
import { refineHuWithContext } from "../hu/lazy-planner.js";
|
|
9
|
+
import { findParallelGroups, createWorktree, mergeWorktree, removeWorktree } from "../hu/parallel-executor.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Determine whether the HU reviewer result requires a sub-pipeline
|
|
@@ -60,6 +61,92 @@ function buildHuTask(story) {
|
|
|
60
61
|
return `Implement HU ${story.id}`;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Execute a single HU through the coder→sonar→reviewer sub-pipeline.
|
|
66
|
+
* Extracted to support both sequential and parallel execution paths.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} params
|
|
69
|
+
* @returns {Promise<{huId: string, approved: boolean, result?: object, error?: string, blockedDependents?: string[]}>}
|
|
70
|
+
*/
|
|
71
|
+
async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath }) {
|
|
72
|
+
const story = batch.stories.find(s => s.id === storyId);
|
|
73
|
+
|
|
74
|
+
// --- Lazy refinement ---
|
|
75
|
+
if (story.needsRefinement && config) {
|
|
76
|
+
const completedHus = results
|
|
77
|
+
.filter(r => r.approved)
|
|
78
|
+
.map(r => {
|
|
79
|
+
const completedStory = batch.stories.find(s => s.id === r.huId);
|
|
80
|
+
return {
|
|
81
|
+
...completedStory,
|
|
82
|
+
resultSummary: r.result?.summary || r.result?.reason || null
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
emitProgress(emitter, makeEvent("hu:refine-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
87
|
+
message: `Refining HU ${story.id} with context from ${completedHus.length} completed HU(s)`,
|
|
88
|
+
detail: { huId: story.id, completedCount: completedHus.length }
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const refined = await refineHuWithContext(story, completedHus, config, logger);
|
|
93
|
+
Object.assign(story, refined);
|
|
94
|
+
story.needsRefinement = false;
|
|
95
|
+
await saveHuBatch(batchSessionId, batch);
|
|
96
|
+
|
|
97
|
+
emitProgress(emitter, makeEvent("hu:refine-end", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
98
|
+
message: `HU ${story.id} refined successfully`,
|
|
99
|
+
detail: { huId: story.id }
|
|
100
|
+
}));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.warn(`Lazy refinement failed for HU ${story.id}: ${err.message} — proceeding with original`);
|
|
103
|
+
story.needsRefinement = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const huTask = buildHuTask(story);
|
|
108
|
+
|
|
109
|
+
// --- hu:start ---
|
|
110
|
+
emitProgress(emitter, makeEvent("hu:start", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
111
|
+
message: `Starting HU ${story.id}`,
|
|
112
|
+
detail: { huId: story.id, title: story.certified?.title || story.id, worktreePath: worktreePath || null }
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
updateStoryStatus(batch, story.id, HU_STATUS.CODING);
|
|
116
|
+
await saveHuBatch(batchSessionId, batch);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const iterResult = await runIterationFn(huTask);
|
|
120
|
+
const approved = Boolean(iterResult?.approved);
|
|
121
|
+
|
|
122
|
+
if (approved) {
|
|
123
|
+
updateStoryStatus(batch, story.id, HU_STATUS.DONE);
|
|
124
|
+
emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
125
|
+
status: "ok",
|
|
126
|
+
message: `HU ${story.id} completed successfully`,
|
|
127
|
+
detail: { huId: story.id, approved: true }
|
|
128
|
+
}));
|
|
129
|
+
return { huId: story.id, approved: true, result: iterResult };
|
|
130
|
+
} else {
|
|
131
|
+
updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
|
|
132
|
+
emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
133
|
+
status: "fail",
|
|
134
|
+
message: `HU ${story.id} failed`,
|
|
135
|
+
detail: { huId: story.id, approved: false, reason: iterResult?.reason }
|
|
136
|
+
}));
|
|
137
|
+
return { huId: story.id, approved: false, result: iterResult };
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
|
|
141
|
+
emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
142
|
+
status: "fail",
|
|
143
|
+
message: `HU ${story.id} threw: ${err.message}`,
|
|
144
|
+
detail: { huId: story.id, approved: false, error: err.message }
|
|
145
|
+
}));
|
|
146
|
+
return { huId: story.id, approved: false, error: err.message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
63
150
|
/**
|
|
64
151
|
* Run all certified HUs as sub-pipelines in topological order.
|
|
65
152
|
*
|
|
@@ -99,102 +186,85 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
|
|
|
99
186
|
const blockedIds = [];
|
|
100
187
|
let allApproved = true;
|
|
101
188
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!story) continue;
|
|
189
|
+
// --- Group HUs into parallel batches ---
|
|
190
|
+
const parallelBatches = findParallelGroups(certifiedStories, orderedIds);
|
|
105
191
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
192
|
+
for (const group of parallelBatches) {
|
|
193
|
+
// Filter out HUs that were blocked by a failed dependency in a previous batch
|
|
194
|
+
const runnableIds = group.filter(id => {
|
|
195
|
+
const story = batch.stories.find(s => s.id === id);
|
|
196
|
+
return story && story.status !== HU_STATUS.BLOCKED;
|
|
197
|
+
});
|
|
111
198
|
|
|
112
|
-
|
|
113
|
-
if (story.needsRefinement && config) {
|
|
114
|
-
const completedHus = results
|
|
115
|
-
.filter(r => r.approved)
|
|
116
|
-
.map(r => {
|
|
117
|
-
const completedStory = batch.stories.find(s => s.id === r.huId);
|
|
118
|
-
return {
|
|
119
|
-
...completedStory,
|
|
120
|
-
resultSummary: r.result?.summary || r.result?.reason || null
|
|
121
|
-
};
|
|
122
|
-
});
|
|
199
|
+
if (runnableIds.length === 0) continue;
|
|
123
200
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
201
|
+
// Emit parallel batch start event
|
|
202
|
+
emitProgress(emitter, makeEvent("hu:parallel-start", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
203
|
+
message: `Starting parallel batch of ${runnableIds.length} HU(s): ${runnableIds.join(", ")}`,
|
|
204
|
+
detail: { batchIds: runnableIds, parallel: runnableIds.length > 1 }
|
|
205
|
+
}));
|
|
128
206
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
} catch (err) {
|
|
141
|
-
logger.warn(`Lazy refinement failed for HU ${story.id}: ${err.message} — proceeding with original`);
|
|
142
|
-
story.needsRefinement = false;
|
|
207
|
+
if (runnableIds.length === 1) {
|
|
208
|
+
// Single HU: run as before, no worktree needed
|
|
209
|
+
const singleResult = await runSingleHu({
|
|
210
|
+
storyId: runnableIds[0], batch, batchSessionId, runIterationFn,
|
|
211
|
+
emitter, eventBase, logger, config, results
|
|
212
|
+
});
|
|
213
|
+
results.push(singleResult);
|
|
214
|
+
if (!singleResult.approved) {
|
|
215
|
+
allApproved = false;
|
|
216
|
+
const newlyBlocked = blockDependents(batch, singleResult.huId);
|
|
217
|
+
blockedIds.push(...newlyBlocked);
|
|
143
218
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
219
|
+
await saveHuBatch(batchSessionId, batch);
|
|
220
|
+
} else {
|
|
221
|
+
// Multiple HUs: create worktrees, run in parallel
|
|
222
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
223
|
+
const worktrees = new Map();
|
|
147
224
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
225
|
+
// Create worktrees for each HU in the batch
|
|
226
|
+
for (const id of runnableIds) {
|
|
227
|
+
try {
|
|
228
|
+
const wtPath = await createWorktree(projectDir, id);
|
|
229
|
+
worktrees.set(id, wtPath);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
logger.warn(`Failed to create worktree for HU ${id}: ${err.message} — will run sequentially`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
153
234
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
235
|
+
// Run all HUs in the batch concurrently
|
|
236
|
+
const batchPromises = runnableIds.map(async (storyId) => {
|
|
237
|
+
return runSingleHu({
|
|
238
|
+
storyId, batch, batchSessionId, runIterationFn,
|
|
239
|
+
emitter, eventBase, logger, config, results,
|
|
240
|
+
worktreePath: worktrees.get(storyId)
|
|
241
|
+
});
|
|
242
|
+
});
|
|
157
243
|
|
|
158
|
-
|
|
159
|
-
const iterResult = await runIterationFn(huTask);
|
|
160
|
-
const approved = Boolean(iterResult?.approved);
|
|
161
|
-
|
|
162
|
-
if (approved) {
|
|
163
|
-
updateStoryStatus(batch, story.id, HU_STATUS.DONE);
|
|
164
|
-
emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
|
|
165
|
-
status: "ok",
|
|
166
|
-
message: `HU ${story.id} completed successfully`,
|
|
167
|
-
detail: { huId: story.id, approved: true }
|
|
168
|
-
}));
|
|
169
|
-
results.push({ huId: story.id, approved: true, result: iterResult });
|
|
170
|
-
} else {
|
|
171
|
-
updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
|
|
172
|
-
const newlyBlocked = blockDependents(batch, story.id);
|
|
173
|
-
blockedIds.push(...newlyBlocked);
|
|
174
|
-
allApproved = false;
|
|
244
|
+
const batchResults = await Promise.all(batchPromises);
|
|
175
245
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
246
|
+
// Process results: merge successful worktrees sequentially, clean up failed ones
|
|
247
|
+
for (const res of batchResults) {
|
|
248
|
+
results.push(res);
|
|
249
|
+
if (res.approved && worktrees.has(res.huId)) {
|
|
250
|
+
try {
|
|
251
|
+
await mergeWorktree(projectDir, res.huId);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logger.warn(`Failed to merge worktree for HU ${res.huId}: ${err.message}`);
|
|
254
|
+
}
|
|
255
|
+
} else if (!res.approved) {
|
|
256
|
+
allApproved = false;
|
|
257
|
+
const newlyBlocked = blockDependents(batch, res.huId);
|
|
258
|
+
blockedIds.push(...newlyBlocked);
|
|
259
|
+
// Clean up failed worktree
|
|
260
|
+
if (worktrees.has(res.huId)) {
|
|
261
|
+
try { await removeWorktree(projectDir, res.huId); } catch { /* ignore */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
182
264
|
}
|
|
183
|
-
} catch (err) {
|
|
184
|
-
updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
|
|
185
|
-
const newlyBlocked = blockDependents(batch, story.id);
|
|
186
|
-
blockedIds.push(...newlyBlocked);
|
|
187
|
-
allApproved = false;
|
|
188
265
|
|
|
189
|
-
|
|
190
|
-
status: "fail",
|
|
191
|
-
message: `HU ${story.id} threw: ${err.message}`,
|
|
192
|
-
detail: { huId: story.id, approved: false, error: err.message, blockedDependents: newlyBlocked }
|
|
193
|
-
}));
|
|
194
|
-
results.push({ huId: story.id, approved: false, error: err.message, blockedDependents: newlyBlocked });
|
|
266
|
+
await saveHuBatch(batchSessionId, batch);
|
|
195
267
|
}
|
|
196
|
-
|
|
197
|
-
await saveHuBatch(batchSessionId, batch);
|
|
198
268
|
}
|
|
199
269
|
|
|
200
270
|
return { approved: allApproved, results, blockedIds };
|
|
@@ -14,6 +14,8 @@ import { selectModelsForRoles } from "../utils/model-selector.js";
|
|
|
14
14
|
import { createStallDetector } from "../utils/stall-detector.js";
|
|
15
15
|
import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../hu/store.js";
|
|
16
16
|
import { topologicalSort } from "../hu/graph.js";
|
|
17
|
+
import { detectIndicators, selectHeuristic } from "../hu/splitting-detector.js";
|
|
18
|
+
import { generateSplitProposal, formatSplitProposalForFDE, buildSplitDependencies } from "../hu/splitting-generator.js";
|
|
17
19
|
|
|
18
20
|
const ROLE_NAMES = ["planner", "researcher", "architect", "refactorer", "reviewer", "tester", "security", "impeccable"];
|
|
19
21
|
|
|
@@ -679,6 +681,84 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
|
|
|
679
681
|
batch = await createHuBatch(batchSessionId, stories);
|
|
680
682
|
}
|
|
681
683
|
|
|
684
|
+
// --- Splitting detection: check each pending HU for split indicators before 6D evaluation ---
|
|
685
|
+
const storiesToSplit = [...batch.stories].filter(s => s.status === "pending");
|
|
686
|
+
for (const story of storiesToSplit) {
|
|
687
|
+
const indicators = detectIndicators(story.original.text);
|
|
688
|
+
if (indicators.length === 0) continue;
|
|
689
|
+
|
|
690
|
+
let heuristic = selectHeuristic(indicators);
|
|
691
|
+
if (!heuristic) continue;
|
|
692
|
+
|
|
693
|
+
const triedHeuristics = [];
|
|
694
|
+
let splitAccepted = false;
|
|
695
|
+
|
|
696
|
+
while (heuristic && !splitAccepted) {
|
|
697
|
+
const proposal = await generateSplitProposal(
|
|
698
|
+
{ id: story.id, text: story.original.text },
|
|
699
|
+
heuristic, config, logger
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
if (!proposal) {
|
|
703
|
+
triedHeuristics.push(heuristic);
|
|
704
|
+
heuristic = selectHeuristic(indicators, triedHeuristics);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!askQuestion) {
|
|
709
|
+
// Autonomous mode: auto-accept the split
|
|
710
|
+
const updatedSubHUs = buildSplitDependencies(proposal.subHUs, story);
|
|
711
|
+
const storyIndex = batch.stories.findIndex(s => s.id === story.id);
|
|
712
|
+
const newStories = updatedSubHUs.map(sub => ({
|
|
713
|
+
id: sub.id, status: "pending",
|
|
714
|
+
original: { text: `${sub.title}\n\n${sub.text}\n\nAcceptance Criteria:\n${(sub.acceptanceCriteria || []).map(ac => `- ${ac}`).join("\n")}` },
|
|
715
|
+
blocked_by: sub.blocked_by || [], certified: null, quality: null,
|
|
716
|
+
context_requests: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
|
717
|
+
}));
|
|
718
|
+
batch.stories.splice(storyIndex, 1, ...newStories);
|
|
719
|
+
splitAccepted = true;
|
|
720
|
+
emitProgress(emitter, makeEvent("hu-reviewer:split-accepted", { ...eventBase, stage: "hu-reviewer" }, {
|
|
721
|
+
message: `Auto-accepted split of ${story.id} into ${updatedSubHUs.length} sub-HUs`,
|
|
722
|
+
detail: { originalId: story.id, subHUs: updatedSubHUs.map(s => s.id), heuristic }
|
|
723
|
+
}));
|
|
724
|
+
} else {
|
|
725
|
+
// Interactive mode: ask FDE for confirmation
|
|
726
|
+
const formatted = formatSplitProposalForFDE(proposal);
|
|
727
|
+
const question = `Split proposed for ${story.id}:\n${formatted}\nAccept? (yes/no/try another)`;
|
|
728
|
+
emitProgress(emitter, makeEvent("hu-reviewer:split-proposal", { ...eventBase, stage: "hu-reviewer" }, {
|
|
729
|
+
message: `Split proposal for ${story.id} using heuristic: ${heuristic}`,
|
|
730
|
+
detail: { originalId: story.id, heuristic, subHUCount: proposal.subHUs.length }
|
|
731
|
+
}));
|
|
732
|
+
const answer = await askQuestion(question, { iteration: 0, stage: "hu-reviewer" });
|
|
733
|
+
if (!answer || answer.toLowerCase().startsWith("yes")) {
|
|
734
|
+
const updatedSubHUs = buildSplitDependencies(proposal.subHUs, story);
|
|
735
|
+
const storyIndex = batch.stories.findIndex(s => s.id === story.id);
|
|
736
|
+
const newStories = updatedSubHUs.map(sub => ({
|
|
737
|
+
id: sub.id, status: "pending",
|
|
738
|
+
original: { text: `${sub.title}\n\n${sub.text}\n\nAcceptance Criteria:\n${(sub.acceptanceCriteria || []).map(ac => `- ${ac}`).join("\n")}` },
|
|
739
|
+
blocked_by: sub.blocked_by || [], certified: null, quality: null,
|
|
740
|
+
context_requests: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
|
741
|
+
}));
|
|
742
|
+
batch.stories.splice(storyIndex, 1, ...newStories);
|
|
743
|
+
splitAccepted = true;
|
|
744
|
+
emitProgress(emitter, makeEvent("hu-reviewer:split-accepted", { ...eventBase, stage: "hu-reviewer" }, {
|
|
745
|
+
message: `FDE accepted split of ${story.id} into ${updatedSubHUs.length} sub-HUs`,
|
|
746
|
+
detail: { originalId: story.id, subHUs: updatedSubHUs.map(s => s.id), heuristic }
|
|
747
|
+
}));
|
|
748
|
+
} else if (answer.toLowerCase().includes("try") || answer.toLowerCase().startsWith("no")) {
|
|
749
|
+
triedHeuristics.push(heuristic);
|
|
750
|
+
heuristic = selectHeuristic(indicators, triedHeuristics);
|
|
751
|
+
if (!heuristic) {
|
|
752
|
+
updateStoryStatus(batch, story.id, "needs_context");
|
|
753
|
+
}
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
await saveHuBatch(batchSessionId, batch);
|
|
760
|
+
}
|
|
761
|
+
|
|
682
762
|
// --- Evaluate loop (re-evaluate entire batch until all certified or needs_context with no askQuestion) ---
|
|
683
763
|
const huReviewerOnOutput = ({ stream, line }) => {
|
|
684
764
|
emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "hu-reviewer" }, {
|