substrate-ai 0.5.1 → 0.5.2

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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { AdapterTelemetryPersistence, AppError, DEFAULT_CONFIG, DEFAULT_ROUTING_POLICY, DoltClient, DoltNotInstalled, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, FileStateStore, GitClient, GrammarLoader, IngestionServer, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SUBSTRATE_OWNED_SETTINGS_KEYS, SymbolParser, VALID_PHASES, buildPipelineStatusOutput, checkDoltInstalled, createConfigSystem, createContextCompiler, createDatabaseAdapter, createDispatcher, createDoltClient, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStateStore, createStopAfterGate, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, initSchema, initializeDolt, isSyncAdapter, parseDbTimestampAsUtc, registerHealthCommand, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-BD0Ugp7F.js";
2
+ import { AdapterTelemetryPersistence, AppError, DEFAULT_CONFIG, DEFAULT_ROUTING_POLICY, DoltClient, DoltNotInstalled, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, FileStateStore, GitClient, GrammarLoader, IngestionServer, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SUBSTRATE_OWNED_SETTINGS_KEYS, SymbolParser, VALID_PHASES, WorkGraphRepository, buildPipelineStatusOutput, checkDoltInstalled, createConfigSystem, createContextCompiler, createDatabaseAdapter, createDispatcher, createDoltClient, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStateStore, createStopAfterGate, detectCycles, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, initSchema, initializeDolt, isSyncAdapter, parseDbTimestampAsUtc, registerHealthCommand, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-CxoTrYdA.js";
3
3
  import { createLogger } from "../logger-D2fS2ccL.js";
4
4
  import { AdapterRegistry } from "../adapter-registry-BkUvZSKJ.js";
5
5
  import { CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, PartialSubstrateConfigSchema } from "../config-migrator-DtZW1maj.js";
@@ -1004,6 +1004,26 @@ function registerConfigCommand(program, _version) {
1004
1004
  });
1005
1005
  }
1006
1006
 
1007
+ //#endregion
1008
+ //#region src/modules/work-graph/errors.ts
1009
+ /**
1010
+ * Work-graph error types.
1011
+ *
1012
+ * Story 31-7: Cycle Detection in Work Graph
1013
+ */
1014
+ /**
1015
+ * Thrown by `EpicIngester.ingest()` when the provided dependency list
1016
+ * contains a cycle. The `cycle` field contains the path of story keys
1017
+ * that form the cycle (first and last element are the same).
1018
+ */
1019
+ var CyclicDependencyError = class extends Error {
1020
+ constructor(cycle) {
1021
+ super(`Cyclic dependency detected: ${cycle.join(" → ")}`);
1022
+ this.cycle = cycle;
1023
+ this.name = "CyclicDependencyError";
1024
+ }
1025
+ };
1026
+
1007
1027
  //#endregion
1008
1028
  //#region src/cli/commands/resume.ts
1009
1029
  const logger$16 = createLogger("resume-cmd");
@@ -1017,7 +1037,6 @@ function mapInternalPhaseToEventPhase(internalPhase) {
1017
1037
  case "IN_REVIEW": return "code-review";
1018
1038
  case "IN_MINOR_FIX":
1019
1039
  case "IN_MAJOR_FIX": return "fix";
1020
- case "IN_TEST_PLANNING": return "test-planning";
1021
1040
  default: return null;
1022
1041
  }
1023
1042
  }
@@ -1490,6 +1509,44 @@ async function runStatusAction(options) {
1490
1509
  });
1491
1510
  try {
1492
1511
  await initSchema(adapter);
1512
+ let workGraph;
1513
+ try {
1514
+ const wgRepo = new WorkGraphRepository(adapter);
1515
+ const allStories = await adapter.query(`SELECT story_key, title, status FROM wg_stories`);
1516
+ if (allStories.length > 0) {
1517
+ const readyStoriesRaw = await wgRepo.getReadyStories();
1518
+ const blockedStoriesRaw = await wgRepo.getBlockedStories();
1519
+ const readyKeys = new Set(readyStoriesRaw.map((s) => s.story_key));
1520
+ const blockedKeys = new Set(blockedStoriesRaw.map((b) => b.story.story_key));
1521
+ const inProgressCount = allStories.filter((s) => s.status === "in_progress").length;
1522
+ const completeCount = allStories.filter((s) => s.status === "complete").length;
1523
+ const escalatedCount = allStories.filter((s) => s.status === "escalated").length;
1524
+ workGraph = {
1525
+ summary: {
1526
+ ready: readyKeys.size,
1527
+ blocked: blockedKeys.size,
1528
+ inProgress: inProgressCount,
1529
+ complete: completeCount,
1530
+ escalated: escalatedCount
1531
+ },
1532
+ readyStories: readyStoriesRaw.map((s) => ({
1533
+ key: s.story_key,
1534
+ title: s.title ?? s.story_key
1535
+ })),
1536
+ blockedStories: blockedStoriesRaw.map((b) => ({
1537
+ key: b.story.story_key,
1538
+ title: b.story.title ?? b.story.story_key,
1539
+ blockers: b.blockers.map((bl) => ({
1540
+ key: bl.key,
1541
+ title: bl.title,
1542
+ status: bl.status
1543
+ }))
1544
+ }))
1545
+ };
1546
+ }
1547
+ } catch (err) {
1548
+ logger$15.debug({ err }, "Work graph query failed, continuing without work graph data");
1549
+ }
1493
1550
  let run;
1494
1551
  if (runId !== void 0 && runId !== "") run = await getPipelineRunById(adapter, runId);
1495
1552
  else run = await getLatestRun(adapter);
@@ -1557,7 +1614,8 @@ async function runStatusAction(options) {
1557
1614
  stories_per_hour: storiesPerHour,
1558
1615
  cost_usd: totalCostUsd
1559
1616
  },
1560
- story_states: storeStories
1617
+ story_states: storeStories,
1618
+ workGraph: workGraph ?? null
1561
1619
  };
