substrate-ai 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@ import { createLogger } from "../logger-KeHncl-f.js";
4
4
  import { createEventBus } from "../helpers-CElYrONe.js";
5
5
  import { AdapterRegistry, BudgetConfigSchema, CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, ConfigError, CostTrackerConfigSchema, DEFAULT_CONFIG, DoltClient, DoltNotInstalled, EXPERIMENT_RESULT, GlobalSettingsSchema, IngestionServer, MonitorDatabaseImpl, OPERATIONAL_FINDING, PartialGlobalSettingsSchema, PartialProviderConfigSchema, ProvidersSchema, RoutingRecommender, STORY_METRICS, TelemetryConfigSchema, addTokenUsage, aggregateTokenUsageForRun, checkDoltInstalled, compareRunMetrics, createAmendmentRun, createConfigSystem, createDecision, createDoltClient, createPipelineRun, getActiveDecisions, getAllCostEntriesFiltered, getBaselineRunMetrics, getDecisionsByCategory, getDecisionsByPhaseForRun, getLatestCompletedRun, getLatestRun, getPipelineRunById, getPlanningCostTotal, getRetryableEscalations, getRunMetrics, getSessionCostSummary, getSessionCostSummaryFiltered, getStoryMetricsForRun, getTokenUsageSummary, incrementRunRestarts, initSchema, initializeDolt, listRequirements, listRunMetrics, loadParentRunDecisions, supersedeDecision, tagRunAsBaseline, updatePipelineRun } from "../dist-CLvAwmT7.js";
6
6
  import "../adapter-registry-DXLMTmfD.js";
