substrate-ai 0.18.1 → 0.19.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.
@@ -6671,6 +6671,43 @@ function defaultFailResult(error, tokenUsage) {
6671
6671
  };
6672
6672
  }
6673
6673
  /**
6674
+ * Count test cases (`it(`, `test(`, `it.each(`, `test.each(`) in modified
6675
+ * test files. Returns a structured summary the reviewer can use as ground
6676
+ * truth instead of manually estimating test counts from code inspection.
6677
+ */
6678
+ async function countTestMetrics(filesModified, cwd) {
6679
+ if (!filesModified || filesModified.length === 0) return "";
6680
+ const testFiles = filesModified.filter((f$1) => f$1.includes(".test.") || f$1.includes(".spec.") || f$1.includes("__tests__"));
6681
+ if (testFiles.length === 0) return "";
6682
+ const results = [];
6683
+ let totalCount = 0;
6684
+ for (const file of testFiles) try {
6685
+ const out = execSync(`grep -cE "^\\s*(it|test|it\\.each|test\\.each)\\s*\\(" "${file}" 2>/dev/null || echo 0`, {
6686
+ cwd,
6687
+ encoding: "utf-8",
6688
+ timeout: 5e3
6689
+ }).trim();
6690
+ const count = parseInt(out, 10) || 0;
6691
+ if (count > 0) {
6692
+ results.push({
6693
+ file: file.split("/").pop(),
6694
+ count
6695
+ });
6696
+ totalCount += count;
6697
+ }
6698
+ } catch {}
6699
+ if (totalCount === 0) return "";
6700
+ const lines = [
6701
+ `VERIFIED TEST COUNT (automated — use as ground truth):`,
6702
+ `Total test cases: ${totalCount} across ${results.length} test file(s)`,
6703
+ ...results.map((r) => ` ${r.file}: ${r.count} test(s)`),
6704
+ "",
6705
+ "IMPORTANT: Use this verified count when evaluating AC test coverage thresholds.",
6706
+ "Do NOT manually estimate test counts — use the numbers above."
6707
+ ];
6708
+ return lines.join("\n");
6709
+ }
6710
+ /**
6674
6711
  * Execute the compiled code-review workflow.
6675
6712
  *
6676
6713
  * Steps:
@@ -6808,6 +6845,8 @@ async function runCodeReview(deps, params) {
6808
6845
  }, "Injecting prior findings into code-review prompt");
6809
6846
  }
6810
6847
  } catch {}
6848
+ const testMetricsContent = await countTestMetrics(filesModified, cwd);
6849
+ if (testMetricsContent) logger$12.debug({ storyKey }, "Injecting verified test-count metrics into code-review context");
6811
6850
  const sections = [
6812
6851
  {
6813
6852
  name: "story_content",
@@ -6819,6 +6858,11 @@ async function runCodeReview(deps, params) {
6819
6858
  content: gitDiffContent,
6820
6859
  priority: "important"
6821
6860
  },
6861
+ {
6862
+ name: "test_metrics",
6863
+ content: testMetricsContent,
6864
+ priority: "important"
6865
+ },
6822
6866
  {
6823
6867
  name: "previous_findings",
6824
6868
  content: previousFindingsContent,
@@ -9715,6 +9759,52 @@ function extractExpectedStoryTitle(shardContent, storyKey) {
9715
9759
  }
9716
9760
  return null;
9717
9761
  }
9762
+ /**
9763
+ * Check whether a story's expected NEW files already exist in the working tree,
9764
+ * indicating the story was implicitly implemented by adjacent stories.
9765
+ *
9766
+ * Parses the consolidated epics document for the story's "Files likely touched"
9767
+ * section and checks for files marked as "(new)". If all expected new files
9768
+ * already exist, the story is considered implicitly covered.
9769
+ *
9770
+ * Returns `true` if the story appears already covered, `false` otherwise.
9771
+ */
9772
+ function isImplicitlyCovered(storyKey, projectRoot) {
9773
+ const planningDir = join$1(projectRoot, "_bmad-output", "planning-artifacts");
9774
+ if (!existsSync(planningDir)) return false;
9775
+ let epicsPath;
9776
+ try {
9777
+ const entries = readdirSync(planningDir, { encoding: "utf-8" });
9778
+ const match$1 = entries.find((e) => /^epics[-.].*\.md$/i.test(e) && !/^epic-\d+/.test(e));
9779
+ if (match$1) epicsPath = join$1(planningDir, match$1);
9780
+ } catch {
9781
+ return false;
9782
+ }
9783
+ if (!epicsPath) return false;
9784
+ let content;
9785
+ try {
9786
+ content = readFileSync(epicsPath, "utf-8");
9787
+ } catch {
9788
+ return false;
9789
+ }
9790
+ const escapedKey = storyKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9791
+ const storyHeading = new RegExp(`^###\\s+Story\\s+${escapedKey}[:\\s]`, "m");
9792
+ const headingMatch = storyHeading.exec(content);
9793
+ if (!headingMatch) return false;
9794
+ const sectionStart = headingMatch.index;
9795
+ const nextHeading = content.indexOf("\n### Story ", sectionStart + 1);
9796
+ const section = nextHeading > 0 ? content.slice(sectionStart, nextHeading) : content.slice(sectionStart);
9797
+ const filesIdx = section.indexOf("Files likely touched:");
9798
+ if (filesIdx < 0) return false;
9799
+ const filesBlock = section.slice(filesIdx);
9800
+ const newFilePattern = /^-\s*`([^`]+)`\s*\(new\)/gm;
9801
+ const expectedNewFiles = [];
9802
+ let fm;
9803
+ while ((fm = newFilePattern.exec(filesBlock)) !== null) if (fm[1]) expectedNewFiles.push(fm[1]);
9804
+ if (expectedNewFiles.length === 0) return false;
9805
+ const existCount = expectedNewFiles.filter((f$1) => existsSync(join$1(projectRoot, f$1))).length;
9806
+ return existCount === expectedNewFiles.length;
9807
+ }
9718
9808
  const TITLE_OVERLAP_WARNING_THRESHOLD = .3;
9719
9809
  /**
9720
9810
  * Map a StoryPhase to the corresponding WgStoryStatus for wg_stories writes.
@@ -10318,6 +10408,25 @@ function createImplementationOrchestrator(deps) {
10318
10408
  }
10319
10409
  }
10320
10410
  } catch {}
10411
+ if (storyFilePath === void 0 && projectRoot && isImplicitlyCovered(storyKey, projectRoot)) {
10412
+ logger$21.info({ storyKey }, `Story ${storyKey} appears implicitly covered — all expected new files already exist. Skipping create-story.`);
10413
+ endPhase(storyKey, "create-story");
10414
+ eventBus.emit("orchestrator:story-phase-complete", {
10415
+ storyKey,
10416
+ phase: "IN_STORY_CREATION",
10417
+ result: {
10418
+ result: "success",
10419
+ story_key: storyKey,
10420
+ implicitlyCovered: true
10421
+ }
10422
+ });
10423
+ updateStory(storyKey, {
10424
+ phase: "COMPLETE",
10425
+ completedAt: new Date().toISOString()
10426
+ });
10427
+ await persistState();
10428
+ return;
10429
+ }
10321
10430
  if (storyFilePath === void 0) try {
10322
10431
  incrementDispatches(storyKey);
10323
10432
  const createResult = await runCreateStory({
@@ -12259,7 +12368,7 @@ function createImplementationOrchestrator(deps) {
12259
12368
  * @returns Sorted, deduplicated array of story keys in "N-M" format
12260
12369
  */
12261
12370
  async function resolveStoryKeys(db, projectRoot, opts) {
12262
- if (opts?.explicit !== void 0 && opts.explicit.length > 0) return opts.explicit;
12371
+ if (opts?.explicit !== void 0 && opts.explicit.length > 0) return topologicalSortByDependencies(opts.explicit, projectRoot);
12263
12372
  let keys = [];
12264
12373
  const readyKeys = await db.queryReadyStories();
12265
12374
  if (readyKeys.length > 0) {
@@ -12406,6 +12515,12 @@ function findEpicsFile(projectRoot) {
12406
12515
  const fullPath = join$1(projectRoot, candidate);
12407
12516
  if (existsSync(fullPath)) return fullPath;
12408
12517
  }
12518
+ const planningDir = join$1(projectRoot, "_bmad-output", "planning-artifacts");
12519
+ if (existsSync(planningDir)) try {
12520
+ const entries = readdirSync(planningDir, { encoding: "utf-8" });
12521
+ const match$1 = entries.filter((e) => /^epics[-.].*\.md$/i.test(e) && !/^epic-\d+/.test(e)).sort();
12522
+ if (match$1.length > 0) return join$1(planningDir, match$1[0]);
12523
+ } catch {}
12409
12524
  return void 0;
12410
12525
  }
12411
12526
  /**
@@ -12512,6 +12627,112 @@ function sortStoryKeys(keys) {
12512
12627
  return a.localeCompare(b);
12513
12628
  });
12514
12629
  }
12630
+ /**
12631
+ * Parse inter-story dependencies from the consolidated epics document.
12632
+ *
12633
+ * Scans for patterns like:
12634
+ * ### Story 50-2: Title
12635
+ * **Dependencies:** 50-1
12636
+ *
12637
+ * Returns a Map where key=storyKey, value=Set of dependency keys.
12638
+ * Only returns dependencies that are within the provided storyKeys set
12639
+ * (external dependencies to other epics are ignored for ordering purposes).
12640
+ */
12641
+ function parseEpicsDependencies(projectRoot, storyKeys) {
12642
+ const deps = new Map();
12643
+ const epicsPath = findEpicsFile(projectRoot);
12644
+ if (epicsPath === void 0) return deps;
12645
+ let content;
12646
+ try {
12647
+ content = readFileSync(epicsPath, "utf-8");
12648
+ } catch {
12649
+ return deps;
12650
+ }
12651
+ const storyPattern = /^###\s+Story\s+(\d+)-(\d+)[:\s]/gm;
12652
+ const depPattern = /^\*\*Dependencies:\*\*\s*(.+)$/gm;
12653
+ const storyPositions = [];
12654
+ let match$1;
12655
+ while ((match$1 = storyPattern.exec(content)) !== null) storyPositions.push({
12656
+ key: `${match$1[1]}-${match$1[2]}`,
12657
+ pos: match$1.index
12658
+ });
12659
+ for (let i = 0; i < storyPositions.length; i++) {
12660
+ const story = storyPositions[i];
12661
+ const nextStoryPos = i + 1 < storyPositions.length ? storyPositions[i + 1].pos : content.length;
12662
+ const section = content.slice(story.pos, nextStoryPos);
12663
+ depPattern.lastIndex = 0;
12664
+ const depMatch = depPattern.exec(section);
12665
+ if (depMatch === null || /^none$/i.test(depMatch[1].trim())) continue;
12666
+ const depText = depMatch[1];
12667
+ const storyDeps = new Set();
12668
+ const rangeMatch = /(\d+)-(\d+)\s+through\s+\1-(\d+)/i.exec(depText);
12669
+ if (rangeMatch !== null) {
12670
+ const epic = rangeMatch[1];
12671
+ const start = Number(rangeMatch[2]);
12672
+ const end = Number(rangeMatch[3]);
12673
+ for (let n$1 = start; n$1 <= end; n$1++) {
12674
+ const depKey = `${epic}-${n$1}`;
12675
+ if (storyKeys.has(depKey)) storyDeps.add(depKey);
12676
+ }
12677
+ } else {
12678
+ const keyPattern = /(\d+-\d+[a-z]?)/g;
12679
+ let km;
12680
+ while ((km = keyPattern.exec(depText)) !== null) {
12681
+ const depKey = km[1];
12682
+ if (storyKeys.has(depKey)) storyDeps.add(depKey);
12683
+ }
12684
+ }
12685
+ if (storyDeps.size > 0) deps.set(story.key, storyDeps);
12686
+ }
12687
+ return deps;
12688
+ }
12689
+ /**
12690
+ * Topologically sort explicit story keys by inter-story dependencies.
12691
+ *
12692
+ * Parses the consolidated epics document for dependency metadata, builds
12693
+ * a DAG, and returns keys in dependency-first order using Kahn's algorithm.
12694
+ * Stories with no dependencies come first; stories that depend on others
12695
+ * are placed after their prerequisites.
12696
+ *
12697
+ * Falls back to numeric sort if no epics document exists or no
12698
+ * dependencies are found among the provided keys.
12699
+ */
12700
+ function topologicalSortByDependencies(keys, projectRoot) {
12701
+ if (keys.length <= 1) return keys;
12702
+ const keySet = new Set(keys);
12703
+ const deps = parseEpicsDependencies(projectRoot, keySet);
12704
+ if (deps.size === 0) return sortStoryKeys(keys);
12705
+ const inDegree = new Map();
12706
+ const successors = new Map();
12707
+ for (const key of keys) {
12708
+ inDegree.set(key, 0);
12709
+ successors.set(key, new Set());
12710
+ }
12711
+ for (const [dependent, depSet] of deps) {
12712
+ if (!keySet.has(dependent)) continue;
12713
+ for (const dep of depSet) {
12714
+ if (!keySet.has(dep)) continue;
12715
+ successors.get(dep).add(dependent);
12716
+ inDegree.set(dependent, (inDegree.get(dependent) ?? 0) + 1);
12717
+ }
12718
+ }
12719
+ const result = [];
12720
+ const processed = new Set();
12721
+ while (processed.size < keys.length) {
12722
+ const wave = [];
12723
+ for (const key of keys) if (!processed.has(key) && (inDegree.get(key) ?? 0) === 0) wave.push(key);
12724
+ if (wave.length === 0) {
12725
+ for (const key of sortStoryKeys(keys)) if (!processed.has(key)) result.push(key);
12726
+ break;
12727
+ }
12728
+ for (const key of sortStoryKeys(wave)) {
12729
+ result.push(key);
12730
+ processed.add(key);
12731
+ for (const succ of successors.get(key) ?? []) inDegree.set(succ, (inDegree.get(succ) ?? 0) - 1);
12732
+ }
12733
+ }
12734
+ return result;
12735
+ }
12515
12736
 
12516
12737
  //#endregion
12517
12738
  //#region src/modules/phase-orchestrator/phase-detection.ts
@@ -21100,7 +21321,9 @@ function buildGraphNode(id, attrs, subgraphClass, defaultMaxRetries) {
21100
21321
  autoStatus: attrBool(attrs, "auto_status", true),
21101
21322
  allowPartial: attrBool(attrs, "allow_partial", false),
21102
21323
  toolCommand: attrStr(attrs, "tool_command", ""),
21103
- backend: attrStr(attrs, "backend", "")
21324
+ backend: attrStr(attrs, "backend", ""),
21325
+ maxParallel: attrInt(attrs, "maxParallel", 0),
21326
+ joinPolicy: attrStr(attrs, "joinPolicy", "")
21104
21327
  };
21105
21328
  }
21106
21329
  function buildGraphEdge(fromNode, toNode, attrs) {
@@ -21776,7 +21999,7 @@ const conditionSyntaxRule = {
21776
21999
  const diagnostics = [];
21777
22000
  for (let i = 0; i < graph.edges.length; i++) {
21778
22001
  const edge = graph.edges[i];
21779
- if (edge.condition) try {
22002
+ if (edge.condition && !edge.condition.trim().startsWith("llm:")) try {
21780
22003
  parseCondition(edge.condition);
21781
22004
  } catch (err) {
21782
22005
  if (err instanceof ConditionParseError) diagnostics.push({
@@ -21969,6 +22192,102 @@ function createValidator() {
21969
22192
  };
21970
22193
  }
21971
22194
 
22195
+ //#endregion
22196
+ //#region packages/factory/dist/graph/llm-evaluator.js
22197
+ /**
22198
+ * LLM-based edge condition evaluator for the factory graph engine.
22199
+ *
22200
+ * Provides pure, side-effect-free functions for detecting `llm:` prefixed
22201
+ * conditions, building evaluation prompts, parsing boolean responses, and
22202
+ * executing an LLM-backed condition evaluation with safe error handling.
22203
+ *
22204
+ * Zero external package imports — all LLM wiring is done by the caller
22205
+ * (edge-selector.ts) via the injectable `llmCall` parameter.
22206
+ *
22207
+ * Story 50-4.
22208
+ */
22209
+ /**
22210
+ * Returns `true` iff the condition string starts with the `"llm:"` prefix
22211
+ * (after trimming leading/trailing whitespace).
22212
+ */
22213
+ function isLlmCondition(condition) {
22214
+ return condition.trim().startsWith("llm:");
22215
+ }
22216
+ /**
22217
+ * Strips the `"llm:"` prefix and trims surrounding whitespace from the
22218
+ * remainder of the condition string.
22219
+ *
22220
+ * @param condition - A condition string beginning with `"llm:"`.
22221
+ * @returns The extracted question text, trimmed.
22222
+ */
22223
+ function extractLlmQuestion(condition) {
22224
+ return condition.trim().slice(4).trim();
22225
+ }
22226
+ /**
22227
+ * Builds an LLM evaluation prompt that includes the question and a JSON
22228
+ * block of the current context snapshot, with an explicit instruction to
22229
+ * answer with only "yes" or "no".
22230
+ *
22231
+ * @param question - The routing question extracted from the condition.
22232
+ * @param contextSnapshot - Shallow copy of the current execution context.
22233
+ * @returns The fully constructed prompt string.
22234
+ */
22235
+ function buildEvaluationPrompt(question, contextSnapshot) {
22236
+ return [
22237
+ `You are evaluating a routing condition in a software pipeline.`,
22238
+ ``,
22239
+ `Context:`,
22240
+ JSON.stringify(contextSnapshot, null, 2),
22241
+ ``,
22242
+ `Question: ${question}`,
22243
+ ``,
22244
+ `Answer with exactly "yes" or "no".`
22245
+ ].join("\n");
22246
+ }
22247
+ /**
22248
+ * Parses an LLM yes/no response into a boolean value.
22249
+ *
22250
+ * Returns `true` if the cleaned (trimmed, lowercased) response starts with or
22251
+ * contains one of: `"yes"`, `"true"`, `"affirmative"`, `"correct"`, `"1"`.
22252
+ * Returns `false` otherwise, including for an empty string.
22253
+ *
22254
+ * @param response - Raw text response from the LLM.
22255
+ * @returns `true` for affirmative responses, `false` for all others.
22256
+ */
22257
+ function parseLlmBoolResponse(response) {
22258
+ const cleaned = response.trim().toLowerCase();
22259
+ const affirmatives = [
22260
+ "yes",
22261
+ "true",
22262
+ "affirmative",
22263
+ "correct",
22264
+ "1"
22265
+ ];
22266
+ return affirmatives.some((token) => cleaned === token || cleaned.startsWith(token + " ") || cleaned.startsWith(token + "\n"));
22267
+ }
22268
+ /**
22269
+ * Evaluates an LLM edge condition asynchronously.
22270
+ *
22271
+ * Builds the evaluation prompt, calls the injectable `llmCall` function,
22272
+ * and parses the response via `parseLlmBoolResponse`. If any step throws,
22273
+ * returns `false` silently — never re-throws.
22274
+ *
22275
+ * @param question - The routing question to evaluate.
22276
+ * @param contextSnapshot - Shallow copy of the current execution context.
22277
+ * @param llmCall - Injectable async function that calls an LLM and
22278
+ * returns the raw text response.
22279
+ * @returns `true` if the LLM responds affirmatively, `false` otherwise or on error.
22280
+ */
22281
+ async function evaluateLlmCondition(question, contextSnapshot, llmCall) {
22282
+ try {
22283
+ const prompt = buildEvaluationPrompt(question, contextSnapshot);
22284
+ const response = await llmCall(prompt);
22285
+ return parseLlmBoolResponse(response);
22286
+ } catch {
22287
+ return false;
22288
+ }
22289
+ }
22290
+
21972
22291
  //#endregion
21973
22292
  //#region packages/factory/dist/graph/edge-selector.js
21974
22293
  /**
@@ -22010,6 +22329,7 @@ function bestByWeightThenLexical(edges) {
22010
22329
  * Select the best outgoing edge from `node` according to the 5-step Attractor spec.
22011
22330
  *
22012
22331
  * Step 1: Condition-matched edges — highest weight, lexical tiebreak.
22332
+ * Edges with `llm:` prefix conditions are evaluated via LLM call.
22013
22333
  * Step 2: Preferred label match on unconditional edges — first match wins.
22014
22334
  * Step 3: Suggested next IDs on unconditional edges — first suggestedNextId wins.
22015
22335
  * Step 4: Highest weight among all unconditional edges.
@@ -22019,14 +22339,48 @@ function bestByWeightThenLexical(edges) {
22019
22339
  * @param outcome - The outcome returned by the node's handler.
22020
22340
  * @param context - The current execution context (used for condition evaluation).
22021
22341
  * @param graph - The full graph (source of edges).
22342
+ * @param options - Optional injectable overrides (e.g. llmCall for testing).
22022
22343
  * @returns The selected `GraphEdge`, or `null` if no outgoing edges exist.
22023
22344
  */
22024
- function selectEdge(node, outcome, context, graph) {
22345
+ async function selectEdge(node, outcome, context, graph, options) {
22025
22346
  const outgoing = graph.edges.filter((e) => e.fromNode === node.id);
22026
22347
  if (outgoing.length === 0) return null;
22348
+ const defaultLlmCall$1 = (prompt) => callLLM({
22349
+ model: node.llmModel || "claude-haiku-4-5",
22350
+ provider: node.llmProvider || "anthropic",
22351
+ reasoningEffort: "low",
22352
+ prompt
22353
+ }).then((r) => r.text);
22354
+ const llmCall = options?.llmCall ?? defaultLlmCall$1;
22027
22355
  const conditionMatches = [];
22028
22356
  const snapshot = context.snapshot();
22029
- for (const edge of outgoing) if (edge.condition && edge.condition.trim() !== "") try {
22357
+ for (const edge of outgoing) if (edge.condition && edge.condition.trim() !== "") if (isLlmCondition(edge.condition)) {
22358
+ const question = extractLlmQuestion(edge.condition);
22359
+ let evalError = null;
22360
+ const trackingLlmCall = async (prompt) => {
22361
+ try {
22362
+ return await llmCall(prompt);
22363
+ } catch (err) {
22364
+ evalError = err instanceof Error ? err.message : String(err);
22365
+ throw err;
22366
+ }
22367
+ };
22368
+ const matched = await evaluateLlmCondition(question, snapshot, trackingLlmCall);
22369
+ options?.eventBus?.emit("graph:llm-edge-evaluated", {
22370
+ runId: options.runId ?? "unknown",
22371
+ nodeId: node.id,
22372
+ question,
22373
+ result: matched
22374
+ });
22375
+ context.set("llm.edge_eval_count", context.getNumber("llm.edge_eval_count", 0) + 1);
22376
+ if (evalError !== null) {
22377
+ const existing = context.get("llm.edge_eval_errors");
22378
+ const errors = Array.isArray(existing) ? existing : [];
22379
+ errors.push(evalError);
22380
+ context.set("llm.edge_eval_errors", errors);
22381
+ }
22382
+ if (matched) conditionMatches.push(edge);
22383
+ } else try {
22030
22384
  if (evaluateCondition(edge.condition, snapshot)) conditionMatches.push(edge);
22031
22385
  } catch {}
22032
22386
  if (conditionMatches.length > 0) return bestByWeightThenLexical(conditionMatches);
@@ -22987,6 +23341,111 @@ function resolveFidelity(node, incomingEdge, graph) {
22987
23341
  return "";
22988
23342
  }
22989
23343
 
23344
+ //#endregion
23345
+ //#region packages/factory/dist/stylesheet/resolver.js
23346
+ /**
23347
+ * Specificity-based resolver for the model stylesheet.
23348
+ *
23349
+ * Given a parsed stylesheet and a graph node, `resolveNodeStyles` determines
23350
+ * which LLM routing properties apply to that node by:
23351
+ * 1. Filtering rules to only those whose selector matches the node.
23352
+ * 2. Sorting matching rules by specificity ascending (stable sort so that
23353
+ * equal-specificity rules preserve source order).
23354
+ * 3. Iterating the sorted list and letting each rule overwrite properties
23355
+ * — the last rule at the highest specificity wins; for ties the rule
23356
+ * appearing later in the original stylesheet wins.
23357
+ *
23358
+ * **Caller contract**: `resolveNodeStyles` does NOT enforce the "explicit node
23359
+ * attribute wins" rule. The caller (executor / node preparation layer) is
23360
+ * responsible for the final merge:
23361
+ * ```typescript
23362
+ * const resolved = resolveNodeStyles(node, stylesheet)
23363
+ * const finalModel = node.llmModel || resolved.llmModel || graph.defaultLlmModel || ''
23364
+ * ```
23365
+ *
23366
+ * Story 42-7.
23367
+ */
23368
+ /**
23369
+ * Return `true` if the given `selector` matches the provided `node`.
23370
+ *
23371
+ * Matching rules:
23372
+ * - `universal`: always matches every node.
23373
+ * - `shape`: matches when `node.shape === selector.value`.
23374
+ * - `class`: matches when the node's `class` field, split on commas and
23375
+ * trimmed, contains `selector.value` (case-sensitive).
23376
+ * - `id`: matches when `node.id === selector.value`.
23377
+ */
23378
+ function matchesNode(node, selector) {
23379
+ switch (selector.type) {
23380
+ case "universal": return true;
23381
+ case "shape": return node.shape === selector.value;
23382
+ case "class": {
23383
+ const tokens = node.class.split(",").map((t) => t.trim());
23384
+ return tokens.includes(selector.value);
23385
+ }
23386
+ case "id": return node.id === selector.value;
23387
+ default: return false;
23388
+ }
23389
+ }
23390
+ /**
23391
+ * Resolve LLM routing properties for a single graph node using the parsed stylesheet.
23392
+ *
23393
+ * @param node - The graph node to resolve properties for.
23394
+ * @param stylesheet - A parsed stylesheet (array of `StylesheetRule` in source order).
23395
+ * @returns A `ResolvedNodeStyles` object containing only the properties that
23396
+ * were resolved from matching rules. Properties absent from matching rules
23397
+ * are omitted (not set to `undefined` explicitly).
23398
+ *
23399
+ * **Important**: this function does NOT check the node's own attributes.
23400
+ * Callers must give explicit node attributes (`node.llmModel`, etc.) priority
23401
+ * over the values returned here.
23402
+ */
23403
+ function resolveNodeStyles(node, stylesheet) {
23404
+ const matchingRules = stylesheet.filter((rule) => matchesNode(node, rule.selector));
23405
+ matchingRules.sort((a, b) => a.selector.specificity - b.selector.specificity);
23406
+ const resolved = {};
23407
+ for (const rule of matchingRules) for (const decl of rule.declarations) if (decl.property === "llm_model") resolved.llmModel = decl.value;
23408
+ else if (decl.property === "llm_provider") resolved.llmProvider = decl.value;
23409
+ else if (decl.property === "reasoning_effort") resolved.reasoningEffort = decl.value;
23410
+ return resolved;
23411
+ }
23412
+
23413
+ //#endregion
23414
+ //#region packages/factory/dist/graph/transformer.js
23415
+ /**
23416
+ * Apply stylesheet rules to all nodes in the given graph.
23417
+ *
23418
+ * Merges `inheritedStylesheet` (parent rules) with the graph's own
23419
+ * `modelStylesheet` rules, parent rules first so that local child rules win at
23420
+ * equal specificity via source-order tie-breaking (later rule wins).
23421
+ *
23422
+ * For each node, the resolved properties are applied only if the node does NOT
23423
+ * already have a non-empty explicit value (explicit values are preserved).
23424
+ *
23425
+ * This function **mutates** graph nodes in-place — consistent with how the
23426
+ * DOT parser sets node fields after parsing.
23427
+ *
23428
+ * **Idempotency**: calling `applyStylesheet` twice on the same graph is safe
23429
+ * because nodes with an existing non-empty `llmModel` (or `llmProvider` /
23430
+ * `reasoningEffort`) are never overwritten.
23431
+ *
23432
+ * @param graph - The graph whose nodes should be styled.
23433
+ * @param inheritedStylesheet - Optional stylesheet rules inherited from a
23434
+ * parent graph. Prepended before the graph's own rules so that child local
23435
+ * rules win at equal specificity. Pass `undefined` when there is no parent.
23436
+ */
23437
+ function applyStylesheet(graph, inheritedStylesheet) {
23438
+ const localRules = graph.modelStylesheet ? parseStylesheet(graph.modelStylesheet) : [];
23439
+ const effectiveStylesheet = [...inheritedStylesheet ?? [], ...localRules];
23440
+ if (effectiveStylesheet.length === 0) return;
23441
+ for (const node of graph.nodes.values()) {
23442
+ const resolved = resolveNodeStyles(node, effectiveStylesheet);
23443
+ if (!node.llmModel && resolved.llmModel) node.llmModel = resolved.llmModel;
23444
+ if (!node.llmProvider && resolved.llmProvider) node.llmProvider = resolved.llmProvider;
23445
+ if (!node.reasoningEffort && resolved.reasoningEffort) node.reasoningEffort = resolved.reasoningEffort;
23446
+ }
23447
+ }
23448
+
22990
23449
  //#endregion
22991
23450
  //#region packages/factory/dist/graph/executor.js
22992
23451
  /**
@@ -23081,6 +23540,7 @@ async function dispatchWithRetry(node, context, graph, config, nodeRetries) {
23081
23540
  */
23082
23541
  function createGraphExecutor() {
23083
23542
  return { async run(graph, config) {
23543
+ applyStylesheet(graph, config.inheritedStylesheet);
23084
23544
  const checkpointManager = new CheckpointManager();
23085
23545
  const checkpointFilePath = path.join(config.logsRoot, "checkpoint.json");
23086
23546
  const controller = createConvergenceController();
@@ -23176,7 +23636,10 @@ function createGraphExecutor() {
23176
23636
  if (resumeState.completedNodes.has(checkpoint.currentNode)) {
23177
23637
  const lastNode = graph.nodes.get(checkpoint.currentNode);
23178
23638
  if (lastNode) {
23179
- const nextEdge = selectEdge(lastNode, { status: "SUCCESS" }, context, graph);
23639
+ const nextEdge = await selectEdge(lastNode, { status: "SUCCESS" }, context, graph, {
23640
+ ...config.eventBus !== void 0 ? { eventBus: config.eventBus } : {},
23641
+ runId: config.runId
23642
+ });
23180
23643
  if (nextEdge) {
23181
23644
  const nextNode = graph.nodes.get(nextEdge.toNode);
23182
23645
  if (!nextNode) throw new Error(`Edge target node "${nextEdge.toNode}" not found in graph`);
@@ -23189,6 +23652,7 @@ function createGraphExecutor() {
23189
23652
  else currentNode = graph.startNode();
23190
23653
  }
23191
23654
  } else currentNode = graph.startNode();
23655
+ context.set("__runId", config.runId ?? "unknown");
23192
23656
  while (true) {
23193
23657
  const sessionResult = sessionManager.checkBudget((config.wallClockCapMs ?? 0) / 1e3);
23194
23658
  if (!sessionResult.allowed) {
@@ -23279,7 +23743,10 @@ function createGraphExecutor() {
23279
23743
  return { status: "SUCCESS" };
23280
23744
  }
23281
23745
  if (resumeCompletedSet?.has(currentNode.id)) {
23282
- const skipEdge = selectEdge(currentNode, { status: "SUCCESS" }, context, graph);
23746
+ const skipEdge = await selectEdge(currentNode, { status: "SUCCESS" }, context, graph, {
23747
+ ...config.eventBus !== void 0 ? { eventBus: config.eventBus } : {},
23748
+ runId: config.runId
23749
+ });
23283
23750
  if (!skipEdge) {
23284
23751
  await persistExit("failed", `No outgoing edge from node ${currentNode.id}`);
23285
23752
  return {
@@ -23464,7 +23931,10 @@ function createGraphExecutor() {
23464
23931
  };
23465
23932
  }
23466
23933
  }
23467
- const edge = selectEdge(currentNode, outcome, context, graph);
23934
+ const edge = await selectEdge(currentNode, outcome, context, graph, {
23935
+ ...config.eventBus !== void 0 ? { eventBus: config.eventBus } : {},
23936
+ runId: config.runId
23937
+ });
23468
23938
  if (!edge) {
23469
23939
  await persistExit("failed", `No outgoing edge from node ${currentNode.id}`);
23470
23940
  return {
@@ -31087,75 +31557,6 @@ const conditionalHandler = async (_node, _context, _graph) => {
31087
31557
  return { status: "SUCCESS" };
31088
31558
  };
31089
31559
 
31090
- //#endregion
31091
- //#region packages/factory/dist/stylesheet/resolver.js
31092
- /**
31093
- * Specificity-based resolver for the model stylesheet.
31094
- *
31095
- * Given a parsed stylesheet and a graph node, `resolveNodeStyles` determines
31096
- * which LLM routing properties apply to that node by:
31097
- * 1. Filtering rules to only those whose selector matches the node.
31098
- * 2. Sorting matching rules by specificity ascending (stable sort so that
31099
- * equal-specificity rules preserve source order).
31100
- * 3. Iterating the sorted list and letting each rule overwrite properties
31101
- * — the last rule at the highest specificity wins; for ties the rule
31102
- * appearing later in the original stylesheet wins.
31103
- *
31104
- * **Caller contract**: `resolveNodeStyles` does NOT enforce the "explicit node
31105
- * attribute wins" rule. The caller (executor / node preparation layer) is
31106
- * responsible for the final merge:
31107
- * ```typescript
31108
- * const resolved = resolveNodeStyles(node, stylesheet)
31109
- * const finalModel = node.llmModel || resolved.llmModel || graph.defaultLlmModel || ''
31110
- * ```
31111
- *
31112
- * Story 42-7.
31113
- */
31114
- /**
31115
- * Return `true` if the given `selector` matches the provided `node`.
31116
- *
31117
- * Matching rules:
31118
- * - `universal`: always matches every node.
31119
- * - `shape`: matches when `node.shape === selector.value`.
31120
- * - `class`: matches when the node's `class` field, split on commas and
31121
- * trimmed, contains `selector.value` (case-sensitive).
31122
- * - `id`: matches when `node.id === selector.value`.
31123
- */
31124
- function matchesNode(node, selector) {
31125
- switch (selector.type) {
31126
- case "universal": return true;
31127
- case "shape": return node.shape === selector.value;
31128
- case "class": {
31129
- const tokens = node.class.split(",").map((t) => t.trim());
31130
- return tokens.includes(selector.value);
31131
- }
31132
- case "id": return node.id === selector.value;
31133
- default: return false;
31134
- }
31135
- }
31136
- /**
31137
- * Resolve LLM routing properties for a single graph node using the parsed stylesheet.
31138
- *
31139
- * @param node - The graph node to resolve properties for.
31140
- * @param stylesheet - A parsed stylesheet (array of `StylesheetRule` in source order).
31141
- * @returns A `ResolvedNodeStyles` object containing only the properties that
31142
- * were resolved from matching rules. Properties absent from matching rules
31143
- * are omitted (not set to `undefined` explicitly).
31144
- *
31145
- * **Important**: this function does NOT check the node's own attributes.
31146
- * Callers must give explicit node attributes (`node.llmModel`, etc.) priority
31147
- * over the values returned here.
31148
- */
31149
- function resolveNodeStyles(node, stylesheet) {
31150
- const matchingRules = stylesheet.filter((rule) => matchesNode(node, rule.selector));
31151
- matchingRules.sort((a, b) => a.selector.specificity - b.selector.specificity);
31152
- const resolved = {};
31153
- for (const rule of matchingRules) for (const decl of rule.declarations) if (decl.property === "llm_model") resolved.llmModel = decl.value;
31154
- else if (decl.property === "llm_provider") resolved.llmProvider = decl.value;
31155
- else if (decl.property === "reasoning_effort") resolved.reasoningEffort = decl.value;
31156
- return resolved;
31157
- }
31158
-
31159
31560
  //#endregion
31160
31561
  //#region packages/factory/dist/handlers/codergen-handler.js
31161
31562
  const DEFAULT_MODEL = "claude-sonnet-4-5";
@@ -31422,6 +31823,762 @@ function createWaitHumanHandler(options) {
31422
31823
  };
31423
31824
  }
31424
31825
 
31826
+ //#endregion
31827
+ //#region packages/factory/dist/handlers/join-policy.js
31828
+ /**
31829
+ * Join policy types, pure evaluator, and branch cancellation manager
31830
+ * for parallel node fan-out/fan-in coordination.
31831
+ *
31832
+ * Design constraints:
31833
+ * - Zero external package imports (pure TypeScript/built-ins only)
31834
+ * - `evaluateJoinPolicy` is a pure synchronous function (no I/O, no side effects)
31835
+ * - `BranchCancellationManager` uses the globally-available AbortController (Node 18+)
31836
+ *
31837
+ * Story 50-3 (AC6).
31838
+ */
31839
+ /**
31840
+ * Pure synchronous function that decides what the parallel handler should do
31841
+ * after a set of branches has completed.
31842
+ *
31843
+ * Called incrementally after each branch finishes: pass the full `completed`
31844
+ * array (including all branches resolved so far) and the `total` branch count.
31845
+ *
31846
+ * @param config - Join policy configuration parsed from the parallel node.
31847
+ * @param completed - All branch results collected so far (any order).
31848
+ * @param total - Total number of branches in the fan-out set.
31849
+ * @returns A `JoinDecision` indicating whether to continue, wait, or fail.
31850
+ */
31851
+ function evaluateJoinPolicy(config, completed, total) {
31852
+ const successes = completed.filter((r) => r.outcome === "SUCCESS");
31853
+ const failures = completed.filter((r) => r.outcome === "FAIL");
31854
+ switch (config.policy) {
31855
+ case "wait_all": {
31856
+ if (completed.length < total) return { action: "wait" };
31857
+ return {
31858
+ action: "continue",
31859
+ results: completed
31860
+ };
31861
+ }
31862
+ case "first_success": {
31863
+ if (successes.length >= 1) return {
31864
+ action: "continue",
31865
+ results: completed
31866
+ };
31867
+ if (completed.length >= total) return {
31868
+ action: "fail",
31869
+ reason: `first_success: all ${total} branches failed`
31870
+ };
31871
+ return { action: "wait" };
31872
+ }
31873
+ case "quorum": {
31874
+ const needed = config.quorum_size ?? 1;
31875
+ if (needed <= 0) return {
31876
+ action: "fail",
31877
+ reason: "quorum_size must be >= 1"
31878
+ };
31879
+ if (successes.length >= needed) return {
31880
+ action: "continue",
31881
+ results: completed
31882
+ };
31883
+ const remaining = total - completed.length;
31884
+ if (successes.length + remaining < needed) return {
31885
+ action: "fail",
31886
+ reason: `quorum unreachable: ${failures.length} failed, needed ${needed} of ${total}`
31887
+ };
31888
+ return { action: "wait" };
31889
+ }
31890
+ default: {
31891
+ const _exhaustive = config.policy;
31892
+ return {
31893
+ action: "fail",
31894
+ reason: `unknown join policy: ${String(_exhaustive)}`
31895
+ };
31896
+ }
31897
+ }
31898
+ }
31899
+ /**
31900
+ * Manages one `AbortController` per branch in a parallel fan-out execution.
31901
+ *
31902
+ * Typical usage:
31903
+ * 1. Construct with the total branch count.
31904
+ * 2. Pass `getSignal(i)` to the i-th branch executor.
31905
+ * 3. After the join condition is met, call `cancelRemaining(completedSet)`.
31906
+ * 4. Await `drainAsync(timeoutMs)` to give cancelled branches time to clean up.
31907
+ */
31908
+ var BranchCancellationManager = class {
31909
+ controllers;
31910
+ /**
31911
+ * @param branchCount - Total number of branches; allocates one AbortController per branch.
31912
+ */
31913
+ constructor(branchCount) {
31914
+ this.controllers = Array.from({ length: branchCount }, () => new AbortController());
31915
+ }
31916
+ /**
31917
+ * Return the AbortSignal for the branch at the given zero-based index.
31918
+ * The signal is not yet aborted; it becomes aborted when `cancelRemaining` includes the index.
31919
+ */
31920
+ getSignal(index) {
31921
+ const ctrl = this.controllers[index];
31922
+ if (ctrl === void 0) throw new RangeError(`Branch index ${index} is out of range (branchCount=${this.controllers.length})`);
31923
+ return ctrl.signal;
31924
+ }
31925
+ /**
31926
+ * Abort all branches whose index is NOT in `completedIndices`.
31927
+ * Branches that have already completed are not re-aborted.
31928
+ *
31929
+ * @param completedIndices - Set of branch indices that finished successfully/naturally.
31930
+ */
31931
+ cancelRemaining(completedIndices) {
31932
+ this.controllers.forEach((ctrl, i) => {
31933
+ if (!completedIndices.has(i)) ctrl.abort();
31934
+ });
31935
+ }
31936
+ /**
31937
+ * Wait `timeoutMs` milliseconds for in-flight branches to honour their AbortSignals
31938
+ * and finish any cleanup work before the parallel handler resolves.
31939
+ *
31940
+ * @param timeoutMs - Drain window in milliseconds (default 5000).
31941
+ */
31942
+ async drainAsync(timeoutMs) {
31943
+ await new Promise((resolve$6) => setTimeout(resolve$6, timeoutMs));
31944
+ }
31945
+ };
31946
+
31947
+ //#endregion
31948
+ //#region packages/factory/dist/handlers/parallel.js
31949
+ /**
31950
+ * Maps a BranchResult outcome string to a StageStatus for event payloads.
31951
+ *
31952
+ * BranchResult.outcome: 'SUCCESS' | 'FAIL' | 'CANCELLED'
31953
+ * StageStatus: 'SUCCESS' | 'FAIL' | 'PARTIAL_SUCCESS' | 'RETRY' | 'SKIPPED'
31954
+ *
31955
+ * Mapping:
31956
+ * 'SUCCESS' → 'SUCCESS'
31957
+ * 'CANCELLED' → 'SKIPPED'
31958
+ * 'FAIL' → 'FAIL'
31959
+ */
31960
+ function branchOutcomeToStatus(outcome) {
31961
+ if (outcome === "SUCCESS") return "SUCCESS";
31962
+ if (outcome === "CANCELLED") return "SKIPPED";
31963
+ return "FAIL";
31964
+ }
31965
+ /**
31966
+ * Enrich a join-policy `BranchResult` with fan-in-compatible fields so that
31967
+ * `fan-in.ts` can read `parallel.results` without type-shape mismatch.
31968
+ *
31969
+ * The returned object is a superset of `BranchResult` — it preserves every
31970
+ * original field (index, outcome, contextSnapshot, error) AND adds the fields
31971
+ * that `fan-in.ts` requires (branch_id, status, context_updates, failure_reason).
31972
+ *
31973
+ * Mapping:
31974
+ * index → branch_id (same numeric value)
31975
+ * outcome → status ('SUCCESS' | 'FAIL'/'CANCELLED' → 'SUCCESS' | 'FAILURE')
31976
+ * contextSnapshot → context_updates (shallow context snapshot)
31977
+ * error → failure_reason (human-readable failure description)
31978
+ */
31979
+ function toBridgeBranchResult(result) {
31980
+ return {
31981
+ ...result,
31982
+ branch_id: result.index,
31983
+ status: result.outcome === "SUCCESS" ? "SUCCESS" : "FAILURE",
31984
+ context_updates: result.contextSnapshot,
31985
+ failure_reason: result.error
31986
+ };
31987
+ }
31988
+ /**
31989
+ * Run an array of async task factories with at most `limit` executing
31990
+ * concurrently at any point in time, preserving result order.
31991
+ */
31992
+ async function runWithConcurrencyLimit(tasks, limit) {
31993
+ const results = new Array(tasks.length);
31994
+ const executing = new Set();
31995
+ for (let i = 0; i < tasks.length; i++) {
31996
+ const idx = i;
31997
+ const p = tasks[idx]().then((r) => {
31998
+ results[idx] = r;
31999
+ }).finally(() => {
32000
+ executing.delete(p);
32001
+ });
32002
+ executing.add(p);
32003
+ if (executing.size >= limit) await Promise.race(executing);
32004
+ }
32005
+ await Promise.all(executing);
32006
+ return results;
32007
+ }
32008
+ function parseJoinPolicyConfig(node) {
32009
+ const policyRaw = node.attrs?.["join_policy"] ?? node.joinPolicy ?? "wait_all";
32010
+ const policy = policyRaw === "first_success" || policyRaw === "quorum" ? policyRaw : "wait_all";
32011
+ const quorumSizeRaw = node.attrs?.["quorum_size"];
32012
+ const quorum_size = quorumSizeRaw !== void 0 ? parseInt(quorumSizeRaw, 10) : void 0;
32013
+ const drainRaw = node.attrs?.["cancel_drain_timeout_ms"];
32014
+ const cancel_drain_timeout_ms = drainRaw !== void 0 ? parseInt(drainRaw, 10) : void 0;
32015
+ return {
32016
+ policy,
32017
+ ...quorum_size !== void 0 && { quorum_size },
32018
+ ...cancel_drain_timeout_ms !== void 0 && { cancel_drain_timeout_ms }
32019
+ };
32020
+ }
32021
+ /**
32022
+ * Build an async task that executes a single branch node and returns a
32023
+ * `BranchResult`. Isolation is guaranteed via `context.clone()`.
32024
+ */
32025
+ function makeBranchTask(edge, index, signal, context, graph, options) {
32026
+ return async () => {
32027
+ if (signal.aborted) return {
32028
+ index,
32029
+ outcome: "CANCELLED"
32030
+ };
32031
+ const branchNode = graph.nodes.get(edge.toNode);
32032
+ if (!branchNode) return {
32033
+ index,
32034
+ outcome: "FAIL",
32035
+ error: `Branch node "${edge.toNode}" not found in graph`
32036
+ };
32037
+ const branchCtx = context.clone();
32038
+ branchCtx.set("_branch.abort_signal", signal);
32039
+ branchCtx.set("_branch.index", index);
32040
+ const handler = options.handlerRegistry.resolve(branchNode);
32041
+ let branchOutcome;
32042
+ try {
32043
+ branchOutcome = await handler(branchNode, branchCtx, graph);
32044
+ } catch (err) {
32045
+ const msg = err instanceof Error ? err.message : String(err);
32046
+ return {
32047
+ index,
32048
+ outcome: "FAIL",
32049
+ error: msg,
32050
+ contextSnapshot: branchCtx.snapshot()
32051
+ };
32052
+ }
32053
+ if (signal.aborted) return {
32054
+ index,
32055
+ outcome: "CANCELLED",
32056
+ contextSnapshot: branchCtx.snapshot()
32057
+ };
32058
+ const isSuccess = branchOutcome.status === "SUCCESS" || branchOutcome.status === "PARTIAL_SUCCESS";
32059
+ const result = {
32060
+ index,
32061
+ outcome: isSuccess ? "SUCCESS" : "FAIL",
32062
+ contextSnapshot: branchCtx.snapshot()
32063
+ };
32064
+ if (!isSuccess) {
32065
+ const reason = branchOutcome.failureReason;
32066
+ if (reason !== void 0) result.error = reason;
32067
+ }
32068
+ return result;
32069
+ };
32070
+ }
32071
+ /**
32072
+ * Create a `parallel` node handler that fans out to all outgoing branch nodes
32073
+ * concurrently, applies a configurable join policy, and writes results to context.
32074
+ *
32075
+ * @param options.handlerRegistry Registry used to resolve each branch node's
32076
+ * handler at invocation time.
32077
+ */
32078
+ function createParallelHandler(options) {
32079
+ return async (node, context, graph) => {
32080
+ const branchEdges = graph.outgoingEdges(node.id);
32081
+ const branchCount = branchEdges.length;
32082
+ if (branchCount === 0) {
32083
+ context.set("parallel.results", []);
32084
+ return { status: "SUCCESS" };
32085
+ }
32086
+ const config = parseJoinPolicyConfig(node);
32087
+ const maxParallel = node.maxParallel ?? 0;
32088
+ const drainMs = config.cancel_drain_timeout_ms ?? 5e3;
32089
+ const cancellationManager = new BranchCancellationManager(branchCount);
32090
+ const runId = context.getString("__runId", "unknown");
32091
+ options.eventBus?.emit("graph:parallel-started", {
32092
+ runId,
32093
+ nodeId: node.id,
32094
+ branchCount,
32095
+ maxParallel: maxParallel > 0 ? maxParallel : branchCount,
32096
+ policy: config.policy
32097
+ });
32098
+ if (config.policy === "wait_all") {
32099
+ const tasks = branchEdges.map((edge, index) => {
32100
+ const baseTask = makeBranchTask(edge, index, cancellationManager.getSignal(index), context, graph, options);
32101
+ return async () => {
32102
+ options.eventBus?.emit("graph:parallel-branch-started", {
32103
+ runId,
32104
+ nodeId: node.id,
32105
+ branchIndex: index
32106
+ });
32107
+ const branchStart = Date.now();
32108
+ const result = await baseTask();
32109
+ const durationMs = Date.now() - branchStart;
32110
+ options.eventBus?.emit("graph:parallel-branch-completed", {
32111
+ runId,
32112
+ nodeId: node.id,
32113
+ branchIndex: index,
32114
+ status: branchOutcomeToStatus(result.outcome),
32115
+ durationMs
32116
+ });
32117
+ return result;
32118
+ };
32119
+ });
32120
+ const results = maxParallel > 0 ? await runWithConcurrencyLimit(tasks, maxParallel) : await Promise.all(tasks.map((t) => t()));
32121
+ context.set("parallel.results", results.map(toBridgeBranchResult));
32122
+ const completedCount = results.filter((r) => r.outcome === "SUCCESS").length;
32123
+ const cancelledCount = results.filter((r) => r.outcome === "CANCELLED").length;
32124
+ options.eventBus?.emit("graph:parallel-completed", {
32125
+ runId,
32126
+ nodeId: node.id,
32127
+ completedCount,
32128
+ cancelledCount,
32129
+ policy: config.policy
32130
+ });
32131
+ return { status: "SUCCESS" };
32132
+ }
32133
+ const completed = [];
32134
+ const completedIndices = new Set();
32135
+ const pendingEvents = [];
32136
+ const waiters = [];
32137
+ function push(event) {
32138
+ const w = waiters.shift();
32139
+ if (w !== void 0) w(event);
32140
+ else pendingEvents.push(event);
32141
+ }
32142
+ function waitForNext() {
32143
+ return new Promise((resolve$6) => {
32144
+ const e = pendingEvents.shift();
32145
+ if (e !== void 0) resolve$6(e);
32146
+ else waiters.push(resolve$6);
32147
+ });
32148
+ }
32149
+ const branchStarts = new Map();
32150
+ const activeTasks = branchEdges.map((edge, index) => {
32151
+ const task = makeBranchTask(edge, index, cancellationManager.getSignal(index), context, graph, options);
32152
+ options.eventBus?.emit("graph:parallel-branch-started", {
32153
+ runId,
32154
+ nodeId: node.id,
32155
+ branchIndex: index
32156
+ });
32157
+ branchStarts.set(index, Date.now());
32158
+ return task().then((result) => {
32159
+ const branchStart = branchStarts.get(index) ?? Date.now();
32160
+ const durationMs = Date.now() - branchStart;
32161
+ options.eventBus?.emit("graph:parallel-branch-completed", {
32162
+ runId,
32163
+ nodeId: node.id,
32164
+ branchIndex: index,
32165
+ status: branchOutcomeToStatus(result.outcome),
32166
+ durationMs
32167
+ });
32168
+ push({ result });
32169
+ });
32170
+ });
32171
+ let joinOutcome = "SUCCESS";
32172
+ let settled = false;
32173
+ let joinErrorMsg;
32174
+ for (let i = 0; i < branchCount; i++) {
32175
+ const { result } = await waitForNext();
32176
+ if (completedIndices.has(result.index)) continue;
32177
+ completed.push(result);
32178
+ completedIndices.add(result.index);
32179
+ const decision = evaluateJoinPolicy(config, completed, branchCount);
32180
+ if (decision.action === "wait") continue;
32181
+ settled = true;
32182
+ joinOutcome = decision.action === "continue" ? "SUCCESS" : "FAIL";
32183
+ if (decision.action === "fail") joinErrorMsg = decision.reason;
32184
+ const uncancelledCount = branchCount - completedIndices.size;
32185
+ cancellationManager.cancelRemaining(completedIndices);
32186
+ if (uncancelledCount > 0) {
32187
+ await cancellationManager.drainAsync(drainMs);
32188
+ while (pendingEvents.length > 0) {
32189
+ const e = pendingEvents.shift();
32190
+ if (e !== void 0 && !completedIndices.has(e.result.index)) {
32191
+ completed.push(e.result);
32192
+ completedIndices.add(e.result.index);
32193
+ }
32194
+ }
32195
+ for (let j$1 = 0; j$1 < branchCount; j$1++) if (!completedIndices.has(j$1)) {
32196
+ completed.push({
32197
+ index: j$1,
32198
+ outcome: "CANCELLED"
32199
+ });
32200
+ completedIndices.add(j$1);
32201
+ }
32202
+ }
32203
+ context.set("parallel.results", completed.map(toBridgeBranchResult));
32204
+ if (decision.action === "continue") {
32205
+ if (config.policy === "first_success") {
32206
+ const winner = completed.find((r) => r.outcome === "SUCCESS");
32207
+ if (winner !== void 0) context.set("parallel.winner_index", winner.index);
32208
+ }
32209
+ if (config.policy === "quorum") {
32210
+ const successCount = completed.filter((r) => r.outcome === "SUCCESS").length;
32211
+ context.set("parallel.quorum_reached", successCount);
32212
+ }
32213
+ } else context.set("parallel.join_error", joinErrorMsg ?? decision.reason);
32214
+ break;
32215
+ }
32216
+ Promise.allSettled(activeTasks);
32217
+ if (!settled) context.set("parallel.results", completed.map(toBridgeBranchResult));
32218
+ const finalCompletedCount = completed.filter((r) => r.outcome === "SUCCESS").length;
32219
+ const finalCancelledCount = completed.filter((r) => r.outcome === "CANCELLED").length;
32220
+ options.eventBus?.emit("graph:parallel-completed", {
32221
+ runId,
32222
+ nodeId: node.id,
32223
+ completedCount: finalCompletedCount,
32224
+ cancelledCount: finalCancelledCount,
32225
+ policy: config.policy
32226
+ });
32227
+ return { status: joinOutcome === "SUCCESS" ? "SUCCESS" : "FAILURE" };
32228
+ };
32229
+ }
32230
+
32231
+ //#endregion
32232
+ //#region packages/factory/dist/handlers/fan-in.js
32233
+ /** Lower number = better rank. FAILURE is excluded before ranking. */
32234
+ const OUTCOME_RANK = {
32235
+ SUCCESS: 0,
32236
+ PARTIAL_SUCCESS: 1,
32237
+ NEEDS_RETRY: 2,
32238
+ FAILURE: 3,
32239
+ ESCALATE: 4
32240
+ };
32241
+ /**
32242
+ * Select the best non-FAILURE branch from `results`.
32243
+ *
32244
+ * Sort order (ascending priority):
32245
+ * 1. `OUTCOME_RANK[status]` ascending (SUCCESS wins)
32246
+ * 2. `score` descending (higher is better; `undefined` treated as `0`)
32247
+ * 3. `branch_id` ascending (stable tiebreak)
32248
+ *
32249
+ * Returns `null` when every branch has status `FAILURE`.
32250
+ */
32251
+ function rankBranches(results) {
32252
+ const eligible = results.filter((r) => r.status !== "FAILURE");
32253
+ if (eligible.length === 0) return null;
32254
+ const sorted = [...eligible].sort((a, b) => {
32255
+ const rankDiff = (OUTCOME_RANK[a.status] ?? 99) - (OUTCOME_RANK[b.status] ?? 99);
32256
+ if (rankDiff !== 0) return rankDiff;
32257
+ const scoreA = a.score ?? 0;
32258
+ const scoreB = b.score ?? 0;
32259
+ if (scoreB !== scoreA) return scoreB - scoreA;
32260
+ return a.branch_id - b.branch_id;
32261
+ });
32262
+ return sorted[0] ?? null;
32263
+ }
32264
+ /**
32265
+ * Build the prompt sent to the LLM for selection-based fan-in.
32266
+ *
32267
+ * The prompt prepends `nodePrompt`, then lists each branch with its
32268
+ * `branch_id`, `status`, `score`, and the keys present in `context_updates`
32269
+ * (values omitted for token efficiency). The LLM is instructed to reply with
32270
+ * just the integer `branch_id` of the best candidate.
32271
+ */
32272
+ function buildSelectionPrompt(nodePrompt, results) {
32273
+ const branchSummaries = results.map((r) => {
32274
+ const contextKeys = r.context_updates && Object.keys(r.context_updates).length > 0 ? Object.keys(r.context_updates).join(", ") : "(none)";
32275
+ return `Branch ${r.branch_id}: status=${r.status}, score=${r.score ?? 0}, context_update_keys=[${contextKeys}]`;
32276
+ }).join("\n");
32277
+ return `${nodePrompt}\n\nParallel branch results:\n${branchSummaries}\n\nReply with only the integer branch_id of the best candidate.`;
32278
+ }
32279
+ /**
32280
+ * Parse an LLM response to extract the winning `branch_id`.
32281
+ *
32282
+ * Scans the response text for the first integer that matches a valid
32283
+ * `branch_id` in `results`. Returns the matching `BranchResult`, or `null`
32284
+ * (triggering heuristic fallback) if no valid branch_id is found.
32285
+ * Logs a warning on fallback.
32286
+ */
32287
+ function parseLlmWinnerResponse(response, results) {
32288
+ const validIds = new Set(results.map((r) => r.branch_id));
32289
+ const matches = response.match(/\d+/g);
32290
+ if (matches) for (const m of matches) {
32291
+ const id = parseInt(m, 10);
32292
+ if (validIds.has(id)) return results.find((r) => r.branch_id === id) ?? null;
32293
+ }
32294
+ console.warn(`[fan-in] LLM response did not contain a valid branch_id; falling back to heuristic selection. Response: "${response}"`);
32295
+ return null;
32296
+ }
32297
+ /**
32298
+ * Default `llmCall` implementation that wraps `callLLM` from `@substrate-ai/core`
32299
+ * with fixed default routing parameters.
32300
+ */
32301
+ async function defaultLlmCall(prompt) {
32302
+ const result = await callLLM({
32303
+ model: "claude-sonnet-4-5",
32304
+ provider: "anthropic",
32305
+ reasoningEffort: "low",
32306
+ prompt
32307
+ });
32308
+ return result.text;
32309
+ }
32310
+ /**
32311
+ * Create a `parallel.fan_in` node handler.
32312
+ *
32313
+ * Execution steps:
32314
+ * 1. Read `parallel.results` from context; return FAILURE if absent or empty.
32315
+ * 2. If `node.prompt` is non-empty, call LLM to select winner; fall back to
32316
+ * heuristic on parse failure.
32317
+ * 3. Use heuristic `rankBranches` when no prompt or LLM fallback.
32318
+ * 4. If all branches failed, return FAILURE with aggregated failure reasons.
32319
+ * 5. Merge winner's `context_updates`, set `parallel.fan_in.best_id` and
32320
+ * `parallel.fan_in.best_outcome`, return SUCCESS.
32321
+ *
32322
+ * @param options - Optional configuration (inject `llmCall` for testing).
32323
+ */
32324
+ function createFanInHandler(options) {
32325
+ const llmCallFn = options?.llmCall ?? defaultLlmCall;
32326
+ return async (node, context, _graph) => {
32327
+ const rawResults = context.get("parallel.results");
32328
+ if (!rawResults || !Array.isArray(rawResults) || rawResults.length === 0) return {
32329
+ status: "FAILURE",
32330
+ failureReason: "fan-in: no parallel results found in context (parallel.results is absent or empty)"
32331
+ };
32332
+ const results = rawResults;
32333
+ let winner = null;
32334
+ if (node.prompt && node.prompt.trim().length > 0) {
32335
+ const prompt = buildSelectionPrompt(node.prompt, results);
32336
+ try {
32337
+ const response = await llmCallFn(prompt);
32338
+ winner = parseLlmWinnerResponse(response, results);
32339
+ } catch (err) {
32340
+ console.warn(`[fan-in] LLM call failed; falling back to heuristic selection. Error: ${String(err)}`);
32341
+ winner = null;
32342
+ }
32343
+ if (winner === null) winner = rankBranches(results);
32344
+ } else winner = rankBranches(results);
32345
+ if (winner === null) {
32346
+ const reasons = results.map((r) => r.failure_reason ?? `branch ${r.branch_id}: no reason provided`).join("; ");
32347
+ return {
32348
+ status: "FAILURE",
32349
+ failureReason: `fan-in: all branches failed — ${reasons}`
32350
+ };
32351
+ }
32352
+ if (winner.context_updates && Object.keys(winner.context_updates).length > 0) context.applyUpdates(winner.context_updates);
32353
+ context.set("parallel.fan_in.best_id", winner.branch_id);
32354
+ context.set("parallel.fan_in.best_outcome", winner.status);
32355
+ return { status: "SUCCESS" };
32356
+ };
32357
+ }
32358
+
32359
+ //#endregion
32360
+ //#region packages/factory/dist/handlers/subgraph.js
32361
+ /**
32362
+ * Converts the sub-executor's `events.ts:StageStatus` back to the handler
32363
+ * return type `types.ts:OutcomeStatus`.
32364
+ *
32365
+ * Mapping:
32366
+ * 'SUCCESS' → 'SUCCESS'
32367
+ * 'PARTIAL_SUCCESS'→ 'PARTIAL_SUCCESS'
32368
+ * all others → 'FAILURE' (covers 'FAIL', 'RETRY', 'SKIPPED')
32369
+ */
32370
+ function denormalizeStatus(status) {
32371
+ if (status === "SUCCESS") return "SUCCESS";
32372
+ if (status === "PARTIAL_SUCCESS") return "PARTIAL_SUCCESS";
32373
+ return "FAILURE";
32374
+ }
32375
+ /**
32376
+ * Factory function that creates a handler for `type="subgraph"` nodes.
32377
+ *
32378
+ * Each subgraph node references an external `.dot` file, loads and validates
32379
+ * it, then executes it as a nested sub-pipeline. Parent context is seeded into
32380
+ * the sub-executor and context updates from the subgraph are propagated back.
32381
+ */
32382
+ function createSubgraphHandler(options) {
32383
+ return async (node, context, graph) => {
32384
+ const graphFile = node.attrs?.["graph_file"];
32385
+ if (!graphFile) return {
32386
+ status: "FAILURE",
32387
+ failureReason: `Subgraph node "${node.id}" is missing required attribute graph_file`
32388
+ };
32389
+ const currentDepth = context.getNumber("subgraph._depth", 0);
32390
+ const maxDepth = options.maxDepth ?? 5;
32391
+ if (currentDepth >= maxDepth) return {
32392
+ status: "FAILURE",
32393
+ failureReason: `Subgraph depth limit exceeded (max ${maxDepth}): node "${node.id}"`
32394
+ };
32395
+ const filePath = path.isAbsolute(graphFile) ? graphFile : path.join(options.baseDir, graphFile);
32396
+ const loader = options.graphFileLoader ?? ((fp) => readFile$1(fp, "utf-8"));
32397
+ let dotSource;
32398
+ try {
32399
+ dotSource = await loader(filePath);
32400
+ } catch (err) {
32401
+ const msg = err instanceof Error ? err.message : String(err);
32402
+ return {
32403
+ status: "FAILURE",
32404
+ failureReason: `Subgraph node "${node.id}": failed to load "${filePath}": ${msg}`
32405
+ };
32406
+ }
32407
+ let subgraph;
32408
+ try {
32409
+ subgraph = parseGraph(dotSource);
32410
+ } catch (err) {
32411
+ const msg = err instanceof Error ? err.message : String(err);
32412
+ return {
32413
+ status: "FAILURE",
32414
+ failureReason: `Subgraph node "${node.id}": failed to parse "${filePath}": ${msg}`
32415
+ };
32416
+ }
32417
+ try {
32418
+ createValidator().validateOrRaise(subgraph);
32419
+ } catch (err) {
32420
+ const msg = err instanceof Error ? err.message : String(err);
32421
+ return {
32422
+ status: "FAILURE",
32423
+ failureReason: `Subgraph node "${node.id}": validation failed for "${filePath}": ${msg}`
32424
+ };
32425
+ }
32426
+ const runId = context.getString("__runId", options.runId ?? "unknown");
32427
+ options.eventBus?.emit("graph:subgraph-started", {
32428
+ runId,
32429
+ nodeId: node.id,
32430
+ graphFile: filePath,
32431
+ depth: currentDepth
32432
+ });
32433
+ const subgraphStart = Date.now();
32434
+ const parentStylesheet = graph.modelStylesheet ? parseStylesheet(graph.modelStylesheet) : void 0;
32435
+ const subConfig = {
32436
+ runId: randomUUID(),
32437
+ logsRoot: options.logsRoot ?? tmpdir(),
32438
+ handlerRegistry: options.handlerRegistry,
32439
+ initialContext: {
32440
+ ...context.snapshot(),
32441
+ "subgraph._depth": currentDepth + 1
32442
+ },
32443
+ ...options.eventBus !== void 0 ? { eventBus: options.eventBus } : {},
32444
+ ...parentStylesheet !== void 0 ? { inheritedStylesheet: parentStylesheet } : {}
32445
+ };
32446
+ let subOutcome;
32447
+ try {
32448
+ subOutcome = await createGraphExecutor().run(subgraph, subConfig);
32449
+ } catch (err) {
32450
+ const msg = err instanceof Error ? err.message : String(err);
32451
+ const durationMs$1 = Date.now() - subgraphStart;
32452
+ options.eventBus?.emit("graph:subgraph-completed", {
32453
+ runId,
32454
+ nodeId: node.id,
32455
+ graphFile: filePath,
32456
+ depth: currentDepth,
32457
+ status: "FAIL",
32458
+ durationMs: durationMs$1
32459
+ });
32460
+ return {
32461
+ status: "FAILURE",
32462
+ failureReason: `Subgraph node "${node.id}": executor threw: ${msg}`
32463
+ };
32464
+ }
32465
+ const durationMs = Date.now() - subgraphStart;
32466
+ options.eventBus?.emit("graph:subgraph-completed", {
32467
+ runId,
32468
+ nodeId: node.id,
32469
+ graphFile: filePath,
32470
+ depth: currentDepth,
32471
+ status: subOutcome.status === "SUCCESS" ? "SUCCESS" : subOutcome.status === "PARTIAL_SUCCESS" ? "PARTIAL_SUCCESS" : "FAIL",
32472
+ durationMs
32473
+ });
32474
+ if (subOutcome.contextUpdates) context.applyUpdates(subOutcome.contextUpdates);
32475
+ return {
32476
+ status: denormalizeStatus(subOutcome.status),
32477
+ ...subOutcome.contextUpdates !== void 0 && { contextUpdates: subOutcome.contextUpdates },
32478
+ ...subOutcome.notes !== void 0 && { notes: subOutcome.notes },
32479
+ ...subOutcome.failureReason !== void 0 && { failureReason: subOutcome.failureReason }
32480
+ };
32481
+ };
32482
+ }
32483
+
32484
+ //#endregion
32485
+ //#region packages/factory/dist/handlers/manager-loop.js
32486
+ /**
32487
+ * Factory function that creates a handler for `type="stack.manager_loop"` nodes.
32488
+ *
32489
+ * Each invocation loads the body graph once, then executes it in a loop for up
32490
+ * to `max_cycles` cycles (default 10). Stop conditions (context key or LLM-based)
32491
+ * allow early exit. Stall detection injects recovery steering after consecutive
32492
+ * non-SUCCESS body executions.
32493
+ */
32494
+ function createManagerLoopHandler(options) {
32495
+ return async (node, context, _graph) => {
32496
+ const graphFile = node.attrs?.["graph_file"];
32497
+ if (!graphFile) return {
32498
+ status: "FAILURE",
32499
+ failureReason: `Manager loop node "${node.id}" is missing required attribute graph_file`
32500
+ };
32501
+ const rawMaxCycles = node.attrs?.["max_cycles"];
32502
+ let maxCycles = 10;
32503
+ if (rawMaxCycles !== void 0 && rawMaxCycles !== "") {
32504
+ const parsed = parseInt(rawMaxCycles, 10);
32505
+ maxCycles = isNaN(parsed) ? 10 : Math.max(1, parsed);
32506
+ }
32507
+ const filePath = path.isAbsolute(graphFile) ? graphFile : path.join(options.baseDir ?? process.cwd(), graphFile);
32508
+ const loader = options.graphFileLoader ?? ((fp) => readFile$1(fp, "utf-8"));
32509
+ let dotSource;
32510
+ try {
32511
+ dotSource = await loader(filePath);
32512
+ } catch (err) {
32513
+ const msg = err instanceof Error ? err.message : String(err);
32514
+ return {
32515
+ status: "FAILURE",
32516
+ failureReason: `Manager loop node "${node.id}": failed to load "${filePath}": ${msg}`
32517
+ };
32518
+ }
32519
+ let bodyGraph;
32520
+ try {
32521
+ bodyGraph = parseGraph(dotSource);
32522
+ } catch (err) {
32523
+ const msg = err instanceof Error ? err.message : String(err);
32524
+ return {
32525
+ status: "FAILURE",
32526
+ failureReason: `Manager loop node "${node.id}": failed to parse "${filePath}": ${msg}`
32527
+ };
32528
+ }
32529
+ try {
32530
+ createValidator().validateOrRaise(bodyGraph);
32531
+ } catch (err) {
32532
+ const msg = err instanceof Error ? err.message : String(err);
32533
+ return {
32534
+ status: "FAILURE",
32535
+ failureReason: `Manager loop node "${node.id}": validation failed for "${filePath}": ${msg}`
32536
+ };
32537
+ }
32538
+ const stopCondition = node.attrs?.["stop_condition"];
32539
+ let consecutiveFailures = 0;
32540
+ for (let cycle = 1; cycle <= maxCycles; cycle++) {
32541
+ context.set("manager_loop.cycle", cycle);
32542
+ const bodyConfig = {
32543
+ runId: randomUUID(),
32544
+ logsRoot: options.logsRoot ?? tmpdir(),
32545
+ handlerRegistry: options.handlerRegistry,
32546
+ initialContext: context.snapshot()
32547
+ };
32548
+ const bodyOutcome = await createGraphExecutor().run(bodyGraph, bodyConfig);
32549
+ if (bodyOutcome.contextUpdates) context.applyUpdates(bodyOutcome.contextUpdates);
32550
+ context.set("manager_loop.cycles_completed", cycle);
32551
+ context.set("manager_loop.last_outcome", bodyOutcome.status);
32552
+ if (bodyOutcome.status === "SUCCESS") {
32553
+ consecutiveFailures = 0;
32554
+ context.set("manager_loop.steering.mode", "normal");
32555
+ context.set("manager_loop.steering.hints", []);
32556
+ } else {
32557
+ consecutiveFailures++;
32558
+ if (consecutiveFailures >= (options.maxStallCycles ?? 2)) {
32559
+ context.set("manager_loop.steering.mode", "recovery");
32560
+ context.set("manager_loop.steering.hints", [`Previous ${consecutiveFailures} attempts returned ${bodyOutcome.status}. Consider a different strategy.`, "Review context state and adjust approach before retrying."]);
32561
+ }
32562
+ }
32563
+ if (stopCondition) {
32564
+ let shouldStop = false;
32565
+ if (isLlmCondition(stopCondition)) {
32566
+ if (options.llmCall !== void 0) {
32567
+ const question = extractLlmQuestion(stopCondition);
32568
+ shouldStop = await evaluateLlmCondition(question, context.snapshot(), options.llmCall);
32569
+ }
32570
+ } else shouldStop = Boolean(context.get(stopCondition));
32571
+ if (shouldStop) {
32572
+ context.set("manager_loop.stop_reason", "stop_condition");
32573
+ return { status: "SUCCESS" };
32574
+ }
32575
+ }
32576
+ }
32577
+ context.set("manager_loop.stop_reason", "max_cycles");
32578
+ return { status: "SUCCESS" };
32579
+ };
32580
+ }
32581
+
31425
32582
  //#endregion
31426
32583
  //#region packages/factory/dist/handlers/registry.js
31427
32584
  var HandlerRegistry = class {
@@ -31480,19 +32637,40 @@ var HandlerRegistry = class {
31480
32637
  * Story 42-10 adds codergen as the explicit "codergen" type, as the handler for
31481
32638
  * `shape=box` nodes, and as the registry-level default for any node that has no
31482
32639
  * recognised type or shape mapping.
32640
+ *
32641
+ * Story 50-5 extends options to `DefaultRegistryOptions` and registers the `subgraph` type.
31483
32642
  */
31484
32643
  function createDefaultRegistry(options) {
31485
32644
  const registry = new HandlerRegistry();
32645
+ registry.register("parallel", createParallelHandler({
32646
+ handlerRegistry: registry,
32647
+ ...options?.eventBus !== void 0 ? { eventBus: options.eventBus } : {},
32648
+ ...options?.runId !== void 0 ? { runId: options.runId } : {}
32649
+ }));
32650
+ registry.registerShape("component", "parallel");
31486
32651
  registry.register("start", startHandler);
31487
32652
  registry.register("exit", exitHandler);
31488
32653
  registry.register("conditional", conditionalHandler);
31489
32654
  registry.register("codergen", createCodergenHandler(options));
31490
32655
  registry.register("tool", createToolHandler());
31491
32656
  registry.register("wait.human", createWaitHumanHandler());
32657
+ registry.register("parallel.fan_in", createFanInHandler());
32658
+ registry.register("subgraph", createSubgraphHandler({
32659
+ handlerRegistry: registry,
32660
+ baseDir: options?.baseDir ?? process.cwd(),
32661
+ ...options?.eventBus !== void 0 ? { eventBus: options.eventBus } : {},
32662
+ ...options?.runId !== void 0 ? { runId: options.runId } : {}
32663
+ }));
32664
+ registry.register("stack.manager_loop", createManagerLoopHandler({
32665
+ handlerRegistry: registry,
32666
+ baseDir: options?.baseDir ?? process.cwd(),
32667
+ ...options?.llmCall !== void 0 ? { llmCall: options.llmCall } : {}
32668
+ }));
31492
32669
  registry.registerShape("Mdiamond", "start");
31493
32670
  registry.registerShape("Msquare", "exit");
31494
32671
  registry.registerShape("diamond", "conditional");
31495
32672
  registry.registerShape("box", "codergen");
32673
+ registry.registerShape("tripleoctagon", "parallel.fan_in");
31496
32674
  registry.setDefault(createCodergenHandler(options));
31497
32675
  return registry;
31498
32676
  }
@@ -36738,6 +37916,149 @@ function registerContextCommand(factoryCmd, version, storageDir, engineFactory)
36738
37916
  });
36739
37917
  }
36740
37918
 
37919
+ //#endregion
37920
+ //#region packages/factory/dist/templates/index.js
37921
+ /**
37922
+ * Pipeline Template Catalog — pre-built DOT graph pipeline templates for common patterns.
37923
+ *
37924
+ * Story 50-10.
37925
+ *
37926
+ * Node type strings used:
37927
+ * - `start` — start node (registered by default registry)
37928
+ * - `exit` — exit node (registered by default registry)
37929
+ * - `codergen` — coding agent node (registered by default registry)
37930
+ * - `parallel` — parallel fan-out node (story 50-1, shape=component)
37931
+ * - `parallel.fan_in`— fan-in/merge node (story 50-2, shape=tripleoctagon)
37932
+ */
37933
+ const trycycleDotContent = `digraph trycycle {
37934
+ // Trycycle Pattern: iterative refinement with eval gates
37935
+ // Flow: define → plan → eval_plan ⇄ implement → eval_impl → exit
37936
+ // eval nodes can loop back to their upstream node on revision_needed.
37937
+
37938
+ start [type="start"];
37939
+ define [type="codergen", label="Define Requirements"];
37940
+ plan [type="codergen", label="Plan Implementation"];
37941
+ eval_plan [type="codergen", label="Evaluate Plan"];
37942
+ implement [type="codergen", label="Implement"];
37943
+ eval_impl [type="codergen", label="Evaluate Implementation"];
37944
+ exit [type="exit"];
37945
+
37946
+ start -> define;
37947
+ define -> plan;
37948
+ plan -> eval_plan;
37949
+ eval_plan -> implement [label="approved"];
37950
+ eval_plan -> plan [label="revision_needed"];
37951
+ implement -> eval_impl;
37952
+ eval_impl -> exit [label="approved"];
37953
+ eval_impl -> implement [label="revision_needed"];
37954
+ }
37955
+ `;
37956
+ const dualReviewDotContent = `digraph dual_review {
37957
+ // Dual-Review Pattern: fan-out to two independent reviewers, then fan-in
37958
+ // Flow: implement → (reviewer_a + reviewer_b) in parallel → merge → exit
37959
+
37960
+ start [type="start"];
37961
+ implement [type="codergen", label="Implement"];
37962
+ review_parallel [type="parallel", label="Fan-Out to Reviewers"];
37963
+ reviewer_a [type="codergen", label="Reviewer A"];
37964
+ reviewer_b [type="codergen", label="Reviewer B"];
37965
+ review_merge [type="parallel.fan_in",label="Merge Reviews"];
37966
+ exit [type="exit"];
37967
+
37968
+ start -> implement;
37969
+ implement -> review_parallel;
37970
+ review_parallel -> reviewer_a;
37971
+ review_parallel -> reviewer_b;
37972
+ reviewer_a -> review_merge;
37973
+ reviewer_b -> review_merge;
37974
+ review_merge -> exit;
37975
+ }
37976
+ `;
37977
+ const parallelExplorationDotContent = `digraph parallel_exploration {
37978
+ // Parallel-Exploration Pattern: dispatch multiple approaches concurrently,
37979
+ // select the best-scoring result, then refine the winner.
37980
+ // Flow: (approach_a + approach_b) in parallel → select best → refine → exit
37981
+
37982
+ start [type="start"];
37983
+ explore_parallel[type="parallel", label="Fan-Out Exploration"];
37984
+ approach_a [type="codergen", label="Approach A"];
37985
+ approach_b [type="codergen", label="Approach B"];
37986
+ select_best [type="parallel.fan_in", label="Select Best Candidate", selection="best"];
37987
+ refine [type="codergen", label="Refine Winner"];
37988
+ exit [type="exit"];
37989
+
37990
+ start -> explore_parallel;
37991
+ explore_parallel -> approach_a;
37992
+ explore_parallel -> approach_b;
37993
+ approach_a -> select_best;
37994
+ approach_b -> select_best;
37995
+ select_best -> refine;
37996
+ refine -> exit;
37997
+ }
37998
+ `;
37999
+ const stagedValidationDotContent = `digraph staged_validation {
38000
+ // Staged-Validation Pattern: sequential quality gates
38001
+ // Flow: implement → lint → test → validate → exit
38002
+ // Each stage is a separate codergen node; no parallel branches.
38003
+
38004
+ start [type="start"];
38005
+ implement[type="codergen", label="Implement"];
38006
+ lint [type="codergen", label="Lint"];
38007
+ test [type="codergen", label="Test"];
38008
+ validate [type="codergen", label="Validate"];
38009
+ exit [type="exit"];
38010
+
38011
+ start -> implement;
38012
+ implement -> lint;
38013
+ lint -> test;
38014
+ test -> validate;
38015
+ validate -> exit;
38016
+ }
38017
+ `;
38018
+ const trycycleTemplate = {
38019
+ name: "trycycle",
38020
+ description: "Iterative refinement loop with plan and implementation eval gates",
38021
+ dotContent: trycycleDotContent
38022
+ };
38023
+ const dualReviewTemplate = {
38024
+ name: "dual-review",
38025
+ description: "Fan-out to two independent reviewers, then fan-in to merge results",
38026
+ dotContent: dualReviewDotContent
38027
+ };
38028
+ const parallelExplorationTemplate = {
38029
+ name: "parallel-exploration",
38030
+ description: "Dispatch parallel implementation approaches and select the best candidate",
38031
+ dotContent: parallelExplorationDotContent
38032
+ };
38033
+ const stagedValidationTemplate = {
38034
+ name: "staged-validation",
38035
+ description: "Sequential quality gate stages: implement → lint → test → validate",
38036
+ dotContent: stagedValidationDotContent
38037
+ };
38038
+ /**
38039
+ * Map of all built-in pipeline templates, keyed by template name.
38040
+ * Insertion order determines display order in `factory templates list`.
38041
+ */
38042
+ const PIPELINE_TEMPLATES = new Map([
38043
+ [trycycleTemplate.name, trycycleTemplate],
38044
+ [dualReviewTemplate.name, dualReviewTemplate],
38045
+ [parallelExplorationTemplate.name, parallelExplorationTemplate],
38046
+ [stagedValidationTemplate.name, stagedValidationTemplate]
38047
+ ]);
38048
+ /**
38049
+ * Returns all available pipeline template entries in insertion order.
38050
+ */
38051
+ function listPipelineTemplates() {
38052
+ return Array.from(PIPELINE_TEMPLATES.values());
38053
+ }
38054
+ /**
38055
+ * Returns the pipeline template entry for the given name (case-sensitive),
38056
+ * or `undefined` if not found.
38057
+ */
38058
+ function getPipelineTemplate(name) {
38059
+ return PIPELINE_TEMPLATES.get(name);
38060
+ }
38061
+
36741
38062
  //#endregion
36742
38063
  //#region packages/factory/dist/twins/schema.js
36743
38064
  /**
@@ -37776,6 +39097,28 @@ function registerFactoryCommand(program, options) {
37776
39097
  process.exit(1);
37777
39098
  }
37778
39099
  });
39100
+ const templatesCmd = factoryCmd.command("templates").description("Manage reusable DOT graph pipeline templates");
39101
+ templatesCmd.command("list").description("List available pipeline templates").action(() => {
39102
+ const templates = listPipelineTemplates();
39103
+ for (const t of templates) process.stdout.write(` ${t.name.padEnd(24)} ${t.description}\n`);
39104
+ });
39105
+ templatesCmd.command("init").description("Create a pipeline.dot from a template").requiredOption("--template <name>", "Template name (see: factory templates list)").option("--output <path>", "Output file path (default: pipeline.dot)", "pipeline.dot").action(async (opts) => {
39106
+ const entry = getPipelineTemplate(opts.template);
39107
+ if (!entry) {
39108
+ const available = listPipelineTemplates().map((t) => t.name).join(", ");
39109
+ process.stderr.write(`Error: Unknown template '${opts.template}'. Available: ${available}\n`);
39110
+ process.exit(1);
39111
+ return;
39112
+ }
39113
+ try {
39114
+ await writeFile$1(opts.output, entry.dotContent, "utf-8");
39115
+ process.stdout.write(`Created ${opts.output} from template '${entry.name}'\n`);
39116
+ } catch (err) {
39117
+ const msg = err instanceof Error ? err.message : String(err);
39118
+ process.stderr.write(`Error: ${msg}\n`);
39119
+ process.exit(1);
39120
+ }
39121
+ });
37779
39122
  }
37780
39123
 
37781
39124
  //#endregion
@@ -38104,6 +39447,7 @@ async function runRunAction(options) {
38104
39447
  });
38105
39448
  } catch {}
38106
39449
  let tokenCeilings;
39450
+ let dispatchTimeouts;
38107
39451
  let telemetryEnabled = false;
38108
39452
  let telemetryPort = 4318;
38109
39453
  try {
@@ -38111,6 +39455,10 @@ async function runRunAction(options) {
38111
39455
  await configSystem.load();
38112
39456
  const cfg = configSystem.getConfig();
38113
39457
  tokenCeilings = cfg.token_ceilings;
39458
+ if (cfg.dispatch_timeouts) {
39459
+ dispatchTimeouts = Object.fromEntries(Object.entries(cfg.dispatch_timeouts).filter(([, v]) => v !== void 0));
39460
+ logger.info({ dispatchTimeouts }, "Loaded dispatch timeout overrides from config");
39461
+ }
38114
39462
  if (cfg.telemetry?.enabled === true) {
38115
39463
  telemetryEnabled = true;
38116
39464
  telemetryPort = cfg.telemetry.port ?? 4318;
@@ -38397,7 +39745,10 @@ async function runRunAction(options) {
38397
39745
  const dispatcher = createDispatcher({
38398
39746
  eventBus,
38399
39747
  adapterRegistry: injectedRegistry,
38400
- config: { routingResolver }
39748
+ config: {
39749
+ routingResolver,
39750
+ ...dispatchTimeouts ? { defaultTimeouts: dispatchTimeouts } : {}
39751
+ }
38401
39752
  });
38402
39753
  eventBus.on("orchestrator:story-phase-complete", (payload) => {
38403
39754
  try {
@@ -39264,4 +40615,4 @@ function registerRunCommand(program, _version = "0.0.0", projectRoot = process.c
39264
40615
 
39265
40616
  //#endregion
39266
40617
  export { AdapterTelemetryPersistence, AppError, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, GitClient, GrammarLoader, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SymbolParser, createContextCompiler, createDispatcher, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, createTelemetryAdvisor, formatPhaseCompletionSummary, getFactoryRunSummaries, getScenarioResultsForRun, getTwinRunsForRun, listGraphRuns, normalizeGraphSummaryToStatus, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
39267
- //# sourceMappingURL=run-gXtnH8lO.js.map
40618
+ //# sourceMappingURL=run-Byzy10gG.js.map