1562
1620
  process.stdout.write(formatOutput(enhancedOutput, "json", true) + "\n");
1563
1621
  } else {
@@ -1603,6 +1661,22 @@ async function runStatusAction(options) {
1603
1661
  process.stdout.write("\nStateStore Story States:\n");
1604
1662
  for (const s of storeStories) process.stdout.write(` ${s.storyKey}: ${s.phase} (${s.reviewCycles} review cycles)\n`);
1605
1663
  }
1664
+ if (workGraph !== void 0) {
1665
+ const { summary, readyStories, blockedStories } = workGraph;
1666
+ process.stdout.write("\nWork Graph:\n");
1667
+ process.stdout.write(` ${summary.inProgress} in progress, ${summary.ready} ready, ${summary.blocked} blocked, ${summary.complete} complete, ${summary.escalated} escalated\n`);
1668
+ if (readyStories.length > 0) {
1669
+ process.stdout.write("\n Ready to dispatch:\n");
1670
+ for (const s of readyStories) process.stdout.write(` ${s.key}: ${s.title}\n`);
1671
+ }
1672
+ if (blockedStories.length > 0) {
1673
+ process.stdout.write("\n Blocked:\n");
1674
+ for (const b of blockedStories) {
1675
+ process.stdout.write(` ${b.key}: ${b.title}\n`);
1676
+ for (const bl of b.blockers) process.stdout.write(` waiting on ${bl.key} (${bl.status}): ${bl.title}\n`);
1677
+ }
1678
+ }
1679
+ }
1606
1680
  process.stdout.write("\n");
1607
1681
  process.stdout.write(formatTokenTelemetry(tokenSummary) + "\n");
1608
1682
  }