7
- import { 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, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-DDUeFC-I.js";
7
+ import { 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, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-CUMPhuVq.js";
8
8
  import "../errors-D1LU8CZ9.js";
9
9
  import "../routing-CcBOCuC9.js";
10
10
  import "../decisions-C0pz9Clx.js";
@@ -4359,7 +4359,7 @@ async function runSupervisorAction(options, deps = {}) {
4359
4359
  await initSchema(expAdapter);
4360
4360
  const { runRunAction: runPipeline } = await import(
4361
4361
  /* @vite-ignore */
4362
- "../run-BV2zNwIC.js"
4362
+ "../run-CZo7hpsh.js"
4363
4363
  );
4364
4364
  const runStoryFn = async (opts) => {
4365
4365
  const exitCode = await runPipeline({
@@ -22162,6 +22162,10 @@ var RunStateManager = class {
22162
22162
  */
22163
22163
  function createConvergenceController() {
22164
22164
  const outcomes = new Map();
22165
+ /** Returns true only when id is non-empty AND exists in graph.nodes. */
22166
+ function isValidTarget(id, graph) {
22167
+ return id !== "" && graph.nodes.has(id);
22168
+ }
22165
22169
  return {
22166
22170
  recordOutcome(nodeId, status) {
22167
22171
  outcomes.set(nodeId, status);
@@ -22177,10 +22181,387 @@ function createConvergenceController() {
22177
22181
  satisfied: failingNodes.length === 0,
22178
22182
  failingNodes
22179
22183
  };
22184
+ },
22185
+ checkGoalGates(graph, runId, eventBus) {
22186
+ const failedGates = [];
22187
+ for (const [id, node] of graph.nodes) {
22188
+ if (!node.goalGate) continue;
22189
+ const status = outcomes.get(id);
22190
+ const satisfied = status === "SUCCESS" || status === "PARTIAL_SUCCESS";
22191
+ eventBus?.emit("graph:goal-gate-checked", {
22192
+ runId,
22193
+ nodeId: id,
22194
+ satisfied
22195
+ });
22196
+ if (!satisfied) failedGates.push(id);
22197
+ }
22198
+ return {
22199
+ satisfied: failedGates.length === 0,
22200
+ failedGates
22201
+ };
22202
+ },
22203
+ resolveRetryTarget(failedNode, graph) {
22204
+ const candidates = [
22205
+ failedNode.retryTarget,
22206
+ failedNode.fallbackRetryTarget,
22207
+ graph.retryTarget,
22208
+ graph.fallbackRetryTarget
22209
+ ];
22210
+ for (const candidate of candidates) if (isValidTarget(candidate, graph)) return candidate;
22211
+ return null;
22180
22212
  }
22181
22213
  };
22182
22214
  }
22183
22215
 
22216
+ //#endregion
22217
+ //#region packages/factory/dist/convergence/budget.js
22218
+ const DEFAULT_BACKOFF = {
22219
+ initialDelay: 200,
22220
+ factor: 2,
22221
+ maxDelay: 6e4,
22222
+ jitterFactor: .5
22223
+ };
22224
+ /**
22225
+ * Compute the delay (in milliseconds) before the next retry attempt.
22226
+ *
22227
+ * Formula (before jitter):
22228
+ * `baseDelay = initialDelay * factor^attemptIndex`
22229
+ * `cappedDelay = Math.min(baseDelay, maxDelay)`
22230
+ *
22231
+ * Jitter:
22232
+ * `jitter = (Math.random() * 2 - 1) * jitterFactor * cappedDelay`
22233
+ * `delay = Math.max(0, Math.round(cappedDelay + jitter))`
22234
+ *
22235
+ * Passing `{ jitterFactor: 0 }` disables jitter for deterministic tests.
22236
+ *
22237
+ * @param attemptIndex - Zero-based index of the current attempt (0 = first retry).
22238
+ * @param options - Optional overrides for the backoff parameters.
22239
+ */
22240
+ function computeBackoffDelay(attemptIndex, options) {
22241
+ const { initialDelay, factor, maxDelay, jitterFactor } = {
22242
+ ...DEFAULT_BACKOFF,
22243
+ ...options
22244
+ };
22245
+ const baseDelay = initialDelay * Math.pow(factor, attemptIndex);
22246
+ const cappedDelay = Math.min(baseDelay, maxDelay);
22247
+ const jitter = (Math.random() * 2 - 1) * jitterFactor * cappedDelay;
22248
+ return Math.max(0, Math.round(cappedDelay + jitter));
22249
+ }
22250
+ /**
22251
+ * Determine whether a pipeline is permitted to dispatch the next node.
22252
+ *
22253
+ * **Unlimited mode:** When `cap === 0` the function returns `{ allowed: true }`
22254
+ * immediately, regardless of `accumulatedCost`. This matches the
22255
+ * `FactoryConfigSchema` default of `budget_cap_usd: 0` which means "no limit".
22256
+ *
22257
+ * **Strict greater-than boundary:** Enforcement triggers only when
22258
+ * `accumulatedCost > cap`. A cost that exactly equals the cap is still allowed,
22259
+ * consistent with the PRD wording "halts *before* dispatching further nodes
22260
+ * when accumulated cost **exceeds** the cap."
22261
+ *
22262
+ * @param accumulatedCost - Total cost (USD) spent so far during this pipeline run.
22263
+ * @param cap - Maximum allowed cost (USD). `0` disables enforcement.
22264
+ * @returns `{ allowed: true }` when the pipeline may continue, or
22265
+ * `{ allowed: false, reason: '...' }` when the budget is exhausted.
22266
+ */
22267
+ function checkPipelineBudget(accumulatedCost, cap) {
22268
+ if (cap === 0) return { allowed: true };
22269
+ if (accumulatedCost > cap) return {
22270
+ allowed: false,
22271
+ reason: `pipeline budget exhausted: $${accumulatedCost.toFixed(2)} > $${cap.toFixed(2)}`
22272
+ };
22273
+ return { allowed: true };
22274
+ }
22275
+ /**
22276
+ * Tracks accumulated cost for a single pipeline run and enforces a configurable
22277
+ * spending cap via `checkPipelineBudget`.
22278
+ *
22279
+ * **Lifecycle:** Create one instance per pipeline run. Story 45-8 will call
22280
+ * `addCost()` after each node dispatch completes and `checkBudget()` before
22281
+ * the next dispatch. Call `reset()` between pipeline runs or in tests to clear
22282
+ * accumulated state.
22283
+ */
22284
+ var PipelineBudgetManager = class {
22285
+ totalCost = 0;
22286
+ /**
22287
+ * Add `amount` USD to the running total for this pipeline run.
22288
+ *
22289
+ * @param amount - Cost (USD) for the just-completed node dispatch.
22290
+ */
22291
+ addCost(amount) {
22292
+ this.totalCost += amount;
22293
+ }
22294
+ /**
22295
+ * Return the total cost (USD) accumulated so far during this pipeline run.
22296
+ */
22297
+ getTotalCost() {
22298
+ return this.totalCost;
22299
+ }
22300
+ /**
22301
+ * Reset the accumulated cost to zero.
22302
+ * Useful for test isolation and future pipeline reuse scenarios.
22303
+ */
22304
+ reset() {
22305
+ this.totalCost = 0;
22306
+ }
22307
+ /**
22308
+ * Determine whether the pipeline may dispatch the next node, delegating to
22309
+ * `checkPipelineBudget` with the current accumulated cost.
22310
+ *
22311
+ * @param cap - Maximum allowed cost (USD). `0` disables enforcement.
22312
+ */
22313
+ checkBudget(cap) {
22314
+ return checkPipelineBudget(this.totalCost, cap);
22315
+ }
22316
+ };
22317
+ /**
22318
+ * Determine whether a pipeline session is permitted to dispatch the next node
22319
+ * based on wall-clock elapsed time.
22320
+ *
22321
+ * **Unlimited mode:** When `capMs === 0` the function returns `{ allowed: true }`
22322
+ * immediately, regardless of `elapsedMs`. This matches the `FactoryConfigSchema`
22323
+ * default of `wall_clock_cap_seconds: 0` which means "no limit".
22324
+ *
22325
+ * **Strict greater-than boundary:** Enforcement triggers only when
22326
+ * `elapsedMs > capMs`. An elapsed time that exactly equals the cap is still
22327
+ * allowed, consistent with the PRD wording "halts *before* dispatching further
22328
+ * nodes when elapsed time **exceeds** the cap."
22329
+ *
22330
+ * @param elapsedMs - Milliseconds elapsed since the pipeline session started.
22331
+ * @param capMs - Maximum allowed elapsed time in milliseconds. `0` disables
22332
+ * enforcement (unlimited mode).
22333
+ * @returns `{ allowed: true }` when the session may continue, or
22334
+ * `{ allowed: false, reason: 'wall clock budget exhausted' }` when the
22335
+ * cap has been exceeded.
22336
+ */
22337
+ function checkSessionBudget(elapsedMs, capMs) {
22338
+ if (capMs === 0) return { allowed: true };
22339
+ if (elapsedMs > capMs) return {
22340
+ allowed: false,
22341
+ reason: "wall clock budget exhausted"
22342
+ };
22343
+ return { allowed: true };
22344
+ }
22345
+ /**
22346
+ * Tracks wall-clock elapsed time for a single pipeline session and enforces a
22347
+ * configurable time cap via `checkSessionBudget`.
22348
+ *
22349
+ * **Lifecycle:** Create one instance per pipeline run, constructed at pipeline
22350
+ * launch. Story 45-8 will call `checkBudget()` before each node dispatch as
22351
+ * the highest-priority budget check (before `PipelineBudgetManager`).
22352
+ * Call `reset()` between pipeline runs or in tests for
22353
+ * isolation.
22354
+ *
22355
+ * **Cap 0 means unlimited:** A `capSeconds` value of `0` passed to `checkBudget`
22356
+ * disables all wall-clock enforcement and always returns `{ allowed: true }`.
22357
+ */
22358
+ var SessionBudgetManager = class {
22359
+ startTime;
22360
+ constructor() {
22361
+ this.startTime = Date.now();
22362
+ }
22363
+ /**
22364
+ * Return the number of milliseconds elapsed since this manager was constructed
22365
+ * (or since the last `reset()` call). Always returns a non-negative number.
22366
+ */
22367
+ getElapsedMs() {
22368
+ return Date.now() - this.startTime;
22369
+ }
22370
+ /**
22371
+ * Reset the session start timestamp to the current time. Subsequent calls to
22372
+ * `getElapsedMs()` will measure from this new baseline. Useful for test
22373
+ * isolation and future pipeline reuse scenarios.
22374
+ */
22375
+ reset() {
22376
+ this.startTime = Date.now();
22377
+ }
22378
+ /**
22379
+ * Determine whether the pipeline session may dispatch the next node, delegating
22380
+ * to `checkSessionBudget` with the current elapsed time converted from seconds
22381
+ * to milliseconds.
22382
+ *
22383
+ * @param capSeconds - Maximum allowed elapsed time in **seconds** (as stored in
22384
+ * `FactoryConfig.wall_clock_cap_seconds`). A value of `0`
22385
+ * disables enforcement.
22386
+ */
22387
+ checkBudget(capSeconds) {
22388
+ return checkSessionBudget(this.getElapsedMs(), capSeconds * 1e3);
22389
+ }
22390
+ };
22391
+
22392
+ //#endregion
22393
+ //#region packages/factory/dist/convergence/plateau.js
22394
+ /**
22395
+ * Plateau detection for the convergence loop.
22396
+ * Story 45-6: provides pure plateau detection primitives — no I/O, no side effects.
22397
+ *
22398
+ * Algorithm: Track the last N satisfaction scores (N = `window`, default 3).
22399
+ * If max−min of the window falls strictly below threshold, declare plateau.
22400
+ *
22401
+ * Consumed by:
22402
+ * - Story 45-8 (convergence controller integration)
22403
+ */
22404
+ const DEFAULT_WINDOW = 3;
22405
+ const DEFAULT_THRESHOLD = .05;
22406
+ /**
22407
+ * Create a new PlateauDetector with the given options.
22408
+ *
22409
+ * **Defaults:** `window=3`, `threshold=0.05` — matching `FactoryConfigSchema.plateau_window`
22410
+ * and `FactoryConfigSchema.plateau_threshold`. Story 45-8 will read these values from
22411
+ * `FactoryConfig` and pass them in.
22412
+ *
22413
+ * **Insufficient-data guard:** `isPlateaued()` always returns `false` when fewer than
22414
+ * `window` scores have been recorded. A plateau can only be declared once the window is full.
22415
+ *
22416
+ * @param options - Optional configuration for window size and threshold.
22417
+ */
22418
+ function createPlateauDetector(options) {
22419
+ const window = options?.window ?? DEFAULT_WINDOW;
22420
+ const threshold = options?.threshold ?? DEFAULT_THRESHOLD;
22421
+ let scores = [];
22422
+ return {
22423
+ recordScore(_iteration, score) {
22424
+ scores.push(score);
22425
+ scores = scores.slice(-window);
22426
+ },
22427
+ isPlateaued() {
22428
+ if (scores.length < window) return false;
22429
+ const delta = Math.max(...scores) - Math.min(...scores);
22430
+ return delta < threshold;
22431
+ },
22432
+ getWindow() {
22433
+ return window;
22434
+ },
22435
+ getScores() {
22436
+ return [...scores];
22437
+ }
22438
+ };
22439
+ }
22440
+ /**
22441
+ * Check whether the detector has reached a plateau and, if so, emit the
22442
+ * `convergence:plateau-detected` event on the provided event bus.
22443
+ *
22444
+ * This mirrors the `checkGoalGates()` pattern:
22445
+ * - Pure detection is isolated in `PlateauDetector` (no side effects).
22446
+ * - Event emission is isolated here in this wrapper.
22447
+ * - Callers may omit `eventBus` for pure check behavior (no event is emitted).
22448
+ *
22449
+ * @param detector - A `PlateauDetector` instance.
22450
+ * @param context - Run/node identifiers and an optional event bus.
22451
+ * @returns `{ plateaued: true, scores }` with event emitted when plateaued;
22452
+ * `{ plateaued: false, scores }` with no event emitted otherwise.
22453
+ */
22454
+ function checkPlateauAndEmit(detector, context) {
22455
+ const { runId, nodeId, eventBus } = context;
22456
+ const scores = detector.getScores();
22457
+ if (detector.isPlateaued()) {
22458
+ eventBus?.emit("convergence:plateau-detected", {
22459
+ runId,
22460
+ nodeId,
22461
+ scores,
22462
+ window: detector.getWindow()
22463
+ });
22464
+ return {
22465
+ plateaued: true,
22466
+ scores
22467
+ };
22468
+ }
22469
+ return {
22470
+ plateaued: false,
22471
+ scores
22472
+ };
22473
+ }
22474
+
22475
+ //#endregion
22476
+ //#region packages/factory/dist/convergence/remediation.js
22477
+ /**
22478
+ * Remediation context injection for the convergence loop.
22479
+ * Story 45-7: builds structured remediation context from failure data and
22480
+ * injects it into a retried node's IGraphContext.
22481
+ *
22482
+ * Architecture reference: Section 6.5 — Remediation Context fields
22483
+ *
22484
+ * Pure functions (`formatScenarioDiff`, `deriveFixScope`, `buildRemediationContext`)
22485
+ * have no I/O and no side effects.
22486
+ * Only `injectRemediationContext` mutates state (the IGraphContext).
22487
+ *
22488
+ * Consumed by:
22489
+ * - Story 45-8 (convergence controller integration with executor)
22490
+ * - CodergenBackend handlers (via `getRemediationContext`)
22491
+ */
22492
+ /**
22493
+ * The agreed key under which remediation context is stored in `IGraphContext`.
22494
+ * Namespaced under `convergence.` to avoid collision with user-defined context keys.
22495
+ * Story 45-8 writes this key; CodergenBackend handlers read it via `getRemediationContext()`.
22496
+ */
22497
+ const REMEDIATION_CONTEXT_KEY = "convergence.remediation";
22498
+ /**
22499
+ * Formats a human-readable diff of failed scenarios from a ScenarioRunResult.
22500
+ *
22501
+ * This is a pure formatting function with no side effects. For each failed
22502
+ * scenario it produces a line `"- {name}: {stderr || stdout || '(no output)'}"`,
22503
+ * preferring stderr (most useful for debugging), falling back to stdout
22504
+ * (some tools write errors to stdout), then to the literal `'(no output)'`.
22505
+ *
22506
+ * Returns `"All scenarios passed"` when there are no failures.
22507
+ */
22508
+ function formatScenarioDiff(results) {
22509
+ const failed = results.scenarios.filter((s$1) => s$1.status === "fail");
22510
+ if (failed.length === 0) return "All scenarios passed";
22511
+ const lines = failed.map((s$1) => {
22512
+ const output = s$1.stderr || s$1.stdout || "(no output)";
22513
+ return `- ${s$1.name}: ${output}`;
22514
+ });
22515
+ return lines.join("\n");
22516
+ }
22517
+ /**
22518
+ * Derives a focused fix instruction string from failed scenarios.
22519
+ *
22520
+ * This function produces human-readable fix instructions for the retried agent.
22521
+ * Returns `"Fix {n} failing scenario{s}: {name1}, {name2}, ..."` when there are
22522
+ * failures, or `""` when all scenarios pass.
22523
+ *
22524
+ * Pluralization: singular "scenario" when n === 1, plural "scenarios" otherwise.
22525
+ */
22526
+ function deriveFixScope(results) {
22527
+ const failed = results.scenarios.filter((s$1) => s$1.status === "fail");
22528
+ if (failed.length === 0) return "";
22529
+ const n$1 = failed.length;
22530
+ const plural = n$1 === 1 ? "scenario" : "scenarios";
22531
+ const names = failed.map((s$1) => s$1.name).join(", ");
22532
+ return `Fix ${n$1} failing ${plural}: ${names}`;
22533
+ }
22534
+ /**
22535
+ * Builds a complete `RemediationContext` from the provided parameters.
22536
+ *
22537
+ * `scenarioResults` is optional — first-iteration retries may not have scenario
22538
+ * data yet. When omitted, `scenarioDiff` defaults to
22539
+ * `"No scenario results available"` and `fixScope` defaults to `""`.
22540
+ *
22541
+ * Stores `satisfactionScoreHistory` as a defensive copy (`[...params.satisfactionScoreHistory]`)
22542
+ * so external mutation of the caller's array does not corrupt the stored history.
22543
+ */
22544
+ function buildRemediationContext(params) {
22545
+ const scenarioDiff = params.scenarioResults ? formatScenarioDiff(params.scenarioResults) : "No scenario results available";
22546
+ const fixScope = params.scenarioResults ? deriveFixScope(params.scenarioResults) : "";
22547
+ return {
22548
+ previousFailureReason: params.previousFailureReason,
22549
+ scenarioDiff,
22550
+ iterationCount: params.iterationCount,
22551
+ satisfactionScoreHistory: [...params.satisfactionScoreHistory],
22552
+ fixScope
22553
+ };
22554
+ }
22555
+ /**
22556
+ * Injects a `RemediationContext` into an `IGraphContext` under `REMEDIATION_CONTEXT_KEY`.
22557
+ *
22558
+ * Called by the executor's retry loop before dispatching to the retried node —
22559
+ * story 45-8 wires this call into the graph executor.
22560
+ */
22561
+ function injectRemediationContext(context, remediation) {
22562
+ context.set(REMEDIATION_CONTEXT_KEY, remediation);
22563
+ }
22564
+
22184
22565
  //#endregion
22185
22566
  //#region packages/factory/dist/graph/executor.js
22186
22567
  /**
@@ -22220,17 +22601,6 @@ function normalizeOutcomeStatus(raw) {
22220
22601
  };
22221
22602
  }
22222
22603
  /**
22223
- * Compute exponential backoff delay with ±50% jitter.
22224
- *
22225
- * @param attempt - Zero-indexed attempt number (0 = first retry, 1 = second, etc.)
22226
- * @returns Delay in milliseconds, floored at 0 and capped at 60,000ms
22227
- */
22228
- function computeBackoffDelay(attempt) {
22229
- const rawDelay = Math.min(200 * Math.pow(2, attempt), 6e4);
22230
- const jitter = rawDelay * .5 * (2 * Math.random() - 1);
22231
- return Math.max(0, rawDelay + jitter);
22232
- }
22233
- /**
22234
22604
  * Dispatch a node handler with exponential backoff retry on FAIL outcomes.
22235
22605
  *
22236
22606
  * Emits `graph:node-retried` before each retry attempt.
@@ -22289,6 +22659,13 @@ function createGraphExecutor() {
22289
22659
  const checkpointManager = new CheckpointManager();
22290
22660
  const checkpointFilePath = path.join(config.logsRoot, "checkpoint.json");
22291
22661
  const controller = createConvergenceController();
22662
+ const sessionManager = new SessionBudgetManager();
22663
+ const pipelineManager = new PipelineBudgetManager();
22664
+ const plateauDetector = createPlateauDetector({
22665
+ ...config.plateauWindow !== void 0 ? { window: config.plateauWindow } : {},
22666
+ ...config.plateauThreshold !== void 0 ? { threshold: config.plateauThreshold } : {}
22667
+ });
22668
+ let convergenceIteration = 0;
22292
22669
  let completedNodes = [];
22293
22670
  let nodeRetries = {};
22294
22671
  let context = new GraphContext();
@@ -22328,23 +22705,64 @@ function createGraphExecutor() {
22328
22705
  }
22329
22706
  } else currentNode = graph.startNode();
22330
22707
  while (true) {
22708
+ const sessionResult = sessionManager.checkBudget((config.wallClockCapMs ?? 0) / 1e3);
22709
+ if (!sessionResult.allowed) {
22710
+ config.eventBus?.emit("convergence:budget-exhausted", {
22711
+ runId: config.runId,
22712
+ level: "session",
22713
+ reason: sessionResult.reason
22714
+ });
22715
+ return {
22716
+ status: "FAIL",
22717
+ failureReason: `Session budget exceeded: ${sessionResult.reason}`
22718
+ };
22719
+ }
22720
+ const pipelineResult = pipelineManager.checkBudget(config.pipelineBudgetCapUsd ?? 0);
22721
+ if (!pipelineResult.allowed) {
22722
+ config.eventBus?.emit("convergence:budget-exhausted", {
22723
+ runId: config.runId,
22724
+ level: "pipeline",
22725
+ reason: pipelineResult.reason
22726
+ });
22727
+ return {
22728
+ status: "FAIL",
22729
+ failureReason: `Pipeline budget exceeded: ${pipelineResult.reason}`
22730
+ };
22731
+ }
22331
22732
  const exitNode = graph.exitNode();
22332
22733
  if (currentNode.id === exitNode.id) {
22333
- const gateResult = controller.evaluateGates(graph);
22734
+ const gateResult = controller.checkGoalGates(graph, config.runId, config.eventBus);
22334
22735
  if (!gateResult.satisfied) {
22335
- const failingNodeId = gateResult.failingNodes[0];
22736
+ const failingNodeId = gateResult.failedGates[0];
22336
22737
  const failingGateNode = graph.nodes.get(failingNodeId);
22337
- const retryTarget = failingGateNode?.retryTarget || failingGateNode?.fallbackRetryTarget || graph.retryTarget || graph.fallbackRetryTarget;
22338
- if (retryTarget) {
22339
- const retryNode = graph.nodes.get(retryTarget);
22340
- if (!retryNode) throw new Error(`Retry target node "${retryTarget}" not found in graph`);
22341
- currentNode = retryNode;
22342
- continue;
22343
- }
22344
- return {
22738
+ const retryTargetId = failingGateNode ? controller.resolveRetryTarget(failingGateNode, graph) : null;
22739
+ if (!retryTargetId) return {
22345
22740
  status: "FAIL",
22346
22741
  failureReason: "Goal gate failed: no retry target"
22347
22742
  };
22743
+ const retryNode = graph.nodes.get(retryTargetId);
22744
+ if (!retryNode) throw new Error(`Retry target node "${retryTargetId}" not found in graph`);
22745
+ convergenceIteration++;
22746
+ const satisfactionScore = context.getNumber("satisfaction_score", 0);
22747
+ plateauDetector.recordScore(convergenceIteration, satisfactionScore);
22748
+ const plateauResult = checkPlateauAndEmit(plateauDetector, {
22749
+ runId: config.runId,
22750
+ nodeId: retryTargetId,
22751
+ ...config.eventBus ? { eventBus: config.eventBus } : {}
22752
+ });
22753
+ if (plateauResult.plateaued) return {
22754
+ status: "FAIL",
22755
+ failureReason: `Convergence plateau detected after ${convergenceIteration} iteration(s): scores plateaued at [${plateauResult.scores.join(", ")}]`
22756
+ };
22757
+ const remediation = buildRemediationContext({
22758
+ previousFailureReason: `Goal gate unsatisfied: ${gateResult.failedGates.join(", ")}`,
22759
+ iterationCount: convergenceIteration,
22760
+ satisfactionScoreHistory: plateauResult.scores
22761
+ });
22762
+ injectRemediationContext(context, remediation);
22763
+ skipCycleCheck = true;
22764
+ currentNode = retryNode;
22765
+ continue;
22348
22766
  }
22349
22767
  return { status: "SUCCESS" };
22350
22768
  }
@@ -22441,6 +22859,8 @@ function createGraphExecutor() {
22441
22859
  controller.recordOutcome(nodeToDispatch.id, controllerStatus);
22442
22860
  }
22443
22861
  if (outcome.contextUpdates) for (const [key, value] of Object.entries(outcome.contextUpdates)) context.set(key, value);
22862
+ const nodeCost = context.getNumber("factory.lastNodeCostUsd", 0);
22863
+ if (nodeCost > 0) pipelineManager.addCost(nodeCost);
22444
22864
  if (!skipCompletedPush) completedNodes.push(currentNode.id);
22445
22865
  skipCompletedPush = false;
22446
22866
  await checkpointManager.save(config.logsRoot, {
@@ -22455,7 +22875,7 @@ function createGraphExecutor() {
22455
22875
  checkpointPath: checkpointFilePath
22456
22876
  });
22457
22877
  if (outcome.status === "FAIL") {
22458
- const retryTarget = currentNode.retryTarget || currentNode.fallbackRetryTarget || graph.retryTarget || graph.fallbackRetryTarget;
22878
+ const retryTarget = controller.resolveRetryTarget(currentNode, graph);
22459
22879
  if (retryTarget) {
22460
22880
  const retryNode = graph.nodes.get(retryTarget);
22461
22881
  if (!retryNode) throw new Error(`Retry target node "${retryTarget}" not found in graph`);
@@ -27851,13 +28271,19 @@ function registerFactoryCommand(program) {
27851
28271
  const logsRoot = path.join(projectDir, ".substrate", "runs", runId);
27852
28272
  const stateManager = new RunStateManager({ runDir: logsRoot });
27853
28273
  await stateManager.initRun(dotSource);
28274
+ /** wallClockCapMs: FactoryConfig.wall_clock_cap_seconds × 1000 (story 45-10) */
28275
+ const factoryConfig = await loadFactoryConfig(projectDir, opts.config);
27854
28276
  const executor = createGraphExecutor();
27855
28277
  await executor.run(graph, {
27856
28278
  runId,
27857
28279
  logsRoot,
27858
28280
  handlerRegistry: createDefaultRegistry(),
27859
28281
  eventBus,
27860
- dotSource
28282
+ dotSource,
28283
+ wallClockCapMs: (factoryConfig.factory?.wall_clock_cap_seconds ?? 0) * 1e3,
28284
+ pipelineBudgetCapUsd: factoryConfig.factory?.budget_cap_usd ?? 0,
28285
+ plateauWindow: factoryConfig.factory?.plateau_window ?? 3,
28286
+ plateauThreshold: factoryConfig.factory?.plateau_threshold ?? .05
27861
28287
  });
27862
28288
  } catch (err) {
27863
28289
  const msg = err instanceof Error ? err.message : String(err);
@@ -29340,4 +29766,4 @@ function registerRunCommand(program, _version = "0.0.0", projectRoot = process.c
29340
29766
 
29341
29767
  //#endregion
29342
29768
  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, normalizeGraphSummaryToStatus, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
29343
- //# sourceMappingURL=run-DDUeFC-I.js.map
29769
+ //# sourceMappingURL=run-CUMPhuVq.js.map
@@ -2,7 +2,7 @@ import "./health-DswaC1q5.js";
2
2
  import "./logger-KeHncl-f.js";
3
3
  import "./helpers-CElYrONe.js";
4
4
  import "./dist-CLvAwmT7.js";
5
- import { normalizeGraphSummaryToStatus, registerRunCommand, runRunAction } from "./run-DDUeFC-I.js";
5
+ import { normalizeGraphSummaryToStatus, registerRunCommand, runRunAction } from "./run-CUMPhuVq.js";
6
6
  import "./routing-CcBOCuC9.js";
7
7
  import "./decisions-C0pz9Clx.js";
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrate-ai",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Substrate — multi-agent orchestration daemon for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",