substrate-ai 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -229,6 +229,15 @@ These commands are invoked by AI agents (Claude Code, Codex, Gemini CLI) during
229
229
  | `substrate monitor recommendations` | Display routing recommendations from performance data |
230
230
  | `substrate cost` | View cost and token usage summary |
231
231
 
232
+ ### Export & Sharing
233
+
234
+ | Command | Description |
235
+ |---------|-------------|
236
+ | `substrate export` | Export planning artifacts (product brief, PRD, architecture, epics) as markdown |
237
+ | `substrate export --run-id <id>` | Export artifacts from a specific pipeline run |
238
+ | `substrate export --output-dir <dir>` | Write to a custom directory (default: `_bmad-output/planning-artifacts/`) |
239
+ | `substrate export --output-format json` | Emit JSON result to stdout for agent consumption |
240
+
232
241
  ### Worktree Management
233
242
 
234
243
  | Command | Description |
package/dist/cli/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import { createLogger, deepMask } from "../logger-C6n1g8uP.js";
3
3
  import { AdapterRegistry, createEventBus } from "../event-bus-J-bw-pkp.js";
4
4
  import { CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, PartialSubstrateConfigSchema, SUPPORTED_CONFIG_FORMAT_VERSIONS, SubstrateConfigSchema, defaultConfigMigrator } from "../version-manager-impl-BpVx2DkY.js";
