substrate-ai 0.2.24 → 0.2.26

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.
@@ -1,11 +1,11 @@
1
1
  import { createLogger } from "./logger-D2fS2ccL.js";
2
- import { createEventBus, createTuiApp, isTuiCapable, printNonTtyWarning } from "./event-bus-CAvDMst7.js";
2
+ import { createEventBus, createTuiApp, isTuiCapable, printNonTtyWarning, sleep } from "./helpers-DljGJnFF.js";
3
3
  import { addTokenUsage, createDecision, createPipelineRun, createRequirement, getArtifactByTypeForRun, getArtifactsByRun, getDecisionsByCategory, getDecisionsByPhase, getDecisionsByPhaseForRun, getLatestRun, getPipelineRunById, getRunningPipelineRuns, getTokenUsageSummary, registerArtifact, updatePipelineRun, updatePipelineRunConfig, upsertDecision } from "./decisions-Dq4cAA2L.js";
4
4
  import { ESCALATION_DIAGNOSIS, OPERATIONAL_FINDING, STORY_METRICS, STORY_OUTCOME, TEST_EXPANSION_FINDING, TEST_PLAN, aggregateTokenUsageForRun, aggregateTokenUsageForStory, getStoryMetricsForRun, writeRunMetrics, writeStoryMetrics } from "./operational-CnMlvWqc.js";
5
5
  import { createRequire } from "module";
6
6
  import { dirname, join } from "path";
7
7
  import { access, readFile, readdir, stat } from "fs/promises";
8
- import { existsSync, mkdirSync, readFileSync } from "fs";
8
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
9
9
  import yaml from "js-yaml";
10
10
  import { createRequire as createRequire$1 } from "node:module";
11
11
  import { z } from "zod";
@@ -677,7 +677,9 @@ const PackManifestSchema = z.object({
677
677
  prompts: z.record(z.string(), z.string()),
678
678
  constraints: z.record(z.string(), z.string()),
679
679
  templates: z.record(z.string(), z.string()),
680
- conflictGroups: z.record(z.string(), z.string()).optional()
680
+ conflictGroups: z.record(z.string(), z.string()).optional(),
681
+ verifyCommand: z.union([z.string(), z.literal(false)]).optional(),
682
+ verifyTimeoutMs: z.number().optional()
681
683
  });
682
684
  const ConstraintSeveritySchema = z.enum([
683
685
  "required",
@@ -1244,6 +1246,8 @@ function buildPipelineStatusOutput(run, tokenSummary, decisionsCount, storiesCou
1244
1246
  }
1245
1247
  }
1246
1248
  } catch {}
1249
+ const derivedStoriesCount = storiesSummary !== void 0 ? storiesSummary.completed + storiesSummary.in_progress + storiesSummary.escalated + storiesSummary.pending : storiesCount;
1250
+ const derivedStoriesCompleted = storiesSummary !== void 0 ? storiesSummary.completed : 0;
1247
1251
  return {
1248
1252
  run_id: run.id,
1249
1253
  current_phase: currentPhase,
@@ -1254,7 +1258,8 @@ function buildPipelineStatusOutput(run, tokenSummary, decisionsCount, storiesCou
1254
1258
  cost_usd: totalCost
1255
1259
  },
1256
1260
  decisions_count: decisionsCount,
1257
- stories_count: storiesCount,
1261
+ stories_count: derivedStoriesCount,
1262
+ stories_completed: derivedStoriesCompleted,
1258
1263
  last_activity: run.updated_at,
1259
1264
  staleness_seconds: Math.round((Date.now() - parseDbTimestampAsUtc(run.updated_at).getTime()) / 1e3),
1260
1265
  last_event_ts: run.updated_at,
@@ -2209,6 +2214,69 @@ const PIPELINE_EVENT_METADATA = [
2209
2214
  description: "Error message."
2210
2215
  }
2211
2216
  ]
2217
+ },
2218
+ {
2219
+ type: "story:zero-diff-escalation",
2220
+ description: "Dev-story reported COMPLETE but git diff shows no file changes (phantom completion).",
2221
+ when: "After dev-story succeeds with zero file changes in working tree.",
2222
+ fields: [
2223
+ {
2224
+ name: "ts",
2225
+ type: "string",
2226
+ description: "Timestamp."
2227
+ },
2228
+ {
2229
+ name: "storyKey",
2230
+ type: "string",
2231
+ description: "Story key."
2232
+ },
2233
+ {
2234
+ name: "reason",
2235
+ type: "string",
2236
+ description: "Always \"zero-diff-on-complete\"."
2237
+ }
2238
+ ]
2239
+ },
2240
+ {
2241
+ type: "story:build-verification-failed",
2242
+ description: "Build verification command (default: npm run build) exited with non-zero code or timed out.",
2243
+ when: "After dev-story and zero-diff check pass, but before code-review is dispatched.",
2244
+ fields: [
2245
+ {
2246
+ name: "ts",
2247
+ type: "string",
2248
+ description: "Timestamp."
2249
+ },
2250
+ {
2251
+ name: "storyKey",
2252
+ type: "string",
2253
+ description: "Story key."
2254
+ },
2255
+ {
2256
+ name: "exitCode",
2257
+ type: "number",
2258
+ description: "Exit code from the build command (-1 for timeout)."
2259
+ },
2260
+ {
2261
+ name: "output",
2262
+ type: "string",
2263
+ description: "Combined stdout+stderr from the build command (truncated to 2000 chars)."
2264
+ }
2265
+ ]
2266
+ },
2267
+ {
2268
+ type: "story:build-verification-passed",
2269
+ description: "Build verification command exited with code 0 — compilation clean.",
2270
+ when: "After dev-story completes and build verification command succeeds.",
2271
+ fields: [{
2272
+ name: "ts",
2273
+ type: "string",
2274
+ description: "Timestamp."
2275
+ }, {
2276
+ name: "storyKey",
2277
+ type: "string",
2278
+ description: "Story key."
2279
+ }]
2212
2280
  }
2213
2281
  ];
2214
2282
  /**
@@ -2969,6 +3037,7 @@ const MIN_FREE_MEMORY_BYTES = (() => {
2969
3037
  return 256 * 1024 * 1024;
2970
3038
  })();
2971
3039
  const MEMORY_PRESSURE_POLL_MS = 1e4;
3040
+ let _lastKnownPressureLevel = 0;
2972
3041
  /**
2973
3042
  * Get available system memory in bytes, accounting for platform differences.
2974
3043
  *
@@ -2998,6 +3067,7 @@ function getAvailableMemory() {
2998
3067
  timeout: 1e3,
2999
3068
  encoding: "utf-8"
3000
3069
  }).trim(), 10);
3070
+ _lastKnownPressureLevel = pressureLevel;
3001
3071
  if (pressureLevel >= 4) {
3002
3072
  logger$16.warn({ pressureLevel }, "macOS kernel reports critical memory pressure");
3003
3073
  return 0;
@@ -3025,6 +3095,7 @@ function getAvailableMemory() {
3025
3095
  return freemem();
3026
3096
  }
3027
3097
  }
3098
+ _lastKnownPressureLevel = 0;
3028
3099
  return freemem();
3029
3100
  }
3030
3101
  var MutableDispatchHandle = class {
@@ -3430,12 +3501,28 @@ var DispatcherImpl = class {
3430
3501
  if (free < MIN_FREE_MEMORY_BYTES) {
3431
3502
  logger$16.warn({
3432
3503
  freeMB: Math.round(free / 1024 / 1024),
3433
- thresholdMB: Math.round(MIN_FREE_MEMORY_BYTES / 1024 / 1024)
3504
+ thresholdMB: Math.round(MIN_FREE_MEMORY_BYTES / 1024 / 1024),
3505
+ pressureLevel: _lastKnownPressureLevel
3434
3506
  }, "Memory pressure detected — holding dispatch queue");
3435
3507
  return true;
3436
3508
  }
3437
3509
  return false;
3438
3510
  }
3511
+ /**
3512
+ * Return current memory pressure state (Story 23-8, AC1).
3513
+ *
3514
+ * Used by the orchestrator before dispatching a story phase so it can
3515
+ * implement backoff-retry without waiting on the dispatcher's internal queue.
3516
+ */
3517
+ getMemoryState() {
3518
+ const free = getAvailableMemory();
3519
+ return {
3520
+ freeMB: Math.round(free / 1024 / 1024),
3521
+ thresholdMB: Math.round(MIN_FREE_MEMORY_BYTES / 1024 / 1024),
3522
+ pressureLevel: _lastKnownPressureLevel,
3523
+ isPressured: free < MIN_FREE_MEMORY_BYTES
3524
+ };
3525
+ }
3439
3526
  _startMemoryPressureTimer() {
3440
3527
  if (this._memoryPressureTimer !== null) return;
3441
3528
  this._memoryPressureTimer = setInterval(() => {
@@ -3450,6 +3537,114 @@ var DispatcherImpl = class {
3450
3537
  }
3451
3538
  }
3452
3539
  };
3540
+ /** Default command for the build verification gate */
3541
+ const DEFAULT_VERIFY_COMMAND = "npm run build";
3542
+ /** Default timeout in milliseconds for the build verification gate */
3543
+ const DEFAULT_VERIFY_TIMEOUT_MS = 6e4;
3544
+ /**
3545
+ * Run the build verification gate synchronously.
3546
+ *
3547
+ * Executes the configured verifyCommand (default: "npm run build") in the
3548
+ * project root directory, capturing stdout and stderr. On success (exit 0)
3549
+ * returns { status: 'passed' }. On failure or timeout, returns a structured
3550
+ * result with status, exitCode, output, and reason.
3551
+ *
3552
+ * AC4/5: reads verifyCommand from options (or defaults to 'npm run build').
3553
+ * AC6: if verifyCommand is empty string or false, returns { status: 'skipped' }.
3554
+ * AC8: timeout is configurable via verifyTimeoutMs (default 60 s).
3555
+ */
3556
+ function runBuildVerification(options) {
3557
+ const { verifyCommand, verifyTimeoutMs, projectRoot } = options;
3558
+ const cmd = verifyCommand === void 0 ? DEFAULT_VERIFY_COMMAND : verifyCommand;
3559
+ if (!cmd) return { status: "skipped" };
3560
+ const timeoutMs = verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS;
3561
+ try {
3562
+ const stdout = execSync(cmd, {
3563
+ cwd: projectRoot,
3564
+ timeout: timeoutMs,
3565
+ encoding: "utf-8"
3566
+ });
3567
+ return {
3568
+ status: "passed",
3569
+ exitCode: 0,
3570
+ output: typeof stdout === "string" ? stdout : ""
3571
+ };
3572
+ } catch (err) {
3573
+ if (err != null && typeof err === "object") {
3574
+ const e = err;
3575
+ const isTimeout = e.killed === true;
3576
+ const exitCode = typeof e.status === "number" ? e.status : 1;
3577
+ const rawStdout = e.stdout;
3578
+ const rawStderr = e.stderr;
3579
+ const stdoutStr = typeof rawStdout === "string" ? rawStdout : Buffer.isBuffer(rawStdout) ? rawStdout.toString("utf-8") : "";
3580
+ const stderrStr = typeof rawStderr === "string" ? rawStderr : Buffer.isBuffer(rawStderr) ? rawStderr.toString("utf-8") : "";
3581
+ const combinedOutput = [stdoutStr, stderrStr].filter((s) => s.length > 0).join("\n");
3582
+ if (isTimeout) return {
3583
+ status: "timeout",
3584
+ exitCode: -1,
3585
+ output: combinedOutput,
3586
+ reason: "build-verification-timeout"
3587
+ };
3588
+ return {
3589
+ status: "failed",
3590
+ exitCode,
3591
+ output: combinedOutput,
3592
+ reason: "build-verification-failed"
3593
+ };
3594
+ }
3595
+ return {
3596
+ status: "failed",
3597
+ exitCode: 1,
3598
+ output: String(err),
3599
+ reason: "build-verification-failed"
3600
+ };
3601
+ }
3602
+ }
3603
+ /**
3604
+ * Check git working tree for modified files using `git diff --name-only HEAD`
3605
+ * (unstaged + staged changes to tracked files) and `git diff --cached --name-only`
3606
+ * (staged new files not yet in HEAD). Returns a deduplicated array of file paths.
3607
+ *
3608
+ * Returns an empty array when:
3609
+ * - No files have been modified or staged
3610
+ * - Git commands fail (e.g., not in a git repo, git not installed)
3611
+ *
3612
+ * Used by the zero-diff detection gate (Story 24-1) to catch phantom completions
3613
+ * where a dev-story agent reported COMPLETE but made no actual file changes.
3614
+ *
3615
+ * @param workingDir - Directory to run git commands in (defaults to process.cwd())
3616
+ * @returns Array of changed file paths; empty when nothing changed
3617
+ */
3618
+ function checkGitDiffFiles(workingDir = process.cwd()) {
3619
+ const results = new Set();
3620
+ try {
3621
+ const unstaged = execSync("git diff --name-only HEAD", {
3622
+ cwd: workingDir,
3623
+ encoding: "utf-8",
3624
+ timeout: 5e3,
3625
+ stdio: [
3626
+ "ignore",
3627
+ "pipe",
3628
+ "pipe"
3629
+ ]
3630
+ });
3631
+ unstaged.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).forEach((f) => results.add(f));
3632
+ } catch {}
3633
+ try {
3634
+ const staged = execSync("git diff --cached --name-only", {
3635
+ cwd: workingDir,
3636
+ encoding: "utf-8",
3637
+ timeout: 5e3,
3638
+ stdio: [
3639
+ "ignore",
3640
+ "pipe",
3641
+ "pipe"
3642
+ ]
3643
+ });
3644
+ staged.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).forEach((f) => results.add(f));
3645
+ } catch {}
3646
+ return Array.from(results);
3647
+ }
3453
3648
  /**
3454
3649
  * Create a new Dispatcher instance.
3455
3650
  *
@@ -5868,120 +6063,403 @@ function detectConflictGroups(storyKeys, config) {
5868
6063
  }
5869
6064
 
5870
6065
  //#endregion
5871
- //#region src/modules/implementation-orchestrator/seed-methodology-context.ts
5872
- const logger$7 = createLogger("implementation-orchestrator:seed");
5873
- /** Max chars for the architecture summary seeded into decisions */
5874
- const MAX_ARCH_CHARS = 6e3;
5875
- /** Max chars per epic shard (fallback when per-story extraction returns null) */
5876
- const MAX_EPIC_SHARD_CHARS = 12e3;
5877
- /** Max chars for test patterns */
5878
- const MAX_TEST_PATTERNS_CHARS = 2e3;
6066
+ //#region src/cli/commands/health.ts
6067
+ const logger$7 = createLogger("health-cmd");
6068
+ /** Default stall threshold in seconds also used by supervisor default */
6069
+ const DEFAULT_STALL_THRESHOLD_SECONDS = 600;
5879
6070
  /**
5880
- * Seed the decision store with methodology-level context from planning artifacts.
5881
- *
5882
- * Reads the following from `{projectRoot}/_bmad-output/planning-artifacts/`:
5883
- * - architecture.md → solutioning/architecture decisions
5884
- * - epics.md → implementation/epic-shard decisions (one per epic)
5885
- * - package.json solutioning/test-patterns decisions (framework detection)
6071
+ * Determine whether a ps output line represents the substrate pipeline orchestrator.
6072
+ * Handles invocation via:
6073
+ * - `substrate run` (globally installed)
6074
+ * - `substrate-ai run`
6075
+ * - `node dist/cli/index.js run` (npm run substrate:dev)
6076
+ * - `npx substrate run`
6077
+ * - any node process whose command contains `run` with `--events` or `--stories`
5886
6078
  *
5887
- * @param db - SQLite database instance
5888
- * @param projectRoot - Absolute path to the target project root
5889
- * @returns SeedResult with counts of decisions created and categories skipped
6079
+ * When `projectRoot` is provided, additionally checks that the command line
6080
+ * contains that path (via `--project-root` flag or as part of the binary/CWD path).
6081
+ * This ensures multi-project environments match the correct orchestrator.
5890
6082
  */
