substrate-ai 0.1.25 → 0.1.27

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
@@ -9,7 +9,7 @@ import { fileURLToPath } from "url";
9
9
  import { dirname, extname, isAbsolute, join, relative, resolve } from "path";
10
10
  import { access, mkdir, readFile, readdir, stat, writeFile } from "fs/promises";
11
11
  import { execFile } from "child_process";
12
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
12
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
13
13
  import yaml, { dump, load } from "js-yaml";
14
14
  import { z } from "zod";
15
15
  import { fileURLToPath as fileURLToPath$1 } from "node:url";
@@ -6786,6 +6786,41 @@ Initialize a methodology pack and decision store.
6786
6786
  \`\`\`
6787
6787
  substrate auto init [--pack bmad] [--project-root .]
6788
6788
  \`\`\`
6789
+
6790
+ ### substrate auto supervisor
6791
+ Long-running process that monitors pipeline health, kills stalled runs, and auto-restarts.
6792
+
6793
+ \`\`\`
6794
+ substrate auto supervisor [options]
6795
+ \`\`\`
6796
+
6797
+ Options:
6798
+ - \`--poll-interval <seconds>\` — Health check interval (default: 60)
6799
+ - \`--stall-threshold <seconds>\` — Staleness before killing (default: 600)
6800
+ - \`--max-restarts <n>\` — Maximum restart attempts (default: 3)
6801
+ - \`--output-format <format>\` — Output format: human (default) or json
6802
+
6803
+ Exit codes: 0 = all succeeded, 1 = failures/escalations, 2 = max restarts exceeded.
6804
+
6805
+ ### substrate auto metrics
6806
+ Show historical pipeline run metrics and cross-run comparison.
6807
+
6808
+ \`\`\`
6809
+ substrate auto metrics [options]
6810
+ \`\`\`
6811
+
6812
+ Options:
6813
+ - \`--limit <n>\` — Number of runs to show (default: 10)
6814
+ - \`--compare <run-id-a,run-id-b>\` — Compare two runs side-by-side (token, time, review cycle deltas)
6815
+ - \`--tag-baseline <run-id>\` — Mark a run as the performance baseline
6816
+ - \`--output-format <format>\` — Output format: human (default) or json
6817
+
6818
+ ### substrate auto health
6819
+ Check pipeline health, stall detection, and process status.
6820
+
6821
+ \`\`\`
6822
+ substrate auto health [--output-format json]
6823
+ \`\`\`
6789
6824
  `;
6790
6825
  }
6791
6826
  /**
@@ -7427,7 +7462,7 @@ const DEFAULT_TIMEOUTS = {
7427
7462
  "arch-decisions": 24e4,
7428
7463
  "arch-patterns": 24e4,
7429
7464
  "story-epics": 24e4,
7430
- "story-stories": 3e5
7465
+ "story-stories": 6e5
7431
7466
  };
7432
7467
  /**
7433
7468
  * Default max agentic turns per task type.
@@ -8385,6 +8420,12 @@ function writeStoryMetrics(db, input) {
8385
8420
  stmt.run(input.run_id, input.story_key, input.result, input.phase_durations_json ?? null, input.started_at ?? null, input.completed_at ?? null, input.wall_clock_seconds ?? 0, input.input_tokens ?? 0, input.output_tokens ?? 0, input.cost_usd ?? 0, input.review_cycles ?? 0, input.dispatches ?? 0);
8386
8421
  }
8387
8422
  /**
8423
+ * Get all story metrics for a given run.
8424
+ */
8425
+ function getStoryMetricsForRun(db, runId) {
8426
+ return db.prepare("SELECT * FROM story_metrics WHERE run_id = ? ORDER BY id ASC").all(runId);
8427
+ }
8428
+ /**
8388
8429
  * Compare two runs and return percentage deltas for key numeric fields.
8389
8430
  * Positive deltas mean run B is larger/longer than run A.
8390
8431
  * Returns null if either run does not exist.
@@ -8432,6 +8473,27 @@ function aggregateTokenUsageForRun(db, runId) {
8432
8473
  cost: 0
8433
8474
  };
8434
8475
  }
8476
+ /**
8477
+ * Aggregate token usage for a specific story within a pipeline run.
8478
+ * Matches rows where the metadata JSON contains the given storyKey.
8479
+ */
8480
+ function aggregateTokenUsageForStory(db, runId, storyKey) {
8481
+ const row = db.prepare(`
8482
+ SELECT
8483
+ COALESCE(SUM(input_tokens), 0) as input,
8484
+ COALESCE(SUM(output_tokens), 0) as output,
8485
+ COALESCE(SUM(cost_usd), 0) as cost
8486
+ FROM token_usage
8487
+ WHERE pipeline_run_id = ?
8488
+ AND metadata IS NOT NULL
8489
+ AND json_extract(metadata, '$.storyKey') = ?
8490
+ `).get(runId, storyKey);
8491
+ return row ?? {
8492
+ input: 0,
8493
+ output: 0,
8494
+ cost: 0
8495
+ };
8496
+ }
8435
8497
 