5
- import { DatabaseWrapper, SUBSTRATE_OWNED_SETTINGS_KEYS, VALID_PHASES, buildPipelineStatusOutput, createContextCompiler, createDispatcher, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getSubstrateDefaultSettings, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, runAnalysisPhase, runMigrations, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-D3ZscMlL.js";
5
+ import { DatabaseWrapper, SUBSTRATE_OWNED_SETTINGS_KEYS, VALID_PHASES, buildPipelineStatusOutput, createContextCompiler, createDispatcher, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getSubstrateDefaultSettings, parseDbTimestampAsUtc, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, runAnalysisPhase, runMigrations, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-0IlA2ubQ.js";
6
6
  import { ConfigError, ConfigIncompatibleFormatError } from "../errors-BPqtzQ4U.js";
7
7
  import { addTokenUsage, createDecision, getDecisionsByPhaseForRun, getLatestRun, getPipelineRunById, getTokenUsageSummary, listRequirements, updatePipelineRun } from "../decisions-DNYByk0U.js";
8
- import { compareRunMetrics, getBaselineRunMetrics, getRunMetrics, getStoryMetricsForRun, incrementRunRestarts, listRunMetrics, tagRunAsBaseline } from "../metrics-BSg8VIHd.js";
8
+ import { aggregateTokenUsageForRun, compareRunMetrics, getBaselineRunMetrics, getRunMetrics, getStoryMetricsForRun, incrementRunRestarts, listRunMetrics, tagRunAsBaseline } from "../metrics-BSg8VIHd.js";
9
9
  import { abortMerge, createWorktree, getConflictingFiles, getMergedFiles, getOrphanedWorktrees, performMerge, removeBranch, removeWorktree, simulateMerge, verifyGitVersion } from "../git-utils-BtI5eNoN.js";
10
10
  import { registerUpgradeCommand } from "../upgrade-rV26kdh3.js";
11
11
  import { createRequire } from "module";
@@ -2404,20 +2404,46 @@ function registerAmendCommand(program, _version = "0.0.0", projectRoot = process
2404
2404
  //#endregion
2405
2405
  //#region src/cli/commands/health.ts
2406
2406
  const logger$11 = createLogger("health-cmd");
2407
- function inspectProcessTree() {
2407
+ /** Default stall threshold in seconds — also used by supervisor default */
2408
+ const DEFAULT_STALL_THRESHOLD_SECONDS = 600;
2409
+ /**
2410
+ * Determine whether a ps output line represents the substrate pipeline orchestrator.
2411
+ * Handles invocation via:
2412
+ * - `substrate run` (globally installed)
2413
+ * - `substrate-ai run`
2414
+ * - `node dist/cli/index.js run` (npm run substrate:dev)
2415
+ * - `npx substrate run`
2416
+ * - any node process whose command contains `run` with `--events` or `--stories`
2417
+ */
2418
+ function isOrchestratorProcessLine(line) {
2419
+ if (line.includes("grep")) return false;
2420
+ if (line.includes("substrate run")) return true;
2421
+ if (line.includes("substrate-ai run")) return true;
2422
+ if (line.includes("index.js run")) return true;
2423
+ if (line.includes("node") && /\srun(\s|$)/.test(line) && (line.includes("--events") || line.includes("--stories"))) return true;
2424
+ return false;
2425
+ }
2426
+ function inspectProcessTree(execFileSyncOverride) {
2408
2427
  const result = {
2409
2428
  orchestrator_pid: null,
2410
2429
  child_pids: [],
2411
2430
  zombies: []
2412
2431
  };
2413
2432
  try {
2414
- const { execFileSync } = __require("node:child_process");
2415
- const psOutput = execFileSync("ps", ["-eo", "pid,ppid,stat,command"], {
2433
+ let psOutput;
2434
+ if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid,stat,command"], {
2416
2435
  encoding: "utf-8",
2417
2436
  timeout: 5e3
2418
2437
  });
2438
+ else {
2439
+ const { execFileSync } = __require("node:child_process");
2440
+ psOutput = execFileSync("ps", ["-eo", "pid,ppid,stat,command"], {
2441
+ encoding: "utf-8",
2442
+ timeout: 5e3
2443
+ });
2444
+ }
2419
2445
  const lines = psOutput.split("\n");
2420
- for (const line of lines) if (line.includes("substrate run") && !line.includes("grep")) {
2446
+ for (const line of lines) if (isOrchestratorProcessLine(line)) {
2421
2447
  const match = line.trim().match(/^(\d+)/);
2422
2448
  if (match) {
2423
2449
  result.orchestrator_pid = parseInt(match[1], 10);
@@ -2478,7 +2504,7 @@ async function getAutoHealthData(options) {
2478
2504
  if (runId !== void 0) run = getPipelineRunById(db, runId);
2479
2505
  else run = getLatestRun(db);
2480
2506
  if (run === void 0) return NO_PIPELINE;
2481
- const updatedAt = new Date(run.updated_at);
2507
+ const updatedAt = parseDbTimestampAsUtc(run.updated_at);
2482
2508
  const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
2483
2509
  let storyDetails = {};
2484
2510
  let active = 0;
@@ -2500,8 +2526,9 @@ async function getAutoHealthData(options) {
2500
2526
  } catch {}
2501
2527
  const processInfo = inspectProcessTree();
2502
2528
  let verdict = "NO_PIPELINE_RUNNING";
2503
- if (run.status === "running") if (processInfo.zombies.length > 0) verdict = "STALLED";
2504
- else if (stalenessSeconds > 600) verdict = "STALLED";
2529
+ if (run.status === "running") if (processInfo.orchestrator_pid === null && active === 0 && completed > 0) verdict = "NO_PIPELINE_RUNNING";
2530
+ else if (processInfo.zombies.length > 0) verdict = "STALLED";
2531
+ else if (stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "STALLED";
2505
2532
  else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length === 0 && active > 0) verdict = "STALLED";
2506
2533
  else verdict = "HEALTHY";
2507
2534
  else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
@@ -2527,124 +2554,31 @@ async function getAutoHealthData(options) {
2527
2554
  }
2528
2555
  }
2529
2556
  async function runHealthAction(options) {
2530
- const { outputFormat, runId, projectRoot } = options;
2531
- const dbRoot = await resolveMainRepoRoot(projectRoot);
2532
- const dbPath = join(dbRoot, ".substrate", "substrate.db");
2533
- if (!existsSync(dbPath)) {
2534
- const output = {
2535
- verdict: "NO_PIPELINE_RUNNING",
2536
- run_id: null,
2537
- status: null,
2538
- current_phase: null,
2539
- staleness_seconds: 0,
2540
- last_activity: "",
2541
- process: {
2542
- orchestrator_pid: null,
2543
- child_pids: [],
2544
- zombies: []
2545
- },
2546
- stories: {
2547
- active: 0,
2548
- completed: 0,
2549
- escalated: 0,
2550
- details: {}
2551
- }
2552
- };
2553
- if (outputFormat === "json") process.stdout.write(formatOutput(output, "json", true) + "\n");
2554
- else process.stdout.write("NO_PIPELINE_RUNNING — no substrate database found\n");
2555
- return 0;
2556
- }
2557
- const dbWrapper = new DatabaseWrapper(dbPath);
2557
+ const { outputFormat } = options;
2558
2558
  try {
2559
- dbWrapper.open();
2560
- const db = dbWrapper.db;
2561
- let run;
2562
- if (runId !== void 0) run = getPipelineRunById(db, runId);
2563
- else run = getLatestRun(db);
2564
- if (run === void 0) {
2565
- const output$1 = {
2566
- verdict: "NO_PIPELINE_RUNNING",
2567
- run_id: null,
2568
- status: null,
2569
- current_phase: null,
2570
- staleness_seconds: 0,
2571
- last_activity: "",
2572
- process: {
2573
- orchestrator_pid: null,
2574
- child_pids: [],
2575
- zombies: []
2576
- },
2577
- stories: {
2578
- active: 0,
2579
- completed: 0,
2580
- escalated: 0,
2581
- details: {}
2582
- }
2583
- };
2584
- if (outputFormat === "json") process.stdout.write(formatOutput(output$1, "json", true) + "\n");
2585
- else process.stdout.write("NO_PIPELINE_RUNNING — no pipeline runs found\n");
2586
- return 0;
2587
- }
2588
- const updatedAt = new Date(run.updated_at);
2589
- const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
2590
- let storyDetails = {};
2591
- let active = 0;
2592
- let completed = 0;
2593
- let escalated = 0;
2594
- try {
2595
- if (run.token_usage_json) {
2596
- const state = JSON.parse(run.token_usage_json);
2597
- if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
2598
- storyDetails[key] = {
2599
- phase: s.phase,
2600
- review_cycles: s.reviewCycles
2601
- };
2602
- if (s.phase === "COMPLETE") completed++;
2603
- else if (s.phase === "ESCALATED") escalated++;
2604
- else if (s.phase !== "PENDING") active++;
2605
- }
2606
- }
2607
- } catch {}
2608
- const processInfo = inspectProcessTree();
2609
- let verdict = "NO_PIPELINE_RUNNING";
2610
- if (run.status === "running") if (processInfo.zombies.length > 0) verdict = "STALLED";
2611
- else if (stalenessSeconds > 600) verdict = "STALLED";
2612
- else if (processInfo.orchestrator_pid !== null && processInfo.child_pids.length === 0 && active > 0) verdict = "STALLED";
2613
- else verdict = "HEALTHY";
2614
- else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
2615
- const output = {
2616
- verdict,
2617
- run_id: run.id,
2618
- status: run.status,
2619
- current_phase: run.current_phase,
2620
- staleness_seconds: stalenessSeconds,
2621
- last_activity: run.updated_at,
2622
- process: processInfo,
2623
- stories: {
2624
- active,
2625
- completed,
2626
- escalated,
2627
- details: storyDetails
2628
- }
2629
- };
2630
- if (outputFormat === "json") process.stdout.write(formatOutput(output, "json", true) + "\n");
2559
+ const health = await getAutoHealthData(options);
2560
+ if (outputFormat === "json") process.stdout.write(formatOutput(health, "json", true) + "\n");
2631
2561
  else {
2632
- const verdictLabel = verdict === "HEALTHY" ? "HEALTHY" : verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
2562
+ const verdictLabel = health.verdict === "HEALTHY" ? "HEALTHY" : health.verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
2633
2563
  process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
2634
- process.stdout.write(` Run: ${run.id}\n`);
2635
- process.stdout.write(` Status: ${run.status}\n`);
2636
- process.stdout.write(` Phase: ${run.current_phase ?? "N/A"}\n`);
2637
- process.stdout.write(` Last Active: ${run.updated_at} (${stalenessSeconds}s ago)\n`);
2638
- if (processInfo.orchestrator_pid !== null) {
2639
- process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
2640
- process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
2641
- if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
2642
- process.stdout.write("\n");
2643
- } else process.stdout.write(" Orchestrator: not running\n");
2644
- if (Object.keys(storyDetails).length > 0) {
2645
- process.stdout.write("\n Stories:\n");
2646
- for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
2647
- process.stdout.write(`\n Summary: ${active} active, ${completed} completed, ${escalated} escalated\n`);
2564
+ if (health.run_id !== null) {
2565
+ process.stdout.write(` Run: ${health.run_id}\n`);
2566
+ process.stdout.write(` Status: ${health.status}\n`);
2567
+ process.stdout.write(` Phase: ${health.current_phase ?? "N/A"}\n`);
2568
+ process.stdout.write(` Last Active: ${health.last_activity} (${health.staleness_seconds}s ago)\n`);
2569
+ const processInfo = health.process;
2570
+ if (processInfo.orchestrator_pid !== null) {
2571
+ process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
2572
+ process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
2573
+ if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
2574
+ process.stdout.write("\n");
2575
+ } else process.stdout.write(" Orchestrator: not running\n");
2576
+ const storyDetails = health.stories.details;
2577
+ if (Object.keys(storyDetails).length > 0) {
2578
+ process.stdout.write("\n Stories:\n");
2579
+ for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
2580
+ process.stdout.write(`\n Summary: ${health.stories.active} active, ${health.stories.completed} completed, ${health.stories.escalated} escalated\n`);
2581
+ }
2648
2582
  }
2649
2583
  }
2650
2584
  return 0;
@@ -2654,10 +2588,6 @@ async function runHealthAction(options) {
2654
2588
  else process.stderr.write(`Error: ${msg}\n`);
2655
2589
  logger$11.error({ err }, "health action failed");
2656
2590
  return 1;
2657
- } finally {
2658
- try {
2659
- dbWrapper.close();
2660
- } catch {}
2661
2591
  }
2662
2592
  }
2663
2593
  function registerHealthCommand(program, _version = "0.0.0", projectRoot = process.cwd()) {
@@ -2700,6 +2630,36 @@ function defaultSupervisorDeps() {
2700
2630
  }
2701
2631
  };
2702
2632
  })(),
2633
+ getTokenSnapshot: (runId, projectRoot) => {
2634
+ try {
2635
+ const dbPath = join(projectRoot, ".substrate", "substrate.db");
2636
+ if (!existsSync(dbPath)) return {
2637
+ input: 0,
2638
+ output: 0,
2639
+ cost_usd: 0
2640
+ };
2641
+ const dbWrapper = new DatabaseWrapper(dbPath);
2642
+ try {
2643
+ dbWrapper.open();
2644
+ const agg = aggregateTokenUsageForRun(dbWrapper.db, runId);
2645
+ return {
2646
+ input: agg.input,
2647
+ output: agg.output,
2648
+ cost_usd: agg.cost
2649
+ };
2650
+ } finally {
2651
+ try {
2652
+ dbWrapper.close();
2653
+ } catch {}
2654
+ }
2655
+ } catch {
2656
+ return {
2657
+ input: 0,
2658
+ output: 0,
2659
+ cost_usd: 0
2660
+ };
2661
+ }
2662
+ },
2703
2663
  runAnalysis: async (runId, projectRoot) => {
2704
2664
  const dbPath = join(projectRoot, ".substrate", "substrate.db");
2705
2665
  if (!existsSync(dbPath)) return;
@@ -2741,7 +2701,7 @@ function defaultSupervisorDeps() {
2741
2701
  */
2742
2702
  async function runSupervisorAction(options, deps = {}) {
2743
2703
  const { pollInterval, stallThreshold, maxRestarts, outputFormat, projectRoot, runId, pack, experiment, maxExperiments } = options;
2744
- const { getHealth, killPid, resumePipeline, sleep, incrementRestarts, runAnalysis } = {
2704
+ const { getHealth, killPid, resumePipeline, sleep, incrementRestarts, runAnalysis, getTokenSnapshot } = {
2745
2705
  ...defaultSupervisorDeps(),
2746
2706
  ...deps
2747
2707
  };
@@ -2765,6 +2725,36 @@ async function runSupervisorAction(options, deps = {}) {
2765
2725
  projectRoot
2766
2726
  });
2767
2727
  const ts = new Date().toISOString();
2728
+ if (outputFormat === "json") {
2729
+ const tokenSnapshot = health.run_id !== null ? getTokenSnapshot(health.run_id, projectRoot) : {
2730
+ input: 0,
2731
+ output: 0,
2732
+ cost_usd: 0
2733
+ };
2734
+ const proc = health.process ?? {
2735
+ orchestrator_pid: null,
2736
+ child_pids: [],
2737
+ zombies: []
2738
+ };
2739
+ emitEvent({
2740
+ type: "supervisor:poll",
2741
+ run_id: health.run_id,
2742
+ verdict: health.verdict,
2743
+ staleness_seconds: health.staleness_seconds,
2744
+ stories: {
2745
+ active: health.stories.active,
2746
+ completed: health.stories.completed,
2747
+ escalated: health.stories.escalated
2748
+ },
2749
+ story_details: health.stories.details,
2750
+ tokens: tokenSnapshot,
2751
+ process: {
2752
+ orchestrator_pid: proc.orchestrator_pid,
2753
+ child_count: proc.child_pids.length,
2754
+ zombie_count: proc.zombies.length
2755
+ }
2756
+ });
2757
+ }
2768
2758
  log(`[${ts}] Health: ${health.verdict} | staleness=${health.staleness_seconds}s | stories: active=${health.stories.active} completed=${health.stories.completed} escalated=${health.stories.escalated}`);
2769
2759
  if (health.verdict === "NO_PIPELINE_RUNNING") {
2770
2760
  const elapsedSeconds = Math.round((Date.now() - startTime) / 1e3);
@@ -2843,7 +2833,7 @@ async function runSupervisorAction(options, deps = {}) {
2843
2833
  const expDb = expDbWrapper.db;
2844
2834
  const { runRunAction: runPipeline } = await import(
2845
2835
  /* @vite-ignore */
2846
- "../run-Bwyy5-RY.js"
2836
+ "../run-Chc5BzIz.js"
2847
2837
  );
2848
2838
  const runStoryFn = async (opts) => {
2849
2839
  const exitCode = await runPipeline({
package/dist/index.d.ts CHANGED
@@ -231,6 +231,45 @@ interface StoryStallEvent {
231
231
  /** Milliseconds since the last progress event */
232
232
  elapsed_ms: number;
233
233
  }
234
+ /**
235
+ * Emitted after each `getHealth()` call in the supervisor poll loop.
236
+ * Allows agents to observe health state, story progress, and token costs
237
+ * on every cycle without needing a separate health query.
238
+ */
239
+ interface SupervisorPollEvent {
240
+ type: 'supervisor:poll';
241
+ /** ISO-8601 timestamp generated at emit time */
242
+ ts: string;
243
+ /** Current pipeline run ID, or null if no run is active */
244
+ run_id: string | null;
245
+ /** Health verdict from the most recent getHealth() call */
246
+ verdict: 'HEALTHY' | 'STALLED' | 'NO_PIPELINE_RUNNING';
247
+ /** Seconds since the last pipeline activity */
248
+ staleness_seconds: number;
249
+ /** Story counts from the health snapshot */
250
+ stories: {
251
+ active: number;
252
+ completed: number;
253
+ escalated: number;
254
+ };
255
+ /** Per-story phase and review cycle details */
256
+ story_details: Record<string, {
257
+ phase: string;
258
+ review_cycles: number;
259
+ }>;
260
+ /** Cumulative token/cost snapshot for the current run */
261
+ tokens: {
262
+ input: number;
263
+ output: number;
264
+ cost_usd: number;
265
+ };
266
+ /** Process health from the health snapshot */
267
+ process: {
268
+ orchestrator_pid: number | null;
269
+ child_count: number;
270
+ zombie_count: number;
271
+ };
272
+ }
234
273
  /**
235
274
  * Emitted when the supervisor kills a stalled pipeline process tree.
236
275
  */
@@ -389,7 +428,7 @@ interface SupervisorExperimentErrorEvent {
389
428
  * }
390
429
  * ```
391
430
  */
392
- type PipelineEvent = PipelineStartEvent | PipelineCompleteEvent | StoryPhaseEvent | StoryDoneEvent | StoryEscalationEvent | StoryWarnEvent | StoryLogEvent | PipelineHeartbeatEvent | StoryStallEvent | SupervisorKillEvent | SupervisorRestartEvent | SupervisorAbortEvent | SupervisorSummaryEvent | SupervisorAnalysisCompleteEvent | SupervisorAnalysisErrorEvent | SupervisorExperimentStartEvent | SupervisorExperimentSkipEvent | SupervisorExperimentRecommendationsEvent | SupervisorExperimentCompleteEvent | SupervisorExperimentErrorEvent; //#endregion
431
+ type PipelineEvent = PipelineStartEvent | PipelineCompleteEvent | StoryPhaseEvent | StoryDoneEvent | StoryEscalationEvent | StoryWarnEvent | StoryLogEvent | PipelineHeartbeatEvent | StoryStallEvent | SupervisorPollEvent | SupervisorKillEvent | SupervisorRestartEvent | SupervisorAbortEvent | SupervisorSummaryEvent | SupervisorAnalysisCompleteEvent | SupervisorAnalysisErrorEvent | SupervisorExperimentStartEvent | SupervisorExperimentSkipEvent | SupervisorExperimentRecommendationsEvent | SupervisorExperimentCompleteEvent | SupervisorExperimentErrorEvent; //#endregion
393
432
  //#region src/core/errors.d.ts
394
433
 
395
434
  /**