5891
- function seedMethodologyContext(db, projectRoot) {
6083
+ function isOrchestratorProcessLine(line, projectRoot) {
6084
+ if (line.includes("grep")) return false;
6085
+ let isOrchestrator = false;
6086
+ if (line.includes("substrate run")) isOrchestrator = true;
6087
+ else if (line.includes("substrate-ai run")) isOrchestrator = true;
6088
+ else if (line.includes("index.js run")) isOrchestrator = true;
6089
+ else if (line.includes("node") && /\srun(\s|$)/.test(line) && (line.includes("--events") || line.includes("--stories"))) isOrchestrator = true;
6090
+ if (!isOrchestrator) return false;
6091
+ if (projectRoot !== void 0) return line.includes(projectRoot);
6092
+ return true;
6093
+ }
6094
+ function inspectProcessTree(opts) {
6095
+ const { projectRoot, substrateDirPath, execFileSync: execFileSyncOverride, readFileSync: readFileSyncOverride } = opts ?? {};
5892
6096
  const result = {
5893
- decisionsCreated: 0,
5894
- skippedCategories: []
6097
+ orchestrator_pid: null,
6098
+ child_pids: [],
6099
+ zombies: []
5895
6100
  };
5896
6101
  try {
5897
- const archCount = seedArchitecture(db, projectRoot);
5898
- if (archCount === -1) result.skippedCategories.push("architecture");
5899
- else result.decisionsCreated += archCount;
5900
- const epicCount = seedEpicShards(db, projectRoot);
5901
- if (epicCount === -1) result.skippedCategories.push("epic-shard");
5902
- else result.decisionsCreated += epicCount;
5903
- const testCount = seedTestPatterns(db, projectRoot);
5904
- if (testCount === -1) result.skippedCategories.push("test-patterns");
5905
- else result.decisionsCreated += testCount;
5906
- logger$7.info({
5907
- decisionsCreated: result.decisionsCreated,
5908
- skippedCategories: result.skippedCategories
5909
- }, "Methodology context seeding complete");
5910
- } catch (err) {
5911
- logger$7.warn({ error: err instanceof Error ? err.message : String(err) }, "Methodology context seeding failed (non-fatal)");
5912
- }
5913
- return result;
5914
- }
5915
- /**
5916
- * Seed architecture constraints from architecture.md.
5917
- * Extracts key sections (tech stack, ADRs, component overview) as separate decisions.
5918
- * Returns number of decisions created, or -1 if skipped (already seeded).
5919
- */
5920
- function seedArchitecture(db, projectRoot) {
5921
- const existing = getDecisionsByPhase(db, "solutioning");
5922
- if (existing.some((d) => d.category === "architecture")) return -1;
5923
- const archPath = findArtifact(projectRoot, [
5924
- "_bmad-output/planning-artifacts/architecture.md",
5925
- "_bmad-output/architecture/architecture.md",
5926
- "_bmad-output/architecture.md"
5927
- ]);
5928
- if (archPath === void 0) return 0;
5929
- const content = readFileSync$1(archPath, "utf-8");
5930
- if (content.length === 0) return 0;
5931
- const sections = extractArchSections(content);
5932
- let count = 0;
5933
- for (const section of sections) {
5934
- createDecision(db, {
5935
- pipeline_run_id: null,
5936
- phase: "solutioning",
5937
- category: "architecture",
5938
- key: section.key,
5939
- value: section.value.slice(0, MAX_ARCH_CHARS),
5940
- rationale: "Seeded from planning artifacts at orchestrator startup"
5941
- });
5942
- count++;
5943
- }
5944
- if (count === 0) {
5945
- createDecision(db, {
5946
- pipeline_run_id: null,
5947
- phase: "solutioning",
5948
- category: "architecture",
5949
- key: "full",
5950
- value: content.slice(0, MAX_ARCH_CHARS),
5951
- rationale: "Seeded from planning artifacts at orchestrator startup (full file)"
6102
+ let psOutput;
6103
+ if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid,stat,command"], {
6104
+ encoding: "utf-8",
6105
+ timeout: 5e3
5952
6106
  });
5953
- count = 1;
5954
- }
5955
- logger$7.debug({ count }, "Seeded architecture decisions");
5956
- return count;
6107
+ else {
6108
+ const { execFileSync } = __require("node:child_process");
6109
+ psOutput = execFileSync("ps", ["-eo", "pid,ppid,stat,command"], {
6110
+ encoding: "utf-8",
6111
+ timeout: 5e3
6112
+ });
6113
+ }
6114
+ const lines = psOutput.split("\n");
6115
+ if (substrateDirPath !== void 0) try {
6116
+ const readFileSyncFn = readFileSyncOverride ?? ((path$1, encoding) => readFileSync$1(path$1, encoding));
6117
+ const pidContent = readFileSyncFn(join(substrateDirPath, "orchestrator.pid"), "utf-8");
6118
+ const pid = parseInt(pidContent.trim(), 10);
6119
+ if (!isNaN(pid) && pid > 0) {
6120
+ const isAlive = lines.some((line) => {
6121
+ const parts = line.trim().split(/\s+/);
6122
+ if (parts.length < 3) return false;
6123
+ return parseInt(parts[0], 10) === pid && !parts[2].includes("Z");
6124
+ });
6125
+ if (isAlive) result.orchestrator_pid = pid;
6126
+ }
6127
+ } catch {}
6128
+ if (result.orchestrator_pid === null) {
6129
+ for (const line of lines) if (isOrchestratorProcessLine(line, projectRoot)) {
6130
+ const match = line.trim().match(/^(\d+)/);
6131
+ if (match) {
6132
+ result.orchestrator_pid = parseInt(match[1], 10);
6133
+ break;
6134
+ }
6135
+ }
6136
+ }
6137
+ if (result.orchestrator_pid !== null) for (const line of lines) {
6138
+ const parts = line.trim().split(/\s+/);
6139
+ if (parts.length >= 3) {
6140
+ const pid = parseInt(parts[0], 10);
6141
+ const ppid = parseInt(parts[1], 10);
6142
+ const stat$2 = parts[2];
6143
+ if (ppid === result.orchestrator_pid && pid !== result.orchestrator_pid) {
6144
+ result.child_pids.push(pid);
6145
+ if (stat$2.includes("Z")) result.zombies.push(pid);
6146
+ }
6147
+ }
6148
+ }
6149
+ } catch {}
6150
+ return result;
5957
6151
  }
5958
6152
  /**
5959
- * Seed epic shards from epics.md.
5960
- * Parses each epic section and creates an implementation/epic-shard decision.
5961
- *
5962
- * Uses content-hash comparison (AC1, AC2, AC6):
5963
- * - Computes SHA-256 of the epics file and compares to the stored `epic-shard-hash` decision.
5964
- * - If hashes match: skip re-seeding (unchanged file).
5965
- * - If hash differs or no hash stored: delete existing epic-shard decisions and re-seed.
6153
+ * Collect all descendant PIDs of the given root PIDs by walking the process
6154
+ * tree recursively. This ensures that grandchildren of the orchestrator
6155
+ * (e.g. node subprocesses spawned by `claude -p`) are also killed during
6156
+ * stall recovery, leaving no orphan processes.
5966
6157
  *
5967
- * Returns number of decisions created, or -1 if skipped (hash unchanged).
6158
+ * Returns only the descendants the root PIDs themselves are NOT included.
5968
6159
  */
