substrate-ai 0.18.0 → 0.19.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.
@@ -12259,7 +12259,7 @@ function createImplementationOrchestrator(deps) {
12259
12259
  * @returns Sorted, deduplicated array of story keys in "N-M" format
12260
12260
  */
12261
12261
  async function resolveStoryKeys(db, projectRoot, opts) {
12262
- if (opts?.explicit !== void 0 && opts.explicit.length > 0) return opts.explicit;
12262
+ if (opts?.explicit !== void 0 && opts.explicit.length > 0) return topologicalSortByDependencies(opts.explicit, projectRoot);
12263
12263
  let keys = [];
12264
12264
  const readyKeys = await db.queryReadyStories();
12265
12265
  if (readyKeys.length > 0) {
@@ -12406,6 +12406,12 @@ function findEpicsFile(projectRoot) {
12406
12406
  const fullPath = join$1(projectRoot, candidate);
12407
12407
  if (existsSync(fullPath)) return fullPath;
12408
12408
  }
12409
+ const planningDir = join$1(projectRoot, "_bmad-output", "planning-artifacts");
12410
+ if (existsSync(planningDir)) try {
12411
+ const entries = readdirSync(planningDir, { encoding: "utf-8" });
12412
+ const match$1 = entries.filter((e) => /^epics[-.].*\.md$/i.test(e) && !/^epic-\d+/.test(e)).sort();
12413
+ if (match$1.length > 0) return join$1(planningDir, match$1[0]);
12414
+ } catch {}
12409
12415
  return void 0;
12410
12416
  }
12411
12417
  /**
@@ -12512,6 +12518,112 @@ function sortStoryKeys(keys) {
12512
12518
  return a.localeCompare(b);
12513
12519
  });
12514
12520
  }
12521
+ /**
12522
+ * Parse inter-story dependencies from the consolidated epics document.
12523
+ *
12524
+ * Scans for patterns like:
12525
+ * ### Story 50-2: Title
12526
+ * **Dependencies:** 50-1
12527
+ *
12528
+ * Returns a Map where key=storyKey, value=Set of dependency keys.
12529
+ * Only returns dependencies that are within the provided storyKeys set
12530
+ * (external dependencies to other epics are ignored for ordering purposes).
12531
+ */
12532
+ function parseEpicsDependencies(projectRoot, storyKeys) {
12533
+ const deps = new Map();
12534
+ const epicsPath = findEpicsFile(projectRoot);
12535
+ if (epicsPath === void 0) return deps;
12536
+ let content;
12537
+ try {
12538
+ content = readFileSync(epicsPath, "utf-8");
12539
+ } catch {
12540
+ return deps;
12541
+ }
12542
+ const storyPattern = /^###\s+Story\s+(\d+)-(\d+)[:\s]/gm;
12543
+ const depPattern = /^\*\*Dependencies:\*\*\s*(.+)$/gm;
12544
+ const storyPositions = [];
12545
+ let match$1;
12546
+ while ((match$1 = storyPattern.exec(content)) !== null) storyPositions.push({
12547
+ key: `${match$1[1]}-${match$1[2]}`,
12548
+ pos: match$1.index
12549
+ });
12550
+ for (let i = 0; i < storyPositions.length; i++) {
12551
+ const story = storyPositions[i];
12552
+ const nextStoryPos = i + 1 < storyPositions.length ? storyPositions[i + 1].pos : content.length;
12553
+ const section = content.slice(story.pos, nextStoryPos);
12554
+ depPattern.lastIndex = 0;
12555
+ const depMatch = depPattern.exec(section);
12556
+ if (depMatch === null || /^none$/i.test(depMatch[1].trim())) continue;
12557
+ const depText = depMatch[1];
12558
+ const storyDeps = new Set();
12559
+ const rangeMatch = /(\d+)-(\d+)\s+through\s+\1-(\d+)/i.exec(depText);
12560
+ if (rangeMatch !== null) {
12561
+ const epic = rangeMatch[1];
12562
+ const start = Number(rangeMatch[2]);
12563
+ const end = Number(rangeMatch[3]);
12564
+ for (let n$1 = start; n$1 <= end; n$1++) {
12565
+ const depKey = `${epic}-${n$1}`;
12566
+ if (storyKeys.has(depKey)) storyDeps.add(depKey);
12567
+ }
12568
+ } else {
12569
+ const keyPattern = /(\d+-\d+[a-z]?)/g;
12570
+ let km;
12571
+ while ((km = keyPattern.exec(depText)) !== null) {
12572
+ const depKey = km[1];
12573
+ if (storyKeys.has(depKey)) storyDeps.add(depKey);
12574
+ }
12575
+ }
12576
+ if (storyDeps.size > 0) deps.set(story.key, storyDeps);
12577
+ }
12578
+ return deps;
12579
+ }
12580
+ /**
12581
+ * Topologically sort explicit story keys by inter-story dependencies.
12582
+ *
12583
+ * Parses the consolidated epics document for dependency metadata, builds
12584
+ * a DAG, and returns keys in dependency-first order using Kahn's algorithm.
12585
+ * Stories with no dependencies come first; stories that depend on others
12586
+ * are placed after their prerequisites.
12587
+ *
12588
+ * Falls back to numeric sort if no epics document exists or no
12589
+ * dependencies are found among the provided keys.
12590
+ */
12591
+ function topologicalSortByDependencies(keys, projectRoot) {
12592
+ if (keys.length <= 1) return keys;
12593
+ const keySet = new Set(keys);
12594
+ const deps = parseEpicsDependencies(projectRoot, keySet);
12595
+ if (deps.size === 0) return sortStoryKeys(keys);
12596
+ const inDegree = new Map();
12597
+ const successors = new Map();
12598
+ for (const key of keys) {
12599
+ inDegree.set(key, 0);
12600
+ successors.set(key, new Set());
12601
+ }
12602
+ for (const [dependent, depSet] of deps) {
12603
+ if (!keySet.has(dependent)) continue;
12604
+ for (const dep of depSet) {
12605
+ if (!keySet.has(dep)) continue;
12606
+ successors.get(dep).add(dependent);
12607
+ inDegree.set(dependent, (inDegree.get(dependent) ?? 0) + 1);
12608
+ }
12609
+ }
12610
+ const result = [];
12611
+ const processed = new Set();
12612
+ while (processed.size < keys.length) {
12613
+ const wave = [];
12614
+ for (const key of keys) if (!processed.has(key) && (inDegree.get(key) ?? 0) === 0) wave.push(key);
12615
+ if (wave.length === 0) {
12616
+ for (const key of sortStoryKeys(keys)) if (!processed.has(key)) result.push(key);
12617
+ break;
12618
+ }
12619
+ for (const key of sortStoryKeys(wave)) {
12620
+ result.push(key);
12621
+ processed.add(key);
12622
+ for (const succ of successors.get(key) ?? []) inDegree.set(succ, (inDegree.get(succ) ?? 0) - 1);
12623
+ }
12624
+ }
12625
+ return result;
12626
+ }
12515
12627
 
12516
12628
  //#endregion
12517
12629
  //#region src/modules/phase-orchestrator/phase-detection.ts
@@ -21100,7 +21212,9 @@ function buildGraphNode(id, attrs, subgraphClass, defaultMaxRetries) {
21100
21212
  autoStatus: attrBool(attrs, "auto_status", true),
21101
21213
  allowPartial: attrBool(attrs, "allow_partial", false),
21102
21214
  toolCommand: attrStr(attrs, "tool_command", ""),
21103
- backend: attrStr(attrs, "backend", "")
21215
+ backend: attrStr(attrs, "backend", ""),
21216
+ maxParallel: attrInt(attrs, "maxParallel", 0),
21217
+ joinPolicy: attrStr(attrs, "joinPolicy", "")
21104
21218
  };
21105
21219
  }
21106
21220
  function buildGraphEdge(fromNode, toNode, attrs) {
@@ -21776,7 +21890,7 @@ const conditionSyntaxRule = {
21776
21890
  const diagnostics = [];
21777
21891
  for (let i = 0; i < graph.edges.length; i++) {
21778
21892
  const edge = graph.edges[i];
21779
- if (edge.condition) try {
21893
+ if (edge.condition && !edge.condition.trim().startsWith("llm:")) try {
21780
21894
  parseCondition(edge.condition);
21781
21895
  } catch (err) {
21782
21896
  if (err instanceof ConditionParseError) diagnostics.push({
@@ -21969,6 +22083,102 @@ function createValidator() {
21969
22083
  };
21970
22084
  }
21971
22085
 
22086
+ //#endregion
22087
+ //#region packages/factory/dist/graph/llm-evaluator.js
22088
+ /**
22089
+ * LLM-based edge condition evaluator for the factory graph engine.
22090
+ *
22091
+ * Provides pure, side-effect-free functions for detecting `llm:` prefixed
22092
+ * conditions, building evaluation prompts, parsing boolean responses, and
22093
+ * executing an LLM-backed condition evaluation with safe error handling.
22094
+ *
22095
+ * Zero external package imports — all LLM wiring is done by the caller
22096
+ * (edge-selector.ts) via the injectable `llmCall` parameter.
22097
+ *
22098
+ * Story 50-4.
22099
+ */
22100
+ /**
22101
+ * Returns `true` iff the condition string starts with the `"llm:"` prefix
22102
+ * (after trimming leading/trailing whitespace).
22103
+ */
22104
+ function isLlmCondition(condition) {
22105
+ return condition.trim().startsWith("llm:");
22106
+ }
22107
+ /**
22108
+ * Strips the `"llm:"` prefix and trims surrounding whitespace from the
22109
+ * remainder of the condition string.
22110
+ *
22111
+ * @param condition - A condition string beginning with `"llm:"`.
22112
+ * @returns The extracted question text, trimmed.
22113
+ */
22114
+ function extractLlmQuestion(condition) {
22115
+ return condition.trim().slice(4).trim();
22116
+ }
22117
+ /**
22118
+ * Builds an LLM evaluation prompt that includes the question and a JSON
22119
+ * block of the current context snapshot, with an explicit instruction to
22120
+ * answer with only "yes" or "no".
22121
+ *
22122
+ * @param question - The routing question extracted from the condition.
22123
+ * @param contextSnapshot - Shallow copy of the current execution context.
22124
+ * @returns The fully constructed prompt string.
22125
+ */
22126
+ function buildEvaluationPrompt(question, contextSnapshot) {
22127
+ return [
22128
+ `You are evaluating a routing condition in a software pipeline.`,
22129
+ ``,
22130
+ `Context:`,
22131
+ JSON.stringify(contextSnapshot, null, 2),
22132
+ ``,
22133
+ `Question: ${question}`,
22134
+ ``,
22135
+ `Answer with exactly "yes" or "no".`
22136
+ ].join("\n");
22137
+ }
22138
+ /**
22139
+ * Parses an LLM yes/no response into a boolean value.
22140
+ *
22141
+ * Returns `true` if the cleaned (trimmed, lowercased) response starts with or
22142
+ * contains one of: `"yes"`, `"true"`, `"affirmative"`, `"correct"`, `"1"`.
22143
+ * Returns `false` otherwise, including for an empty string.
22144
+ *
22145
+ * @param response - Raw text response from the LLM.
22146
+ * @returns `true` for affirmative responses, `false` for all others.
22147
+ */
22148
+ function parseLlmBoolResponse(response) {
22149
+ const cleaned = response.trim().toLowerCase();
22150
+ const affirmatives = [
22151
+ "yes",
22152
+ "true",
22153
+ "affirmative",
22154
+ "correct",
22155
+ "1"
22156
+ ];
22157
+ return affirmatives.some((token) => cleaned === token || cleaned.startsWith(token + " ") || cleaned.startsWith(token + "\n"));
22158
+ }
22159
+ /**
22160
+ * Evaluates an LLM edge condition asynchronously.
22161
+ *
22162
+ * Builds the evaluation prompt, calls the injectable `llmCall` function,
22163
+ * and parses the response via `parseLlmBoolResponse`. If any step throws,
22164
+ * returns `false` silently — never re-throws.
22165
+ *
22166
+ * @param question - The routing question to evaluate.
22167
+ * @param contextSnapshot - Shallow copy of the current execution context.
22168
+ * @param llmCall - Injectable async function that calls an LLM and
22169
+ * returns the raw text response.
22170
+ * @returns `true` if the LLM responds affirmatively, `false` otherwise or on error.
22171
+ */
22172
+ async function evaluateLlmCondition(question, contextSnapshot, llmCall) {
22173
+ try {
22174
+ const prompt = buildEvaluationPrompt(question, contextSnapshot);
22175
+ const response = await llmCall(prompt);
22176
+ return parseLlmBoolResponse(response);
22177
+ } catch {
22178
+ return false;
22179
+ }
22180
+ }
22181
+
21972
22182
  //#endregion
21973
22183
  //#region packages/factory/dist/graph/edge-selector.js
21974
22184
  /**
@@ -22010,6 +22220,7 @@ function bestByWeightThenLexical(edges) {
22010
22220
  * Select the best outgoing edge from `node` according to the 5-step Attractor spec.
22011
22221
  *
22012
22222
  * Step 1: Condition-matched edges — highest weight, lexical tiebreak.
22223
+ * Edges with `llm:` prefix conditions are evaluated via LLM call.
22013
22224
  * Step 2: Preferred label match on unconditional edges — first match wins.
22014
22225
  * Step 3: Suggested next IDs on unconditional edges — first suggestedNextId wins.
22015
22226
  * Step 4: Highest weight among all unconditional edges.
@@ -22019,14 +22230,48 @@ function bestByWeightThenLexical(edges) {
22019
22230
  * @param outcome - The outcome returned by the node's handler.
22020
22231
  * @param context - The current execution context (used for condition evaluation).
22021
22232
  * @param graph - The full graph (source of edges).
22233
+ * @param options - Optional injectable overrides (e.g. llmCall for testing).
22022
22234
  * @returns The selected `GraphEdge`, or `null` if no outgoing edges exist.
22023
22235
  */
22024
- function selectEdge(node, outcome, context, graph) {
22236
+ async function selectEdge(node, outcome, context, graph, options) {
22025
22237
  const outgoing = graph.edges.filter((e) => e.fromNode === node.id);
22026
22238
  if (outgoing.length === 0) return null;
22239
+ const defaultLlmCall$1 = (prompt) => callLLM({
22240
+ model: node.llmModel || "claude-haiku-4-5",
22241
+ provider: node.llmProvider || "anthropic",
22242
+ reasoningEffort: "low",
22243
+ prompt
22244
+ }).then((r) => r.text);
22245
+ const llmCall = options?.llmCall ?? defaultLlmCall$1;
22027
22246
  const conditionMatches = [];
22028
22247
  const snapshot = context.snapshot();
22029
- for (const edge of outgoing) if (edge.condition && edge.condition.trim() !== "") try {
22248
+ for (const edge of outgoing) if (edge.condition && edge.condition.trim() !== "") if (isLlmCondition(edge.condition)) {
22249
+ const question = extractLlmQuestion(edge.condition);
22250
+ let evalError = null;
22251
+ const trackingLlmCall = async (prompt) => {
22252
+ try {
22253
+ return await llmCall(prompt);
22254
+ } catch (err) {
22255
+ evalError = err instanceof Error ? err.message : String(err);
22256
+ throw err;
22257
+ }
22258
+ };
22259
+ const matched = await evaluateLlmCondition(question, snapshot, trackingLlmCall);
22260
+ options?.eventBus?.emit("graph:llm-edge-evaluated", {
22261
+ runId: options.runId ?? "unknown",
22262
+ nodeId: node.id,
22263
+ question,
22264
+ result: matched
22265
+ });
22266
+ context.set("llm.edge_eval_count", context.getNumber("llm.edge_eval_count", 0) + 1);
22267
+ if (evalError !== null) {
22268
+ const existing = context.get("llm.edge_eval_errors");
22269
+ const errors = Array.isArray(existing) ? existing : [];
22270
+ errors.push(evalError);
22271
+ context.set("llm.edge_eval_errors", errors);
22272
+ }
22273
+ if (matched) conditionMatches.push(edge);
22274
+ } else try {
22030
22275
  if (evaluateCondition(edge.condition, snapshot)) conditionMatches.push(edge);
22031
22276
  } catch {}
22032
22277
  if (conditionMatches.length > 0) return bestByWeightThenLexical(conditionMatches);
@@ -22987,6 +23232,111 @@ function resolveFidelity(node, incomingEdge, graph) {
22987
23232
  return "";
22988
23233
  }
22989
23234
 
23235
+ //#endregion
23236
+ //#region packages/factory/dist/stylesheet/resolver.js
23237
+ /**
23238
+ * Specificity-based resolver for the model stylesheet.
23239
+ *
23240
+ * Given a parsed stylesheet and a graph node, `resolveNodeStyles` determines
23241
+ * which LLM routing properties apply to that node by:
23242
+ * 1. Filtering rules to only those whose selector matches the node.
23243
+ * 2. Sorting matching rules by specificity ascending (stable sort so that
23244
+ * equal-specificity rules preserve source order).
23245
+ * 3. Iterating the sorted list and letting each rule overwrite properties
23246
+ * — the last rule at the highest specificity wins; for ties the rule
23247
+ * appearing later in the original stylesheet wins.
23248
+ *
23249
+ * **Caller contract**: `resolveNodeStyles` does NOT enforce the "explicit node
23250
+ * attribute wins" rule. The caller (executor / node preparation layer) is
23251
+ * responsible for the final merge:
23252
+ * ```typescript
23253
+ * const resolved = resolveNodeStyles(node, stylesheet)
23254
+ * const finalModel = node.llmModel || resolved.llmModel || graph.defaultLlmModel || ''
23255
+ * ```
23256
+ *
23257
+ * Story 42-7.
23258
+ */
23259
+ /**
23260
+ * Return `true` if the given `selector` matches the provided `node`.
23261
+ *
23262
+ * Matching rules:
23263
+ * - `universal`: always matches every node.
23264
+ * - `shape`: matches when `node.shape === selector.value`.
23265
+ * - `class`: matches when the node's `class` field, split on commas and
23266
+ * trimmed, contains `selector.value` (case-sensitive).
23267
+ * - `id`: matches when `node.id === selector.value`.
23268
+ */
23269
+ function matchesNode(node, selector) {
23270
+ switch (selector.type) {
23271
+ case "universal": return true;
23272
+ case "shape": return node.shape === selector.value;
23273
+ case "class": {
23274
+ const tokens = node.class.split(",").map((t) => t.trim());
23275
+ return tokens.includes(selector.value);
23276
+ }
23277
+ case "id": return node.id === selector.value;
23278
+ default: return false;
23279
+ }
23280
+ }
23281
+ /**
23282
+ * Resolve LLM routing properties for a single graph node using the parsed stylesheet.
23283
+ *
23284
+ * @param node - The graph node to resolve properties for.
23285
+ * @param stylesheet - A parsed stylesheet (array of `StylesheetRule` in source order).
23286
+ * @returns A `ResolvedNodeStyles` object containing only the properties that
23287
+ * were resolved from matching rules. Properties absent from matching rules
23288
+ * are omitted (not set to `undefined` explicitly).
23289
+ *
23290
+ * **Important**: this function does NOT check the node's own attributes.
23291
+ * Callers must give explicit node attributes (`node.llmModel`, etc.) priority
23292
+ * over the values returned here.
23293
+ */
23294
+ function resolveNodeStyles(node, stylesheet) {
23295
+ const matchingRules = stylesheet.filter((rule) => matchesNode(node, rule.selector));
23296
+ matchingRules.sort((a, b) => a.selector.specificity - b.selector.specificity);
23297
+ const resolved = {};
23298
+ for (const rule of matchingRules) for (const decl of rule.declarations) if (decl.property === "llm_model") resolved.llmModel = decl.value;
23299
+ else if (decl.property === "llm_provider") resolved.llmProvider = decl.value;
23300
+ else if (decl.property === "reasoning_effort") resolved.reasoningEffort = decl.value;
23301
+ return resolved;
23302
+ }
23303
+
23304
+ //#endregion
23305
+ //#region packages/factory/dist/graph/transformer.js
23306
+ /**
23307
+ * Apply stylesheet rules to all nodes in the given graph.
23308
+ *
23309
+ * Merges `inheritedStylesheet` (parent rules) with the graph's own
23310
+ * `modelStylesheet` rules, parent rules first so that local child rules win at
23311
+ * equal specificity via source-order tie-breaking (later rule wins).
23312
+ *
23313
+ * For each node, the resolved properties are applied only if the node does NOT
23314
+ * already have a non-empty explicit value (explicit values are preserved).
23315
+ *
23316
+ * This function **mutates** graph nodes in-place — consistent with how the
23317
+ * DOT parser sets node fields after parsing.
23318
+ *
23319
+ * **Idempotency**: calling `applyStylesheet` twice on the same graph is safe
23320
+ * because nodes with an existing non-empty `llmModel` (or `llmProvider` /
23321
+ * `reasoningEffort`) are never overwritten.
23322
+ *
23323
+ * @param graph - The graph whose nodes should be styled.
23324
+ * @param inheritedStylesheet - Optional stylesheet rules inherited from a
23325
+ * parent graph. Prepended before the graph's own rules so that child local
23326
+ * rules win at equal specificity. Pass `undefined` when there is no parent.
23327
+ */
23328
+ function applyStylesheet(graph, inheritedStylesheet) {
23329
+ const localRules = graph.modelStylesheet ? parseStylesheet(graph.modelStylesheet) : [];
23330
+ const effectiveStylesheet = [...inheritedStylesheet ?? [], ...localRules];
23331
+ if (effectiveStylesheet.length === 0) return;
23332
+ for (const node of graph.nodes.values()) {
23333
+ const resolved = resolveNodeStyles(node, effectiveStylesheet);
23334
+ if (!node.llmModel && resolved.llmModel) node.llmModel = resolved.llmModel;
23335
+ if (!node.llmProvider && resolved.llmProvider) node.llmProvider = resolved.llmProvider;
23336
+ if (!node.reasoningEffort && resolved.reasoningEffort) node.reasoningEffort = resolved.reasoningEffort;
23337
+ }
23338
+ }
23339
+
22990
23340
  //#endregion
22991
23341
  //#region packages/factory/dist/graph/executor.js
22992
23342
  /**
@@ -23081,6 +23431,7 @@ async function dispatchWithRetry(node, context, graph, config, nodeRetries) {
23081
23431
  */
23082
23432
  function createGraphExecutor() {
23083
23433
  return { async run(graph, config) {
23434
+ applyStylesheet(graph, config.inheritedStylesheet);
23084
23435
  const checkpointManager = new CheckpointManager();
23085
23436
  const checkpointFilePath = path.join(config.logsRoot, "checkpoint.json");
23086
23437
  const controller = createConvergenceController();
@@ -23176,7 +23527,10 @@ function createGraphExecutor() {
23176
23527
  if (resumeState.completedNodes.has(checkpoint.currentNode)) {
23177
23528
  const lastNode = graph.nodes.get(checkpoint.currentNode);
23178
23529
  if (lastNode) {
23179
- const nextEdge = selectEdge(lastNode, { status: "SUCCESS" }, context, graph);
23530
+ const nextEdge = await selectEdge(lastNode, { status: "SUCCESS" }, context, graph, {
23531
+ ...config.eventBus !== void 0 ? { eventBus: config.eventBus } : {},
23532
+ runId: config.runId
23533
+ });
23180
23534
  if (nextEdge) {
23181
23535
  const nextNode = graph.nodes.get(nextEdge.toNode);
23182
23536
  if (!nextNode) throw new Error(`Edge target node "${nextEdge.toNode}" not found in graph`);
@@ -23189,6 +23543,7 @@ function createGraphExecutor() {
23189
23543
  else currentNode = graph.startNode();
23190
23544
  }
23191
23545
  } else currentNode = graph.startNode();
23546
+ context.set("__runId", config.runId ?? "unknown");
23192
23547
  while (true) {
23193
23548
  const sessionResult = sessionManager.checkBudget((config.wallClockCapMs ?? 0) / 1e3);
23194
23549
  if (!sessionResult.allowed) {
@@ -23279,7 +23634,10 @@ function createGraphExecutor() {
23279
23634
  return { status: "SUCCESS" };
23280
23635
  }
23281
23636
  if (resumeCompletedSet?.has(currentNode.id)) {
23282
- const skipEdge = selectEdge(currentNode, { status: "SUCCESS" }, context, graph);
23637
+ const skipEdge = await selectEdge(currentNode, { status: "SUCCESS" }, context, graph, {
23638
+ ...config.eventBus !== void 0 ? { eventBus: config.eventBus } : {},
23639
+ runId: config.runId
23640
+ });
23283
23641
  if (!skipEdge) {
23284
23642
  await persistExit("failed", `No outgoing edge from node ${currentNode.id}`);
23285
23643
  return {
@@ -23464,7 +23822,10 @@ function createGraphExecutor() {
23464
23822
  };
23465
23823
  }
23466
23824
  }
23467
- const edge = selectEdge(currentNode, outcome, context, graph);
23825
+ const edge = await selectEdge(currentNode, outcome, context, graph, {
23826
+ ...config.eventBus !== void 0 ? { eventBus: config.eventBus } : {},
23827
+ runId: config.runId
23828
+ });
23468
23829
  if (!edge) {
23469
23830
  await persistExit("failed", `No outgoing edge from node ${currentNode.id}`);
23470
23831
  return {
@@ -31087,75 +31448,6 @@ const conditionalHandler = async (_node, _context, _graph) => {
31087
31448
  return { status: "SUCCESS" };
31088
31449
  };
31089
31450
 
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
31451
  //#endregion
31160
31452
  //#region packages/factory/dist/handlers/codergen-handler.js
31161
31453
  const DEFAULT_MODEL = "claude-sonnet-4-5";
@@ -31423,32 +31715,788 @@ function createWaitHumanHandler(options) {
31423
31715
  }
31424
31716
 
31425
31717
  //#endregion
31426
- //#region packages/factory/dist/handlers/registry.js
31427
- var HandlerRegistry = class {
31428
- /** Maps node type handler function */
31429
- _handlers = new Map();
31430
- /** Maps DOT shape name → canonical node type */
31431
- _shapeMap = new Map();
31432
- /** Fallback handler when no type or shape match is found */
31433
- _default;
31434
- /**
31435
- * Register a handler for the given node type.
31436
- * Overwrites if already registered.
31437
- */
31438
- register(type, handler) {
31439
- this._handlers.set(type, handler);
31440
- }
31441
- /**
31442
- * Register a shape type mapping.
31443
- * Overwrites if already registered.
31444
- */
31445
- registerShape(shape, type) {
31446
- this._shapeMap.set(shape, type);
31447
- }
31448
- /**
31449
- * Set the default handler used when no type or shape match is found.
31450
- */
31451
- setDefault(handler) {
31718
+ //#region packages/factory/dist/handlers/join-policy.js
31719
+ /**
31720
+ * Join policy types, pure evaluator, and branch cancellation manager
31721
+ * for parallel node fan-out/fan-in coordination.
31722
+ *
31723
+ * Design constraints:
31724
+ * - Zero external package imports (pure TypeScript/built-ins only)
31725
+ * - `evaluateJoinPolicy` is a pure synchronous function (no I/O, no side effects)
31726
+ * - `BranchCancellationManager` uses the globally-available AbortController (Node 18+)
31727
+ *
31728
+ * Story 50-3 (AC6).
31729
+ */
31730
+ /**
31731
+ * Pure synchronous function that decides what the parallel handler should do
31732
+ * after a set of branches has completed.
31733
+ *
31734
+ * Called incrementally after each branch finishes: pass the full `completed`
31735
+ * array (including all branches resolved so far) and the `total` branch count.
31736
+ *
31737
+ * @param config - Join policy configuration parsed from the parallel node.
31738
+ * @param completed - All branch results collected so far (any order).
31739
+ * @param total - Total number of branches in the fan-out set.
31740
+ * @returns A `JoinDecision` indicating whether to continue, wait, or fail.
31741
+ */
31742
+ function evaluateJoinPolicy(config, completed, total) {
31743
+ const successes = completed.filter((r) => r.outcome === "SUCCESS");
31744
+ const failures = completed.filter((r) => r.outcome === "FAIL");
31745
+ switch (config.policy) {
31746
+ case "wait_all": {
31747
+ if (completed.length < total) return { action: "wait" };
31748
+ return {
31749
+ action: "continue",
31750
+ results: completed
31751
+ };
31752
+ }
31753
+ case "first_success": {
31754
+ if (successes.length >= 1) return {
31755
+ action: "continue",
31756
+ results: completed
31757
+ };
31758
+ if (completed.length >= total) return {
31759
+ action: "fail",
31760
+ reason: `first_success: all ${total} branches failed`
31761
+ };
31762
+ return { action: "wait" };
31763
+ }
31764
+ case "quorum": {
31765
+ const needed = config.quorum_size ?? 1;
31766
+ if (needed <= 0) return {
31767
+ action: "fail",
31768
+ reason: "quorum_size must be >= 1"
31769
+ };
31770
+ if (successes.length >= needed) return {
31771
+ action: "continue",
31772
+ results: completed
31773
+ };
31774
+ const remaining = total - completed.length;
31775
+ if (successes.length + remaining < needed) return {
31776
+ action: "fail",
31777
+ reason: `quorum unreachable: ${failures.length} failed, needed ${needed} of ${total}`
31778
+ };
31779
+ return { action: "wait" };
31780
+ }
31781
+ default: {
31782
+ const _exhaustive = config.policy;
31783
+ return {
31784
+ action: "fail",
31785
+ reason: `unknown join policy: ${String(_exhaustive)}`
31786
+ };
31787
+ }
31788
+ }
31789
+ }
31790
+ /**
31791
+ * Manages one `AbortController` per branch in a parallel fan-out execution.
31792
+ *
31793
+ * Typical usage:
31794
+ * 1. Construct with the total branch count.
31795
+ * 2. Pass `getSignal(i)` to the i-th branch executor.
31796
+ * 3. After the join condition is met, call `cancelRemaining(completedSet)`.
31797
+ * 4. Await `drainAsync(timeoutMs)` to give cancelled branches time to clean up.
31798
+ */
31799
+ var BranchCancellationManager = class {
31800
+ controllers;
31801
+ /**
31802
+ * @param branchCount - Total number of branches; allocates one AbortController per branch.
31803
+ */
31804
+ constructor(branchCount) {
31805
+ this.controllers = Array.from({ length: branchCount }, () => new AbortController());
31806
+ }
31807
+ /**
31808
+ * Return the AbortSignal for the branch at the given zero-based index.
31809
+ * The signal is not yet aborted; it becomes aborted when `cancelRemaining` includes the index.
31810
+ */
31811
+ getSignal(index) {
31812
+ const ctrl = this.controllers[index];
31813
+ if (ctrl === void 0) throw new RangeError(`Branch index ${index} is out of range (branchCount=${this.controllers.length})`);
31814
+ return ctrl.signal;
31815
+ }
31816
+ /**
31817
+ * Abort all branches whose index is NOT in `completedIndices`.
31818
+ * Branches that have already completed are not re-aborted.
31819
+ *
31820
+ * @param completedIndices - Set of branch indices that finished successfully/naturally.
31821
+ */
31822
+ cancelRemaining(completedIndices) {
31823
+ this.controllers.forEach((ctrl, i) => {
31824
+ if (!completedIndices.has(i)) ctrl.abort();
31825
+ });
31826
+ }
31827
+ /**
31828
+ * Wait `timeoutMs` milliseconds for in-flight branches to honour their AbortSignals
31829
+ * and finish any cleanup work before the parallel handler resolves.
31830
+ *
31831
+ * @param timeoutMs - Drain window in milliseconds (default 5000).
31832
+ */
31833
+ async drainAsync(timeoutMs) {
31834
+ await new Promise((resolve$6) => setTimeout(resolve$6, timeoutMs));
31835
+ }
31836
+ };
31837
+
31838
+ //#endregion
31839
+ //#region packages/factory/dist/handlers/parallel.js
31840
+ /**
31841
+ * Maps a BranchResult outcome string to a StageStatus for event payloads.
31842
+ *
31843
+ * BranchResult.outcome: 'SUCCESS' | 'FAIL' | 'CANCELLED'
31844
+ * StageStatus: 'SUCCESS' | 'FAIL' | 'PARTIAL_SUCCESS' | 'RETRY' | 'SKIPPED'
31845
+ *
31846
+ * Mapping:
31847
+ * 'SUCCESS' → 'SUCCESS'
31848
+ * 'CANCELLED' → 'SKIPPED'
31849
+ * 'FAIL' → 'FAIL'
31850
+ */
31851
+ function branchOutcomeToStatus(outcome) {
31852
+ if (outcome === "SUCCESS") return "SUCCESS";
31853
+ if (outcome === "CANCELLED") return "SKIPPED";
31854
+ return "FAIL";
31855
+ }
31856
+ /**
31857
+ * Enrich a join-policy `BranchResult` with fan-in-compatible fields so that
31858
+ * `fan-in.ts` can read `parallel.results` without type-shape mismatch.
31859
+ *
31860
+ * The returned object is a superset of `BranchResult` — it preserves every
31861
+ * original field (index, outcome, contextSnapshot, error) AND adds the fields
31862
+ * that `fan-in.ts` requires (branch_id, status, context_updates, failure_reason).
31863
+ *
31864
+ * Mapping:
31865
+ * index → branch_id (same numeric value)
31866
+ * outcome → status ('SUCCESS' | 'FAIL'/'CANCELLED' → 'SUCCESS' | 'FAILURE')
31867
+ * contextSnapshot → context_updates (shallow context snapshot)
31868
+ * error → failure_reason (human-readable failure description)
31869
+ */
31870
+ function toBridgeBranchResult(result) {
31871
+ return {
31872
+ ...result,
31873
+ branch_id: result.index,
31874
+ status: result.outcome === "SUCCESS" ? "SUCCESS" : "FAILURE",
31875
+ context_updates: result.contextSnapshot,
31876
+ failure_reason: result.error
31877
+ };
31878
+ }
31879
+ /**
31880
+ * Run an array of async task factories with at most `limit` executing
31881
+ * concurrently at any point in time, preserving result order.
31882
+ */
31883
+ async function runWithConcurrencyLimit(tasks, limit) {
31884
+ const results = new Array(tasks.length);
31885
+ const executing = new Set();
31886
+ for (let i = 0; i < tasks.length; i++) {
31887
+ const idx = i;
31888
+ const p = tasks[idx]().then((r) => {
31889
+ results[idx] = r;
31890
+ }).finally(() => {
31891
+ executing.delete(p);
31892
+ });
31893
+ executing.add(p);
31894
+ if (executing.size >= limit) await Promise.race(executing);
31895
+ }
31896
+ await Promise.all(executing);
31897
+ return results;
31898
+ }
31899
+ function parseJoinPolicyConfig(node) {
31900
+ const policyRaw = node.attrs?.["join_policy"] ?? node.joinPolicy ?? "wait_all";
31901
+ const policy = policyRaw === "first_success" || policyRaw === "quorum" ? policyRaw : "wait_all";
31902
+ const quorumSizeRaw = node.attrs?.["quorum_size"];
31903
+ const quorum_size = quorumSizeRaw !== void 0 ? parseInt(quorumSizeRaw, 10) : void 0;
31904
+ const drainRaw = node.attrs?.["cancel_drain_timeout_ms"];
31905
+ const cancel_drain_timeout_ms = drainRaw !== void 0 ? parseInt(drainRaw, 10) : void 0;
31906
+ return {
31907
+ policy,
31908
+ ...quorum_size !== void 0 && { quorum_size },
31909
+ ...cancel_drain_timeout_ms !== void 0 && { cancel_drain_timeout_ms }
31910
+ };
31911
+ }
31912
+ /**
31913
+ * Build an async task that executes a single branch node and returns a
31914
+ * `BranchResult`. Isolation is guaranteed via `context.clone()`.
31915
+ */
31916
+ function makeBranchTask(edge, index, signal, context, graph, options) {
31917
+ return async () => {
31918
+ if (signal.aborted) return {
31919
+ index,
31920
+ outcome: "CANCELLED"
31921
+ };
31922
+ const branchNode = graph.nodes.get(edge.toNode);
31923
+ if (!branchNode) return {
31924
+ index,
31925
+ outcome: "FAIL",
31926
+ error: `Branch node "${edge.toNode}" not found in graph`
31927
+ };
31928
+ const branchCtx = context.clone();
31929
+ branchCtx.set("_branch.abort_signal", signal);
31930
+ branchCtx.set("_branch.index", index);
31931
+ const handler = options.handlerRegistry.resolve(branchNode);
31932
+ let branchOutcome;
31933
+ try {
31934
+ branchOutcome = await handler(branchNode, branchCtx, graph);
31935
+ } catch (err) {
31936
+ const msg = err instanceof Error ? err.message : String(err);
31937
+ return {
31938
+ index,
31939
+ outcome: "FAIL",
31940
+ error: msg,
31941
+ contextSnapshot: branchCtx.snapshot()
31942
+ };
31943
+ }
31944
+ if (signal.aborted) return {
31945
+ index,
31946
+ outcome: "CANCELLED",
31947
+ contextSnapshot: branchCtx.snapshot()
31948
+ };
31949
+ const isSuccess = branchOutcome.status === "SUCCESS" || branchOutcome.status === "PARTIAL_SUCCESS";
31950
+ const result = {
31951
+ index,
31952
+ outcome: isSuccess ? "SUCCESS" : "FAIL",
31953
+ contextSnapshot: branchCtx.snapshot()
31954
+ };
31955
+ if (!isSuccess) {
31956
+ const reason = branchOutcome.failureReason;
31957
+ if (reason !== void 0) result.error = reason;
31958
+ }
31959
+ return result;
31960
+ };
31961
+ }
31962
+ /**
31963
+ * Create a `parallel` node handler that fans out to all outgoing branch nodes
31964
+ * concurrently, applies a configurable join policy, and writes results to context.
31965
+ *
31966
+ * @param options.handlerRegistry Registry used to resolve each branch node's
31967
+ * handler at invocation time.
31968
+ */
31969
+ function createParallelHandler(options) {
31970
+ return async (node, context, graph) => {
31971
+ const branchEdges = graph.outgoingEdges(node.id);
31972
+ const branchCount = branchEdges.length;
31973
+ if (branchCount === 0) {
31974
+ context.set("parallel.results", []);
31975
+ return { status: "SUCCESS" };
31976
+ }
31977
+ const config = parseJoinPolicyConfig(node);
31978
+ const maxParallel = node.maxParallel ?? 0;
31979
+ const drainMs = config.cancel_drain_timeout_ms ?? 5e3;
31980
+ const cancellationManager = new BranchCancellationManager(branchCount);
31981
+ const runId = context.getString("__runId", "unknown");
31982
+ options.eventBus?.emit("graph:parallel-started", {
31983
+ runId,
31984
+ nodeId: node.id,
31985
+ branchCount,
31986
+ maxParallel: maxParallel > 0 ? maxParallel : branchCount,
31987
+ policy: config.policy
31988
+ });
31989
+ if (config.policy === "wait_all") {
31990
+ const tasks = branchEdges.map((edge, index) => {
31991
+ const baseTask = makeBranchTask(edge, index, cancellationManager.getSignal(index), context, graph, options);
31992
+ return async () => {
31993
+ options.eventBus?.emit("graph:parallel-branch-started", {
31994
+ runId,
31995
+ nodeId: node.id,
31996
+ branchIndex: index
31997
+ });
31998
+ const branchStart = Date.now();
31999
+ const result = await baseTask();
32000
+ const durationMs = Date.now() - branchStart;
32001
+ options.eventBus?.emit("graph:parallel-branch-completed", {
32002
+ runId,
32003
+ nodeId: node.id,
32004
+ branchIndex: index,
32005
+ status: branchOutcomeToStatus(result.outcome),
32006
+ durationMs
32007
+ });
32008
+ return result;
32009
+ };
32010
+ });
32011
+ const results = maxParallel > 0 ? await runWithConcurrencyLimit(tasks, maxParallel) : await Promise.all(tasks.map((t) => t()));
32012
+ context.set("parallel.results", results.map(toBridgeBranchResult));
32013
+ const completedCount = results.filter((r) => r.outcome === "SUCCESS").length;
32014
+ const cancelledCount = results.filter((r) => r.outcome === "CANCELLED").length;
32015
+ options.eventBus?.emit("graph:parallel-completed", {
32016
+ runId,
32017
+ nodeId: node.id,
32018
+ completedCount,
32019
+ cancelledCount,
32020
+ policy: config.policy
32021
+ });
32022
+ return { status: "SUCCESS" };
32023
+ }
32024
+ const completed = [];
32025
+ const completedIndices = new Set();
32026
+ const pendingEvents = [];
32027
+ const waiters = [];
32028
+ function push(event) {
32029
+ const w = waiters.shift();
32030
+ if (w !== void 0) w(event);
32031
+ else pendingEvents.push(event);
32032
+ }
32033
+ function waitForNext() {
32034
+ return new Promise((resolve$6) => {
32035
+ const e = pendingEvents.shift();
32036
+ if (e !== void 0) resolve$6(e);
32037
+ else waiters.push(resolve$6);
32038
+ });
32039
+ }
32040
+ const branchStarts = new Map();
32041
+ const activeTasks = branchEdges.map((edge, index) => {
32042
+ const task = makeBranchTask(edge, index, cancellationManager.getSignal(index), context, graph, options);
32043
+ options.eventBus?.emit("graph:parallel-branch-started", {
32044
+ runId,
32045
+ nodeId: node.id,
32046
+ branchIndex: index
32047
+ });
32048
+ branchStarts.set(index, Date.now());
32049
+ return task().then((result) => {
32050
+ const branchStart = branchStarts.get(index) ?? Date.now();
32051
+ const durationMs = Date.now() - branchStart;
32052
+ options.eventBus?.emit("graph:parallel-branch-completed", {
32053
+ runId,
32054
+ nodeId: node.id,
32055
+ branchIndex: index,
32056
+ status: branchOutcomeToStatus(result.outcome),
32057
+ durationMs
32058
+ });
32059
+ push({ result });
32060
+ });
32061
+ });
32062
+ let joinOutcome = "SUCCESS";
32063
+ let settled = false;
32064
+ let joinErrorMsg;
32065
+ for (let i = 0; i < branchCount; i++) {
32066
+ const { result } = await waitForNext();
32067
+ if (completedIndices.has(result.index)) continue;
32068
+ completed.push(result);
32069
+ completedIndices.add(result.index);
32070
+ const decision = evaluateJoinPolicy(config, completed, branchCount);
32071
+ if (decision.action === "wait") continue;
32072
+ settled = true;
32073
+ joinOutcome = decision.action === "continue" ? "SUCCESS" : "FAIL";
32074
+ if (decision.action === "fail") joinErrorMsg = decision.reason;
32075
+ const uncancelledCount = branchCount - completedIndices.size;
32076
+ cancellationManager.cancelRemaining(completedIndices);
32077
+ if (uncancelledCount > 0) {
32078
+ await cancellationManager.drainAsync(drainMs);
32079
+ while (pendingEvents.length > 0) {
32080
+ const e = pendingEvents.shift();
32081
+ if (e !== void 0 && !completedIndices.has(e.result.index)) {
32082
+ completed.push(e.result);
32083
+ completedIndices.add(e.result.index);
32084
+ }
32085
+ }
32086
+ for (let j$1 = 0; j$1 < branchCount; j$1++) if (!completedIndices.has(j$1)) {
32087
+ completed.push({
32088
+ index: j$1,
32089
+ outcome: "CANCELLED"
32090
+ });
32091
+ completedIndices.add(j$1);
32092
+ }
32093
+ }
32094
+ context.set("parallel.results", completed.map(toBridgeBranchResult));
32095
+ if (decision.action === "continue") {
32096
+ if (config.policy === "first_success") {
32097
+ const winner = completed.find((r) => r.outcome === "SUCCESS");
32098
+ if (winner !== void 0) context.set("parallel.winner_index", winner.index);
32099
+ }
32100
+ if (config.policy === "quorum") {
32101
+ const successCount = completed.filter((r) => r.outcome === "SUCCESS").length;
32102
+ context.set("parallel.quorum_reached", successCount);
32103
+ }
32104
+ } else context.set("parallel.join_error", joinErrorMsg ?? decision.reason);
32105
+ break;
32106
+ }
32107
+ Promise.allSettled(activeTasks);
32108
+ if (!settled) context.set("parallel.results", completed.map(toBridgeBranchResult));
32109
+ const finalCompletedCount = completed.filter((r) => r.outcome === "SUCCESS").length;
32110
+ const finalCancelledCount = completed.filter((r) => r.outcome === "CANCELLED").length;
32111
+ options.eventBus?.emit("graph:parallel-completed", {
32112
+ runId,
32113
+ nodeId: node.id,
32114
+ completedCount: finalCompletedCount,
32115
+ cancelledCount: finalCancelledCount,
32116
+ policy: config.policy
32117
+ });
32118
+ return { status: joinOutcome === "SUCCESS" ? "SUCCESS" : "FAILURE" };
32119
+ };
32120
+ }
32121
+
32122
+ //#endregion
32123
+ //#region packages/factory/dist/handlers/fan-in.js
32124
+ /** Lower number = better rank. FAILURE is excluded before ranking. */
32125
+ const OUTCOME_RANK = {
32126
+ SUCCESS: 0,
32127
+ PARTIAL_SUCCESS: 1,
32128
+ NEEDS_RETRY: 2,
32129
+ FAILURE: 3,
32130
+ ESCALATE: 4
32131
+ };
32132
+ /**
32133
+ * Select the best non-FAILURE branch from `results`.
32134
+ *
32135
+ * Sort order (ascending priority):
32136
+ * 1. `OUTCOME_RANK[status]` ascending (SUCCESS wins)
32137
+ * 2. `score` descending (higher is better; `undefined` treated as `0`)
32138
+ * 3. `branch_id` ascending (stable tiebreak)
32139
+ *
32140
+ * Returns `null` when every branch has status `FAILURE`.
32141
+ */
32142
+ function rankBranches(results) {
32143
+ const eligible = results.filter((r) => r.status !== "FAILURE");
32144
+ if (eligible.length === 0) return null;
32145
+ const sorted = [...eligible].sort((a, b) => {
32146
+ const rankDiff = (OUTCOME_RANK[a.status] ?? 99) - (OUTCOME_RANK[b.status] ?? 99);
32147
+ if (rankDiff !== 0) return rankDiff;
32148
+ const scoreA = a.score ?? 0;
32149
+ const scoreB = b.score ?? 0;
32150
+ if (scoreB !== scoreA) return scoreB - scoreA;
32151
+ return a.branch_id - b.branch_id;
32152
+ });
32153
+ return sorted[0] ?? null;
32154
+ }
32155
+ /**
32156
+ * Build the prompt sent to the LLM for selection-based fan-in.
32157
+ *
32158
+ * The prompt prepends `nodePrompt`, then lists each branch with its
32159
+ * `branch_id`, `status`, `score`, and the keys present in `context_updates`
32160
+ * (values omitted for token efficiency). The LLM is instructed to reply with
32161
+ * just the integer `branch_id` of the best candidate.
32162
+ */
32163
+ function buildSelectionPrompt(nodePrompt, results) {
32164
+ const branchSummaries = results.map((r) => {
32165
+ const contextKeys = r.context_updates && Object.keys(r.context_updates).length > 0 ? Object.keys(r.context_updates).join(", ") : "(none)";
32166
+ return `Branch ${r.branch_id}: status=${r.status}, score=${r.score ?? 0}, context_update_keys=[${contextKeys}]`;
32167
+ }).join("\n");
32168
+ return `${nodePrompt}\n\nParallel branch results:\n${branchSummaries}\n\nReply with only the integer branch_id of the best candidate.`;
32169
+ }
32170
+ /**
32171
+ * Parse an LLM response to extract the winning `branch_id`.
32172
+ *
32173
+ * Scans the response text for the first integer that matches a valid
32174
+ * `branch_id` in `results`. Returns the matching `BranchResult`, or `null`
32175
+ * (triggering heuristic fallback) if no valid branch_id is found.
32176
+ * Logs a warning on fallback.
32177
+ */
32178
+ function parseLlmWinnerResponse(response, results) {
32179
+ const validIds = new Set(results.map((r) => r.branch_id));
32180
+ const matches = response.match(/\d+/g);
32181
+ if (matches) for (const m of matches) {
32182
+ const id = parseInt(m, 10);
32183
+ if (validIds.has(id)) return results.find((r) => r.branch_id === id) ?? null;
32184
+ }
32185
+ console.warn(`[fan-in] LLM response did not contain a valid branch_id; falling back to heuristic selection. Response: "${response}"`);
32186
+ return null;
32187
+ }
32188
+ /**
32189
+ * Default `llmCall` implementation that wraps `callLLM` from `@substrate-ai/core`
32190
+ * with fixed default routing parameters.
32191
+ */
32192
+ async function defaultLlmCall(prompt) {
32193
+ const result = await callLLM({
32194
+ model: "claude-sonnet-4-5",
32195
+ provider: "anthropic",
32196
+ reasoningEffort: "low",
32197
+ prompt
32198
+ });
32199
+ return result.text;
32200
+ }
32201
+ /**
32202
+ * Create a `parallel.fan_in` node handler.
32203
+ *
32204
+ * Execution steps:
32205
+ * 1. Read `parallel.results` from context; return FAILURE if absent or empty.
32206
+ * 2. If `node.prompt` is non-empty, call LLM to select winner; fall back to
32207
+ * heuristic on parse failure.
32208
+ * 3. Use heuristic `rankBranches` when no prompt or LLM fallback.
32209
+ * 4. If all branches failed, return FAILURE with aggregated failure reasons.
32210
+ * 5. Merge winner's `context_updates`, set `parallel.fan_in.best_id` and
32211
+ * `parallel.fan_in.best_outcome`, return SUCCESS.
32212
+ *
32213
+ * @param options - Optional configuration (inject `llmCall` for testing).
32214
+ */
32215
+ function createFanInHandler(options) {
32216
+ const llmCallFn = options?.llmCall ?? defaultLlmCall;
32217
+ return async (node, context, _graph) => {
32218
+ const rawResults = context.get("parallel.results");
32219
+ if (!rawResults || !Array.isArray(rawResults) || rawResults.length === 0) return {
32220
+ status: "FAILURE",
32221
+ failureReason: "fan-in: no parallel results found in context (parallel.results is absent or empty)"
32222
+ };
32223
+ const results = rawResults;
32224
+ let winner = null;
32225
+ if (node.prompt && node.prompt.trim().length > 0) {
32226
+ const prompt = buildSelectionPrompt(node.prompt, results);
32227
+ try {
32228
+ const response = await llmCallFn(prompt);
32229
+ winner = parseLlmWinnerResponse(response, results);
32230
+ } catch (err) {
32231
+ console.warn(`[fan-in] LLM call failed; falling back to heuristic selection. Error: ${String(err)}`);
32232
+ winner = null;
32233
+ }
32234
+ if (winner === null) winner = rankBranches(results);
32235
+ } else winner = rankBranches(results);
32236
+ if (winner === null) {
32237
+ const reasons = results.map((r) => r.failure_reason ?? `branch ${r.branch_id}: no reason provided`).join("; ");
32238
+ return {
32239
+ status: "FAILURE",
32240
+ failureReason: `fan-in: all branches failed — ${reasons}`
32241
+ };
32242
+ }
32243
+ if (winner.context_updates && Object.keys(winner.context_updates).length > 0) context.applyUpdates(winner.context_updates);
32244
+ context.set("parallel.fan_in.best_id", winner.branch_id);
32245
+ context.set("parallel.fan_in.best_outcome", winner.status);
32246
+ return { status: "SUCCESS" };
32247
+ };
32248
+ }
32249
+
32250
+ //#endregion
32251
+ //#region packages/factory/dist/handlers/subgraph.js
32252
+ /**
32253
+ * Converts the sub-executor's `events.ts:StageStatus` back to the handler
32254
+ * return type `types.ts:OutcomeStatus`.
32255
+ *
32256
+ * Mapping:
32257
+ * 'SUCCESS' → 'SUCCESS'
32258
+ * 'PARTIAL_SUCCESS'→ 'PARTIAL_SUCCESS'
32259
+ * all others → 'FAILURE' (covers 'FAIL', 'RETRY', 'SKIPPED')
32260
+ */
32261
+ function denormalizeStatus(status) {
32262
+ if (status === "SUCCESS") return "SUCCESS";
32263
+ if (status === "PARTIAL_SUCCESS") return "PARTIAL_SUCCESS";
32264
+ return "FAILURE";
32265
+ }
32266
+ /**
32267
+ * Factory function that creates a handler for `type="subgraph"` nodes.
32268
+ *
32269
+ * Each subgraph node references an external `.dot` file, loads and validates
32270
+ * it, then executes it as a nested sub-pipeline. Parent context is seeded into
32271
+ * the sub-executor and context updates from the subgraph are propagated back.
32272
+ */
32273
+ function createSubgraphHandler(options) {
32274
+ return async (node, context, graph) => {
32275
+ const graphFile = node.attrs?.["graph_file"];
32276
+ if (!graphFile) return {
32277
+ status: "FAILURE",
32278
+ failureReason: `Subgraph node "${node.id}" is missing required attribute graph_file`
32279
+ };
32280
+ const currentDepth = context.getNumber("subgraph._depth", 0);
32281
+ const maxDepth = options.maxDepth ?? 5;
32282
+ if (currentDepth >= maxDepth) return {
32283
+ status: "FAILURE",
32284
+ failureReason: `Subgraph depth limit exceeded (max ${maxDepth}): node "${node.id}"`
32285
+ };
32286
+ const filePath = path.isAbsolute(graphFile) ? graphFile : path.join(options.baseDir, graphFile);
32287
+ const loader = options.graphFileLoader ?? ((fp) => readFile$1(fp, "utf-8"));
32288
+ let dotSource;
32289
+ try {
32290
+ dotSource = await loader(filePath);
32291
+ } catch (err) {
32292
+ const msg = err instanceof Error ? err.message : String(err);
32293
+ return {
32294
+ status: "FAILURE",
32295
+ failureReason: `Subgraph node "${node.id}": failed to load "${filePath}": ${msg}`
32296
+ };
32297
+ }
32298
+ let subgraph;
32299
+ try {
32300
+ subgraph = parseGraph(dotSource);
32301
+ } catch (err) {
32302
+ const msg = err instanceof Error ? err.message : String(err);
32303
+ return {
32304
+ status: "FAILURE",
32305
+ failureReason: `Subgraph node "${node.id}": failed to parse "${filePath}": ${msg}`
32306
+ };
32307
+ }
32308
+ try {
32309
+ createValidator().validateOrRaise(subgraph);
32310
+ } catch (err) {
32311
+ const msg = err instanceof Error ? err.message : String(err);
32312
+ return {
32313
+ status: "FAILURE",
32314
+ failureReason: `Subgraph node "${node.id}": validation failed for "${filePath}": ${msg}`
32315
+ };
32316
+ }
32317
+ const runId = context.getString("__runId", options.runId ?? "unknown");
32318
+ options.eventBus?.emit("graph:subgraph-started", {
32319
+ runId,
32320
+ nodeId: node.id,
32321
+ graphFile: filePath,
32322
+ depth: currentDepth
32323
+ });
32324
+ const subgraphStart = Date.now();
32325
+ const parentStylesheet = graph.modelStylesheet ? parseStylesheet(graph.modelStylesheet) : void 0;
32326
+ const subConfig = {
32327
+ runId: randomUUID(),
32328
+ logsRoot: options.logsRoot ?? tmpdir(),
32329
+ handlerRegistry: options.handlerRegistry,
32330
+ initialContext: {
32331
+ ...context.snapshot(),
32332
+ "subgraph._depth": currentDepth + 1
32333
+ },
32334
+ ...options.eventBus !== void 0 ? { eventBus: options.eventBus } : {},
32335
+ ...parentStylesheet !== void 0 ? { inheritedStylesheet: parentStylesheet } : {}
32336
+ };
32337
+ let subOutcome;
32338
+ try {
32339
+ subOutcome = await createGraphExecutor().run(subgraph, subConfig);
32340
+ } catch (err) {
32341
+ const msg = err instanceof Error ? err.message : String(err);
32342
+ const durationMs$1 = Date.now() - subgraphStart;
32343
+ options.eventBus?.emit("graph:subgraph-completed", {
32344
+ runId,
32345
+ nodeId: node.id,
32346
+ graphFile: filePath,
32347
+ depth: currentDepth,
32348
+ status: "FAIL",
32349
+ durationMs: durationMs$1
32350
+ });
32351
+ return {
32352
+ status: "FAILURE",
32353
+ failureReason: `Subgraph node "${node.id}": executor threw: ${msg}`
32354
+ };
32355
+ }
32356
+ const durationMs = Date.now() - subgraphStart;
32357
+ options.eventBus?.emit("graph:subgraph-completed", {
32358
+ runId,
32359
+ nodeId: node.id,
32360
+ graphFile: filePath,
32361
+ depth: currentDepth,
32362
+ status: subOutcome.status === "SUCCESS" ? "SUCCESS" : subOutcome.status === "PARTIAL_SUCCESS" ? "PARTIAL_SUCCESS" : "FAIL",
32363
+ durationMs
32364
+ });
32365
+ if (subOutcome.contextUpdates) context.applyUpdates(subOutcome.contextUpdates);
32366
+ return {
32367
+ status: denormalizeStatus(subOutcome.status),
32368
+ ...subOutcome.contextUpdates !== void 0 && { contextUpdates: subOutcome.contextUpdates },
32369
+ ...subOutcome.notes !== void 0 && { notes: subOutcome.notes },
32370
+ ...subOutcome.failureReason !== void 0 && { failureReason: subOutcome.failureReason }
32371
+ };
32372
+ };
32373
+ }
32374
+
32375
+ //#endregion
32376
+ //#region packages/factory/dist/handlers/manager-loop.js
32377
+ /**
32378
+ * Factory function that creates a handler for `type="stack.manager_loop"` nodes.
32379
+ *
32380
+ * Each invocation loads the body graph once, then executes it in a loop for up
32381
+ * to `max_cycles` cycles (default 10). Stop conditions (context key or LLM-based)
32382
+ * allow early exit. Stall detection injects recovery steering after consecutive
32383
+ * non-SUCCESS body executions.
32384
+ */
32385
+ function createManagerLoopHandler(options) {
32386
+ return async (node, context, _graph) => {
32387
+ const graphFile = node.attrs?.["graph_file"];
32388
+ if (!graphFile) return {
32389
+ status: "FAILURE",
32390
+ failureReason: `Manager loop node "${node.id}" is missing required attribute graph_file`
32391
+ };
32392
+ const rawMaxCycles = node.attrs?.["max_cycles"];
32393
+ let maxCycles = 10;
32394
+ if (rawMaxCycles !== void 0 && rawMaxCycles !== "") {
32395
+ const parsed = parseInt(rawMaxCycles, 10);
32396
+ maxCycles = isNaN(parsed) ? 10 : Math.max(1, parsed);
32397
+ }
32398
+ const filePath = path.isAbsolute(graphFile) ? graphFile : path.join(options.baseDir ?? process.cwd(), graphFile);
32399
+ const loader = options.graphFileLoader ?? ((fp) => readFile$1(fp, "utf-8"));
32400
+ let dotSource;
32401
+ try {
32402
+ dotSource = await loader(filePath);
32403
+ } catch (err) {
32404
+ const msg = err instanceof Error ? err.message : String(err);
32405
+ return {
32406
+ status: "FAILURE",
32407
+ failureReason: `Manager loop node "${node.id}": failed to load "${filePath}": ${msg}`
32408
+ };
32409
+ }
32410
+ let bodyGraph;
32411
+ try {
32412
+ bodyGraph = parseGraph(dotSource);
32413
+ } catch (err) {
32414
+ const msg = err instanceof Error ? err.message : String(err);
32415
+ return {
32416
+ status: "FAILURE",
32417
+ failureReason: `Manager loop node "${node.id}": failed to parse "${filePath}": ${msg}`
32418
+ };
32419
+ }
32420
+ try {
32421
+ createValidator().validateOrRaise(bodyGraph);
32422
+ } catch (err) {
32423
+ const msg = err instanceof Error ? err.message : String(err);
32424
+ return {
32425
+ status: "FAILURE",
32426
+ failureReason: `Manager loop node "${node.id}": validation failed for "${filePath}": ${msg}`
32427
+ };
32428
+ }
32429
+ const stopCondition = node.attrs?.["stop_condition"];
32430
+ let consecutiveFailures = 0;
32431
+ for (let cycle = 1; cycle <= maxCycles; cycle++) {
32432
+ context.set("manager_loop.cycle", cycle);
32433
+ const bodyConfig = {
32434
+ runId: randomUUID(),
32435
+ logsRoot: options.logsRoot ?? tmpdir(),
32436
+ handlerRegistry: options.handlerRegistry,
32437
+ initialContext: context.snapshot()
32438
+ };
32439
+ const bodyOutcome = await createGraphExecutor().run(bodyGraph, bodyConfig);
32440
+ if (bodyOutcome.contextUpdates) context.applyUpdates(bodyOutcome.contextUpdates);
32441
+ context.set("manager_loop.cycles_completed", cycle);
32442
+ context.set("manager_loop.last_outcome", bodyOutcome.status);
32443
+ if (bodyOutcome.status === "SUCCESS") {
32444
+ consecutiveFailures = 0;
32445
+ context.set("manager_loop.steering.mode", "normal");
32446
+ context.set("manager_loop.steering.hints", []);
32447
+ } else {
32448
+ consecutiveFailures++;
32449
+ if (consecutiveFailures >= (options.maxStallCycles ?? 2)) {
32450
+ context.set("manager_loop.steering.mode", "recovery");
32451
+ context.set("manager_loop.steering.hints", [`Previous ${consecutiveFailures} attempts returned ${bodyOutcome.status}. Consider a different strategy.`, "Review context state and adjust approach before retrying."]);
32452
+ }
32453
+ }
32454
+ if (stopCondition) {
32455
+ let shouldStop = false;
32456
+ if (isLlmCondition(stopCondition)) {
32457
+ if (options.llmCall !== void 0) {
32458
+ const question = extractLlmQuestion(stopCondition);
32459
+ shouldStop = await evaluateLlmCondition(question, context.snapshot(), options.llmCall);
32460
+ }
32461
+ } else shouldStop = Boolean(context.get(stopCondition));
32462
+ if (shouldStop) {
32463
+ context.set("manager_loop.stop_reason", "stop_condition");
32464
+ return { status: "SUCCESS" };
32465
+ }
32466
+ }
32467
+ }
32468
+ context.set("manager_loop.stop_reason", "max_cycles");
32469
+ return { status: "SUCCESS" };
32470
+ };
32471
+ }
32472
+
32473
+ //#endregion
32474
+ //#region packages/factory/dist/handlers/registry.js
32475
+ var HandlerRegistry = class {
32476
+ /** Maps node type → handler function */
32477
+ _handlers = new Map();
32478
+ /** Maps DOT shape name → canonical node type */
32479
+ _shapeMap = new Map();
32480
+ /** Fallback handler when no type or shape match is found */
32481
+ _default;
32482
+ /**
32483
+ * Register a handler for the given node type.
32484
+ * Overwrites if already registered.
32485
+ */
32486
+ register(type, handler) {
32487
+ this._handlers.set(type, handler);
32488
+ }
32489
+ /**
32490
+ * Register a shape → type mapping.
32491
+ * Overwrites if already registered.
32492
+ */
32493
+ registerShape(shape, type) {
32494
+ this._shapeMap.set(shape, type);
32495
+ }
32496
+ /**
32497
+ * Set the default handler used when no type or shape match is found.
32498
+ */
32499
+ setDefault(handler) {
31452
32500
  this._default = handler;
31453
32501
  }
31454
32502
  /**
@@ -31480,19 +32528,40 @@ var HandlerRegistry = class {
31480
32528
  * Story 42-10 adds codergen as the explicit "codergen" type, as the handler for
31481
32529
  * `shape=box` nodes, and as the registry-level default for any node that has no
31482
32530
  * recognised type or shape mapping.
32531
+ *
32532
+ * Story 50-5 extends options to `DefaultRegistryOptions` and registers the `subgraph` type.
31483
32533
  */
31484
32534
  function createDefaultRegistry(options) {
31485
32535
  const registry = new HandlerRegistry();
32536
+ registry.register("parallel", createParallelHandler({
32537
+ handlerRegistry: registry,
32538
+ ...options?.eventBus !== void 0 ? { eventBus: options.eventBus } : {},
32539
+ ...options?.runId !== void 0 ? { runId: options.runId } : {}
32540
+ }));
32541
+ registry.registerShape("component", "parallel");
31486
32542
  registry.register("start", startHandler);
31487
32543
  registry.register("exit", exitHandler);
31488
32544
  registry.register("conditional", conditionalHandler);
31489
32545
  registry.register("codergen", createCodergenHandler(options));
31490
32546
  registry.register("tool", createToolHandler());
31491
32547
  registry.register("wait.human", createWaitHumanHandler());
32548
+ registry.register("parallel.fan_in", createFanInHandler());
32549
+ registry.register("subgraph", createSubgraphHandler({
32550
+ handlerRegistry: registry,
32551
+ baseDir: options?.baseDir ?? process.cwd(),
32552
+ ...options?.eventBus !== void 0 ? { eventBus: options.eventBus } : {},
32553
+ ...options?.runId !== void 0 ? { runId: options.runId } : {}
32554
+ }));
32555
+ registry.register("stack.manager_loop", createManagerLoopHandler({
32556
+ handlerRegistry: registry,
32557
+ baseDir: options?.baseDir ?? process.cwd(),
32558
+ ...options?.llmCall !== void 0 ? { llmCall: options.llmCall } : {}
32559
+ }));
31492
32560
  registry.registerShape("Mdiamond", "start");
31493
32561
  registry.registerShape("Msquare", "exit");
31494
32562
  registry.registerShape("diamond", "conditional");
31495
32563
  registry.registerShape("box", "codergen");
32564
+ registry.registerShape("tripleoctagon", "parallel.fan_in");
31496
32565
  registry.setDefault(createCodergenHandler(options));
31497
32566
  return registry;
31498
32567
  }
@@ -36738,6 +37807,149 @@ function registerContextCommand(factoryCmd, version, storageDir, engineFactory)
36738
37807
  });
36739
37808
  }
36740
37809
 
37810
+ //#endregion
37811
+ //#region packages/factory/dist/templates/index.js
37812
+ /**
37813
+ * Pipeline Template Catalog — pre-built DOT graph pipeline templates for common patterns.
37814
+ *
37815
+ * Story 50-10.
37816
+ *
37817
+ * Node type strings used:
37818
+ * - `start` — start node (registered by default registry)
37819
+ * - `exit` — exit node (registered by default registry)
37820
+ * - `codergen` — coding agent node (registered by default registry)
37821
+ * - `parallel` — parallel fan-out node (story 50-1, shape=component)
37822
+ * - `parallel.fan_in`— fan-in/merge node (story 50-2, shape=tripleoctagon)
37823
+ */
37824
+ const trycycleDotContent = `digraph trycycle {
37825
+ // Trycycle Pattern: iterative refinement with eval gates
37826
+ // Flow: define → plan → eval_plan ⇄ implement → eval_impl → exit
37827
+ // eval nodes can loop back to their upstream node on revision_needed.
37828
+
37829
+ start [type="start"];
37830
+ define [type="codergen", label="Define Requirements"];
37831
+ plan [type="codergen", label="Plan Implementation"];
37832
+ eval_plan [type="codergen", label="Evaluate Plan"];
37833
+ implement [type="codergen", label="Implement"];
37834
+ eval_impl [type="codergen", label="Evaluate Implementation"];
37835
+ exit [type="exit"];
37836
+
37837
+ start -> define;
37838
+ define -> plan;
37839
+ plan -> eval_plan;
37840
+ eval_plan -> implement [label="approved"];
37841
+ eval_plan -> plan [label="revision_needed"];
37842
+ implement -> eval_impl;
37843
+ eval_impl -> exit [label="approved"];
37844
+ eval_impl -> implement [label="revision_needed"];
37845
+ }
37846
+ `;
37847
+ const dualReviewDotContent = `digraph dual_review {
37848
+ // Dual-Review Pattern: fan-out to two independent reviewers, then fan-in
37849
+ // Flow: implement → (reviewer_a + reviewer_b) in parallel → merge → exit
37850
+
37851
+ start [type="start"];
37852
+ implement [type="codergen", label="Implement"];
37853
+ review_parallel [type="parallel", label="Fan-Out to Reviewers"];
37854
+ reviewer_a [type="codergen", label="Reviewer A"];
37855
+ reviewer_b [type="codergen", label="Reviewer B"];
37856
+ review_merge [type="parallel.fan_in",label="Merge Reviews"];
37857
+ exit [type="exit"];
37858
+
37859
+ start -> implement;
37860
+ implement -> review_parallel;
37861
+ review_parallel -> reviewer_a;
37862
+ review_parallel -> reviewer_b;
37863
+ reviewer_a -> review_merge;
37864
+ reviewer_b -> review_merge;
37865
+ review_merge -> exit;
37866
+ }
37867
+ `;
37868
+ const parallelExplorationDotContent = `digraph parallel_exploration {
37869
+ // Parallel-Exploration Pattern: dispatch multiple approaches concurrently,
37870
+ // select the best-scoring result, then refine the winner.
37871
+ // Flow: (approach_a + approach_b) in parallel → select best → refine → exit
37872
+
37873
+ start [type="start"];
37874
+ explore_parallel[type="parallel", label="Fan-Out Exploration"];
37875
+ approach_a [type="codergen", label="Approach A"];
37876
+ approach_b [type="codergen", label="Approach B"];
37877
+ select_best [type="parallel.fan_in", label="Select Best Candidate", selection="best"];
37878
+ refine [type="codergen", label="Refine Winner"];
37879
+ exit [type="exit"];
37880
+
37881
+ start -> explore_parallel;
37882
+ explore_parallel -> approach_a;
37883
+ explore_parallel -> approach_b;
37884
+ approach_a -> select_best;
37885
+ approach_b -> select_best;
37886
+ select_best -> refine;
37887
+ refine -> exit;
37888
+ }
37889
+ `;
37890
+ const stagedValidationDotContent = `digraph staged_validation {
37891
+ // Staged-Validation Pattern: sequential quality gates
37892
+ // Flow: implement → lint → test → validate → exit
37893
+ // Each stage is a separate codergen node; no parallel branches.
37894
+
37895
+ start [type="start"];
37896
+ implement[type="codergen", label="Implement"];
37897
+ lint [type="codergen", label="Lint"];
37898
+ test [type="codergen", label="Test"];
37899
+ validate [type="codergen", label="Validate"];
37900
+ exit [type="exit"];
37901
+
37902
+ start -> implement;
37903
+ implement -> lint;
37904
+ lint -> test;
37905
+ test -> validate;
37906
+ validate -> exit;
37907
+ }
37908
+ `;
37909
+ const trycycleTemplate = {
37910
+ name: "trycycle",
37911
+ description: "Iterative refinement loop with plan and implementation eval gates",
37912
+ dotContent: trycycleDotContent
37913
+ };
37914
+ const dualReviewTemplate = {
37915
+ name: "dual-review",
37916
+ description: "Fan-out to two independent reviewers, then fan-in to merge results",
37917
+ dotContent: dualReviewDotContent
37918
+ };
37919
+ const parallelExplorationTemplate = {
37920
+ name: "parallel-exploration",
37921
+ description: "Dispatch parallel implementation approaches and select the best candidate",
37922
+ dotContent: parallelExplorationDotContent
37923
+ };
37924
+ const stagedValidationTemplate = {
37925
+ name: "staged-validation",
37926
+ description: "Sequential quality gate stages: implement → lint → test → validate",
37927
+ dotContent: stagedValidationDotContent
37928
+ };
37929
+ /**
37930
+ * Map of all built-in pipeline templates, keyed by template name.
37931
+ * Insertion order determines display order in `factory templates list`.
37932
+ */
37933
+ const PIPELINE_TEMPLATES = new Map([
37934
+ [trycycleTemplate.name, trycycleTemplate],
37935
+ [dualReviewTemplate.name, dualReviewTemplate],
37936
+ [parallelExplorationTemplate.name, parallelExplorationTemplate],
37937
+ [stagedValidationTemplate.name, stagedValidationTemplate]
37938
+ ]);
37939
+ /**
37940
+ * Returns all available pipeline template entries in insertion order.
37941
+ */
37942
+ function listPipelineTemplates() {
37943
+ return Array.from(PIPELINE_TEMPLATES.values());
37944
+ }
37945
+ /**
37946
+ * Returns the pipeline template entry for the given name (case-sensitive),
37947
+ * or `undefined` if not found.
37948
+ */
37949
+ function getPipelineTemplate(name) {
37950
+ return PIPELINE_TEMPLATES.get(name);
37951
+ }
37952
+
36741
37953
  //#endregion
36742
37954
  //#region packages/factory/dist/twins/schema.js
36743
37955
  /**
@@ -37776,6 +38988,28 @@ function registerFactoryCommand(program, options) {
37776
38988
  process.exit(1);
37777
38989
  }
37778
38990
  });
38991
+ const templatesCmd = factoryCmd.command("templates").description("Manage reusable DOT graph pipeline templates");
38992
+ templatesCmd.command("list").description("List available pipeline templates").action(() => {
38993
+ const templates = listPipelineTemplates();
38994
+ for (const t of templates) process.stdout.write(` ${t.name.padEnd(24)} ${t.description}\n`);
38995
+ });
38996
+ 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) => {
38997
+ const entry = getPipelineTemplate(opts.template);
38998
+ if (!entry) {
38999
+ const available = listPipelineTemplates().map((t) => t.name).join(", ");
39000
+ process.stderr.write(`Error: Unknown template '${opts.template}'. Available: ${available}\n`);
39001
+ process.exit(1);
39002
+ return;
39003
+ }
39004
+ try {
39005
+ await writeFile$1(opts.output, entry.dotContent, "utf-8");
39006
+ process.stdout.write(`Created ${opts.output} from template '${entry.name}'\n`);
39007
+ } catch (err) {
39008
+ const msg = err instanceof Error ? err.message : String(err);
39009
+ process.stderr.write(`Error: ${msg}\n`);
39010
+ process.exit(1);
39011
+ }
39012
+ });
37779
39013
  }
37780
39014
 
37781
39015
  //#endregion
@@ -39264,4 +40498,4 @@ function registerRunCommand(program, _version = "0.0.0", projectRoot = process.c
39264
40498
 
39265
40499
  //#endregion
39266
40500
  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
40501
+ //# sourceMappingURL=run-C3MnOuBe.js.map