8436
8498
  //#endregion
8437
8499
  //#region src/modules/compiled-workflows/prompt-assembler.ts
@@ -10444,6 +10506,7 @@ function createImplementationOrchestrator(deps) {
10444
10506
  const startedAt = storyState?.startedAt;
10445
10507
  const completedAt = storyState?.completedAt ?? new Date().toISOString();
10446
10508
  const wallClockSeconds = startedAt ? Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1e3) : 0;
10509
+ const tokenAgg = aggregateTokenUsageForStory(db, config.pipelineRunId, storyKey);
10447
10510
  writeStoryMetrics(db, {
10448
10511
  run_id: config.pipelineRunId,
10449
10512
  story_key: storyKey,
@@ -10452,6 +10515,9 @@ function createImplementationOrchestrator(deps) {
10452
10515
  started_at: startedAt,
10453
10516
  completed_at: completedAt,
10454
10517
  wall_clock_seconds: wallClockSeconds,
10518
+ input_tokens: tokenAgg.input,
10519
+ output_tokens: tokenAgg.output,
10520
+ cost_usd: tokenAgg.cost,
10455
10521
  review_cycles: reviewCycles,
10456
10522
  dispatches: _storyDispatches.get(storyKey) ?? 0
10457
10523
  });
@@ -11885,7 +11951,7 @@ function createPhaseOrchestrator(deps) {
11885
11951
  * direction (step-runner.ts importing from a phase-specific module).
11886
11952
  */
11887
11953
  /** Absolute maximum prompt tokens (model context safety margin) */
11888
- const ABSOLUTE_MAX_PROMPT_TOKENS = 12e3;
11954
+ const ABSOLUTE_MAX_PROMPT_TOKENS = 2e4;
11889
11955
  /** Additional tokens per architecture decision injected into story generation prompt */
11890
11956
  const TOKENS_PER_DECISION = 100;
11891
11957
  /** Priority order for decision categories when summarizing (higher priority kept first) */
@@ -15728,6 +15794,19 @@ const BMAD_BASELINE_TOKENS = 23800;
15728
15794
  /** Story key pattern: <epic>-<story> e.g. "10-1" */
15729
15795
  const STORY_KEY_PATTERN = /^\d+-\d+$/;
15730
15796
  /**
15797
+ * Top-level keys in .claude/settings.json that substrate owns.
15798
+ * On init, these are set/updated unconditionally.
15799
+ * User-defined keys outside this set are never touched.
15800
+ */
15801
+ const SUBSTRATE_OWNED_SETTINGS_KEYS = ["statusLine"];
15802
+ function getSubstrateDefaultSettings() {
15803
+ return { statusLine: {
15804
+ type: "command",
15805
+ command: "bash \"$CLAUDE_PROJECT_DIR\"/.claude/statusline.sh",
15806
+ padding: 0
15807
+ } };
15808
+ }
15809
+ /**
15731
15810
  * Format output according to the requested format.
15732
15811
  */
15733
15812
  function formatOutput(data, format, success = true, errorMessage) {
@@ -16028,6 +16107,54 @@ async function scaffoldClaudeMd(projectRoot) {
16028
16107
  await writeFile(claudeMdPath, newContent, "utf8");
16029
16108
  logger$3.info({ claudeMdPath }, "Wrote substrate section to CLAUDE.md");
16030
16109
  }
16110
+ /**
16111
+ * Scaffold the statusline script from the bundled template.
16112
+ *
16113
+ * Always overwrites — substrate fully owns this file.
16114
+ */
16115
+ async function scaffoldStatuslineScript(projectRoot) {
16116
+ const pkgRoot = findPackageRoot(__dirname);
16117
+ const templateName = "statusline.sh";
16118
+ let templatePath = join(pkgRoot, "dist", "cli", "templates", templateName);
16119
+ if (!existsSync(templatePath)) templatePath = join(pkgRoot, "src", "cli", "templates", templateName);
16120
+ let content;
16121
+ try {
16122
+ content = await readFile(templatePath, "utf8");
16123
+ } catch {
16124
+ logger$3.warn({ templatePath }, "statusline.sh template not found; skipping");
16125
+ return;
16126
+ }
16127
+ const claudeDir = join(projectRoot, ".claude");
16128
+ const statuslinePath = join(claudeDir, "statusline.sh");
16129
+ mkdirSync(claudeDir, { recursive: true });
16130
+ await writeFile(statuslinePath, content, "utf8");
16131
+ chmodSync(statuslinePath, 493);
16132
+ logger$3.info({ statuslinePath }, "Wrote .claude/statusline.sh");
16133
+ }
16134
+ /**
16135
+ * Scaffold or merge .claude/settings.json with substrate-owned settings.
16136
+ *
16137
+ * Merge strategy:
16138
+ * - Keys in SUBSTRATE_OWNED_SETTINGS_KEYS are set/updated unconditionally.
16139
+ * - All other keys (permissions, hooks, etc.) are preserved as-is.
16140
+ * - $schema is added only if not already present.
16141
+ */
16142
+ async function scaffoldClaudeSettings(projectRoot) {
16143
+ const claudeDir = join(projectRoot, ".claude");
16144
+ const settingsPath = join(claudeDir, "settings.json");
16145
+ let existing = {};
16146
+ try {
16147
+ const raw = await readFile(settingsPath, "utf8");
16148
+ existing = JSON.parse(raw);
16149
+ } catch {}
16150
+ const defaults = getSubstrateDefaultSettings();
16151
+ const merged = { ...existing };
16152
+ for (const key of SUBSTRATE_OWNED_SETTINGS_KEYS) merged[key] = defaults[key];
16153
+ if (!merged["$schema"]) merged["$schema"] = "https://json.schemastore.org/claude-code-settings.json";
16154
+ mkdirSync(claudeDir, { recursive: true });
16155
+ await writeFile(settingsPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
16156
+ logger$3.info({ settingsPath }, "Wrote substrate settings to .claude/settings.json");
16157
+ }
16031
16158
  async function runAutoInit(options) {
16032
16159
  const { pack: packName, projectRoot, outputFormat, force = false } = options;
16033
16160
  const packPath = join(projectRoot, "packs", packName);
@@ -16076,6 +16203,8 @@ async function runAutoInit(options) {
16076
16203
  runMigrations(dbWrapper.db);
16077
16204
  dbWrapper.close();
16078
16205
  await scaffoldClaudeMd(projectRoot);
16206
+ await scaffoldStatuslineScript(projectRoot);
16207
+ await scaffoldClaudeSettings(projectRoot);
16079
16208
  const successMsg = `Pack '${packName}' and database initialized successfully at ${dbPath}`;
16080
16209
  if (outputFormat === "json") process.stdout.write(formatOutput({
16081
16210
  pack: packName,
@@ -16260,7 +16389,8 @@ async function runAutoRun(options) {
16260
16389
  agent: "claude-code",
16261
16390
  input_tokens: input,
16262
16391
  output_tokens: output,
16263
- cost_usd: costUsd
16392
+ cost_usd: costUsd,
16393
+ metadata: JSON.stringify({ storyKey: payload.storyKey })
16264
16394
  });
16265
16395
  }
16266
16396
  } catch (err) {
@@ -16526,10 +16656,13 @@ async function runAutoRun(options) {
16526
16656
  const runEndMs = Date.now();
16527
16657
  const runStartMs = new Date(pipelineRun.created_at).getTime();
16528
16658
  const tokenAgg = aggregateTokenUsageForRun(db, pipelineRun.id);
16659
+ const storyMetrics = getStoryMetricsForRun(db, pipelineRun.id);
16660
+ const totalReviewCycles = storyMetrics.reduce((sum, m) => sum + (m.review_cycles ?? 0), 0);
16661
+ const totalDispatches = storyMetrics.reduce((sum, m) => sum + (m.dispatches ?? 0), 0);
16529
16662
  writeRunMetrics(db, {
16530
16663
  run_id: pipelineRun.id,
16531
16664
  methodology: pack.manifest.name,
16532
- status: failedKeys.length > 0 ? "failed" : "completed",
16665
+ status: failedKeys.length > 0 || escalatedKeys.length > 0 ? "failed" : "completed",
16533
16666
  started_at: pipelineRun.created_at,
16534
16667
  completed_at: new Date().toISOString(),
16535
16668
  wall_clock_seconds: Math.round((runEndMs - runStartMs) / 1e3),
@@ -16540,6 +16673,8 @@ async function runAutoRun(options) {
16540
16673
  stories_succeeded: succeededKeys.length,
16541
16674
  stories_failed: failedKeys.length,
16542
16675
  stories_escalated: escalatedKeys.length,
16676
+ total_review_cycles: totalReviewCycles,
16677
+ total_dispatches: totalDispatches,
16543
16678
  concurrency_setting: concurrency
16544
16679
  });
16545
16680
  } catch (metricsErr) {
@@ -17573,7 +17708,7 @@ async function runAutoSupervisor(options, deps = {}) {
17573
17708
  if (health.verdict === "NO_PIPELINE_RUNNING") {
17574
17709
  const elapsedSeconds = Math.round((Date.now() - startTime) / 1e3);
17575
17710
  const succeeded = Object.entries(health.stories.details).filter(([, s]) => s.phase === "COMPLETE").map(([k]) => k);
17576
- const failed = Object.entries(health.stories.details).filter(([, s]) => s.phase !== "COMPLETE" && s.phase !== "PENDING").map(([k]) => k);
17711
+ const failed = Object.entries(health.stories.details).filter(([, s]) => s.phase !== "COMPLETE" && s.phase !== "PENDING" && s.phase !== "ESCALATED").map(([k]) => k);
17577
17712
  const escalated = Object.entries(health.stories.details).filter(([, s]) => s.phase === "ESCALATED").map(([k]) => k);
17578
17713
  emitEvent$1({
17579
17714
  type: "supervisor:summary",
@@ -17585,7 +17720,7 @@ async function runAutoSupervisor(options, deps = {}) {
17585
17720
  restarts: restartCount
17586
17721
  });
17587
17722
  log(`\nPipeline reached terminal state. Elapsed: ${elapsedSeconds}s | succeeded: ${succeeded.length} | failed: ${failed.length} | restarts: ${restartCount}`);
17588
- return failed.length > 0 ? 1 : 0;
17723
+ return failed.length > 0 || escalated.length > 0 ? 1 : 0;
17589
17724
  }
17590
17725
  if (health.staleness_seconds >= stallThreshold) {
17591
17726
  const pids = [...health.process.orchestrator_pid !== null ? [health.process.orchestrator_pid] : [], ...health.process.child_pids];
@@ -17637,22 +17772,23 @@ async function runAutoSupervisor(options, deps = {}) {
17637
17772
  attempt: restartCount
17638
17773
  });
17639
17774
  log(`Supervisor: Restarting pipeline (attempt ${restartCount}/${maxRestarts})`);
17640
- resumePipeline({
17641
- runId: health.run_id ?? void 0,
17642
- outputFormat,
17643
- projectRoot,
17644
- concurrency: 3,
17645
- pack
17646
- }).catch((err) => {
17775
+ try {
17776
+ await resumePipeline({
17777
+ runId: health.run_id ?? void 0,
17778
+ outputFormat,
17779
+ projectRoot,
17780
+ concurrency: 3,
17781
+ pack
17782
+ });
17783
+ } catch (err) {
17647
17784
  const message = err instanceof Error ? err.message : String(err);
17648
17785
  log(`Supervisor: Resume error: ${message}`);
17649
- if (outputFormat === "json") process.stderr.write(JSON.stringify({
17786
+ if (outputFormat === "json") emitEvent$1({
17650
17787
  type: "supervisor:error",
17651
17788
  reason: "resume_failed",
17652
- message,
17653
- ts: new Date().toISOString()
17654
- }) + "\n");
17655
- });
17789
+ message
17790
+ });
17791
+ }
17656
17792
  }
17657
17793
  await sleep(pollInterval * 1e3);
17658
17794
  }
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+ # Substrate AI — persistent status line
3
+ # Receives JSON on stdin with session metadata
4
+
5
+ input=$(cat)
6
+
7
+ MODEL=$(echo "$input" | jq -r '.model.display_name // "Claude"' 2>/dev/null)
8
+ PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' 2>/dev/null | cut -d. -f1)
9
+ COST=$(echo "$input" | jq -r '.session.cost // "0.00"' 2>/dev/null)
10
+ BRANCH=$(echo "$input" | jq -r '.git.branch // ""' 2>/dev/null)
11
+
12
+ BRANCH_PART=""
13
+ if [ -n "$BRANCH" ]; then
14
+ BRANCH_PART=" | $BRANCH"
15
+ fi
16
+
17
+ echo "⚡ substrate-ai | $MODEL | ctx ${PCT}% | \$${COST}${BRANCH_PART}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrate-ai",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Substrate — multi-agent orchestration daemon for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",