5969
- function seedEpicShards(db, projectRoot) {
5970
- const epicsPath = findArtifact(projectRoot, ["_bmad-output/planning-artifacts/epics.md", "_bmad-output/epics.md"]);
5971
- if (epicsPath === void 0) return 0;
5972
- const content = readFileSync$1(epicsPath, "utf-8");
5973
- if (content.length === 0) return 0;
5974
- const currentHash = createHash("sha256").update(content).digest("hex");
5975
- const implementationDecisions = getDecisionsByPhase(db, "implementation");
5976
- const storedHashDecision = implementationDecisions.find((d) => d.category === "epic-shard-hash" && d.key === "epics-file");
5977
- const storedHash = storedHashDecision?.value;
5978
- if (storedHash === currentHash) {
5979
- logger$7.debug({ hash: currentHash }, "Epic shards up-to-date (hash unchanged) — skipping re-seed");
5980
- return -1;
5981
- }
5982
- if (implementationDecisions.some((d) => d.category === "epic-shard")) {
5983
- logger$7.debug({
5984
- storedHash,
6160
+ function getAllDescendantPids(rootPids, execFileSyncOverride) {
6161
+ if (rootPids.length === 0) return [];
6162
+ try {
6163
+ let psOutput;
6164
+ if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid"], {
6165
+ encoding: "utf-8",
6166
+ timeout: 5e3
6167
+ });
6168
+ else {
6169
+ const { execFileSync } = __require("node:child_process");
6170
+ psOutput = execFileSync("ps", ["-eo", "pid,ppid"], {
6171
+ encoding: "utf-8",
6172
+ timeout: 5e3
6173
+ });
6174
+ }
6175
+ const childrenOf = new Map();
6176
+ for (const line of psOutput.split("\n")) {
6177
+ const parts = line.trim().split(/\s+/);
6178
+ if (parts.length >= 2) {
6179
+ const pid = parseInt(parts[0], 10);
6180
+ const ppid = parseInt(parts[1], 10);
6181
+ if (!isNaN(pid) && !isNaN(ppid) && pid > 0) {
6182
+ if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
6183
+ childrenOf.get(ppid).push(pid);
6184
+ }
6185
+ }
6186
+ }
6187
+ const descendants = [];
6188
+ const seen = new Set(rootPids);
6189
+ const queue = [...rootPids];
6190
+ while (queue.length > 0) {
6191
+ const current = queue.shift();
6192
+ const children = childrenOf.get(current) ?? [];
6193
+ for (const child of children) if (!seen.has(child)) {
6194
+ seen.add(child);
6195
+ descendants.push(child);
6196
+ queue.push(child);
6197
+ }
6198
+ }
6199
+ return descendants;
6200
+ } catch {
6201
+ return [];
6202
+ }
6203
+ }
6204
+ /**
6205
+ * Fetch pipeline health data as a structured object without any stdout side-effects.
6206
+ * Used by runSupervisorAction to poll health without formatting overhead.
6207
+ *
6208
+ * Returns a NO_PIPELINE_RUNNING health object for all graceful "no data" cases
6209
+ * (missing DB, missing run, terminal run status). Throws only on unexpected errors.
6210
+ */
6211
+ async function getAutoHealthData(options) {
6212
+ const { runId, projectRoot } = options;
6213
+ const dbRoot = await resolveMainRepoRoot(projectRoot);
6214
+ const dbPath = join(dbRoot, ".substrate", "substrate.db");
6215
+ const NO_PIPELINE = {
6216
+ verdict: "NO_PIPELINE_RUNNING",
6217
+ run_id: null,
6218
+ status: null,
6219
+ current_phase: null,
6220
+ staleness_seconds: 0,
6221
+ last_activity: "",
6222
+ process: {
6223
+ orchestrator_pid: null,
6224
+ child_pids: [],
6225
+ zombies: []
6226
+ },
6227
+ stories: {
6228
+ active: 0,
6229
+ completed: 0,
6230
+ escalated: 0,
6231
+ details: {}
6232
+ }
6233
+ };
6234
+ if (!existsSync$1(dbPath)) return NO_PIPELINE;
6235
+ const dbWrapper = new DatabaseWrapper(dbPath);
6236
+ try {
6237
+ dbWrapper.open();
6238
+ const db = dbWrapper.db;
6239
+ let run;
6240
+ if (runId !== void 0) run = getPipelineRunById(db, runId);
6241
+ else run = getLatestRun(db);
6242
+ if (run === void 0) return NO_PIPELINE;
6243
+ const updatedAt = parseDbTimestampAsUtc(run.updated_at);
6244
+ const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
6245
+ let storyDetails = {};
6246
+ let active = 0;
6247
+ let completed = 0;
6248
+ let escalated = 0;
6249
+ let pending = 0;
6250
+ try {
6251
+ if (run.token_usage_json) {
6252
+ const state = JSON.parse(run.token_usage_json);
6253
+ if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
6254
+ storyDetails[key] = {
6255
+ phase: s.phase,
6256
+ review_cycles: s.reviewCycles
6257
+ };
6258
+ if (s.phase === "COMPLETE") completed++;
6259
+ else if (s.phase === "ESCALATED") escalated++;
6260
+ else if (s.phase === "PENDING") pending++;
6261
+ else active++;
6262
+ }
6263
+ }
6264
+ } catch {}
6265
+ const substrateDirPath = join(dbRoot, ".substrate");
6266
+ const processInfo = inspectProcessTree({
6267
+ projectRoot,
6268
+ substrateDirPath
6269
+ });
6270
+ let verdict = "NO_PIPELINE_RUNNING";
6271
+ if (run.status === "running") if (processInfo.zombies.length > 0) verdict = "STALLED";
6272
+ else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length > 0 && stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "HEALTHY";
6273
+ else if (stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "STALLED";
6274
+ else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length === 0 && active > 0) verdict = "STALLED";
6275
+ else verdict = "HEALTHY";
6276
+ else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
6277
+ return {
6278
+ verdict,
6279
+ run_id: run.id,
6280
+ status: run.status,
6281
+ current_phase: run.current_phase,
6282
+ staleness_seconds: stalenessSeconds,
6283
+ last_activity: run.updated_at,
6284
+ process: processInfo,
6285
+ stories: {
6286
+ active,
6287
+ completed,
6288
+ escalated,
6289
+ pending,
6290
+ details: storyDetails
6291
+ }
6292
+ };
6293
+ } finally {
6294
+ try {
6295
+ dbWrapper.close();
6296
+ } catch {}
6297
+ }
6298
+ }
6299
+ async function runHealthAction(options) {
6300
+ const { outputFormat } = options;
6301
+ try {
6302
+ const health = await getAutoHealthData(options);
6303
+ if (outputFormat === "json") process.stdout.write(formatOutput(health, "json", true) + "\n");
6304
+ else {
6305
+ const verdictLabel = health.verdict === "HEALTHY" ? "HEALTHY" : health.verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
6306
+ process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
6307
+ if (health.run_id !== null) {
6308
+ process.stdout.write(` Run: ${health.run_id}\n`);
6309
+ process.stdout.write(` Status: ${health.status}\n`);
6310
+ process.stdout.write(` Phase: ${health.current_phase ?? "N/A"}\n`);
6311
+ process.stdout.write(` Last Active: ${health.last_activity} (${health.staleness_seconds}s ago)\n`);
6312
+ const processInfo = health.process;
6313
+ if (processInfo.orchestrator_pid !== null) {
6314
+ process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
6315
+ process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
6316
+ if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
6317
+ process.stdout.write("\n");
6318
+ } else process.stdout.write(" Orchestrator: not running\n");
6319
+ const storyDetails = health.stories.details;
6320
+ if (Object.keys(storyDetails).length > 0) {
6321
+ process.stdout.write("\n Stories:\n");
6322
+ for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
6323
+ process.stdout.write(`\n Summary: ${health.stories.active} active, ${health.stories.completed} completed, ${health.stories.escalated} escalated\n`);
6324
+ }
6325
+ }
6326
+ }
6327
+ return 0;
6328
+ } catch (err) {
6329
+ const msg = err instanceof Error ? err.message : String(err);
6330
+ if (outputFormat === "json") process.stdout.write(formatOutput(null, "json", false, msg) + "\n");
6331
+ else process.stderr.write(`Error: ${msg}\n`);
6332
+ logger$7.error({ err }, "health action failed");
6333
+ return 1;
6334
+ }
6335
+ }
6336
+ function registerHealthCommand(program, _version = "0.0.0", projectRoot = process.cwd()) {
6337
+ program.command("health").description("Check pipeline health: process status, stall detection, and verdict").option("--run-id <id>", "Pipeline run ID to query (defaults to latest)").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
6338
+ const outputFormat = opts.outputFormat === "json" ? "json" : "human";
6339
+ const exitCode = await runHealthAction({
6340
+ outputFormat,
6341
+ runId: opts.runId,
6342
+ projectRoot: opts.projectRoot
6343
+ });
6344
+ process.exitCode = exitCode;
6345
+ });
6346
+ }
6347
+
6348
+ //#endregion
6349
+ //#region src/modules/implementation-orchestrator/seed-methodology-context.ts
6350
+ const logger$6 = createLogger("implementation-orchestrator:seed");
6351
+ /** Max chars for the architecture summary seeded into decisions */
6352
+ const MAX_ARCH_CHARS = 6e3;
6353
+ /** Max chars per epic shard (fallback when per-story extraction returns null) */
6354
+ const MAX_EPIC_SHARD_CHARS = 12e3;
6355
+ /** Max chars for test patterns */
6356
+ const MAX_TEST_PATTERNS_CHARS = 2e3;
6357
+ /**
6358
+ * Seed the decision store with methodology-level context from planning artifacts.
6359
+ *
6360
+ * Reads the following from `{projectRoot}/_bmad-output/planning-artifacts/`:
6361
+ * - architecture.md → solutioning/architecture decisions
6362
+ * - epics.md → implementation/epic-shard decisions (one per epic)
6363
+ * - package.json → solutioning/test-patterns decisions (framework detection)
6364
+ *
6365
+ * @param db - SQLite database instance
6366
+ * @param projectRoot - Absolute path to the target project root
6367
+ * @returns SeedResult with counts of decisions created and categories skipped
6368
+ */
6369
+ function seedMethodologyContext(db, projectRoot) {
6370
+ const result = {
6371
+ decisionsCreated: 0,
6372
+ skippedCategories: []
6373
+ };
6374
+ try {
6375
+ const archCount = seedArchitecture(db, projectRoot);
6376
+ if (archCount === -1) result.skippedCategories.push("architecture");
6377
+ else result.decisionsCreated += archCount;
6378
+ const epicCount = seedEpicShards(db, projectRoot);
6379
+ if (epicCount === -1) result.skippedCategories.push("epic-shard");
6380
+ else result.decisionsCreated += epicCount;
6381
+ const testCount = seedTestPatterns(db, projectRoot);
6382
+ if (testCount === -1) result.skippedCategories.push("test-patterns");
6383
+ else result.decisionsCreated += testCount;
6384
+ logger$6.info({
6385
+ decisionsCreated: result.decisionsCreated,
6386
+ skippedCategories: result.skippedCategories
6387
+ }, "Methodology context seeding complete");
6388
+ } catch (err) {
6389
+ logger$6.warn({ error: err instanceof Error ? err.message : String(err) }, "Methodology context seeding failed (non-fatal)");
6390
+ }
6391
+ return result;
6392
+ }
6393
+ /**
6394
+ * Seed architecture constraints from architecture.md.
6395
+ * Extracts key sections (tech stack, ADRs, component overview) as separate decisions.
6396
+ * Returns number of decisions created, or -1 if skipped (already seeded).
6397
+ */
6398
+ function seedArchitecture(db, projectRoot) {
6399
+ const existing = getDecisionsByPhase(db, "solutioning");
6400
+ if (existing.some((d) => d.category === "architecture")) return -1;
6401
+ const archPath = findArtifact(projectRoot, [
6402
+ "_bmad-output/planning-artifacts/architecture.md",
6403
+ "_bmad-output/architecture/architecture.md",
6404
+ "_bmad-output/architecture.md"
6405
+ ]);
6406
+ if (archPath === void 0) return 0;
6407
+ const content = readFileSync$1(archPath, "utf-8");
6408
+ if (content.length === 0) return 0;
6409
+ const sections = extractArchSections(content);
6410
+ let count = 0;
6411
+ for (const section of sections) {
6412
+ createDecision(db, {
6413
+ pipeline_run_id: null,
6414
+ phase: "solutioning",
6415
+ category: "architecture",
6416
+ key: section.key,
6417
+ value: section.value.slice(0, MAX_ARCH_CHARS),
6418
+ rationale: "Seeded from planning artifacts at orchestrator startup"
6419
+ });
6420
+ count++;
6421
+ }
6422
+ if (count === 0) {
6423
+ createDecision(db, {
6424
+ pipeline_run_id: null,
6425
+ phase: "solutioning",
6426
+ category: "architecture",
6427
+ key: "full",
6428
+ value: content.slice(0, MAX_ARCH_CHARS),
6429
+ rationale: "Seeded from planning artifacts at orchestrator startup (full file)"
6430
+ });
6431
+ count = 1;
6432
+ }
6433
+ logger$6.debug({ count }, "Seeded architecture decisions");
6434
+ return count;
6435
+ }
6436
+ /**
6437
+ * Seed epic shards from epics.md.
6438
+ * Parses each epic section and creates an implementation/epic-shard decision.
6439
+ *
6440
+ * Uses content-hash comparison (AC1, AC2, AC6):
6441
+ * - Computes SHA-256 of the epics file and compares to the stored `epic-shard-hash` decision.
6442
+ * - If hashes match: skip re-seeding (unchanged file).
6443
+ * - If hash differs or no hash stored: delete existing epic-shard decisions and re-seed.
6444
+ *
6445
+ * Returns number of decisions created, or -1 if skipped (hash unchanged).
6446
+ */
6447
+ function seedEpicShards(db, projectRoot) {
6448
+ const epicsPath = findArtifact(projectRoot, ["_bmad-output/planning-artifacts/epics.md", "_bmad-output/epics.md"]);
6449
+ if (epicsPath === void 0) return 0;
6450
+ const content = readFileSync$1(epicsPath, "utf-8");
6451
+ if (content.length === 0) return 0;
6452
+ const currentHash = createHash("sha256").update(content).digest("hex");
6453
+ const implementationDecisions = getDecisionsByPhase(db, "implementation");
6454
+ const storedHashDecision = implementationDecisions.find((d) => d.category === "epic-shard-hash" && d.key === "epics-file");
6455
+ const storedHash = storedHashDecision?.value;
6456
+ if (storedHash === currentHash) {
6457
+ logger$6.debug({ hash: currentHash }, "Epic shards up-to-date (hash unchanged) — skipping re-seed");
6458
+ return -1;
6459
+ }
6460
+ if (implementationDecisions.some((d) => d.category === "epic-shard")) {
6461
+ logger$6.debug({
6462
+ storedHash,
5985
6463
  currentHash
5986
6464
  }, "Epics file changed — deleting stale epic-shard decisions");
5987
6465
  db.prepare("DELETE FROM decisions WHERE phase = 'implementation' AND category = 'epic-shard'").run();
@@ -6008,7 +6486,7 @@ function seedEpicShards(db, projectRoot) {
6008
6486
  value: currentHash,
6009
6487
  rationale: "SHA-256 hash of epics file content for change detection"
6010
6488
  });
6011
- logger$7.debug({
6489
+ logger$6.debug({
6012
6490
  count,
6013
6491
  hash: currentHash
6014
6492
  }, "Seeded epic shard decisions");
@@ -6032,7 +6510,7 @@ function seedTestPatterns(db, projectRoot) {
6032
6510
  value: patterns.slice(0, MAX_TEST_PATTERNS_CHARS),
6033
6511
  rationale: "Detected from project configuration at orchestrator startup"
6034
6512
  });
6035
- logger$7.debug("Seeded test patterns decision");
6513
+ logger$6.debug("Seeded test patterns decision");
6036
6514
  return 1;
6037
6515
  }
6038
6516
  /**
@@ -6235,13 +6713,19 @@ function createImplementationOrchestrator(deps) {
6235
6713
  let _lastProgressTs = Date.now();
6236
6714
  let _heartbeatTimer = null;
6237
6715
  const HEARTBEAT_INTERVAL_MS = 3e4;
6238
- const WATCHDOG_TIMEOUT_MS = 6e5;
6716
+ const DEFAULT_STALL_THRESHOLD_MS = 6e5;
6717
+ const DEV_STORY_STALL_THRESHOLD_MS = 9e5;
6239
6718
  const _stalledStories = new Set();
6240
6719
  const _storiesWithStall = new Set();
6241
6720
  const _phaseStartMs = new Map();
6242
6721
  const _phaseEndMs = new Map();
6243
6722
  const _storyDispatches = new Map();
6244
6723
  let _maxConcurrentActual = 0;
6724
+ const MEMORY_PRESSURE_BACKOFF_MS = [
6725
+ 3e4,
6726
+ 6e4,
6727
+ 12e4
6728
+ ];
6245
6729
  function startPhase(storyKey, phase) {
6246
6730
  if (!_phaseStartMs.has(storyKey)) _phaseStartMs.set(storyKey, new Map());
6247
6731
  _phaseStartMs.get(storyKey).set(phase, Date.now());
@@ -6426,6 +6910,9 @@ function createImplementationOrchestrator(deps) {
6426
6910
  _lastProgressTs = Date.now();
6427
6911
  _stalledStories.clear();
6428
6912
  }
6913
+ function getStallThresholdMs(phase) {
6914
+ return phase === "IN_DEV" ? DEV_STORY_STALL_THRESHOLD_MS : DEFAULT_STALL_THRESHOLD_MS;
6915
+ }
6429
6916
  function startHeartbeat() {
6430
6917
  if (_heartbeatTimer !== null) return;
6431
6918
  _heartbeatTimer = setInterval(() => {
@@ -6444,24 +6931,49 @@ function createImplementationOrchestrator(deps) {
6444
6931
  queuedDispatches: queued
6445
6932
  });
6446
6933
  const elapsed = Date.now() - _lastProgressTs;
6447
- if (elapsed >= WATCHDOG_TIMEOUT_MS) {
6448
- for (const [key, s] of _stories) if (s.phase !== "PENDING" && s.phase !== "COMPLETE" && s.phase !== "ESCALATED") {
6449
- if (_stalledStories.has(key)) continue;
6450
- _stalledStories.add(key);
6451
- _storiesWithStall.add(key);
6452
- logger$20.warn({
6453
- storyKey: key,
6454
- phase: s.phase,
6455
- elapsedMs: elapsed
6456
- }, "Watchdog: possible stall detected");
6457
- eventBus.emit("orchestrator:stall", {
6458
- runId: config.pipelineRunId ?? "",
6934
+ let childPids = [];
6935
+ let childActive = false;
6936
+ let processInspected = false;
6937
+ for (const [key, s] of _stories) {
6938
+ if (s.phase === "PENDING" || s.phase === "COMPLETE" || s.phase === "ESCALATED") continue;
6939
+ const threshold = getStallThresholdMs(s.phase);
6940
+ if (elapsed < threshold) continue;
6941
+ if (_stalledStories.has(key)) continue;
6942
+ if (!processInspected) {
6943
+ processInspected = true;
6944
+ try {
6945
+ const processInfo = inspectProcessTree();
6946
+ childPids = processInfo.child_pids;
6947
+ const nonZombieChildren = processInfo.child_pids.filter((pid) => !processInfo.zombies.includes(pid));
6948
+ childActive = nonZombieChildren.length > 0;
6949
+ } catch {}
6950
+ }
6951
+ if (childActive) {
6952
+ _lastProgressTs = Date.now();
6953
+ logger$20.debug({
6459
6954
  storyKey: key,
6460
6955
  phase: s.phase,
6461
- elapsedMs: elapsed,
6462
- childPid: null
6463
- });
6956
+ childPids
6957
+ }, "Staleness exceeded but child processes are active — suppressing stall");
6958
+ break;
6464
6959
  }
6960
+ _stalledStories.add(key);
6961
+ _storiesWithStall.add(key);
6962
+ logger$20.warn({
6963
+ storyKey: key,
6964
+ phase: s.phase,
6965
+ elapsedMs: elapsed,
6966
+ childPids,
6967
+ childActive
6968
+ }, "Watchdog: possible stall detected");
6969
+ eventBus.emit("orchestrator:stall", {
6970
+ runId: config.pipelineRunId ?? "",
6971
+ storyKey: key,
6972
+ phase: s.phase,
6973
+ elapsedMs: elapsed,
6974
+ childPids,
6975
+ childActive
6976
+ });
6465
6977
  }
6466
6978
  }, HEARTBEAT_INTERVAL_MS);
6467
6979
  if (_heartbeatTimer && typeof _heartbeatTimer === "object" && "unref" in _heartbeatTimer) _heartbeatTimer.unref();
@@ -6479,6 +6991,32 @@ function createImplementationOrchestrator(deps) {
6479
6991
  if (_paused && _pauseGate !== null) await _pauseGate.promise;
6480
6992
  }
6481
6993
  /**
6994
+ * Check memory pressure before dispatching a story phase (Story 23-8, AC1).
6995
+ *
6996
+ * When the dispatcher reports memory pressure, this helper waits using
6997
+ * exponential backoff (30 s, 60 s, 120 s) and re-checks after each interval.
6998
+ * If memory is still pressured after all intervals, returns false so the
6999
+ * caller can escalate the story with reason 'memory_pressure_exhausted'.
7000
+ *
7001
+ * If memory is OK (or clears during a wait), returns true immediately.
7002
+ */
7003
+ async function checkMemoryPressure(storyKey) {
7004
+ for (let attempt = 0; attempt < MEMORY_PRESSURE_BACKOFF_MS.length; attempt++) {
7005
+ const memState = dispatcher.getMemoryState();
7006
+ if (!memState.isPressured) return true;
7007
+ logger$20.warn({
7008
+ storyKey,
7009
+ freeMB: memState.freeMB,
7010
+ thresholdMB: memState.thresholdMB,
7011
+ pressureLevel: memState.pressureLevel,
7012
+ attempt: attempt + 1,
7013
+ maxAttempts: MEMORY_PRESSURE_BACKOFF_MS.length
7014
+ }, "Memory pressure before story dispatch — backing off");
7015
+ await sleep(MEMORY_PRESSURE_BACKOFF_MS[attempt] ?? 0);
7016
+ }
7017
+ return !dispatcher.getMemoryState().isPressured;
7018
+ }
7019
+ /**
6482
7020
  * Run the full pipeline for a single story key.
6483
7021
  *
6484
7022
  * Sequence: create-story → dev-story → code-review (with retry/rework up
@@ -6487,6 +7025,28 @@ function createImplementationOrchestrator(deps) {
6487
7025
  */
6488
7026
  async function processStory(storyKey) {
6489
7027
  logger$20.info("Processing story", { storyKey });
7028
+ {
7029
+ const memoryOk = await checkMemoryPressure(storyKey);
7030
+ if (!memoryOk) {
7031
+ logger$20.warn({ storyKey }, "Memory pressure exhausted — escalating story without dispatch");
7032
+ _stories.set(storyKey, {
7033
+ phase: "ESCALATED",
7034
+ reviewCycles: 0,
7035
+ error: "memory_pressure_exhausted",
7036
+ startedAt: new Date().toISOString(),
7037
+ completedAt: new Date().toISOString()
7038
+ });
7039
+ writeStoryMetricsBestEffort(storyKey, "escalated", 0);
7040
+ emitEscalation({
7041
+ storyKey,
7042
+ lastVerdict: "memory_pressure_exhausted",
7043
+ reviewCycles: 0,
7044
+ issues: [`Memory pressure exhausted after ${MEMORY_PRESSURE_BACKOFF_MS.length} backoff attempts`]
7045
+ });
7046
+ persistState();
7047
+ return;
7048
+ }
7049
+ }
6490
7050
  await waitIfPaused();
6491
7051
  if (_state !== "RUNNING") return;
6492
7052
  startPhase(storyKey, "create-story");
@@ -6640,6 +7200,7 @@ function createImplementationOrchestrator(deps) {
6640
7200
  persistState();
6641
7201
  let devFilesModified = [];
6642
7202
  const batchFileGroups = [];
7203
+ let devStoryWasSuccess = false;
6643
7204
  try {
6644
7205
  let storyContentForAnalysis = "";
6645
7206
  try {
@@ -6748,6 +7309,7 @@ function createImplementationOrchestrator(deps) {
6748
7309
  batchIndex: batch.batchIndex,
6749
7310
  error: batchResult.error
6750
7311
  }, "Batch dev-story reported failure — continuing with partial files");
7312
+ else devStoryWasSuccess = true;
6751
7313
  eventBus.emit("orchestrator:story-phase-complete", {
6752
7314
  storyKey,
6753
7315
  phase: "IN_DEV",
@@ -6776,7 +7338,8 @@ function createImplementationOrchestrator(deps) {
6776
7338
  result: devResult
6777
7339
  });
6778
7340
  persistState();
6779
- if (devResult.result === "failed") logger$20.warn("Dev-story reported failure, proceeding to code review", {
7341
+ if (devResult.result === "success") devStoryWasSuccess = true;
7342
+ else logger$20.warn("Dev-story reported failure, proceeding to code review", {
6780
7343
  storyKey,
6781
7344
  error: devResult.error,
6782
7345
  filesModified: devFilesModified.length
@@ -6800,7 +7363,70 @@ function createImplementationOrchestrator(deps) {
6800
7363
  persistState();
6801
7364
  return;
6802
7365
  }
7366
+ if (devStoryWasSuccess) {
7367
+ const changedFiles = checkGitDiffFiles(projectRoot ?? process.cwd());
7368
+ if (changedFiles.length === 0) {
7369
+ logger$20.warn({ storyKey }, "Zero-diff detected after COMPLETE dev-story — no file changes in git working tree");
7370
+ eventBus.emit("orchestrator:zero-diff-escalation", {
7371
+ storyKey,
7372
+ reason: "zero-diff-on-complete"
7373
+ });
7374
+ endPhase(storyKey, "dev-story");
7375
+ updateStory(storyKey, {
7376
+ phase: "ESCALATED",
7377
+ error: "zero-diff-on-complete",
7378
+ completedAt: new Date().toISOString()
7379
+ });
7380
+ writeStoryMetricsBestEffort(storyKey, "escalated", 0);
7381
+ emitEscalation({
7382
+ storyKey,
7383
+ lastVerdict: "zero-diff-on-complete",
7384
+ reviewCycles: 0,
7385
+ issues: ["dev-story completed with COMPLETE verdict but no file changes detected in git diff"]
7386
+ });
7387
+ persistState();
7388
+ return;
7389
+ }
7390
+ }
6803
7391
  endPhase(storyKey, "dev-story");
7392
+ {
7393
+ const buildVerifyResult = runBuildVerification({
7394
+ verifyCommand: pack.manifest.verifyCommand,
7395
+ verifyTimeoutMs: pack.manifest.verifyTimeoutMs,
7396
+ projectRoot: projectRoot ?? process.cwd()
7397
+ });
7398
+ if (buildVerifyResult.status === "passed") {
7399
+ eventBus.emit("story:build-verification-passed", { storyKey });
7400
+ logger$20.info({ storyKey }, "Build verification passed");
7401
+ } else if (buildVerifyResult.status === "failed" || buildVerifyResult.status === "timeout") {
7402
+ const truncatedOutput = (buildVerifyResult.output ?? "").slice(0, 2e3);
7403
+ const reason = buildVerifyResult.reason ?? "build-verification-failed";
7404
+ eventBus.emit("story:build-verification-failed", {
7405
+ storyKey,
7406
+ exitCode: buildVerifyResult.exitCode ?? 1,
7407
+ output: truncatedOutput
7408
+ });
7409
+ logger$20.warn({
7410
+ storyKey,
7411
+ reason,
7412
+ exitCode: buildVerifyResult.exitCode
7413
+ }, "Build verification failed — escalating story");
7414
+ updateStory(storyKey, {
7415
+ phase: "ESCALATED",
7416
+ error: reason,
7417
+ completedAt: new Date().toISOString()
7418
+ });
7419
+ writeStoryMetricsBestEffort(storyKey, "escalated", 0);
7420
+ emitEscalation({
7421
+ storyKey,
7422
+ lastVerdict: reason,
7423
+ reviewCycles: 0,
7424
+ issues: [truncatedOutput]
7425
+ });
7426
+ persistState();
7427
+ return;
7428
+ }
7429
+ }
6804
7430
  let reviewCycles = 0;
6805
7431
  let keepReviewing = true;
6806
7432
  let timeoutRetried = false;
@@ -7304,9 +7930,18 @@ function createImplementationOrchestrator(deps) {
7304
7930
  }
7305
7931
  /**
7306
7932
  * Process a conflict group: run stories sequentially within the group.
7933
+ *
7934
+ * After each story completes (any outcome), a GC hint is issued and a short
7935
+ * pause inserted so the Node.js process can reclaim memory before the next
7936
+ * story dispatch (Story 23-8, AC2).
7307
7937
  */
7308
7938
  async function processConflictGroup(group) {
7309
- for (const storyKey of group) await processStory(storyKey);
7939
+ for (const storyKey of group) {
7940
+ await processStory(storyKey);
7941
+ globalThis.gc?.();
7942
+ const gcPauseMs = config.gcPauseMs ?? 2e3;
7943
+ await sleep(gcPauseMs);
7944
+ }
7310
7945
  }
7311
7946
  /**
7312
7947
  * Promise pool: run up to maxConcurrency groups at a time.
@@ -8091,7 +8726,7 @@ const CritiqueOutputSchema = z.object({
8091
8726
 
8092
8727
  //#endregion
8093
8728
  //#region src/modules/phase-orchestrator/critique-loop.ts
8094
- const logger$6 = createLogger("critique-loop");
8729
+ const logger$5 = createLogger("critique-loop");
8095
8730
  /**
8096
8731
  * Maps a phase name to the critique prompt template name.
8097
8732
  * Falls back to `critique-${phase}` for unknown phases.
@@ -8145,7 +8780,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8145
8780
  critiquePrompt = critiqueTemplate.replace("{{artifact_content}}", currentArtifact).replace("{{project_context}}", projectContext);
8146
8781
  } catch (err) {
8147
8782
  const message = err instanceof Error ? err.message : String(err);
8148
- logger$6.warn({
8783
+ logger$5.warn({
8149
8784
  phaseId,
8150
8785
  promptName: critiquePromptName,
8151
8786
  err: message
@@ -8173,7 +8808,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8173
8808
  critiqueTokens.output += result.tokenEstimate.output;
8174
8809
  if (result.status !== "completed" || result.parsed === null) {
8175
8810
  const errMsg = result.parseError ?? `Critique dispatch ended with status '${result.status}'`;
8176
- logger$6.warn({
8811
+ logger$5.warn({
8177
8812
  phaseId,
8178
8813
  iteration: i + 1,
8179
8814
  err: errMsg
@@ -8192,7 +8827,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8192
8827
  lastCritiqueOutput = critiqueOutput;
8193
8828
  } catch (err) {
8194
8829
  const message = err instanceof Error ? err.message : String(err);
8195
- logger$6.warn({
8830
+ logger$5.warn({
8196
8831
  phaseId,
8197
8832
  iteration: i + 1,
8198
8833
  err: message
@@ -8232,14 +8867,14 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8232
8867
  });
8233
8868
  } catch (err) {
8234
8869
  const message = err instanceof Error ? err.message : String(err);
8235
- logger$6.warn({
8870
+ logger$5.warn({
8236
8871
  phaseId,
8237
8872
  iteration: i + 1,
8238
8873
  err: message
8239
8874
  }, "Critique loop: failed to store critique decision — continuing");
8240
8875
  }
8241
8876
  if (critiqueOutput.verdict === "pass") {
8242
- logger$6.info({
8877
+ logger$5.info({
8243
8878
  phaseId,
8244
8879
  iteration: i + 1
8245
8880
  }, "Critique loop: artifact passed critique — loop complete");
@@ -8252,7 +8887,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8252
8887
  totalMs: Date.now() - startMs
8253
8888
  };
8254
8889
  }
8255
- logger$6.info({
8890
+ logger$5.info({
8256
8891
  phaseId,
8257
8892
  iteration: i + 1,
8258
8893
  issueCount: critiqueOutput.issue_count
@@ -8265,7 +8900,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8265
8900
  refinePrompt = refineTemplate.replace("{{original_artifact}}", currentArtifact).replace("{{critique_issues}}", issuesText).replace("{{phase_context}}", phaseContext);
8266
8901
  } catch (err) {
8267
8902
  const message = err instanceof Error ? err.message : String(err);
8268
- logger$6.warn({
8903
+ logger$5.warn({
8269
8904
  phaseId,
8270
8905
  iteration: i + 1,
8271
8906
  err: message
@@ -8286,7 +8921,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8286
8921
  const originalLength = currentArtifact.length;
8287
8922
  const refinedLength = refineResult.output.length;
8288
8923
  const delta = refinedLength - originalLength;
8289
- logger$6.info({
8924
+ logger$5.info({
8290
8925
  phaseId,
8291
8926
  iteration: i + 1,
8292
8927
  originalLength,
@@ -8295,7 +8930,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8295
8930
  }, "Critique loop: refinement complete");
8296
8931
  currentArtifact = refineResult.output;
8297
8932
  } else {
8298
- logger$6.warn({
8933
+ logger$5.warn({
8299
8934
  phaseId,
8300
8935
  iteration: i + 1,
8301
8936
  status: refineResult.status
@@ -8304,7 +8939,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8304
8939
  }
8305
8940
  } catch (err) {
8306
8941
  const message = err instanceof Error ? err.message : String(err);
8307
- logger$6.warn({
8942
+ logger$5.warn({
8308
8943
  phaseId,
8309
8944
  iteration: i + 1,
8310
8945
  err: message
@@ -8315,12 +8950,12 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8315
8950
  }
8316
8951
  const remainingIssues = lastCritiqueOutput?.issues ?? [];
8317
8952
  if (remainingIssues.length > 0) {
8318
- logger$6.warn({
8953
+ logger$5.warn({
8319
8954
  phaseId,
8320
8955
  maxIterations,
8321
8956
  issueCount: remainingIssues.length
8322
8957
  }, "Critique loop: max iterations reached with unresolved issues");
8323
- for (const issue of remainingIssues) logger$6.warn({
8958
+ for (const issue of remainingIssues) logger$5.warn({
8324
8959
  phaseId,
8325
8960
  severity: issue.severity,
8326
8961
  category: issue.category,
@@ -8339,7 +8974,7 @@ async function runCritiqueLoop(artifact, phaseId, runId, phase, deps, options =
8339
8974
 
8340
8975
  //#endregion
8341
8976
  //#region src/modules/phase-orchestrator/elicitation-selector.ts
8342
- const logger$5 = createLogger("elicitation-selector");
8977
+ const logger$4 = createLogger("elicitation-selector");
8343
8978
  /**
8344
8979
  * Affinity scores (0.0–1.0) for each category per content type.
8345
8980
  *
@@ -8461,10 +9096,10 @@ function loadElicitationMethods() {
8461
9096
  try {
8462
9097
  const content = readFileSync(csvPath, "utf-8");
8463
9098
  const methods = parseMethodsCsv(content);
8464
- logger$5.debug({ count: methods.length }, "Loaded elicitation methods");
9099
+ logger$4.debug({ count: methods.length }, "Loaded elicitation methods");
8465
9100
  return methods;
8466
9101
  } catch (err) {
8467
- logger$5.warn({
9102
+ logger$4.warn({
8468
9103
  csvPath,
8469
9104
  err
8470
9105
  }, "Failed to load elicitation methods CSV");
@@ -8784,7 +9419,7 @@ const ElicitationOutputSchema = z.object({
8784
9419
 
8785
9420
  //#endregion
8786
9421
  //#region src/modules/phase-orchestrator/step-runner.ts
8787
- const logger$4 = createLogger("step-runner");
9422
+ const logger$3 = createLogger("step-runner");
8788
9423
  /**
8789
9424
  * Format an array of decision records into a markdown section for injection.
8790
9425
  *
@@ -8891,7 +9526,7 @@ async function runSteps(steps, deps, runId, phase, params) {
8891
9526
  if (estimatedTokens > budgetTokens) {
8892
9527
  const decisionRefs = step.context.filter((ref) => ref.source.startsWith("decision:"));
8893
9528
  if (decisionRefs.length > 0) {
8894
- logger$4.warn({
9529
+ logger$3.warn({
8895
9530
  step: step.name,
8896
9531
  estimatedTokens,
8897
9532
  budgetTokens
@@ -8918,7 +9553,7 @@ async function runSteps(steps, deps, runId, phase, params) {
8918
9553
  }
8919
9554
  prompt = summarizedPrompt;
8920
9555
  estimatedTokens = Math.ceil(prompt.length / 4);
8921
- if (estimatedTokens <= budgetTokens) logger$4.info({
9556
+ if (estimatedTokens <= budgetTokens) logger$3.info({
8922
9557
  step: step.name,
8923
9558
  estimatedTokens,
8924
9559
  budgetTokens
@@ -9099,7 +9734,7 @@ async function runSteps(steps, deps, runId, phase, params) {
9099
9734
  const critiqueResult = await runCritiqueLoop(artifactContent, phase, runId, phase, deps);
9100
9735
  totalInput += critiqueResult.critiqueTokens.input + critiqueResult.refinementTokens.input;
9101
9736
  totalOutput += critiqueResult.critiqueTokens.output + critiqueResult.refinementTokens.output;
9102
- logger$4.info({
9737
+ logger$3.info({
9103
9738
  step: step.name,
9104
9739
  verdict: critiqueResult.verdict,
9105
9740
  iterations: critiqueResult.iterations,
@@ -9107,7 +9742,7 @@ async function runSteps(steps, deps, runId, phase, params) {
9107
9742
  }, "Step critique loop complete");
9108
9743
  } catch (critiqueErr) {
9109
9744
  const critiqueMsg = critiqueErr instanceof Error ? critiqueErr.message : String(critiqueErr);
9110
- logger$4.warn({
9745
+ logger$3.warn({
9111
9746
  step: step.name,
9112
9747
  err: critiqueMsg
9113
9748
  }, "Step critique loop threw an error — continuing without critique");
@@ -9117,7 +9752,7 @@ async function runSteps(steps, deps, runId, phase, params) {
9117
9752
  const contentType = deriveContentType(phase, step.name);
9118
9753
  const selectedMethods = selectMethods({ content_type: contentType }, usedElicitationMethods);
9119
9754
  if (selectedMethods.length > 0) {
9120
- logger$4.info({
9755
+ logger$3.info({
9121
9756
  step: step.name,
9122
9757
  methods: selectedMethods.map((m) => m.name),
9123
9758
  contentType
@@ -9156,13 +9791,13 @@ async function runSteps(steps, deps, runId, phase, params) {
9156
9791
  key: `${phase}-round-${roundIndex}-insights`,
9157
9792
  value: elicitParsed.insights
9158
9793
  });
9159
- logger$4.info({
9794
+ logger$3.info({
9160
9795
  step: step.name,
9161
9796
  method: method.name,
9162
9797
  roundIndex
9163
9798
  }, "Elicitation insights stored in decision store");
9164
9799
  }
9165
- } else logger$4.warn({
9800
+ } else logger$3.warn({
9166
9801
  step: step.name,
9167
9802
  method: method.name,
9168
9803
  status: elicitResult.status
@@ -9178,7 +9813,7 @@ async function runSteps(steps, deps, runId, phase, params) {
9178
9813
  }
9179
9814
  } catch (elicitErr) {
9180
9815
  const elicitMsg = elicitErr instanceof Error ? elicitErr.message : String(elicitErr);
9181
- logger$4.warn({
9816
+ logger$3.warn({
9182
9817
  step: step.name,
9183
9818
  err: elicitMsg
9184
9819
  }, "Step elicitation threw an error — continuing without elicitation");
@@ -9546,7 +10181,7 @@ async function runAnalysisPhase(deps, params) {
9546
10181
 
9547
10182
  //#endregion
9548
10183
  //#region src/modules/phase-orchestrator/phases/planning.ts
9549
- const logger$3 = createLogger("planning-phase");
10184
+ const logger$2 = createLogger("planning-phase");
9550
10185
  /** Maximum total prompt length in tokens (3,500 tokens × 4 chars/token = 14,000 chars) */
9551
10186
  const MAX_PROMPT_TOKENS = 3500;
9552
10187
  const MAX_PROMPT_CHARS = MAX_PROMPT_TOKENS * 4;
@@ -9773,7 +10408,7 @@ async function runPlanningMultiStep(deps, params) {
9773
10408
  const techConstraintDecisions = allAnalysisDecisions.filter((d) => d.category === "technology-constraints");
9774
10409
  const violation = detectTechStackViolation(techStack, techConstraintDecisions);
9775
10410
  if (violation) {
9776
- logger$3.warn({ violation }, "Tech stack constraint violation detected — retrying step 3 with correction");
10411
+ logger$2.warn({ violation }, "Tech stack constraint violation detected — retrying step 3 with correction");
9777
10412
  const correctionPrefix = `CRITICAL CORRECTION: Your previous output was rejected because it violates the stated technology constraints.\n\nViolation: ${violation}\n\nYou MUST NOT use TypeScript, JavaScript, or Node.js for ANY backend service. Choose from Go, Kotlin/JVM, or Rust as stated in the technology constraints.\n\nRe-generate your output with a compliant tech stack. Everything else (NFRs, domain model, out-of-scope) can remain the same.\n\n---\n\n`;
9778
10413
  const step3Template = await deps.pack.getPrompt("planning-step-3-nfrs");
9779
10414
  const stepOutputs = new Map();
@@ -9800,10 +10435,10 @@ async function runPlanningMultiStep(deps, params) {
9800
10435
  const retryTechStack = retryParsed.tech_stack;
9801
10436
  const retryViolation = retryTechStack ? detectTechStackViolation(retryTechStack, techConstraintDecisions) : null;
9802
10437
  if (!retryViolation) {
9803
- logger$3.info("Retry produced compliant tech stack — using corrected output");
10438
+ logger$2.info("Retry produced compliant tech stack — using corrected output");
9804
10439
  nfrsOutput = retryParsed;
9805
- } else logger$3.warn({ retryViolation }, "Retry still violates constraints — using original output");
9806
- } else logger$3.warn("Retry dispatch failed — using original output");
10440
+ } else logger$2.warn({ retryViolation }, "Retry still violates constraints — using original output");
10441
+ } else logger$2.warn("Retry dispatch failed — using original output");
9807
10442
  }
9808
10443
  }
9809
10444
  const frs = frsOutput.functional_requirements;
@@ -10090,7 +10725,7 @@ const ReadinessOutputSchema = z.object({
10090
10725
 
10091
10726
  //#endregion
10092
10727
  //#region src/modules/phase-orchestrator/phases/solutioning.ts
10093
- const logger$2 = createLogger("solutioning");
10728
+ const logger$1 = createLogger("solutioning");
10094
10729
  /** Base token budget for architecture generation (covers template + requirements) */
10095
10730
  const BASE_ARCH_PROMPT_TOKENS = 3e3;
10096
10731
  /** Base token budget for story generation (covers template + requirements + architecture) */
@@ -10499,7 +11134,7 @@ async function runReadinessCheck(deps, runId) {
10499
11134
  input: tokenEstimate.input,
10500
11135
  output: tokenEstimate.output
10501
11136
  };
10502
- logger$2.info({
11137
+ logger$1.info({
10503
11138
  runId,
10504
11139
  durationMs: dispatchResult.durationMs,
10505
11140
  tokens: tokenEstimate
@@ -10760,7 +11395,7 @@ async function runSolutioningPhase(deps, params) {
10760
11395
  let archResult;
10761
11396
  if (existingArchArtifact) {
10762
11397
  const existingDecisions = getDecisionsByPhaseForRun(deps.db, params.runId, "solutioning").filter((d) => d.category === "architecture");
10763
- logger$2.info({
11398
+ logger$1.info({
10764
11399
  runId: params.runId,
10765
11400
  artifactId: existingArchArtifact.id,
10766
11401
  decisionCount: existingDecisions.length
@@ -10791,7 +11426,7 @@ async function runSolutioningPhase(deps, params) {
10791
11426
  output: totalOutput
10792
11427
  }
10793
11428
  };
10794
- logger$2.info({
11429
+ logger$1.info({
10795
11430
  runId: params.runId,
10796
11431
  decisionCount: archResult.decisions.length,
10797
11432
  mode: hasSteps ? "multi-step" : "single-dispatch"
@@ -10813,7 +11448,7 @@ async function runSolutioningPhase(deps, params) {
10813
11448
  totalInput += readinessResult.tokenUsage.input;
10814
11449
  totalOutput += readinessResult.tokenUsage.output;
10815
11450
  if (readinessResult.verdict === "error") {
10816
- logger$2.error({
11451
+ logger$1.error({
10817
11452
  runId: params.runId,
10818
11453
  error: readinessResult.error
10819
11454
  }, "Readiness check agent failed");
@@ -10829,7 +11464,7 @@ async function runSolutioningPhase(deps, params) {
10829
11464
  }
10830
11465
  };
10831
11466
  }
10832
- logger$2.info({
11467
+ logger$1.info({
10833
11468
  runId: params.runId,
10834
11469
  verdict: readinessResult.verdict,
10835
11470
  coverageScore: readinessResult.coverageScore,
@@ -10845,7 +11480,7 @@ async function runSolutioningPhase(deps, params) {
10845
11480
  key: `finding-${i + 1}`,
10846
11481
  value: JSON.stringify(finding)
10847
11482
  });
10848
- logger$2.error({
11483
+ logger$1.error({
10849
11484
  runId: params.runId,
10850
11485
  verdict: "NOT_READY",
10851
11486
  coverageScore: readinessResult.coverageScore,
@@ -10911,7 +11546,7 @@ async function runSolutioningPhase(deps, params) {
10911
11546
  "",
10912
11547
  "Please generate additional or revised stories to specifically address each blocker above."
10913
11548
  ].join("\n");
10914
- logger$2.info({
11549
+ logger$1.info({
10915
11550
  runId: params.runId,
10916
11551
  blockerCount: blockers.length
10917
11552
  }, "Readiness NEEDS_WORK with blockers — retrying story generation with gap analysis");
@@ -10950,7 +11585,7 @@ async function runSolutioningPhase(deps, params) {
10950
11585
  };
10951
11586
  if (retryReadiness.verdict === "NOT_READY" || retryReadiness.verdict === "NEEDS_WORK") {
10952
11587
  const retryBlockers = retryReadiness.findings.filter((f) => f.severity === "blocker");
10953
- logger$2.error({
11588
+ logger$1.error({
10954
11589
  runId: params.runId,
10955
11590
  verdict: retryReadiness.verdict,
10956
11591
  retryBlockers: retryBlockers.length
@@ -10974,7 +11609,7 @@ async function runSolutioningPhase(deps, params) {
10974
11609
  }
10975
11610
  const retryStories = retryResult.epics.reduce((sum, epic) => sum + epic.stories.length, 0);
10976
11611
  const minorFindings$1 = retryReadiness.findings.filter((f) => f.severity === "minor");
10977
- if (minorFindings$1.length > 0) logger$2.warn({
11612
+ if (minorFindings$1.length > 0) logger$1.warn({
10978
11613
  runId: params.runId,
10979
11614
  minorFindings: minorFindings$1
10980
11615
  }, "Readiness READY with minor findings after retry");
@@ -11003,7 +11638,7 @@ async function runSolutioningPhase(deps, params) {
11003
11638
  };
11004
11639
  }
11005
11640
  const majorFindings = readinessResult.findings.filter((f) => f.severity === "major");
11006
- logger$2.warn({
11641
+ logger$1.warn({
11007
11642
  runId: params.runId,
11008
11643
  majorCount: majorFindings.length,
11009
11644
  findings: readinessResult.findings
@@ -11019,7 +11654,7 @@ async function runSolutioningPhase(deps, params) {
11019
11654
  const minorFindings = readinessResult.findings.filter((f) => f.severity === "minor");
11020
11655
  if (minorFindings.length > 0) {
11021
11656
  const verdictLabel = readinessResult.verdict === "READY" ? "READY" : "NEEDS_WORK (no blockers)";
11022
- logger$2.warn({
11657
+ logger$1.warn({
11023
11658
  runId: params.runId,
11024
11659
  verdict: readinessResult.verdict,
11025
11660
  minorFindings
@@ -11316,381 +11951,119 @@ function buildResearchSteps() {
11316
11951
  {
11317
11952
  field: "technical_findings",
11318
11953
  category: "research",
11319
- key: "technical_findings"
11320
- }
11321
- ],
11322
- elicitate: true
11323
- }, {
11324
- name: "research-step-2-synthesis",
11325
- taskType: "research-synthesis",
11326
- outputSchema: ResearchSynthesisOutputSchema,
11327
- context: [{
11328
- placeholder: "concept",
11329
- source: "param:concept"
11330
- }, {
11331
- placeholder: "raw_findings",
11332
- source: "step:research-step-1-discovery"
11333
- }],
11334
- persist: [
11335
- {
11336
- field: "market_context",
11337
- category: "research",
11338
- key: "market_context"
11339
- },
11340
- {
11341
- field: "competitive_landscape",
11342
- category: "research",
11343
- key: "competitive_landscape"
11344
- },
11345
- {
11346
- field: "technical_feasibility",
11347
- category: "research",
11348
- key: "technical_feasibility"
11349
- },
11350
- {
11351
- field: "risk_flags",
11352
- category: "research",
11353
- key: "risk_flags"
11354
- },
11355
- {
11356
- field: "opportunity_signals",
11357
- category: "research",
11358
- key: "opportunity_signals"
11359
- }
11360
- ],
11361
- registerArtifact: {
11362
- type: "research-findings",
11363
- path: "decision-store://research/research-findings",
11364
- summarize: (parsed) => {
11365
- const risks = Array.isArray(parsed.risk_flags) ? parsed.risk_flags : void 0;
11366
- const opportunities = Array.isArray(parsed.opportunity_signals) ? parsed.opportunity_signals : void 0;
11367
- const count = (risks?.length ?? 0) + (opportunities?.length ?? 0);
11368
- return count > 0 ? `${count} research insights captured (risks + opportunities)` : "Research synthesis complete";
11369
- }
11370
- },
11371
- critique: true
11372
- }];
11373
- }
11374
- /**
11375
- * Execute the research phase of the BMAD pipeline.
11376
- *
11377
- * Runs 2 sequential steps covering discovery and synthesis.
11378
- * Each step builds on prior step decisions via the decision store.
11379
- *
11380
- * On success, a 'research-findings' artifact is registered and research decisions
11381
- * are available to subsequent phases via `decision:research.*`.
11382
- *
11383
- * @param deps - Shared phase dependencies (db, pack, contextCompiler, dispatcher)
11384
- * @param params - Phase parameters (runId, concept)
11385
- * @returns ResearchResult with success/failure status and token usage
11386
- */
11387
- async function runResearchPhase(deps, params) {
11388
- const { runId } = params;
11389
- const zeroTokenUsage = {
11390
- input: 0,
11391
- output: 0
11392
- };
11393
- try {
11394
- const steps = buildResearchSteps();
11395
- const result = await runSteps(steps, deps, runId, "research", { concept: params.concept });
11396
- if (!result.success) return {
11397
- result: "failed",
11398
- error: result.error ?? "research_multi_step_failed",
11399
- details: result.error ?? "Research multi-step execution failed",
11400
- tokenUsage: result.tokenUsage
11401
- };
11402
- const lastStep = result.steps[result.steps.length - 1];
11403
- const artifactId = lastStep?.artifactId;
11404
- if (!artifactId) {
11405
- const artifact = registerArtifact(deps.db, {
11406
- pipeline_run_id: runId,
11407
- phase: "research",
11408
- type: "research-findings",
11409
- path: "decision-store://research/research-findings",
11410
- summary: "Research phase completed"
11411
- });
11412
- return {
11413
- result: "success",
11414
- artifact_id: artifact.id,
11415
- tokenUsage: result.tokenUsage
11416
- };
11417
- }
11418
- return {
11419
- result: "success",
11420
- artifact_id: artifactId,
11421
- tokenUsage: result.tokenUsage
11422
- };
11423
- } catch (err) {
11424
- const message = err instanceof Error ? err.message : String(err);
11425
- return {
11426
- result: "failed",
11427
- error: message,
11428
- tokenUsage: zeroTokenUsage
11429
- };
11430
- }
11431
- }
11432
-
11433
- //#endregion
11434
- //#region src/cli/commands/health.ts
11435
- const logger$1 = createLogger("health-cmd");
11436
- /** Default stall threshold in seconds — also used by supervisor default */
11437
- const DEFAULT_STALL_THRESHOLD_SECONDS = 600;
11438
- /**
11439
- * Determine whether a ps output line represents the substrate pipeline orchestrator.
11440
- * Handles invocation via:
11441
- * - `substrate run` (globally installed)
11442
- * - `substrate-ai run`
11443
- * - `node dist/cli/index.js run` (npm run substrate:dev)
11444
- * - `npx substrate run`
11445
- * - any node process whose command contains `run` with `--events` or `--stories`
11446
- *
11447
- * When `projectRoot` is provided, additionally checks that the command line
11448
- * contains that path (via `--project-root` flag or as part of the binary/CWD path).
11449
- * This ensures multi-project environments match the correct orchestrator.
11450
- */
11451
- function isOrchestratorProcessLine(line, projectRoot) {
11452
- if (line.includes("grep")) return false;
11453
- let isOrchestrator = false;
11454
- if (line.includes("substrate run")) isOrchestrator = true;
11455
- else if (line.includes("substrate-ai run")) isOrchestrator = true;
11456
- else if (line.includes("index.js run")) isOrchestrator = true;
11457
- else if (line.includes("node") && /\srun(\s|$)/.test(line) && (line.includes("--events") || line.includes("--stories"))) isOrchestrator = true;
11458
- if (!isOrchestrator) return false;
11459
- if (projectRoot !== void 0) return line.includes(projectRoot);
11460
- return true;
11461
- }
11462
- function inspectProcessTree(opts) {
11463
- const { projectRoot, execFileSync: execFileSyncOverride } = opts ?? {};
11464
- const result = {
11465
- orchestrator_pid: null,
11466
- child_pids: [],
11467
- zombies: []
11468
- };
11469
- try {
11470
- let psOutput;
11471
- if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid,stat,command"], {
11472
- encoding: "utf-8",
11473
- timeout: 5e3
11474
- });
11475
- else {
11476
- const { execFileSync } = __require("node:child_process");
11477
- psOutput = execFileSync("ps", ["-eo", "pid,ppid,stat,command"], {
11478
- encoding: "utf-8",
11479
- timeout: 5e3
11480
- });
11481
- }
11482
- const lines = psOutput.split("\n");
11483
- for (const line of lines) if (isOrchestratorProcessLine(line, projectRoot)) {
11484
- const match = line.trim().match(/^(\d+)/);
11485
- if (match) {
11486
- result.orchestrator_pid = parseInt(match[1], 10);
11487
- break;
11488
- }
11489
- }
11490
- if (result.orchestrator_pid !== null) for (const line of lines) {
11491
- const parts = line.trim().split(/\s+/);
11492
- if (parts.length >= 3) {
11493
- const pid = parseInt(parts[0], 10);
11494
- const ppid = parseInt(parts[1], 10);
11495
- const stat$2 = parts[2];
11496
- if (ppid === result.orchestrator_pid && pid !== result.orchestrator_pid) {
11497
- result.child_pids.push(pid);
11498
- if (stat$2.includes("Z")) result.zombies.push(pid);
11499
- }
11500
- }
11501
- }
11502
- } catch {}
11503
- return result;
11504
- }
11505
- /**
11506
- * Collect all descendant PIDs of the given root PIDs by walking the process
11507
- * tree recursively. This ensures that grandchildren of the orchestrator
11508
- * (e.g. node subprocesses spawned by `claude -p`) are also killed during
11509
- * stall recovery, leaving no orphan processes.
11510
- *
11511
- * Returns only the descendants — the root PIDs themselves are NOT included.
11512
- */
11513
- function getAllDescendantPids(rootPids, execFileSyncOverride) {
11514
- if (rootPids.length === 0) return [];
11515
- try {
11516
- let psOutput;
11517
- if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid"], {
11518
- encoding: "utf-8",
11519
- timeout: 5e3
11520
- });
11521
- else {
11522
- const { execFileSync } = __require("node:child_process");
11523
- psOutput = execFileSync("ps", ["-eo", "pid,ppid"], {
11524
- encoding: "utf-8",
11525
- timeout: 5e3
11526
- });
11527
- }
11528
- const childrenOf = new Map();
11529
- for (const line of psOutput.split("\n")) {
11530
- const parts = line.trim().split(/\s+/);
11531
- if (parts.length >= 2) {
11532
- const pid = parseInt(parts[0], 10);
11533
- const ppid = parseInt(parts[1], 10);
11534
- if (!isNaN(pid) && !isNaN(ppid) && pid > 0) {
11535
- if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
11536
- childrenOf.get(ppid).push(pid);
11537
- }
11954
+ key: "technical_findings"
11538
11955
  }
11539
- }
11540
- const descendants = [];
11541
- const seen = new Set(rootPids);
11542
- const queue = [...rootPids];
11543
- while (queue.length > 0) {
11544
- const current = queue.shift();
11545
- const children = childrenOf.get(current) ?? [];
11546
- for (const child of children) if (!seen.has(child)) {
11547
- seen.add(child);
11548
- descendants.push(child);
11549
- queue.push(child);
11956
+ ],
11957
+ elicitate: true
11958
+ }, {
11959
+ name: "research-step-2-synthesis",
11960
+ taskType: "research-synthesis",
11961
+ outputSchema: ResearchSynthesisOutputSchema,
11962
+ context: [{
11963
+ placeholder: "concept",
11964
+ source: "param:concept"
11965
+ }, {
11966
+ placeholder: "raw_findings",
11967
+ source: "step:research-step-1-discovery"
11968
+ }],
11969
+ persist: [
11970
+ {
11971
+ field: "market_context",
11972
+ category: "research",
11973
+ key: "market_context"
11974
+ },
11975
+ {
11976
+ field: "competitive_landscape",
11977
+ category: "research",
11978
+ key: "competitive_landscape"
11979
+ },
11980
+ {
11981
+ field: "technical_feasibility",
11982
+ category: "research",
11983
+ key: "technical_feasibility"
11984
+ },
11985
+ {
11986
+ field: "risk_flags",
11987
+ category: "research",
11988
+ key: "risk_flags"
11989
+ },
11990
+ {
11991
+ field: "opportunity_signals",
11992
+ category: "research",
11993
+ key: "opportunity_signals"
11550
11994
  }
11551
- }
11552
- return descendants;
11553
- } catch {
11554
- return [];
11555
- }
11995
+ ],
11996
+ registerArtifact: {
11997
+ type: "research-findings",
11998
+ path: "decision-store://research/research-findings",
11999
+ summarize: (parsed) => {
12000
+ const risks = Array.isArray(parsed.risk_flags) ? parsed.risk_flags : void 0;
12001
+ const opportunities = Array.isArray(parsed.opportunity_signals) ? parsed.opportunity_signals : void 0;
12002
+ const count = (risks?.length ?? 0) + (opportunities?.length ?? 0);
12003
+ return count > 0 ? `${count} research insights captured (risks + opportunities)` : "Research synthesis complete";
12004
+ }
12005
+ },
12006
+ critique: true
12007
+ }];
11556
12008
  }
11557
12009
  /**
11558
- * Fetch pipeline health data as a structured object without any stdout side-effects.
11559
- * Used by runSupervisorAction to poll health without formatting overhead.
12010
+ * Execute the research phase of the BMAD pipeline.
11560
12011
  *
11561
- * Returns a NO_PIPELINE_RUNNING health object for all graceful "no data" cases
11562
- * (missing DB, missing run, terminal run status). Throws only on unexpected errors.
12012
+ * Runs 2 sequential steps covering discovery and synthesis.
12013
+ * Each step builds on prior step decisions via the decision store.
12014
+ *
12015
+ * On success, a 'research-findings' artifact is registered and research decisions
12016
+ * are available to subsequent phases via `decision:research.*`.
12017
+ *
12018
+ * @param deps - Shared phase dependencies (db, pack, contextCompiler, dispatcher)
12019
+ * @param params - Phase parameters (runId, concept)
12020
+ * @returns ResearchResult with success/failure status and token usage
11563
12021
  */
11564
- async function getAutoHealthData(options) {
11565
- const { runId, projectRoot } = options;
11566
- const dbRoot = await resolveMainRepoRoot(projectRoot);
11567
- const dbPath = join(dbRoot, ".substrate", "substrate.db");
11568
- const NO_PIPELINE = {
11569
- verdict: "NO_PIPELINE_RUNNING",
11570
- run_id: null,
11571
- status: null,
11572
- current_phase: null,
11573
- staleness_seconds: 0,
11574
- last_activity: "",
11575
- process: {
11576
- orchestrator_pid: null,
11577
- child_pids: [],
11578
- zombies: []
11579
- },
11580
- stories: {
11581
- active: 0,
11582
- completed: 0,
11583
- escalated: 0,
11584
- details: {}
11585
- }
12022
+ async function runResearchPhase(deps, params) {
12023
+ const { runId } = params;
12024
+ const zeroTokenUsage = {
12025
+ input: 0,
12026
+ output: 0
11586
12027
  };
11587
- if (!existsSync(dbPath)) return NO_PIPELINE;
11588
- const dbWrapper = new DatabaseWrapper(dbPath);
11589
12028
  try {
11590
- dbWrapper.open();
11591
- const db = dbWrapper.db;
11592
- let run;
11593
- if (runId !== void 0) run = getPipelineRunById(db, runId);
11594
- else run = getLatestRun(db);
11595
- if (run === void 0) return NO_PIPELINE;
11596
- const updatedAt = parseDbTimestampAsUtc(run.updated_at);
11597
- const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
11598
- let storyDetails = {};
11599
- let active = 0;
11600
- let completed = 0;
11601
- let escalated = 0;
11602
- try {
11603
- if (run.token_usage_json) {
11604
- const state = JSON.parse(run.token_usage_json);
11605
- if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
11606
- storyDetails[key] = {
11607
- phase: s.phase,
11608
- review_cycles: s.reviewCycles
11609
- };
11610
- if (s.phase === "COMPLETE") completed++;
11611
- else if (s.phase === "ESCALATED") escalated++;
11612
- else if (s.phase !== "PENDING") active++;
11613
- }
11614
- }
11615
- } catch {}
11616
- const processInfo = inspectProcessTree({ projectRoot });
11617
- let verdict = "NO_PIPELINE_RUNNING";
11618
- if (run.status === "running") if (processInfo.orchestrator_pid === null && active === 0 && completed > 0) verdict = "NO_PIPELINE_RUNNING";
11619
- else if (processInfo.zombies.length > 0) verdict = "STALLED";
11620
- else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length > 0 && stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "HEALTHY";
11621
- else if (stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "STALLED";
11622
- else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length === 0 && active > 0) verdict = "STALLED";
11623
- else verdict = "HEALTHY";
11624
- else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
11625
- return {
11626
- verdict,
11627
- run_id: run.id,
11628
- status: run.status,
11629
- current_phase: run.current_phase,
11630
- staleness_seconds: stalenessSeconds,
11631
- last_activity: run.updated_at,
11632
- process: processInfo,
11633
- stories: {
11634
- active,
11635
- completed,
11636
- escalated,
11637
- details: storyDetails
11638
- }
12029
+ const steps = buildResearchSteps();
12030
+ const result = await runSteps(steps, deps, runId, "research", { concept: params.concept });
12031
+ if (!result.success) return {
12032
+ result: "failed",
12033
+ error: result.error ?? "research_multi_step_failed",
12034
+ details: result.error ?? "Research multi-step execution failed",
12035
+ tokenUsage: result.tokenUsage
11639
12036
  };
11640
- } finally {
11641
- try {
11642
- dbWrapper.close();
11643
- } catch {}
11644
- }
11645
- }
11646
- async function runHealthAction(options) {
11647
- const { outputFormat } = options;
11648
- try {
11649
- const health = await getAutoHealthData(options);
11650
- if (outputFormat === "json") process.stdout.write(formatOutput(health, "json", true) + "\n");
11651
- else {
11652
- const verdictLabel = health.verdict === "HEALTHY" ? "HEALTHY" : health.verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
11653
- process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
11654
- if (health.run_id !== null) {
11655
- process.stdout.write(` Run: ${health.run_id}\n`);
11656
- process.stdout.write(` Status: ${health.status}\n`);
11657
- process.stdout.write(` Phase: ${health.current_phase ?? "N/A"}\n`);
11658
- process.stdout.write(` Last Active: ${health.last_activity} (${health.staleness_seconds}s ago)\n`);
11659
- const processInfo = health.process;
11660
- if (processInfo.orchestrator_pid !== null) {
11661
- process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
11662
- process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
11663
- if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
11664
- process.stdout.write("\n");
11665
- } else process.stdout.write(" Orchestrator: not running\n");
11666
- const storyDetails = health.stories.details;
11667
- if (Object.keys(storyDetails).length > 0) {
11668
- process.stdout.write("\n Stories:\n");
11669
- for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
11670
- process.stdout.write(`\n Summary: ${health.stories.active} active, ${health.stories.completed} completed, ${health.stories.escalated} escalated\n`);
11671
- }
11672
- }
12037
+ const lastStep = result.steps[result.steps.length - 1];
12038
+ const artifactId = lastStep?.artifactId;
12039
+ if (!artifactId) {
12040
+ const artifact = registerArtifact(deps.db, {
12041
+ pipeline_run_id: runId,
12042
+ phase: "research",
12043
+ type: "research-findings",
12044
+ path: "decision-store://research/research-findings",
12045
+ summary: "Research phase completed"
12046
+ });
12047
+ return {
12048
+ result: "success",
12049
+ artifact_id: artifact.id,
12050
+ tokenUsage: result.tokenUsage
12051
+ };
11673
12052
  }
11674
- return 0;
12053
+ return {
12054
+ result: "success",
12055
+ artifact_id: artifactId,
12056
+ tokenUsage: result.tokenUsage
12057
+ };
11675
12058
  } catch (err) {
11676
- const msg = err instanceof Error ? err.message : String(err);
11677
- if (outputFormat === "json") process.stdout.write(formatOutput(null, "json", false, msg) + "\n");
11678
- else process.stderr.write(`Error: ${msg}\n`);
11679
- logger$1.error({ err }, "health action failed");
11680
- return 1;
12059
+ const message = err instanceof Error ? err.message : String(err);
12060
+ return {
12061
+ result: "failed",
12062
+ error: message,
12063
+ tokenUsage: zeroTokenUsage
12064
+ };
11681
12065
  }
11682
12066
  }
11683
- function registerHealthCommand(program, _version = "0.0.0", projectRoot = process.cwd()) {
11684
- program.command("health").description("Check pipeline health: process status, stall detection, and verdict").option("--run-id <id>", "Pipeline run ID to query (defaults to latest)").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
11685
- const outputFormat = opts.outputFormat === "json" ? "json" : "human";
11686
- const exitCode = await runHealthAction({
11687
- outputFormat,
11688
- runId: opts.runId,
11689
- projectRoot: opts.projectRoot
11690
- });
11691
- process.exitCode = exitCode;
11692
- });
11693
- }
11694
12067
 
11695
12068
  //#endregion
11696
12069
  //#region src/cli/commands/run.ts
@@ -11755,6 +12128,25 @@ async function runRunAction(options) {
11755
12128
  const dbRoot = await resolveMainRepoRoot(projectRoot);
11756
12129
  const dbDir = join(dbRoot, ".substrate");
11757
12130
  const dbPath = join(dbDir, "substrate.db");
12131
+ mkdirSync(dbDir, { recursive: true });
12132
+ const pidFilePath = join(dbDir, "orchestrator.pid");
12133
+ try {
12134
+ writeFileSync(pidFilePath, String(process.pid), "utf-8");
12135
+ const cleanupPidFile = () => {
12136
+ try {
12137
+ unlinkSync(pidFilePath);
12138
+ } catch {}
12139
+ };
12140
+ process.on("exit", cleanupPidFile);
12141
+ process.once("SIGTERM", () => {
12142
+ cleanupPidFile();
12143
+ process.exit(0);
12144
+ });
12145
+ process.once("SIGINT", () => {
12146
+ cleanupPidFile();
12147
+ process.exit(130);
12148
+ });
12149
+ } catch {}
11758
12150
  if (startPhase !== void 0) return runFullPipeline({
11759
12151
  packName,
11760
12152
  packPath,
@@ -12110,7 +12502,32 @@ async function runRunAction(options) {
12110
12502
  story_key: payload.storyKey,
12111
12503
  phase: payload.phase,
12112
12504
  elapsed_ms: payload.elapsedMs,
12113
- child_pid: payload.childPid
12505
+ child_pids: payload.childPids,
12506
+ child_active: payload.childActive
12507
+ });
12508
+ });
12509
+ eventBus.on("orchestrator:zero-diff-escalation", (payload) => {
12510
+ ndjsonEmitter.emit({
12511
+ type: "story:zero-diff-escalation",
12512
+ ts: new Date().toISOString(),
12513
+ storyKey: payload.storyKey,
12514
+ reason: payload.reason
12515
+ });
12516
+ });
12517
+ eventBus.on("story:build-verification-passed", (payload) => {
12518
+ ndjsonEmitter.emit({
12519
+ type: "story:build-verification-passed",
12520
+ ts: new Date().toISOString(),
12521
+ storyKey: payload.storyKey
12522
+ });
12523
+ });
12524
+ eventBus.on("story:build-verification-failed", (payload) => {
12525
+ ndjsonEmitter.emit({
12526
+ type: "story:build-verification-failed",
12527
+ ts: new Date().toISOString(),
12528
+ storyKey: payload.storyKey,
12529
+ exitCode: payload.exitCode,
12530
+ output: payload.output
12114
12531
  });
12115
12532
  });
12116
12533
  }
@@ -12567,4 +12984,4 @@ function registerRunCommand(program, _version = "0.0.0", projectRoot = process.c
12567
12984
 
12568
12985
  //#endregion
12569
12986
  export { DatabaseWrapper, SUBSTRATE_OWNED_SETTINGS_KEYS, VALID_PHASES, buildPipelineStatusOutput, createContextCompiler, createDispatcher, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, registerHealthCommand, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, runAnalysisPhase, runMigrations, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
12570
- //# sourceMappingURL=run-CT8B9gG9.js.map
12987
+ //# sourceMappingURL=run-CEtHPG4I.js.map