substrate-ai 0.1.19 → 0.1.21

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
@@ -3,6 +3,7 @@ import { AdapterRegistry, ConfigError, ConfigIncompatibleFormatError, DatabaseWr
3
3
  import { CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, PartialSubstrateConfigSchema, SUPPORTED_CONFIG_FORMAT_VERSIONS, SubstrateConfigSchema } from "../config-schema-C9tTMcm1.js";
4
4
  import { defaultConfigMigrator } from "../version-manager-impl-O25ieEjS.js";
5
5
  import { registerUpgradeCommand } from "../upgrade-CHhsJc_q.js";
6
+ import { createRequire } from "module";
6
7
  import { Command } from "commander";
7
8
  import { fileURLToPath } from "url";
8
9
  import { dirname, extname, isAbsolute, join, relative, resolve } from "path";
@@ -23,8 +24,12 @@ import * as readline$1 from "readline";
23
24
  import * as readline from "readline";
24
25
  import { createInterface as createInterface$1 } from "readline";
25
26
  import { randomUUID as randomUUID$1 } from "crypto";
26
- import { createRequire } from "node:module";
27
+ import { createRequire as createRequire$1 } from "node:module";
27
28
 
29
+ //#region rolldown:runtime
30
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
31
+
32
+ //#endregion
28
33
  //#region src/cli/utils/formatting.ts
29
34
  /**
30
35
  * Build adapter list rows from discovery results.
@@ -6492,6 +6497,70 @@ const PIPELINE_EVENT_METADATA = [
6492
6497
  description: "Log message."
6493
6498
  }
6494
6499
  ]
6500
+ },
6501
+ {
6502
+ type: "pipeline:heartbeat",
6503
+ description: "Periodic heartbeat emitted every 30s when no other progress events have fired.",
6504
+ when: "Every 30 seconds during pipeline execution. Allows detection of stalled pipelines.",
6505
+ fields: [
6506
+ {
6507
+ name: "ts",
6508
+ type: "string",
6509
+ description: "ISO-8601 timestamp generated at emit time."
6510
+ },
6511
+ {
6512
+ name: "run_id",
6513
+ type: "string",
6514
+ description: "Pipeline run ID."
6515
+ },
6516
+ {
6517
+ name: "active_dispatches",
6518
+ type: "number",
6519
+ description: "Number of sub-agents currently running."
6520
+ },
6521
+ {
6522
+ name: "completed_dispatches",
6523
+ type: "number",
6524
+ description: "Number of dispatches completed."
6525
+ },
6526
+ {
6527
+ name: "queued_dispatches",
6528
+ type: "number",
6529
+ description: "Number of dispatches waiting to start."
6530
+ }
6531
+ ]
6532
+ },
6533
+ {
6534
+ type: "story:stall",
6535
+ description: "Emitted when the watchdog detects no progress for an extended period (default: 10 minutes).",
6536
+ when: "When a story has shown no progress for longer than the watchdog timeout. Indicates likely stall.",
6537
+ fields: [
6538
+ {
6539
+ name: "ts",
6540
+ type: "string",
6541
+ description: "ISO-8601 timestamp generated at emit time."
6542
+ },
6543
+ {
6544
+ name: "run_id",
6545
+ type: "string",
6546
+ description: "Pipeline run ID."
6547
+ },
6548
+ {
6549
+ name: "story_key",
6550
+ type: "string",
6551
+ description: "Story key that appears stalled."
6552
+ },
6553
+ {
6554
+ name: "phase",
6555
+ type: "string",
6556
+ description: "Phase the story was in when stall was detected."
6557
+ },
6558
+ {
6559
+ name: "elapsed_ms",
6560
+ type: "number",
6561
+ description: "Milliseconds since last progress event."
6562
+ }
6563
+ ]
6495
6564
  }
6496
6565
  ];
6497
6566
  /**
@@ -7214,6 +7283,13 @@ const DEFAULT_TIMEOUTS = {
7214
7283
  * Only defined for task types that benefit from explicit turn budgets.
7215
7284
  */
7216
7285
  const DEFAULT_MAX_TURNS = {
7286
+ "analysis": 15,
7287
+ "planning": 20,
7288
+ "architecture": 25,
7289
+ "story-generation": 30,
7290
+ "readiness-check": 20,
7291
+ "elicitation": 15,
7292
+ "critique": 15,
7217
7293
  "dev-story": 75,
7218
7294
  "major-rework": 50,
7219
7295
  "code-review": 25,
@@ -7900,6 +7976,23 @@ function createDecision(db, input) {
7900
7976
  return row;
7901
7977
  }
7902
7978
  /**
7979
+ * Insert or update a decision record.
7980
+ * If a decision with the same pipeline_run_id, category, and key already exists,
7981
+ * update its value and rationale. Otherwise, insert a new record.
7982
+ */
7983
+ function upsertDecision(db, input) {
7984
+ const validated = CreateDecisionInputSchema.parse(input);
7985
+ const existing = db.prepare("SELECT * FROM decisions WHERE pipeline_run_id = ? AND category = ? AND key = ? LIMIT 1").get(validated.pipeline_run_id ?? null, validated.category, validated.key);
7986
+ if (existing) {
7987
+ updateDecision(db, existing.id, {
7988
+ value: validated.value,
7989
+ rationale: validated.rationale ?? void 0
7990
+ });
7991
+ return db.prepare("SELECT * FROM decisions WHERE id = ?").get(existing.id);
7992
+ }
7993
+ return createDecision(db, input);
7994
+ }
7995
+ /**
7903
7996
  * Get all decisions for a given phase, ordered by created_at ascending.
7904
7997
  */
7905
7998
  function getDecisionsByPhase(db, phase) {
@@ -7915,6 +8008,26 @@ function getDecisionsByPhaseForRun(db, runId, phase) {
7915
8008
  return stmt.all(runId, phase);
7916
8009
  }
7917
8010
  /**
8011
+ * Update a decision's value and/or rationale and set updated_at.
8012
+ */
8013
+ function updateDecision(db, id, updates) {
8014
+ const setClauses = [];
8015
+ const values = [];
8016
+ if (updates.value !== void 0) {
8017
+ setClauses.push("value = ?");
8018
+ values.push(updates.value);
8019
+ }
8020
+ if (updates.rationale !== void 0) {
8021
+ setClauses.push("rationale = ?");
8022
+ values.push(updates.rationale);
8023
+ }
8024
+ if (setClauses.length === 0) return;
8025
+ setClauses.push("updated_at = datetime('now')");
8026
+ values.push(id);
8027
+ const stmt = db.prepare(`UPDATE decisions SET ${setClauses.join(", ")} WHERE id = ?`);
8028
+ stmt.run(...values);
8029
+ }
8030
+ /**
7918
8031
  * Insert a new requirement with status = 'active'.
7919
8032
  */
7920
8033
  function createRequirement(db, input) {
@@ -10022,6 +10135,10 @@ function createImplementationOrchestrator(deps) {
10022
10135
  const _stories = new Map();
10023
10136
  let _paused = false;
10024
10137
  let _pauseGate = null;
10138
+ let _lastProgressTs = Date.now();
10139
+ let _heartbeatTimer = null;
10140
+ const HEARTBEAT_INTERVAL_MS = 3e4;
10141
+ const WATCHDOG_TIMEOUT_MS = 6e5;
10025
10142
  function getStatus() {
10026
10143
  const stories = {};
10027
10144
  for (const [key, s] of _stories) stories[key] = { ...s };
@@ -10043,6 +10160,7 @@ function createImplementationOrchestrator(deps) {
10043
10160
  }
10044
10161
  function persistState() {
10045
10162
  if (config.pipelineRunId === void 0) return;
10163
+ recordProgress();
10046
10164
  try {
10047
10165
  const serialized = JSON.stringify(getStatus());
10048
10166
  updatePipelineRun(db, config.pipelineRunId, {
@@ -10053,6 +10171,50 @@ function createImplementationOrchestrator(deps) {
10053
10171
  logger$31.warn("Failed to persist orchestrator state", { err });
10054
10172
  }
10055
10173
  }
10174
+ function recordProgress() {
10175
+ _lastProgressTs = Date.now();
10176
+ }
10177
+ function startHeartbeat() {
10178
+ if (_heartbeatTimer !== null) return;
10179
+ _heartbeatTimer = setInterval(() => {
10180
+ if (_state !== "RUNNING") return;
10181
+ let active = 0;
10182
+ let completed = 0;
10183
+ let queued = 0;
10184
+ for (const s of _stories.values()) if (s.phase === "COMPLETE" || s.phase === "ESCALATED") completed++;
10185
+ else if (s.phase === "PENDING") queued++;
10186
+ else active++;
10187
+ eventBus.emit("orchestrator:heartbeat", {
10188
+ runId: config.pipelineRunId ?? "",
10189
+ activeDispatches: active,
10190
+ completedDispatches: completed,
10191
+ queuedDispatches: queued
10192
+ });
10193
+ const elapsed = Date.now() - _lastProgressTs;
10194
+ if (elapsed >= WATCHDOG_TIMEOUT_MS) {
10195
+ for (const [key, s] of _stories) if (s.phase !== "PENDING" && s.phase !== "COMPLETE" && s.phase !== "ESCALATED") {
10196
+ logger$31.warn({
10197
+ storyKey: key,
10198
+ phase: s.phase,
10199
+ elapsedMs: elapsed
10200
+ }, "Watchdog: possible stall detected");
10201
+ eventBus.emit("orchestrator:stall", {
10202
+ runId: config.pipelineRunId ?? "",
10203
+ storyKey: key,
10204
+ phase: s.phase,
10205
+ elapsedMs: elapsed
10206
+ });
10207
+ }
10208
+ }
10209
+ }, HEARTBEAT_INTERVAL_MS);
10210
+ if (_heartbeatTimer && typeof _heartbeatTimer === "object" && "unref" in _heartbeatTimer) _heartbeatTimer.unref();
10211
+ }
10212
+ function stopHeartbeat() {
10213
+ if (_heartbeatTimer !== null) {
10214
+ clearInterval(_heartbeatTimer);
10215
+ _heartbeatTimer = null;
10216
+ }
10217
+ }
10056
10218
  /**
10057
10219
  * Wait until the orchestrator is un-paused (if currently paused).
10058
10220
  */
@@ -10770,6 +10932,8 @@ function createImplementationOrchestrator(deps) {
10770
10932
  pipelineRunId: config.pipelineRunId
10771
10933
  });
10772
10934
  persistState();
10935
+ recordProgress();
10936
+ startHeartbeat();
10773
10937
  if (projectRoot !== void 0) {
10774
10938
  const seedResult = seedMethodologyContext(db, projectRoot);
10775
10939
  if (seedResult.decisionsCreated > 0) logger$31.info({
@@ -10786,12 +10950,14 @@ function createImplementationOrchestrator(deps) {
10786
10950
  try {
10787
10951
  await runWithConcurrency(groups, config.maxConcurrency);
10788
10952
  } catch (err) {
10953
+ stopHeartbeat();
10789
10954
  _state = "FAILED";
10790
10955
  _completedAt = new Date().toISOString();
10791
10956
  persistState();
10792
10957
  logger$31.error("Orchestrator failed with unhandled error", { err });
10793
10958
  return getStatus();
10794
10959
  }
10960
+ stopHeartbeat();
10795
10961
  _state = "COMPLETE";
10796
10962
  _completedAt = new Date().toISOString();
10797
10963
  let completed = 0;
@@ -11835,12 +12001,14 @@ function createQualityGate(config) {
11835
12001
 
11836
12002
  //#endregion
11837
12003
  //#region src/modules/phase-orchestrator/phases/solutioning.ts
11838
- /** Maximum total prompt token budget for architecture generation (3,000 tokens × 4 chars = 12,000 chars) */
11839
- const MAX_ARCH_PROMPT_TOKENS = 3e3;
11840
- const MAX_ARCH_PROMPT_CHARS = MAX_ARCH_PROMPT_TOKENS * 4;
11841
- /** Maximum total prompt token budget for story generation (4,000 tokens × 4 chars = 16,000 chars) */
11842
- const MAX_STORY_PROMPT_TOKENS = 4e3;
11843
- const MAX_STORY_PROMPT_CHARS = MAX_STORY_PROMPT_TOKENS * 4;
12004
+ /** Base token budget for architecture generation (covers template + requirements) */
12005
+ const BASE_ARCH_PROMPT_TOKENS = 3e3;
12006
+ /** Base token budget for story generation (covers template + requirements + architecture) */
12007
+ const BASE_STORY_PROMPT_TOKENS = 4e3;
12008
+ /** Additional tokens per architecture decision injected into story generation prompt */
12009
+ const TOKENS_PER_DECISION = 100;
12010
+ /** Absolute maximum prompt tokens (model context safety margin) */
12011
+ const ABSOLUTE_MAX_PROMPT_TOKENS = 12e3;
11844
12012
  /** Placeholder in architecture prompt template */
11845
12013
  const REQUIREMENTS_PLACEHOLDER = "{{requirements}}";
11846
12014
  /** Amendment context framing block prefix */
@@ -11854,6 +12022,58 @@ const STORY_REQUIREMENTS_PLACEHOLDER = "{{requirements}}";
11854
12022
  const STORY_ARCHITECTURE_PLACEHOLDER = "{{architecture_decisions}}";
11855
12023
  /** Gap analysis placeholder used in retry prompt */
11856
12024
  const GAP_ANALYSIS_PLACEHOLDER = "{{gap_analysis}}";
12025
+ /** Priority order for decision categories when summarizing (higher priority kept first) */
12026
+ const DECISION_CATEGORY_PRIORITY = [
12027
+ "data",
12028
+ "auth",
12029
+ "api",
12030
+ "frontend",
12031
+ "infra",
12032
+ "observability",
12033
+ "ci"
12034
+ ];
12035
+ /**
12036
+ * Calculate the dynamic prompt token budget based on the number of decisions
12037
+ * that will be injected into the prompt.
12038
+ *
12039
+ * @param baseBudget - Base token budget for the phase
12040
+ * @param decisionCount - Number of decisions to inject
12041
+ * @returns Calculated token budget, capped at ABSOLUTE_MAX_PROMPT_TOKENS
12042
+ */
12043
+ function calculateDynamicBudget(baseBudget, decisionCount) {
12044
+ const budget = baseBudget + decisionCount * TOKENS_PER_DECISION;
12045
+ return Math.min(budget, ABSOLUTE_MAX_PROMPT_TOKENS);
12046
+ }
12047
+ /**
12048
+ * Summarize architecture decisions into compact key:value one-liners,
12049
+ * dropping rationale and optionally dropping lower-priority categories
12050
+ * to fit within a character budget.
12051
+ *
12052
+ * @param decisions - Full architecture decisions from the decision store
12053
+ * @param maxChars - Maximum character budget for the summarized output
12054
+ * @returns Compact summary string
12055
+ */
12056
+ function summarizeDecisions(decisions, maxChars) {
12057
+ const sorted = [...decisions].sort((a, b) => {
12058
+ const aCat = (a.category ?? "").toLowerCase();
12059
+ const bCat = (b.category ?? "").toLowerCase();
12060
+ const aIdx = DECISION_CATEGORY_PRIORITY.indexOf(aCat);
12061
+ const bIdx = DECISION_CATEGORY_PRIORITY.indexOf(bCat);
12062
+ const aPri = aIdx === -1 ? DECISION_CATEGORY_PRIORITY.length : aIdx;
12063
+ const bPri = bIdx === -1 ? DECISION_CATEGORY_PRIORITY.length : bIdx;
12064
+ return aPri - bPri;
12065
+ });
12066
+ const lines = ["## Architecture Decisions (Summarized)"];
12067
+ let currentLength = lines[0].length;
12068
+ for (const d of sorted) {
12069
+ const truncatedValue = d.value.length > 120 ? d.value.slice(0, 117) + "..." : d.value;
12070
+ const line = `- ${d.key}: ${truncatedValue}`;
12071
+ if (currentLength + line.length + 1 > maxChars) break;
12072
+ lines.push(line);
12073
+ currentLength += line.length + 1;
12074
+ }
12075
+ return lines.join("\n");
12076
+ }
11857
12077
  /**
11858
12078
  * Format functional and non-functional requirements from the planning phase
11859
12079
  * into a compact text block suitable for prompt injection.
@@ -11932,17 +12152,19 @@ async function runArchitectureGeneration(deps, params) {
11932
12152
  const template = await pack.getPrompt("architecture");
11933
12153
  const formattedRequirements = formatRequirements(db, runId);
11934
12154
  let prompt = template.replace(REQUIREMENTS_PLACEHOLDER, formattedRequirements);
12155
+ const dynamicBudgetTokens = calculateDynamicBudget(BASE_ARCH_PROMPT_TOKENS, 0);
12156
+ const dynamicBudgetChars = dynamicBudgetTokens * 4;
11935
12157
  if (amendmentContext !== void 0 && amendmentContext !== "") {
11936
12158
  const framingLen = AMENDMENT_CONTEXT_HEADER.length + AMENDMENT_CONTEXT_FOOTER.length;
11937
- const availableForContext = MAX_ARCH_PROMPT_CHARS - prompt.length - framingLen - TRUNCATED_MARKER.length;
12159
+ const availableForContext = dynamicBudgetChars - prompt.length - framingLen - TRUNCATED_MARKER.length;
11938
12160
  let contextToInject = amendmentContext;
11939
12161
  if (availableForContext <= 0) contextToInject = "";
11940
12162
  else if (amendmentContext.length > availableForContext) contextToInject = amendmentContext.slice(0, availableForContext) + TRUNCATED_MARKER;
11941
12163
  if (contextToInject !== "") prompt += AMENDMENT_CONTEXT_HEADER + contextToInject + AMENDMENT_CONTEXT_FOOTER;
11942
12164
  }
11943
12165
  const estimatedTokens = Math.ceil(prompt.length / 4);
11944
- if (estimatedTokens > MAX_ARCH_PROMPT_TOKENS) return {
11945
- error: `Architecture prompt exceeds token budget: ${estimatedTokens} tokens (max ${MAX_ARCH_PROMPT_TOKENS})`,
12166
+ if (estimatedTokens > dynamicBudgetTokens) return {
12167
+ error: `Architecture prompt exceeds token budget: ${estimatedTokens} tokens (max ${dynamicBudgetTokens})`,
11946
12168
  tokenUsage: zeroTokenUsage
11947
12169
  };
11948
12170
  const handle = dispatcher.dispatch({
@@ -11974,7 +12196,7 @@ async function runArchitectureGeneration(deps, params) {
11974
12196
  tokenUsage
11975
12197
  };
11976
12198
  const decisions = parsed.architecture_decisions;
11977
- for (const decision of decisions) createDecision(db, {
12199
+ for (const decision of decisions) upsertDecision(db, {
11978
12200
  pipeline_run_id: runId,
11979
12201
  phase: "solutioning",
11980
12202
  category: "architecture",
@@ -12017,20 +12239,34 @@ async function runStoryGeneration(deps, params, gapAnalysis) {
12017
12239
  };
12018
12240
  const template = await pack.getPrompt("story-generation");
12019
12241
  const formattedRequirements = formatRequirements(db, runId);
12020
- const formattedArchitecture = formatArchitectureDecisions(db, runId);
12242
+ const archDecisions = getDecisionsByPhaseForRun(db, runId, "solutioning").filter((d) => d.category === "architecture");
12243
+ const dynamicBudgetTokens = calculateDynamicBudget(BASE_STORY_PROMPT_TOKENS, archDecisions.length);
12244
+ const dynamicBudgetChars = dynamicBudgetTokens * 4;
12245
+ let formattedArchitecture = formatArchitectureDecisions(db, runId);
12021
12246
  let prompt = template.replace(STORY_REQUIREMENTS_PLACEHOLDER, formattedRequirements).replace(STORY_ARCHITECTURE_PLACEHOLDER, formattedArchitecture);
12022
12247
  if (gapAnalysis !== void 0) prompt = prompt.replace(GAP_ANALYSIS_PLACEHOLDER, gapAnalysis);
12023
12248
  if (amendmentContext !== void 0 && amendmentContext !== "") {
12024
12249
  const framingLen = AMENDMENT_CONTEXT_HEADER.length + AMENDMENT_CONTEXT_FOOTER.length;
12025
- const availableForContext = MAX_STORY_PROMPT_CHARS - prompt.length - framingLen - TRUNCATED_MARKER.length;
12250
+ const availableForContext = dynamicBudgetChars - prompt.length - framingLen - TRUNCATED_MARKER.length;
12026
12251
  let contextToInject = amendmentContext;
12027
12252
  if (availableForContext <= 0) contextToInject = "";
12028
12253
  else if (amendmentContext.length > availableForContext) contextToInject = amendmentContext.slice(0, availableForContext) + TRUNCATED_MARKER;
12029
12254
  if (contextToInject !== "") prompt += AMENDMENT_CONTEXT_HEADER + contextToInject + AMENDMENT_CONTEXT_FOOTER;
12030
12255
  }
12031
- const estimatedTokens = Math.ceil(prompt.length / 4);
12032
- if (estimatedTokens > MAX_STORY_PROMPT_TOKENS) return {
12033
- error: `Story generation prompt exceeds token budget: ${estimatedTokens} tokens (max ${MAX_STORY_PROMPT_TOKENS})`,
12256
+ let estimatedTokens = Math.ceil(prompt.length / 4);
12257
+ if (estimatedTokens > dynamicBudgetTokens) {
12258
+ const availableForDecisions = dynamicBudgetChars - (prompt.length - formattedArchitecture.length);
12259
+ formattedArchitecture = summarizeDecisions(archDecisions.map((d) => ({
12260
+ key: d.key,
12261
+ value: d.value,
12262
+ category: d.category
12263
+ })), Math.max(availableForDecisions, 200));
12264
+ prompt = template.replace(STORY_REQUIREMENTS_PLACEHOLDER, formattedRequirements).replace(STORY_ARCHITECTURE_PLACEHOLDER, formattedArchitecture);
12265
+ if (gapAnalysis !== void 0) prompt = prompt.replace(GAP_ANALYSIS_PLACEHOLDER, gapAnalysis);
12266
+ estimatedTokens = Math.ceil(prompt.length / 4);
12267
+ }
12268
+ if (estimatedTokens > dynamicBudgetTokens) return {
12269
+ error: `Story generation prompt exceeds token budget: ${estimatedTokens} tokens (max ${dynamicBudgetTokens})`,
12034
12270
  tokenUsage: zeroTokenUsage
12035
12271
  };
12036
12272
  const handle = dispatcher.dispatch({
@@ -12063,7 +12299,7 @@ async function runStoryGeneration(deps, params, gapAnalysis) {
12063
12299
  };
12064
12300
  const epics = parsed.epics;
12065
12301
  for (const [epicIndex, epic] of epics.entries()) {
12066
- createDecision(db, {
12302
+ upsertDecision(db, {
12067
12303
  pipeline_run_id: runId,
12068
12304
  phase: "solutioning",
12069
12305
  category: "epics",
@@ -12073,7 +12309,7 @@ async function runStoryGeneration(deps, params, gapAnalysis) {
12073
12309
  description: epic.description
12074
12310
  })
12075
12311
  });
12076
- for (const story of epic.stories) createDecision(db, {
12312
+ for (const story of epic.stories) upsertDecision(db, {
12077
12313
  pipeline_run_id: runId,
12078
12314
  phase: "solutioning",
12079
12315
  category: "stories",
@@ -12200,7 +12436,23 @@ async function runSolutioningPhase(deps, params) {
12200
12436
  let totalInput = 0;
12201
12437
  let totalOutput = 0;
12202
12438
  try {
12203
- const archResult = await runArchitectureGeneration(deps, params);
12439
+ const existingArchArtifact = getArtifactByTypeForRun(deps.db, params.runId, "solutioning", "architecture");
12440
+ let archResult;
12441
+ if (existingArchArtifact) {
12442
+ const existingDecisions = getDecisionsByPhaseForRun(deps.db, params.runId, "solutioning").filter((d) => d.category === "architecture");
12443
+ archResult = {
12444
+ decisions: existingDecisions.map((d) => ({
12445
+ key: d.key,
12446
+ value: d.value,
12447
+ rationale: d.rationale ?? ""
12448
+ })),
12449
+ artifactId: existingArchArtifact.id,
12450
+ tokenUsage: {
12451
+ input: 0,
12452
+ output: 0
12453
+ }
12454
+ };
12455
+ } else archResult = await runArchitectureGeneration(deps, params);
12204
12456
  totalInput += archResult.tokenUsage.input;
12205
12457
  totalOutput += archResult.tokenUsage.output;
12206
12458
  if ("error" in archResult) return {
@@ -12897,8 +13149,8 @@ const PACKAGE_ROOT = join(__dirname, "..", "..", "..");
12897
13149
  */
12898
13150
  function resolveBmadMethodSrcPath(fromDir = __dirname) {
12899
13151
  try {
12900
- const require = createRequire(join(fromDir, "synthetic.js"));
12901
- const pkgJsonPath = require.resolve("bmad-method/package.json");
13152
+ const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
13153
+ const pkgJsonPath = require$1.resolve("bmad-method/package.json");
12902
13154
  return join(dirname(pkgJsonPath), "src");
12903
13155
  } catch {
12904
13156
  return null;
@@ -12910,9 +13162,9 @@ function resolveBmadMethodSrcPath(fromDir = __dirname) {
12910
13162
  */
12911
13163
  function resolveBmadMethodVersion(fromDir = __dirname) {
12912
13164
  try {
12913
- const require = createRequire(join(fromDir, "synthetic.js"));
12914
- const pkgJsonPath = require.resolve("bmad-method/package.json");
12915
- const pkg = require(pkgJsonPath);
13165
+ const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
13166
+ const pkgJsonPath = require$1.resolve("bmad-method/package.json");
13167
+ const pkg = require$1(pkgJsonPath);
12916
13168
  return pkg.version ?? "unknown";
12917
13169
  } catch {
12918
13170
  return "unknown";
@@ -13033,7 +13285,9 @@ function buildPipelineStatusOutput(run, tokenSummary, decisionsCount, storiesCou
13033
13285
  cost_usd: totalCost
13034
13286
  },
13035
13287
  decisions_count: decisionsCount,
13036
- stories_count: storiesCount
13288
+ stories_count: storiesCount,
13289
+ last_activity: run.updated_at,
13290
+ staleness_seconds: Math.round((Date.now() - new Date(run.updated_at).getTime()) / 1e3)
13037
13291
  };
13038
13292
  }
13039
13293
  /**
@@ -13669,6 +13923,26 @@ async function runAutoRun(options) {
13669
13923
  msg: payload.msg
13670
13924
  });
13671
13925
  });
13926
+ eventBus.on("orchestrator:heartbeat", (payload) => {
13927
+ ndjsonEmitter.emit({
13928
+ type: "pipeline:heartbeat",
13929
+ ts: new Date().toISOString(),
13930
+ run_id: payload.runId,
13931
+ active_dispatches: payload.activeDispatches,
13932
+ completed_dispatches: payload.completedDispatches,
13933
+ queued_dispatches: payload.queuedDispatches
13934
+ });
13935
+ });
13936
+ eventBus.on("orchestrator:stall", (payload) => {
13937
+ ndjsonEmitter.emit({
13938
+ type: "story:stall",
13939
+ ts: new Date().toISOString(),
13940
+ run_id: payload.runId,
13941
+ story_key: payload.storyKey,
13942
+ phase: payload.phase,
13943
+ elapsed_ms: payload.elapsedMs
13944
+ });
13945
+ });
13672
13946
  }
13673
13947
  const orchestrator = createImplementationOrchestrator({
13674
13948
  db,
@@ -13824,6 +14098,7 @@ async function runFullPipeline(options) {
13824
14098
  });
13825
14099
  }
13826
14100
  if (result.result === "failed") {
14101
+ updatePipelineRun(db, runId, { status: "failed" });
13827
14102
  const errorMsg = `Analysis phase failed: ${result.error ?? "unknown error"}${result.details ? ` — ${result.details}` : ""}`;
13828
14103
  if (outputFormat === "human") process.stderr.write(`Error: ${errorMsg}\n`);
13829
14104
  else process.stdout.write(formatOutput(null, "json", false, errorMsg) + "\n");
@@ -13846,6 +14121,7 @@ async function runFullPipeline(options) {
13846
14121
  });
13847
14122
  }
13848
14123
  if (result.result === "failed") {
14124
+ updatePipelineRun(db, runId, { status: "failed" });
13849
14125
  const errorMsg = `Planning phase failed: ${result.error ?? "unknown error"}${result.details ? ` — ${result.details}` : ""}`;
13850
14126
  if (outputFormat === "human") process.stderr.write(`Error: ${errorMsg}\n`);
13851
14127
  else process.stdout.write(formatOutput(null, "json", false, errorMsg) + "\n");
@@ -13868,6 +14144,7 @@ async function runFullPipeline(options) {
13868
14144
  });
13869
14145
  }
13870
14146
  if (result.result === "failed") {
14147
+ updatePipelineRun(db, runId, { status: "failed" });
13871
14148
  const errorMsg = `Solutioning phase failed: ${result.error ?? "unknown error"}${result.details ? ` — ${result.details}` : ""}`;
13872
14149
  if (outputFormat === "human") process.stderr.write(`Error: ${errorMsg}\n`);
13873
14150
  else process.stdout.write(formatOutput(null, "json", false, errorMsg) + "\n");
@@ -14141,6 +14418,7 @@ async function runFullPipelineFromPhase(options) {
14141
14418
  });
14142
14419
  }
14143
14420
  if (result.result === "failed") {
14421
+ updatePipelineRun(db, runId, { status: "failed" });
14144
14422
  const errorMsg = `Analysis phase failed: ${result.error ?? "unknown error"}`;
14145
14423
  if (outputFormat === "human") process.stderr.write(`Error: ${errorMsg}\n`);
14146
14424
  else process.stdout.write(formatOutput(null, "json", false, errorMsg) + "\n");
@@ -14160,6 +14438,7 @@ async function runFullPipelineFromPhase(options) {
14160
14438
  });
14161
14439
  }
14162
14440
  if (result.result === "failed") {
14441
+ updatePipelineRun(db, runId, { status: "failed" });
14163
14442
  const errorMsg = `Planning phase failed: ${result.error ?? "unknown error"}`;
14164
14443
  if (outputFormat === "human") process.stderr.write(`Error: ${errorMsg}\n`);
14165
14444
  else process.stdout.write(formatOutput(null, "json", false, errorMsg) + "\n");
@@ -14179,6 +14458,7 @@ async function runFullPipelineFromPhase(options) {
14179
14458
  });
14180
14459
  }
14181
14460
  if (result.result === "failed") {
14461
+ updatePipelineRun(db, runId, { status: "failed" });
14182
14462
  const errorMsg = `Solutioning phase failed: ${result.error ?? "unknown error"}`;
14183
14463
  if (outputFormat === "human") process.stderr.write(`Error: ${errorMsg}\n`);
14184
14464
  else process.stdout.write(formatOutput(null, "json", false, errorMsg) + "\n");
@@ -14365,6 +14645,175 @@ async function runAutoStatus(options) {
14365
14645
  } catch {}
14366
14646
  }
14367
14647
  }
14648
+ function inspectProcessTree() {
14649
+ const result = {
14650
+ orchestrator_pid: null,
14651
+ child_pids: [],
14652
+ zombies: []
14653
+ };
14654
+ try {
14655
+ const { execFileSync } = __require("node:child_process");
14656
+ const psOutput = execFileSync("ps", ["-eo", "pid,ppid,stat,command"], {
14657
+ encoding: "utf-8",
14658
+ timeout: 5e3
14659
+ });
14660
+ const lines = psOutput.split("\n");
14661
+ for (const line of lines) if (line.includes("substrate auto run") && !line.includes("grep")) {
14662
+ const match = line.trim().match(/^(\d+)/);
14663
+ if (match) {
14664
+ result.orchestrator_pid = parseInt(match[1], 10);
14665
+ break;
14666
+ }
14667
+ }
14668
+ if (result.orchestrator_pid !== null) for (const line of lines) {
14669
+ const parts = line.trim().split(/\s+/);
14670
+ if (parts.length >= 3) {
14671
+ const pid = parseInt(parts[0], 10);
14672
+ const ppid = parseInt(parts[1], 10);
14673
+ const stat$2 = parts[2];
14674
+ if (ppid === result.orchestrator_pid && pid !== result.orchestrator_pid) {
14675
+ result.child_pids.push(pid);
14676
+ if (stat$2.includes("Z")) result.zombies.push(pid);
14677
+ }
14678
+ }
14679
+ }
14680
+ } catch {}
14681
+ return result;
14682
+ }
14683
+ async function runAutoHealth(options) {
14684
+ const { outputFormat, runId, projectRoot } = options;
14685
+ const dbRoot = await resolveMainRepoRoot(projectRoot);
14686
+ const dbPath = join(dbRoot, ".substrate", "substrate.db");
14687
+ if (!existsSync(dbPath)) {
14688
+ const output = {
14689
+ verdict: "NO_PIPELINE_RUNNING",
14690
+ run_id: null,
14691
+ status: null,
14692
+ current_phase: null,
14693
+ staleness_seconds: 0,
14694
+ last_activity: "",
14695
+ process: {
14696
+ orchestrator_pid: null,
14697
+ child_pids: [],
14698
+ zombies: []
14699
+ },
14700
+ stories: {
14701
+ active: 0,
14702
+ completed: 0,
14703
+ escalated: 0,
14704
+ details: {}
14705
+ }
14706
+ };
14707
+ if (outputFormat === "json") process.stdout.write(formatOutput(output, "json", true) + "\n");
14708
+ else process.stdout.write("NO_PIPELINE_RUNNING — no substrate database found\n");
14709
+ return 0;
14710
+ }
14711
+ const dbWrapper = new DatabaseWrapper(dbPath);
14712
+ try {
14713
+ dbWrapper.open();
14714
+ const db = dbWrapper.db;
14715
+ let run;
14716
+ if (runId !== void 0) run = getPipelineRunById(db, runId);
14717
+ else run = getLatestRun(db);
14718
+ if (run === void 0) {
14719
+ const output$1 = {
14720
+ verdict: "NO_PIPELINE_RUNNING",
14721
+ run_id: null,
14722
+ status: null,
14723
+ current_phase: null,
14724
+ staleness_seconds: 0,
14725
+ last_activity: "",
14726
+ process: {
14727
+ orchestrator_pid: null,
14728
+ child_pids: [],
14729
+ zombies: []
14730
+ },
14731
+ stories: {
14732
+ active: 0,
14733
+ completed: 0,
14734
+ escalated: 0,
14735
+ details: {}
14736
+ }
14737
+ };
14738
+ if (outputFormat === "json") process.stdout.write(formatOutput(output$1, "json", true) + "\n");
14739
+ else process.stdout.write("NO_PIPELINE_RUNNING — no pipeline runs found\n");
14740
+ return 0;
14741
+ }
14742
+ const updatedAt = new Date(run.updated_at);
14743
+ const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
14744
+ let storyDetails = {};
14745
+ let active = 0;
14746
+ let completed = 0;
14747
+ let escalated = 0;
14748
+ try {
14749
+ if (run.token_usage_json) {
14750
+ const state = JSON.parse(run.token_usage_json);
14751
+ if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
14752
+ storyDetails[key] = {
14753
+ phase: s.phase,
14754
+ review_cycles: s.reviewCycles
14755
+ };
14756
+ if (s.phase === "COMPLETE") completed++;
14757
+ else if (s.phase === "ESCALATED") escalated++;
14758
+ else if (s.phase !== "PENDING") active++;
14759
+ }
14760
+ }
14761
+ } catch {}
14762
+ const processInfo = inspectProcessTree();
14763
+ let verdict = "NO_PIPELINE_RUNNING";
14764
+ if (run.status === "running") if (processInfo.zombies.length > 0) verdict = "STALLED";
14765
+ else if (stalenessSeconds > 600) verdict = "STALLED";
14766
+ else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length === 0 && active > 0) verdict = "STALLED";
14767
+ else verdict = "HEALTHY";
14768
+ else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
14769
+ const output = {
14770
+ verdict,
14771
+ run_id: run.id,
14772
+ status: run.status,
14773
+ current_phase: run.current_phase,
14774
+ staleness_seconds: stalenessSeconds,
14775
+ last_activity: run.updated_at,
14776
+ process: processInfo,
14777
+ stories: {
14778
+ active,
14779
+ completed,
14780
+ escalated,
14781
+ details: storyDetails
14782
+ }
14783
+ };
14784
+ if (outputFormat === "json") process.stdout.write(formatOutput(output, "json", true) + "\n");
14785
+ else {
14786
+ const verdictLabel = verdict === "HEALTHY" ? "HEALTHY" : verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
14787
+ process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
14788
+ process.stdout.write(` Run: ${run.id}\n`);
14789
+ process.stdout.write(` Status: ${run.status}\n`);
14790
+ process.stdout.write(` Phase: ${run.current_phase ?? "N/A"}\n`);
14791
+ process.stdout.write(` Last Active: ${run.updated_at} (${stalenessSeconds}s ago)\n`);
14792
+ if (processInfo.orchestrator_pid !== null) {
14793
+ process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
14794
+ process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
14795
+ if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
14796
+ process.stdout.write("\n");
14797
+ } else process.stdout.write(" Orchestrator: not running\n");
14798
+ if (Object.keys(storyDetails).length > 0) {
14799
+ process.stdout.write("\n Stories:\n");
14800
+ for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
14801
+ process.stdout.write(`\n Summary: ${active} active, ${completed} completed, ${escalated} escalated\n`);
14802
+ }
14803
+ }
14804
+ return 0;
14805
+ } catch (err) {
14806
+ const msg = err instanceof Error ? err.message : String(err);
14807
+ if (outputFormat === "json") process.stdout.write(formatOutput(null, "json", false, msg) + "\n");
14808
+ else process.stderr.write(`Error: ${msg}\n`);
14809
+ logger$3.error({ err }, "auto health failed");
14810
+ return 1;
14811
+ } finally {
14812
+ try {
14813
+ dbWrapper.close();
14814
+ } catch {}
14815
+ }
14816
+ }
14368
14817
  /**
14369
14818
  * Detect and apply supersessions after a phase completes in an amendment run.
14370
14819
  *
@@ -14552,6 +15001,7 @@ async function runAmendCommand(options) {
14552
15001
  });
14553
15002
  }
14554
15003
  if (result.result === "failed") {
15004
+ updatePipelineRun(db, amendmentRunId, { status: "failed" });
14555
15005
  process.stderr.write(`Error: Analysis phase failed: ${result.error ?? "unknown error"}${result.details ? ` — ${result.details}` : ""}\n`);
14556
15006
  return 1;
14557
15007
  }
@@ -14573,6 +15023,7 @@ async function runAmendCommand(options) {
14573
15023
  });
14574
15024
  }
14575
15025
  if (result.result === "failed") {
15026
+ updatePipelineRun(db, amendmentRunId, { status: "failed" });
14576
15027
  process.stderr.write(`Error: Planning phase failed: ${result.error ?? "unknown error"}${result.details ? ` — ${result.details}` : ""}\n`);
14577
15028
  return 1;
14578
15029
  }
@@ -14594,6 +15045,7 @@ async function runAmendCommand(options) {
14594
15045
  });
14595
15046
  }
14596
15047
  if (result.result === "failed") {
15048
+ updatePipelineRun(db, amendmentRunId, { status: "failed" });
14597
15049
  process.stderr.write(`Error: Solutioning phase failed: ${result.error ?? "unknown error"}${result.details ? ` — ${result.details}` : ""}\n`);
14598
15050
  return 1;
14599
15051
  }
@@ -14730,6 +15182,15 @@ function registerAutoCommand(program, _version = "0.0.0", projectRoot = process.
14730
15182
  });
14731
15183
  process.exitCode = exitCode;
14732
15184
  });
15185
+ auto.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) => {
15186
+ const outputFormat = opts.outputFormat === "json" ? "json" : "human";
15187
+ const exitCode = await runAutoHealth({
15188
+ outputFormat,
15189
+ runId: opts.runId,
15190
+ projectRoot: opts.projectRoot
15191
+ });
15192
+ process.exitCode = exitCode;
15193
+ });
14733
15194
  auto.command("amend").description("Run an amendment pipeline against a completed run and an existing run").option("--concept <text>", "Amendment concept description (inline)").option("--concept-file <path>", "Path to concept file").option("--run-id <id>", "Parent run ID (defaults to latest completed run)").option("--stop-after <phase>", "Stop pipeline after this phase completes").option("--from <phase>", "Start pipeline from this phase").option("--pack <name>", "Methodology pack name", "bmad").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
14734
15195
  const exitCode = await runAmendCommand({
14735
15196
  concept: opts.concept,
@@ -15757,6 +16218,12 @@ function registerMonitorCommand(program, version = "0.0.0", projectRoot = proces
15757
16218
  //#endregion
15758
16219
  //#region src/cli/index.ts
15759
16220
  process.setMaxListeners(30);
16221
+ process.stdout.on("error", (err) => {
16222
+ if (err.code === "EPIPE") process.exit(0);
16223
+ });
16224
+ process.stderr.on("error", (err) => {
16225
+ if (err.code === "EPIPE") process.exit(0);
16226
+ });
15760
16227
  const logger = createLogger("cli");
15761
16228
  /** Resolve the package.json path relative to this file */
15762
16229
  async function getPackageVersion() {
package/dist/index.d.ts CHANGED
@@ -197,6 +197,40 @@ interface StoryLogEvent {
197
197
  /** Log message */
198
198
  msg: string;
199
199
  }
200
+ /**
201
+ * Emitted periodically (every 30s) when no other progress events have fired.
202
+ * Allows parent agents to distinguish "working silently" from "stuck".
203
+ */
204
+ interface PipelineHeartbeatEvent {
205
+ type: 'pipeline:heartbeat';
206
+ /** ISO-8601 timestamp generated at emit time */
207
+ ts: string;
208
+ /** Unique identifier for the current pipeline run */
209
+ run_id: string;
210
+ /** Number of sub-agent dispatches currently running */
211
+ active_dispatches: number;
212
+ /** Number of dispatches that have completed */
213
+ completed_dispatches: number;
214
+ /** Number of dispatches waiting to start */
215
+ queued_dispatches: number;
216
+ }
217
+ /**
218
+ * Emitted when the watchdog timer detects no progress for an extended period.
219
+ * Indicates a likely stall that may require operator intervention.
220
+ */
221
+ interface StoryStallEvent {
222
+ type: 'story:stall';
223
+ /** ISO-8601 timestamp generated at emit time */
224
+ ts: string;
225
+ /** Unique identifier for the current pipeline run */
226
+ run_id: string;
227
+ /** Story key that appears stalled */
228
+ story_key: string;
229
+ /** Phase the story was in when the stall was detected */
230
+ phase: string;
231
+ /** Milliseconds since the last progress event */
232
+ elapsed_ms: number;
233
+ }
200
234
  /**
201
235
  * Discriminated union of all pipeline event types.
202
236
  *
@@ -209,7 +243,7 @@ interface StoryLogEvent {
209
243
  * }
210
244
  * ```
211
245
  */
212
- type PipelineEvent = PipelineStartEvent | PipelineCompleteEvent | StoryPhaseEvent | StoryDoneEvent | StoryEscalationEvent | StoryWarnEvent | StoryLogEvent; //#endregion
246
+ type PipelineEvent = PipelineStartEvent | PipelineCompleteEvent | StoryPhaseEvent | StoryDoneEvent | StoryEscalationEvent | StoryWarnEvent | StoryLogEvent | PipelineHeartbeatEvent | StoryStallEvent; //#endregion
213
247
  //#region src/core/errors.d.ts
214
248
 
215
249
  /**
@@ -793,6 +827,20 @@ interface OrchestratorEvents {
793
827
  'orchestrator:paused': Record<string, never>;
794
828
  /** Implementation orchestrator has been resumed */
795
829
  'orchestrator:resumed': Record<string, never>;
830
+ /** Periodic heartbeat emitted every 30s during pipeline execution */
831
+ 'orchestrator:heartbeat': {
832
+ runId: string;
833
+ activeDispatches: number;
834
+ completedDispatches: number;
835
+ queuedDispatches: number;
836
+ };
837
+ /** Watchdog detected no progress for an extended period */
838
+ 'orchestrator:stall': {
839
+ runId: string;
840
+ storyKey: string;
841
+ phase: string;
842
+ elapsedMs: number;
843
+ };
796
844
  }
797
845
 
798
846
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrate-ai",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Substrate — multi-agent orchestration daemon for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@
64
64
  "js-yaml": "^4.1.1",
65
65
  "pino": "^9.6.0",
66
66
  "semver": "^7.6.3",
67
+ "substrate-ai": "^0.1.19",
67
68
  "zod": "^4.3.6"
68
69
  },
69
70
  "devDependencies": {