@@ -2849,7 +2923,7 @@ async function runSupervisorAction(options, deps = {}) {
2849
2923
  await initSchema(expAdapter);
2850
2924
  const { runRunAction: runPipeline } = await import(
2851
2925
  /* @vite-ignore */
2852
- "../run-B-TUWMCv.js"
2926
+ "../run-BSs4Dn0j.js"
2853
2927
  );
2854
2928
  const runStoryFn = async (opts) => {
2855
2929
  const exitCode = await runPipeline({
@@ -7964,6 +8038,8 @@ var EpicIngester = class {
7964
8038
  * @returns `IngestResult` with counts of affected rows.
7965
8039
  */
7966
8040
  async ingest(stories, dependencies) {
8041
+ const cycle = detectCycles(dependencies);
8042
+ if (cycle !== null) throw new CyclicDependencyError(cycle);
7967
8043
  return this.adapter.transaction(async (tx) => {
7968
8044
  let storiesUpserted = 0;
7969
8045
  for (const story of stories) {
@@ -8056,6 +8132,107 @@ function registerIngestEpicCommand(program) {
8056
8132
  });
8057
8133
  }
8058
8134
 
8135
+ //#endregion
8136
+ //#region src/cli/commands/epic-status.ts
8137
+ function sortByStoryKey(stories) {
8138
+ return [...stories].sort((a, b) => {
8139
+ const numA = parseInt(a.story_key.split("-")[1] ?? "0", 10);
8140
+ const numB = parseInt(b.story_key.split("-")[1] ?? "0", 10);
8141
+ return numA - numB;
8142
+ });
8143
+ }
8144
+ const BADGE_WIDTH = 12;
8145
+ const STATUS_LABELS = {
8146
+ complete: "complete ",
8147
+ in_progress: "in_progress",
8148
+ ready: "ready ",
8149
+ planned: "planned ",
8150
+ escalated: "escalated ",
8151
+ blocked: "blocked "
8152
+ };
8153
+ function getBadge(status, isBlocked) {
8154
+ if (isBlocked) return `[${STATUS_LABELS["blocked"] ?? "blocked "}]`;
8155
+ const label = STATUS_LABELS[status] ?? status.padEnd(BADGE_WIDTH - 2);
8156
+ return `[${label}]`;
8157
+ }
8158
+ async function runEpicStatusAction(epicNum, opts) {
8159
+ const adapter = createDatabaseAdapter({
8160
+ backend: "auto",
8161
+ basePath: process.cwd()
8162
+ });
8163
+ try {
8164
+ await adapter.exec(CREATE_STORIES_TABLE);
8165
+ await adapter.exec(CREATE_STORY_DEPENDENCIES_TABLE);
8166
+ const repo = new WorkGraphRepository(adapter);
8167
+ const rawStories = await repo.listStories({ epic: epicNum });
8168
+ if (rawStories.length === 0) {
8169
+ process.stderr.write(`No stories found for epic ${epicNum} (work graph not populated — run \`substrate ingest-epic\` first)\n`);
8170
+ process.exitCode = 1;
8171
+ return;
8172
+ }
8173
+ const stories = sortByStoryKey(rawStories);
8174
+ const allBlocked = await repo.getBlockedStories();
8175
+ const epicBlockedMap = new Map(allBlocked.filter((b) => b.story.epic === epicNum).map((b) => [b.story.story_key, b]));
8176
+ const allReady = await repo.getReadyStories();
8177
+ const epicReadySet = new Set(allReady.filter((s) => s.epic === epicNum).map((s) => s.story_key));
8178
+ const summary = {
8179
+ total: stories.length,
8180
+ complete: stories.filter((s) => s.status === "complete").length,
8181
+ inProgress: stories.filter((s) => s.status === "in_progress").length,
8182
+ escalated: stories.filter((s) => s.status === "escalated").length,
8183
+ blocked: epicBlockedMap.size,
8184
+ ready: epicReadySet.size - epicBlockedMap.size,
8185
+ planned: stories.filter((s) => (s.status === "planned" || s.status === "ready") && !epicBlockedMap.has(s.story_key) && !epicReadySet.has(s.story_key)).length
8186
+ };
8187
+ if (opts.outputFormat === "json") {
8188
+ const output = {
8189
+ epic: epicNum,
8190
+ stories: stories.map((s) => {
8191
+ const blockedInfo = epicBlockedMap.get(s.story_key);
8192
+ const entry = {
8193
+ key: s.story_key,
8194
+ title: s.title ?? null,
8195
+ status: blockedInfo ? "blocked" : s.status
8196
+ };
8197
+ if (blockedInfo) entry.blockers = blockedInfo.blockers.map((b) => ({
8198
+ key: b.key,
8199
+ title: b.title,
8200
+ status: b.status
8201
+ }));
8202
+ return entry;
8203
+ }),
8204
+ summary
8205
+ };
8206
+ process.stdout.write(JSON.stringify(output, null, 2) + "\n");
8207
+ } else {
8208
+ process.stdout.write(`Epic ${epicNum} — ${stories.length} stories\n\n`);
8209
+ for (const story of stories) {
8210
+ const isBlocked = epicBlockedMap.has(story.story_key);
8211
+ const badge = getBadge(story.status, isBlocked);
8212
+ const keyPadded = story.story_key.padEnd(6);
8213
+ const displayTitle = story.title ?? story.story_key;
8214
+ let line = ` ${badge} ${keyPadded} ${displayTitle}`;
8215
+ if (isBlocked) {
8216
+ const blockedInfo = epicBlockedMap.get(story.story_key);
8217
+ const blockerList = blockedInfo.blockers.map((b) => `${b.key} (${b.status})`).join(", ");
8218
+ line += ` [waiting on: ${blockerList}]`;
8219
+ }
8220
+ process.stdout.write(line + "\n");
8221
+ }
8222
+ process.stdout.write("\n");
8223
+ process.stdout.write(`Epic ${epicNum}: ${summary.complete} complete · ${summary.inProgress} in_progress · ${summary.ready} ready · ${summary.blocked} blocked · ${summary.planned} planned · ${summary.escalated} escalated\n`);
8224
+ }
8225
+ } finally {
8226
+ await adapter.close();
8227
+ }
8228
+ }
8229
+ function registerEpicStatusCommand(program) {
8230
+ program.command("epic-status <epic>").description("Show a generated status view of an epic from the Dolt work graph").option("--output-format <format>", "Output format: human (default) or json", "human").action(async (epic, options) => {
8231
+ const fmt = options.outputFormat === "json" ? "json" : "human";
8232
+ await runEpicStatusAction(epic, { outputFormat: fmt });
8233
+ });
8234
+ }
8235
+
8059
8236
  //#endregion
8060
8237
  //#region src/cli/index.ts
8061
8238
  process.setMaxListeners(20);
@@ -8117,6 +8294,7 @@ async function createProgram() {
8117
8294
  registerBrainstormCommand(program, version);
8118
8295
  registerExportCommand(program, version);
8119
8296
  registerIngestEpicCommand(program);
8297
+ registerEpicStatusCommand(program);
8120
8298
  registerUpgradeCommand(program);
8121
8299
  return program;
8122
8300
  }
@@ -1,4 +1,4 @@
1
- import { registerRunCommand, runRunAction } from "./run-BD0Ugp7F.js";
1
+ import { registerRunCommand, runRunAction } from "./run-CxoTrYdA.js";
2
2
  import "./logger-D2fS2ccL.js";
3
3
  import "./config-migrator-DtZW1maj.js";
4
4
  import "./helpers-BihqWgVe.js";
@@ -215,6 +215,24 @@ var DoltDatabaseAdapter = class {
215
215
  async close() {
216
216
  await this._client.close();
217
217
  }
218
+ /**
219
+ * Query story keys from the `ready_stories` SQL view.
220
+ *
221
+ * Returns story keys whose status is `planned` or `ready` and whose
222
+ * hard dependencies are all `complete` in the work graph.
223
+ *
224
+ * On any SQL error (e.g., view not yet created by story 31-1 schema,
225
+ * or empty stories table), returns `[]` so the caller falls through to
226
+ * the legacy discovery chain.
227
+ */
228
+ async queryReadyStories() {
229
+ try {
230
+ const rows = await this._client.query("SELECT `key` FROM ready_stories ORDER BY `key` ASC", void 0);
231
+ return rows.map((r) => r.key);
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
218
236
  };
219
237
 
220
238
  //#endregion
@@ -242,6 +260,13 @@ var InMemoryDatabaseAdapter = class {
242
260
  async close() {
243
261
  this._tables.clear();
244
262
  }
263
+ /**
264
+ * Work graph not supported in InMemoryDatabaseAdapter.
265
+ * Returns `[]` to signal the caller to use the legacy discovery path.
266
+ */
267
+ async queryReadyStories() {
268
+ return [];
269
+ }
245
270
  _execute(sql, params) {
246
271
  const resolved = this._substituteParams(sql, params);
247
272
  const upper = resolved.trimStart().toUpperCase();
@@ -281,8 +306,8 @@ var InMemoryDatabaseAdapter = class {
281
306
  if (m) this._tables.delete(m[1]);
282
307
  return [];
283
308
  }
284
- _insert(sql) {
285
- const m = /INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\((.+)\)\s*$/is.exec(sql);
309
+ _insert(sql, _ignoreConflicts = false) {
310
+ const m = /INSERT\s+(?:IGNORE\s+)?INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\((.+)\)\s*$/is.exec(sql);
286
311
  if (!m) return [];
287
312
  const tableName = m[1];
288
313
  const cols = m[2].split(",").map((c) => c.trim());
@@ -7198,6 +7223,259 @@ function createDispatcher(options) {
7198
7223
  return new DispatcherImpl(options.eventBus, options.adapterRegistry, config);
7199
7224
  }
7200
7225
 
7226
+ //#endregion
7227
+ //#region src/modules/work-graph/cycle-detector.ts
7228
+ /**
7229
+ * detectCycles — DFS-based cycle detection for story dependency graphs.
7230
+ *
7231
+ * Story 31-7: Cycle Detection in Work Graph
7232
+ *
7233
+ * Pure function; no database or I/O dependencies.
7234
+ */
7235
+ /**
7236
+ * Detect cycles in a directed dependency graph represented as an edge list.
7237
+ *
7238
+ * Each edge `{ story_key, depends_on }` means story_key depends on depends_on
7239
+ * (i.e. story_key → depends_on is the directed edge we traverse).
7240
+ *
7241
+ * Uses iterative DFS with an explicit stack to avoid call-stack overflows on
7242
+ * large graphs, but also supports a nested recursive helper for cycle path
7243
+ * reconstruction.
7244
+ *
7245
+ * @param edges - List of dependency edges to check.
7246
+ * @returns `null` if the graph is acyclic (safe to persist), or a `string[]`
7247
+ * containing the cycle path with the first and last element being the same
7248
+ * story key (e.g. `['A', 'B', 'A']`).
7249
+ */
7250
+ function detectCycles(edges) {
7251
+ const adj = new Map();
7252
+ for (const { story_key, depends_on } of edges) {
7253
+ if (!adj.has(story_key)) adj.set(story_key, []);
7254
+ adj.get(story_key).push(depends_on);
7255
+ }
7256
+ const visited = new Set();
7257
+ const visiting = new Set();
7258
+ const path$2 = [];
7259
+ function dfs(node) {
7260
+ if (visiting.has(node)) {
7261
+ const cycleStart = path$2.indexOf(node);
7262
+ return [...path$2.slice(cycleStart), node];
7263
+ }
7264
+ if (visited.has(node)) return null;
7265
+ visiting.add(node);
7266
+ path$2.push(node);
7267
+ for (const neighbor of adj.get(node) ?? []) {
7268
+ const cycle = dfs(neighbor);
7269
+ if (cycle !== null) return cycle;
7270
+ }
7271
+ path$2.pop();
7272
+ visiting.delete(node);
7273
+ visited.add(node);
7274
+ return null;
7275
+ }
7276
+ const allNodes = new Set([...edges.map((e) => e.story_key), ...edges.map((e) => e.depends_on)]);
7277
+ for (const node of allNodes) if (!visited.has(node)) {
7278
+ const cycle = dfs(node);
7279
+ if (cycle !== null) return cycle;
7280
+ }
7281
+ return null;
7282
+ }
7283
+
7284
+ //#endregion
7285
+ //#region src/modules/state/work-graph-repository.ts
7286
+ var WorkGraphRepository = class {
7287
+ constructor(db) {
7288
+ this.db = db;
7289
+ }
7290
+ /**
7291
+ * Insert or replace a work-graph story node.
7292
+ * Uses DELETE + INSERT so it works on InMemoryDatabaseAdapter (which does
7293
+ * not support ON DUPLICATE KEY UPDATE).
7294
+ */
7295
+ async upsertStory(story) {
7296
+ await this.db.query(`DELETE FROM wg_stories WHERE story_key = ?`, [story.story_key]);
7297
+ await this.db.query(`INSERT INTO wg_stories (story_key, epic, title, status, spec_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
7298
+ story.story_key,
7299
+ story.epic,
7300
+ story.title ?? null,
7301
+ story.status,
7302
+ story.spec_path ?? null,
7303
+ story.created_at ?? null,
7304
+ story.updated_at ?? null,
7305
+ story.completed_at ?? null
7306
+ ]);
7307
+ }
7308
+ /**
7309
+ * Insert a dependency edge. Idempotent — if a row with the same
7310
+ * (story_key, depends_on) already exists it is silently skipped.
7311
+ */
7312
+ async addDependency(dep) {
7313
+ const existing = await this.db.query(`SELECT story_key FROM story_dependencies WHERE story_key = ? AND depends_on = ?`, [dep.story_key, dep.depends_on]);
7314
+ if (existing.length > 0) return;
7315
+ await this.db.query(`INSERT INTO story_dependencies (story_key, depends_on, dependency_type, source, created_at) VALUES (?, ?, ?, ?, ?)`, [
7316
+ dep.story_key,
7317
+ dep.depends_on,
7318
+ dep.dependency_type,
7319
+ dep.source,
7320
+ dep.created_at ?? null
7321
+ ]);
7322
+ }
7323
+ /**
7324
+ * Persist contract-based dependency edges to `story_dependencies` as
7325
+ * best-effort, idempotent writes.
7326
+ *
7327
+ * - edges where `reason` does NOT start with `'dual export:'` are persisted
7328
+ * as `dependency_type = 'blocks'` (hard prerequisites).
7329
+ * - edges where `reason` starts with `'dual export:'` are persisted as
7330
+ * `dependency_type = 'informs'` (serialization hints, not hard gates).
7331
+ *
7332
+ * Idempotency is delegated to `addDependency()`, which skips the INSERT if
7333
+ * a row with the same `(story_key, depends_on)` already exists.
7334
+ *
7335
+ * @param edges - Readonly list of contract dependency edges to persist.
7336
+ */
7337
+ async addContractDependencies(edges) {
7338
+ if (edges.length === 0) return;
7339
+ for (const edge of edges) {
7340
+ const dependency_type = edge.reason?.startsWith("dual export:") ? "informs" : "blocks";
7341
+ await this.addDependency({
7342
+ story_key: edge.to,
7343
+ depends_on: edge.from,
7344
+ dependency_type,
7345
+ source: "contract",
7346
+ created_at: new Date().toISOString()
7347
+ });
7348
+ }
7349
+ }
7350
+ /**
7351
+ * Return all work-graph stories, optionally filtered by epic and/or status.
7352
+ */
7353
+ async listStories(filter$1) {
7354
+ if (!filter$1 || !filter$1.epic && !filter$1.status) return this.db.query(`SELECT * FROM wg_stories`);
7355
+ const conditions = [];
7356
+ const params = [];
7357
+ if (filter$1.epic) {
7358
+ conditions.push(`epic = ?`);
7359
+ params.push(filter$1.epic);
7360
+ }
7361
+ if (filter$1.status) {
7362
+ conditions.push(`status = ?`);
7363
+ params.push(filter$1.status);
7364
+ }
7365
+ const where = conditions.join(" AND ");
7366
+ return this.db.query(`SELECT * FROM wg_stories WHERE ${where}`, params);
7367
+ }
7368
+ /**
7369
+ * Update the `status` (and optionally `completed_at`) of an existing
7370
+ * work-graph story.
7371
+ *
7372
+ * This is a read-modify-write operation: SELECT existing row → build
7373
+ * updated WgStory → upsertStory(). If no row exists for `storyKey` the
7374
+ * call is a no-op (AC4).
7375
+ *
7376
+ * @param storyKey - Story identifier, e.g. "31-4"
7377
+ * @param status - Target WgStoryStatus value
7378
+ * @param opts - Optional `completedAt` ISO string for terminal phases
7379
+ */
7380
+ async updateStoryStatus(storyKey, status, opts) {
7381
+ const rows = await this.db.query(`SELECT * FROM wg_stories WHERE story_key = ?`, [storyKey]);
7382
+ if (rows.length === 0) return;
7383
+ const existing = rows[0];
7384
+ const now = new Date().toISOString();
7385
+ const isTerminal = status === "complete" || status === "escalated";
7386
+ const updated = {
7387
+ ...existing,
7388
+ status,
7389
+ updated_at: now,
7390
+ completed_at: isTerminal ? opts?.completedAt ?? now : existing.completed_at
7391
+ };
7392
+ await this.upsertStory(updated);
7393
+ }
7394
+ /**
7395
+ * Return stories that are eligible for dispatch.
7396
+ *
7397
+ * A story is ready when:
7398
+ * 1. Its status is 'planned' or 'ready', AND
7399
+ * 2. It has no 'blocks' dependency whose blocking story is not 'complete'.
7400
+ *
7401
+ * Soft ('informs') dependencies never block dispatch.
7402
+ *
7403
+ * This is implemented programmatically rather than via the `ready_stories`
7404
+ * VIEW so that the InMemoryDatabaseAdapter can handle it without VIEW support.
7405
+ */
7406
+ async getReadyStories() {
7407
+ const allStories = await this.db.query(`SELECT * FROM wg_stories`);
7408
+ const candidates = allStories.filter((s) => s.status === "planned" || s.status === "ready");
7409
+ if (candidates.length === 0) return [];
7410
+ const deps = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
7411
+ if (deps.length === 0) return candidates;
7412
+ const blockerStatus = new Map(allStories.map((s) => [s.story_key, s.status]));
7413
+ const depsMap = new Map();
7414
+ for (const d of deps) {
7415
+ if (!depsMap.has(d.story_key)) depsMap.set(d.story_key, []);
7416
+ depsMap.get(d.story_key).push(d.depends_on);
7417
+ }
7418
+ return candidates.filter((s) => {
7419
+ const blocking = depsMap.get(s.story_key) ?? [];
7420
+ return blocking.every((dep) => blockerStatus.get(dep) === "complete");
7421
+ });
7422
+ }
7423
+ /**
7424
+ * Return stories that are planned/ready but cannot be dispatched because
7425
+ * at least one hard-blocking ('blocks') dependency is not yet complete.
7426
+ *
7427
+ * For each blocked story, the returned object includes the full WgStory
7428
+ * record plus the list of unsatisfied blockers (key, title, status).
7429
+ *
7430
+ * Soft ('informs') dependencies are ignored here, matching getReadyStories().
7431
+ */
7432
+ /**
7433
+ * Query the database for all 'blocks' dependency rows and run DFS cycle
7434
+ * detection over them.
7435
+ *
7436
+ * Returns an empty array if no cycle is found (consistent with other
7437
+ * repository methods that return empty arrays rather than null).
7438
+ *
7439
+ * Only 'blocks' deps are checked — soft 'informs' deps cannot cause
7440
+ * dispatch deadlocks (AC5).
7441
+ */
7442
+ async detectCycles() {
7443
+ const rows = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
7444
+ const cycle = detectCycles(rows);
7445
+ return cycle ?? [];
7446
+ }
7447
+ async getBlockedStories() {
7448
+ const allStories = await this.db.query(`SELECT * FROM wg_stories`);
7449
+ const candidates = allStories.filter((s) => s.status === "planned" || s.status === "ready");
7450
+ if (candidates.length === 0) return [];
7451
+ const deps = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
7452
+ if (deps.length === 0) return [];
7453
+ const statusMap = new Map(allStories.map((s) => [s.story_key, s]));
7454
+ const depsMap = new Map();
7455
+ for (const d of deps) {
7456
+ if (!depsMap.has(d.story_key)) depsMap.set(d.story_key, []);
7457
+ depsMap.get(d.story_key).push(d.depends_on);
7458
+ }
7459
+ const result = [];
7460
+ for (const story of candidates) {
7461
+ const blockerKeys = depsMap.get(story.story_key) ?? [];
7462
+ const unsatisfied = blockerKeys.filter((key) => statusMap.get(key)?.status !== "complete").map((key) => {
7463
+ const s = statusMap.get(key);
7464
+ return {
7465
+ key,
7466
+ title: s?.title ?? key,
7467
+ status: s?.status ?? "unknown"
7468
+ };
7469
+ });
7470
+ if (unsatisfied.length > 0) result.push({
7471
+ story,
7472
+ blockers: unsatisfied
7473
+ });
7474
+ }
7475
+ return result;
7476
+ }
7477
+ };
7478
+
7201
7479
  //#endregion
7202
7480
  //#region src/modules/state/file-store.ts
7203
7481
  /**
@@ -9186,6 +9464,42 @@ function countFilesInLayout(content) {
9186
9464
  return count;
9187
9465
  }
9188
9466
 
9467
+ //#endregion
9468
+ //#region src/modules/work-graph/spec-migrator.ts
9469
+ /**
9470
+ * spec-migrator — utilities for migrating story spec files away from the
9471
+ * deprecated `Status:` frontmatter field.
9472
+ *
9473
+ * Story 31-8: Deprecate Status Field in Story Spec Frontmatter
9474
+ *
9475
+ * Story status is now exclusively managed in the Dolt work graph
9476
+ * (`wg_stories.status`). These pure functions strip the deprecated field from
9477
+ * spec content before it is injected into agent prompts.
9478
+ */
9479
+ /**
9480
+ * Remove the deprecated `Status:` line from story spec content.
9481
+ * Also removes the blank line immediately following the Status line.
9482
+ * Returns the original content unchanged if no Status line is present.
9483
+ *
9484
+ * The regex is anchored at the start of a line (`^` with multiline flag) so
9485
+ * it does NOT strip lines like `## Status Notes` or `The status is good`.
9486
+ */
9487
+ function stripDeprecatedStatusField(content) {
9488
+ return content.replace(/^Status:[^\n]*\n?(\n)?/m, "");
9489
+ }
9490
+ /**
9491
+ * Detect whether a story spec contains the deprecated Status field.
9492
+ * Returns the status value string (e.g. `'ready-for-dev'`) if found, or
9493
+ * `null` if absent.
9494
+ *
9495
+ * The regex is anchored at line start so incidental uses of the word "Status"
9496
+ * (e.g. in section headings) are not matched.
9497
+ */
9498
+ function detectDeprecatedStatusField(content) {
9499
+ const match$1 = /^Status:\s*(.+)$/m.exec(content);
9500
+ return match$1 !== null ? match$1[1].trim() : null;
9501
+ }
9502
+
9189
9503
  //#endregion
9190
9504
  //#region src/modules/compiled-workflows/dev-story.ts
9191
9505
  const logger$15 = createLogger("compiled-workflows:dev-story");
@@ -9294,6 +9608,14 @@ async function runDevStory(deps, params) {
9294
9608
  }, "Story file is empty");
9295
9609
  return makeFailureResult("story_file_empty");
9296
9610
  }
9611
+ const staleStatus = detectDeprecatedStatusField(storyContent);
9612
+ if (staleStatus !== null) {
9613
+ logger$15.warn({
9614
+ storyFilePath,
9615
+ staleStatus
9616
+ }, "Story spec contains deprecated Status field — stripped before dispatch (status is managed by Dolt work graph)");
9617
+ storyContent = stripDeprecatedStatusField(storyContent);
9618
+ }
9297
9619
  const complexity = computeStoryComplexity(storyContent);
9298
9620
  const resolvedMaxTurns = resolveDevStoryMaxTurns(complexity.complexityScore);
9299
9621
  logComplexityResult(storyKey, complexity, resolvedMaxTurns);
@@ -14451,6 +14773,22 @@ function buildTargetedFilesContent(issueList) {
14451
14773
  return lines.join("\n");
14452
14774
  }
14453
14775
  /**
14776
+ * Map a StoryPhase to the corresponding WgStoryStatus for wg_stories writes.
14777
+ * Returns null for PENDING (no write needed).
14778
+ */
14779
+ function wgStatusForPhase(phase) {
14780
+ switch (phase) {
14781
+ case "PENDING": return null;
14782
+ case "IN_STORY_CREATION":
14783
+ case "IN_TEST_PLANNING":
14784
+ case "IN_DEV":
14785
+ case "IN_REVIEW":
14786
+ case "NEEDS_FIXES": return "in_progress";
14787
+ case "COMPLETE": return "complete";
14788
+ case "ESCALATED": return "escalated";
14789
+ }
14790
+ }
14791
+ /**
14454
14792
  * Factory function that creates an ImplementationOrchestrator instance.
14455
14793
  *
14456
14794
  * @param deps - Injected dependencies (db, pack, contextCompiler, dispatcher,
@@ -14460,6 +14798,8 @@ function buildTargetedFilesContent(issueList) {
14460
14798
  function createImplementationOrchestrator(deps) {
14461
14799
  const { db, pack, contextCompiler, dispatcher, eventBus, config, projectRoot, tokenCeilings, stateStore, telemetryPersistence, ingestionServer, repoMapInjector, maxRepoMapTokens } = deps;
14462
14800
  const logger$26 = createLogger("implementation-orchestrator");
14801
+ const wgRepo = new WorkGraphRepository(db);
14802
+ const _wgInProgressWritten = new Set();
14463
14803
  let _state = "IDLE";
14464
14804
  let _startedAt;
14465
14805
  let _completedAt;
@@ -14730,6 +15070,21 @@ function createImplementationOrchestrator(deps) {
14730
15070
  err,
14731
15071
  storyKey
14732
15072
  }, "rollbackStory failed — branch may persist"));
15073
+ if (updates.phase !== void 0) {
15074
+ const targetStatus = wgStatusForPhase(updates.phase);
15075
+ if (targetStatus !== null) if (targetStatus === "in_progress" && _wgInProgressWritten.has(storyKey)) {} else {
15076
+ const fullUpdated = {
15077
+ ...existing,
15078
+ ...updates
15079
+ };
15080
+ const opts = targetStatus === "complete" || targetStatus === "escalated" ? { completedAt: fullUpdated.completedAt } : void 0;
15081
+ wgRepo.updateStoryStatus(storyKey, targetStatus, opts).catch((err) => logger$26.warn({
15082
+ err,
15083
+ storyKey
15084
+ }, "wg_stories status update failed (best-effort)"));
15085
+ if (targetStatus === "in_progress") _wgInProgressWritten.add(storyKey);
15086
+ }
15087
+ }
14733
15088
  }
14734
15089
  }
14735
15090
  /**
@@ -16210,6 +16565,7 @@ function createImplementationOrchestrator(deps) {
16210
16565
  contractEdges,
16211
16566
  edgeCount: contractEdges.length
16212
16567
  }, "Contract dependency edges detected — applying contract-aware dispatch ordering");
16568
+ wgRepo.addContractDependencies(contractEdges).catch((err) => logger$26.warn({ err }, "contract dep persistence failed (best-effort)"));
16213
16569
  logger$26.info({
16214
16570
  storyCount: storyKeys.length,
16215
16571
  groupCount: batches.reduce((sum, b) => sum + b.length, 0),
@@ -16370,9 +16726,10 @@ function createImplementationOrchestrator(deps) {
16370
16726
  //#endregion
16371
16727
  //#region src/modules/implementation-orchestrator/story-discovery.ts
16372
16728
  /**
16373
- * Unified story key resolution with a 4-level fallback chain.
16729
+ * Unified story key resolution with a 5-level fallback chain.
16374
16730
  *
16375
16731
  * 1. Explicit keys (from --stories flag) — returned as-is
16732
+ * 1.5. ready_stories SQL view — when work graph is populated (story 31-3)
16376
16733
  * 2. Decisions table (category='stories', phase='solutioning')
16377
16734
  * 3. Epic shard decisions (category='epic-shard') — parsed with parseStoryKeysFromEpics
16378
16735
  * 4. epics.md file on disk (via discoverPendingStoryKeys)
@@ -16384,6 +16741,19 @@ function createImplementationOrchestrator(deps) {
16384
16741
  async function resolveStoryKeys(db, projectRoot, opts) {
16385
16742
  if (opts?.explicit !== void 0 && opts.explicit.length > 0) return opts.explicit;
16386
16743
  let keys = [];
16744
+ const readyKeys = await db.queryReadyStories();
16745
+ if (readyKeys.length > 0) {
16746
+ let filteredKeys = readyKeys;
16747
+ if (opts?.epicNumber !== void 0) {
16748
+ const prefix = `${opts.epicNumber}-`;
16749
+ filteredKeys = filteredKeys.filter((k) => k.startsWith(prefix));
16750
+ }
16751
+ if (opts?.filterCompleted === true && filteredKeys.length > 0) {
16752
+ const completedKeys = await getCompletedStoryKeys(db);
16753
+ filteredKeys = filteredKeys.filter((k) => !completedKeys.has(k));
16754
+ }
16755
+ return sortStoryKeys([...new Set(filteredKeys)]);
16756
+ }
16387
16757
  try {
16388
16758
  const sql = opts?.pipelineRunId !== void 0 ? `SELECT key FROM decisions WHERE phase = 'solutioning' AND category = 'stories' AND pipeline_run_id = ? ORDER BY created_at ASC` : `SELECT key FROM decisions WHERE phase = 'solutioning' AND category = 'stories' ORDER BY created_at ASC`;
16389
16759
  const params = opts?.pipelineRunId !== void 0 ? [opts.pipelineRunId] : [];
@@ -21750,5 +22120,5 @@ function registerRunCommand(program, _version = "0.0.0", projectRoot = process.c
21750
22120
  }
21751
22121
 
21752
22122
  //#endregion
21753
- export { AdapterTelemetryPersistence, AppError, DEFAULT_CONFIG, DEFAULT_ROUTING_POLICY, DoltClient, DoltNotInstalled, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, FileStateStore, GitClient, GrammarLoader, IngestionServer, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SUBSTRATE_OWNED_SETTINGS_KEYS, SymbolParser, VALID_PHASES, buildPipelineStatusOutput, checkDoltInstalled, createConfigSystem, createContextCompiler, createDatabaseAdapter, createDispatcher, createDoltClient, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStateStore, createStopAfterGate, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, initSchema, initializeDolt, isSyncAdapter, parseDbTimestampAsUtc, registerHealthCommand, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
21754
- //# sourceMappingURL=run-BD0Ugp7F.js.map
22123
+ export { AdapterTelemetryPersistence, AppError, DEFAULT_CONFIG, DEFAULT_ROUTING_POLICY, DoltClient, DoltNotInstalled, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, FileStateStore, GitClient, GrammarLoader, IngestionServer, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SUBSTRATE_OWNED_SETTINGS_KEYS, SymbolParser, VALID_PHASES, WorkGraphRepository, buildPipelineStatusOutput, checkDoltInstalled, createConfigSystem, createContextCompiler, createDatabaseAdapter, createDispatcher, createDoltClient, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStateStore, createStopAfterGate, detectCycles, findPackageRoot, formatOutput, formatPhaseCompletionSummary, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, initSchema, initializeDolt, isSyncAdapter, parseDbTimestampAsUtc, registerHealthCommand, registerRunCommand, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveMainRepoRoot, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
22124
+ //# sourceMappingURL=run-CxoTrYdA.js.map
package/dist/schema.sql CHANGED
@@ -258,11 +258,10 @@ CREATE INDEX IF NOT EXISTS idx_wg_stories_epic ON wg_stories (epic);
258
258
  -- story_dependencies (Epic 31-1) — directed dependency edges
259
259
  -- ---------------------------------------------------------------------------
260
260
  CREATE TABLE IF NOT EXISTS story_dependencies (
261
- story_key VARCHAR(20) NOT NULL,
262
- depends_on VARCHAR(20) NOT NULL,
263
- dep_type VARCHAR(20) NOT NULL,
264
- source VARCHAR(20) NOT NULL,
265
- created_at DATETIME,
261
+ story_key VARCHAR(50) NOT NULL,
262
+ depends_on VARCHAR(50) NOT NULL,
263
+ dependency_type VARCHAR(50) NOT NULL DEFAULT 'blocks',
264
+ source VARCHAR(50) NOT NULL DEFAULT 'explicit',
266
265
  PRIMARY KEY (story_key, depends_on)
267
266
  );
268
267
 
@@ -276,7 +275,7 @@ CREATE OR REPLACE VIEW ready_stories AS
276
275
  SELECT 1 FROM story_dependencies d
277
276
  JOIN wg_stories dep ON dep.story_key = d.depends_on
278
277
  WHERE d.story_key = s.story_key
279
- AND d.dep_type = 'blocks'
278
+ AND d.dependency_type = 'blocks'
280
279
  AND dep.status <> 'complete'
281
280
  );
282
281
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrate-ai",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Substrate — multi-agent orchestration daemon for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@ Using the context above, write a complete, implementation-ready story file for s
32
32
  - Dev Notes with file paths, import patterns, testing requirements
33
33
  5. **Apply the scope cap** — see Scope Cap Guidance below
34
34
  6. **Write the story file** to: `_bmad-output/implementation-artifacts/{{story_key}}-<kebab-title>.md`
35
- - Status must be: `ready-for-dev`
35
+ - Do NOT add a `Status:` field to the story file — story status is managed exclusively by the Dolt work graph (`wg_stories` table)
36
36
  - Dev Agent Record section must be present but left blank (to be filled by dev agent)
37
37
 
38
38
  ## Interface Contracts Guidance
@@ -1,7 +1,5 @@
1
1
  # Story {epic_num}.{story_num}: {Title}
2
2
 
3
- Status: draft
4
-
5
3
  ## Story
6
4
 
7
5
  As a {role},