substrate-ai 0.20.64 → 0.20.65

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.
Files changed (35) hide show
  1. package/dist/adapter-registry-BbVWH3Yv.js +4 -0
  2. package/dist/cli/index.js +93 -24
  3. package/dist/{decision-router-BA__VYIp.js → decision-router-DblHY8se.js} +1 -1
  4. package/dist/{decisions-4F91LrVD.js → decisions-DilHo99V.js} +2 -2
  5. package/dist/{dist-W2emvN3F.js → dist-K_RRWnBX.js} +2 -2
  6. package/dist/{errors-CKFu8YI9.js → errors-pSiZbn6e.js} +2 -2
  7. package/dist/{experimenter-BgpUcUaW.js → experimenter-DT9v2Pto.js} +1 -1
  8. package/dist/health-DC3y-sR6.js +1715 -0
  9. package/dist/health-qhtWYh49.js +8 -0
  10. package/dist/index-c924O9mj.d.ts +1432 -0
  11. package/dist/index.d.ts +56 -735
  12. package/dist/index.js +2 -2
  13. package/dist/interactive-prompt-C7wpE4z4.js +183 -0
  14. package/dist/{health-DudlnqXd.js → manifest-read-DDkXC3L_.js} +120 -2012
  15. package/dist/modules/interactive-prompt/index.d.ts +86 -0
  16. package/dist/modules/interactive-prompt/index.js +6 -0
  17. package/dist/recovery-engine-BKGBeBnW.js +281 -0
  18. package/dist/{routing-0ykvBl_4.js → routing-CzF0p6lI.js} +2 -2
  19. package/dist/run-DX95j4_D.js +14 -0
  20. package/dist/{run-CCxsv-9M.js → run-DzB4rgkj.js} +224 -31
  21. package/dist/src/modules/decision-router/index.js +1 -1
  22. package/dist/src/modules/recovery-engine/index.d.ts +1101 -0
  23. package/dist/src/modules/recovery-engine/index.js +5 -0
  24. package/dist/{upgrade-OFeC_NIx.js → upgrade-DxzQ1nss.js} +3 -3
  25. package/dist/{upgrade-aW7GYL2F.js → upgrade-MP9XzrI6.js} +2 -2
  26. package/dist/version-manager-impl-GZDUBt0Q.js +4 -0
  27. package/dist/work-graph-repository-DZyJv5pV.js +265 -0
  28. package/package.json +1 -1
  29. package/dist/adapter-registry-k7ZX3Bz6.js +0 -4
  30. package/dist/health-CLNmnZiw.js +0 -6
  31. package/dist/run-ChxsPICN.js +0 -10
  32. package/dist/version-manager-impl-BCSf5E3j.js +0 -4
  33. /package/dist/{decisions-C0pz9Clx.js → decisions-CzSIEeGP.js} +0 -0
  34. /package/dist/{routing-CcBOCuC9.js → routing-DFxoKHDt.js} +0 -0
  35. /package/dist/{version-manager-impl-FH4TTnXm.js → version-manager-impl-qFBiO4Eh.js} +0 -0
@@ -1,1533 +1,62 @@
1
1
  import { createLogger } from "./logger-KeHncl-f.js";
2
- import { DoltClient, DoltQueryError, LEARNING_FINDING, createDatabaseAdapter$1 as createDatabaseAdapter, createDecision, getDecisionsByCategory, getLatestRun, getPipelineRunById, initSchema } from "./dist-W2emvN3F.js";
3
- import { createRequire } from "module";
2
+ import { LEARNING_FINDING, createDecision, getDecisionsByCategory } from "./dist-K_RRWnBX.js";
4
3
  import * as path$1 from "path";
5
- import { dirname, join } from "path";
4
+ import { join } from "path";
6
5
  import { readFile } from "fs/promises";
7
6
  import { EventEmitter } from "node:events";
8
7
  import { YAMLException, load } from "js-yaml";
9
8
  import { existsSync, promises, readFileSync, readdirSync, statSync } from "node:fs";
10
- import { execSync, spawn, spawnSync } from "node:child_process";
9
+ import { execSync, spawn } from "node:child_process";
11
10
  import * as path$2 from "node:path";
12
11
  import { basename as basename$1, dirname as dirname$1, join as join$1, resolve as resolve$1 } from "node:path";
13
12
  import { z } from "zod";
14
- import { mkdir as mkdir$1, open, readFile as readFile$1, unlink, writeFile as writeFile$1 } from "node:fs/promises";
13
+ import { mkdir as mkdir$1, open, readFile as readFile$1, unlink as unlink$1, writeFile as writeFile$1 } from "node:fs/promises";
15
14
  import * as fs from "fs";
16
- import { existsSync as existsSync$1 } from "fs";
17
- import { createRequire as createRequire$1 } from "node:module";
15
+ import { createRequire } from "node:module";
18
16
  import { fileURLToPath } from "node:url";
19
17
  import { execSync as execSync$1 } from "child_process";
20
18
 
21
- //#region rolldown:runtime
22
- var __create = Object.create;
23
- var __defProp = Object.defineProperty;
24
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
25
- var __getOwnPropNames = Object.getOwnPropertyNames;
26
- var __getProtoOf = Object.getPrototypeOf;
27
- var __hasOwnProp = Object.prototype.hasOwnProperty;
28
- var __commonJS = (cb, mod) => function() {
29
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
30
- };
31
- var __copyProps = (to, from, except, desc) => {
32
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
33
- key = keys[i];
34
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
35
- get: ((k) => from[k]).bind(null, key),
36
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
37
- });
38
- }
39
- return to;
40
- };
41
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
42
- value: mod,
43
- enumerable: true
44
- }) : target, mod));
45
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
46
-
47
- //#endregion
48
- //#region src/utils/git-root.ts
49
- /**
50
- * Resolve the main git repository root, even from a linked worktree.
51
- *
52
- * In the main worktree, `--git-common-dir` returns `.git` (relative).
53
- * In a linked worktree, it returns the absolute path to the main `.git` dir.
54
- * Either way, `dirname()` of the resolved absolute path yields the repo root.
55
- *
56
- * Falls back to `cwd` if not in a git repo or git is unavailable.
57
- */
58
- async function resolveMainRepoRoot(cwd = process.cwd()) {
59
- return new Promise((res) => {
60
- let stdout = "";
61
- const proc = spawn("git", ["rev-parse", "--git-common-dir"], {
62
- cwd,
63
- stdio: [
64
- "ignore",
65
- "pipe",
66
- "pipe"
67
- ]
68
- });
69
- if (proc.stdout !== null) proc.stdout.on("data", (chunk) => {
70
- stdout += chunk.toString("utf-8");
71
- });
72
- proc.on("error", () => {
73
- res(cwd);
74
- });
75
- proc.on("close", (code) => {
76
- if (code !== 0) {
77
- res(cwd);
78
- return;
79
- }
80
- const commonDir = stdout.trim();
81
- if (!commonDir) {
82
- res(cwd);
83
- return;
84
- }
85
- const absCommonDir = resolve$1(cwd, commonDir);
86
- res(dirname$1(absCommonDir));
87
- });
88
- });
89
- }
90
-
91
- //#endregion
92
- //#region src/persistence/adapter.ts
93
- /**
94
- * Create a DatabaseAdapter for the specified (or auto-detected) backend.
95
- *
96
- * This shim wraps the core factory and injects the concrete DoltClient
97
- * constructor as the doltClientFactory parameter, so monolith callers
98
- * get Dolt support transparently.
99
- */
100
- function createDatabaseAdapter$1(config) {
101
- return createDatabaseAdapter(config, (repoPath) => new DoltClient({ repoPath }));
102
- }
103
-
104
- //#endregion
105
- //#region src/modules/stop-after/types.ts
106
- /**
107
- * Stop-After Gate Module — Types
108
- *
109
- * Defines the PhaseName type and all parameter/result types for the stop-after gate.
110
- * VALID_PHASES is the canonical source for pipeline phase names; auto.ts imports from here.
111
- */
112
- /** Canonical pipeline phase names. This is the single source of truth for all phase lists. */
113
- const VALID_PHASES = [
114
- "research",
115
- "analysis",
116
- "planning",
117
- "solutioning",
118
- "implementation"
119
- ];
120
- /**
121
- * Alias for VALID_PHASES retained for backward compatibility with existing imports.
122
- * @deprecated Use VALID_PHASES directly.
123
- */
124
- const STOP_AFTER_VALID_PHASES = VALID_PHASES;
125
-
126
- //#endregion
127
- //#region src/cli/commands/pipeline-shared.ts
128
- /**
129
- * Parse a DB timestamp string to a Date, correctly treating it as UTC.
130
- *
131
- * SQLite stores timestamps as "YYYY-MM-DD HH:MM:SS" without a timezone suffix.
132
- * JavaScript's Date constructor parses strings without a timezone suffix as
133
- * *local time*, which causes staleness/duration to be calculated incorrectly
134
- * on machines not in UTC.
135
- *
136
- * Fix: append 'Z' if the string has no timezone marker so it is always
137
- * parsed as UTC.
138
- */
139
- function parseDbTimestampAsUtc(ts) {
140
- if (ts.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts);
141
- return new Date(ts.replace(" ", "T") + "Z");
142
- }
143
- const __filename = fileURLToPath(import.meta.url);
144
- const __dirname$1 = dirname(__filename);
145
- /**
146
- * Find the package root by walking up until we find package.json.
147
- * Works regardless of build output structure (tsdown bundles into
148
- * dist/cli/index.js, not dist/cli/commands/auto.js).
149
- */
150
- function findPackageRoot(startDir) {
151
- let dir = startDir;
152
- while (dir !== dirname(dir)) {
153
- if (existsSync$1(join(dir, "package.json"))) return dir;
154
- dir = dirname(dir);
155
- }
156
- return startDir;
157
- }
158
- const PACKAGE_ROOT = join(__dirname$1, "..", "..", "..");
159
- /**
160
- * Resolve the absolute path to the bmad-method package's src/ directory.
161
- * Uses createRequire so it works in ESM without import.meta.resolve polyfills.
162
- * Returns null if bmad-method is not installed.
163
- */
164
- function resolveBmadMethodSrcPath(fromDir = __dirname$1) {
165
- try {
166
- const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
167
- const pkgJsonPath = require$1.resolve("bmad-method/package.json");
168
- return join(dirname(pkgJsonPath), "src");
169
- } catch {
170
- return null;
171
- }
172
- }
173
- /**
174
- * Read the version field from bmad-method's package.json.
175
- * Returns 'unknown' if not resolvable.
176
- */
177
- function resolveBmadMethodVersion(fromDir = __dirname$1) {
178
- try {
179
- const require$1 = createRequire$1(join(fromDir, "synthetic.js"));
180
- const pkgJsonPath = require$1.resolve("bmad-method/package.json");
181
- const pkg = require$1(pkgJsonPath);
182
- return pkg.version ?? "unknown";
183
- } catch {
184
- return "unknown";
185
- }
186
- }
187
- /** BMAD baseline token total for full pipeline comparison (analysis+planning+solutioning+implementation) */
188
- const BMAD_BASELINE_TOKENS_FULL = 56800;
189
- /** BMAD baseline token total for create+dev+review comparison */
190
- const BMAD_BASELINE_TOKENS = 23800;
191
- /** Story key pattern: e.g. "10-1", "1-1a", "NEW-26", "E6" */
192
- const STORY_KEY_PATTERN$1 = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)?$/;
193
- /**
194
- * Top-level keys in .claude/settings.json that substrate owns.
195
- * On init, these are set/updated unconditionally.
196
- * User-defined keys outside this set are never touched.
197
- */
198
- const SUBSTRATE_OWNED_SETTINGS_KEYS = ["statusLine"];
199
- function getSubstrateDefaultSettings() {
200
- return { statusLine: {
201
- type: "command",
202
- command: "bash \"$CLAUDE_PROJECT_DIR\"/.claude/statusline.sh",
203
- padding: 0
204
- } };
205
- }
206
- /**
207
- * Format output according to the requested format.
208
- */
209
- function formatOutput(data, format, success = true, errorMessage) {
210
- if (format === "json") {
211
- if (!success) return JSON.stringify({
212
- success: false,
213
- error: errorMessage ?? "Unknown error"
214
- });
215
- return JSON.stringify({
216
- success: true,
217
- data
218
- });
219
- }
220
- if (typeof data === "string") return data;
221
- return JSON.stringify(data, null, 2);
222
- }
223
- /**
224
- * Build a human-readable token telemetry display from summary rows.
225
- */
226
- function formatTokenTelemetry(summary, baselineTokens = BMAD_BASELINE_TOKENS) {
227
- if (summary.length === 0) return "No token usage recorded.";
228
- let totalInput = 0;
229
- let totalOutput = 0;
230
- let totalCost = 0;
231
- const lines = ["Pipeline Token Usage:"];
232
- for (const row of summary) {
233
- totalInput += row.total_input_tokens;
234
- totalOutput += row.total_output_tokens;
235
- totalCost += row.total_cost_usd;
236
- const cost = `$${row.total_cost_usd.toFixed(4)}`;
237
- lines.push(` ${row.phase} (${row.agent}): ${row.total_input_tokens.toLocaleString()} input / ${row.total_output_tokens.toLocaleString()} output (${cost})`);
238
- }
239
- lines.push(" " + "─".repeat(55));
240
- const costDisplay = `$${totalCost.toFixed(4)}`;
241
- lines.push(` Total: ${totalInput.toLocaleString()} input / ${totalOutput.toLocaleString()} output (${costDisplay})`);
242
- const totalTokens = totalInput + totalOutput;
243
- const savingsPct = baselineTokens > 0 ? Math.round((baselineTokens - totalTokens) / baselineTokens * 100) : 0;
244
- const savingsLabel = savingsPct >= 0 ? `Savings: ${savingsPct}%` : `Overhead: +${Math.abs(savingsPct)}%`;
245
- lines.push(` BMAD Baseline: ${baselineTokens.toLocaleString()} tokens → ${savingsLabel}`);
246
- return lines.join("\n");
247
- }
248
- /**
249
- * Validate a story key has the expected format: <epic>-<story> (e.g., "10-1").
250
- */
251
- function validateStoryKey(key) {
252
- return STORY_KEY_PATTERN$1.test(key);
253
- }
254
- /**
255
- * Build the AC5 JSON status schema for a pipeline run.
256
- */
257
- function buildPipelineStatusOutput(run, tokenSummary, decisionsCount, storiesCount) {
258
- const phases = {};
259
- const phaseTokenMap = {};
260
- for (const row of tokenSummary) {
261
- if (!phaseTokenMap[row.phase]) phaseTokenMap[row.phase] = {
262
- input: 0,
263
- output: 0
264
- };
265
- phaseTokenMap[row.phase].input += row.total_input_tokens;
266
- phaseTokenMap[row.phase].output += row.total_output_tokens;
267
- }
268
- let phaseHistory = [];
269
- try {
270
- if (run.config_json) {
271
- const config = JSON.parse(run.config_json);
272
- phaseHistory = config.phaseHistory ?? [];
273
- }
274
- } catch {}
275
- const currentPhase = run.current_phase ?? null;
276
- for (const phaseName of VALID_PHASES) {
277
- const historyEntry = phaseHistory.find((h) => h.phase === phaseName);
278
- const tokenUsage = phaseTokenMap[phaseName] ?? {
279
- input: 0,
280
- output: 0
281
- };
282
- if (historyEntry?.completedAt) {
283
- phases[phaseName] = {
284
- status: "complete",
285
- completed_at: historyEntry.completedAt,
286
- token_usage: tokenUsage
287
- };
288
- if (historyEntry.startedAt) phases[phaseName].started_at = historyEntry.startedAt;
289
- } else if (phaseName === currentPhase || historyEntry?.startedAt) phases[phaseName] = {
290
- status: "running",
291
- started_at: historyEntry?.startedAt,
292
- token_usage: tokenUsage
293
- };
294
- else phases[phaseName] = { status: "pending" };
295
- }
296
- let totalInput = 0;
297
- let totalOutput = 0;
298
- let totalCost = 0;
299
- for (const row of tokenSummary) {
300
- totalInput += row.total_input_tokens;
301
- totalOutput += row.total_output_tokens;
302
- totalCost += row.total_cost_usd;
303
- }
304
- let activeDispatches = 0;
305
- let storiesSummary;
306
- try {
307
- if (run.token_usage_json) {
308
- const state = JSON.parse(run.token_usage_json);
309
- if (state.stories && Object.keys(state.stories).length > 0) {
310
- const now = Date.now();
311
- let completed = 0;
312
- let inProgress = 0;
313
- let escalated = 0;
314
- let pending = 0;
315
- const details = {};
316
- for (const [key, s] of Object.entries(state.stories)) {
317
- const phase = s.phase ?? "PENDING";
318
- if (phase !== "PENDING" && phase !== "COMPLETE" && phase !== "ESCALATED") activeDispatches++;
319
- if (phase === "COMPLETE") completed++;
320
- else if (phase === "ESCALATED") escalated++;
321
- else if (phase === "PENDING") pending++;
322
- else inProgress++;
323
- const elapsed = s.startedAt != null ? Math.max(0, Math.round((now - new Date(s.startedAt).getTime()) / 1e3)) : 0;
324
- details[key] = {
325
- phase,
326
- review_cycles: s.reviewCycles ?? 0,
327
- elapsed_seconds: elapsed
328
- };
329
- }
330
- storiesSummary = {
331
- completed,
332
- in_progress: inProgress,
333
- escalated,
334
- pending,
335
- details
336
- };
337
- }
338
- }
339
- } catch {}
340
- const derivedStoriesCount = storiesSummary !== void 0 ? storiesSummary.completed + storiesSummary.in_progress + storiesSummary.escalated + storiesSummary.pending : storiesCount;
341
- const derivedStoriesCompleted = storiesSummary !== void 0 ? storiesSummary.completed : 0;
342
- return {
343
- run_id: run.id,
344
- current_phase: currentPhase,
345
- phases,
346
- total_tokens: {
347
- input: totalInput,
348
- output: totalOutput,
349
- cost_usd: totalCost
350
- },
351
- decisions_count: decisionsCount,
352
- stories_count: derivedStoriesCount,
353
- stories_completed: derivedStoriesCompleted,
354
- last_activity: run.updated_at ?? "",
355
- staleness_seconds: Math.round((Date.now() - parseDbTimestampAsUtc(run.updated_at ?? "").getTime()) / 1e3),
356
- last_event_ts: run.updated_at ?? "",
357
- active_dispatches: activeDispatches,
358
- ...storiesSummary !== void 0 ? { stories: storiesSummary } : {}
359
- };
360
- }
361
- /**
362
- * Format a pipeline status summary in human-readable format.
363
- */
364
- function formatPipelineStatusHuman(status) {
365
- const lines = [];
366
- lines.push(`Pipeline Run: ${status.run_id}`);
367
- lines.push(` Current Phase: ${status.current_phase ?? "N/A"}`);
368
- lines.push("");
369
- lines.push(" Phase Status:");
370
- const statusIcons = {
371
- complete: "[DONE]",
372
- running: "[RUN] ",
373
- pending: "[ ]"
374
- };
375
- for (const [phaseName, phaseInfo] of Object.entries(status.phases)) {
376
- const icon = statusIcons[phaseInfo.status] ?? "[?]";
377
- let line = ` ${icon} ${phaseName}`;
378
- if (phaseInfo.status === "complete" && phaseInfo.completed_at) line += ` (completed: ${phaseInfo.completed_at})`;
379
- if (phaseInfo.token_usage && (phaseInfo.token_usage.input > 0 || phaseInfo.token_usage.output > 0)) line += ` — tokens: ${phaseInfo.token_usage.input.toLocaleString()} in / ${phaseInfo.token_usage.output.toLocaleString()} out`;
380
- lines.push(line);
381
- }
382
- lines.push("");
383
- lines.push(` Total Tokens: ${(status.total_tokens.input + status.total_tokens.output).toLocaleString()} (in: ${status.total_tokens.input.toLocaleString()}, out: ${status.total_tokens.output.toLocaleString()})`);
384
- lines.push(` Total Cost: $${status.total_tokens.cost_usd.toFixed(4)}`);
385
- lines.push(` Decisions: ${status.decisions_count}`);
386
- lines.push(` Stories: ${status.stories_count}`);
387
- if (status.stories !== void 0 && Object.keys(status.stories.details).length > 0) {
388
- lines.push("");
389
- lines.push(" Sprint Progress:");
390
- lines.push(" " + "─".repeat(68));
391
- lines.push(` ${"STORY".padEnd(10)} ${"PHASE".padEnd(24)} ${"CYCLES".padEnd(8)} ELAPSED`);
392
- lines.push(" " + "─".repeat(68));
393
- for (const [key, detail] of Object.entries(status.stories.details)) {
394
- const elapsed = detail.elapsed_seconds > 0 ? `${detail.elapsed_seconds}s` : "-";
395
- lines.push(` ${key.padEnd(10)} ${detail.phase.padEnd(24)} ${String(detail.review_cycles).padEnd(8)} ${elapsed}`);
396
- }
397
- lines.push(" " + "─".repeat(68));
398
- lines.push(` Completed: ${status.stories.completed} In Progress: ${status.stories.in_progress} Escalated: ${status.stories.escalated} Pending: ${status.stories.pending}`);
399
- }
400
- return lines.join("\n");
401
- }
402
- /**
403
- * Format a complete pipeline run summary.
404
- */
405
- function formatPipelineSummary(run, tokenSummary, decisionsCount, storiesCount, durationMs, format) {
406
- let totalInput = 0;
407
- let totalOutput = 0;
408
- let totalCost = 0;
409
- for (const row of tokenSummary) {
410
- totalInput += row.total_input_tokens;
411
- totalOutput += row.total_output_tokens;
412
- totalCost += row.total_cost_usd;
413
- }
414
- const totalTokens = totalInput + totalOutput;
415
- const savingsPct = BMAD_BASELINE_TOKENS_FULL > 0 ? Math.round((BMAD_BASELINE_TOKENS_FULL - totalTokens) / BMAD_BASELINE_TOKENS_FULL * 100) : 0;
416
- const durationSec = Math.round(durationMs / 1e3);
417
- if (format === "json") return JSON.stringify({
418
- run_id: run.id,
419
- status: run.status,
420
- duration_ms: durationMs,
421
- phases_completed: VALID_PHASES.length,
422
- decisions_count: decisionsCount,
423
- stories_count: storiesCount,
424
- token_usage: {
425
- input: totalInput,
426
- output: totalOutput,
427
- total: totalTokens,
428
- cost_usd: totalCost,
429
- bmad_baseline: BMAD_BASELINE_TOKENS_FULL,
430
- savings_pct: savingsPct
431
- }
432
- });
433
- const lines = [
434
- "┌─────────────────────────────────────────────────────┐",
435
- "│ Pipeline Run Summary │",
436
- "└─────────────────────────────────────────────────────┘",
437
- ` Run ID: ${run.id}`,
438
- ` Status: ${run.status}`,
439
- ` Duration: ${durationSec}s`,
440
- ` Phases Complete: ${VALID_PHASES.length}`,
441
- ` Decisions: ${decisionsCount}`,
442
- ` Stories: ${storiesCount}`,
443
- "",
444
- ` Token Usage: ${totalTokens.toLocaleString()} total`,
445
- ` Input: ${totalInput.toLocaleString()}`,
446
- ` Output: ${totalOutput.toLocaleString()}`,
447
- ` Cost: $${totalCost.toFixed(4)}`,
448
- "",
449
- ` BMAD Baseline: ${BMAD_BASELINE_TOKENS_FULL.toLocaleString()} tokens`,
450
- ` Token Savings: ${savingsPct >= 0 ? savingsPct + "%" : "N/A (overhead)"}`
451
- ];
452
- return lines.join("\n");
453
- }
454
-
455
- //#endregion
456
- //#region src/modules/work-graph/cycle-detector.ts
457
- /**
458
- * detectCycles — DFS-based cycle detection for story dependency graphs.
459
- *
460
- * Story 31-7: Cycle Detection in Work Graph
461
- *
462
- * Pure function; no database or I/O dependencies.
463
- */
464
- /**
465
- * Detect cycles in a directed dependency graph represented as an edge list.
466
- *
467
- * Each edge `{ story_key, depends_on }` means story_key depends on depends_on
468
- * (i.e. story_key → depends_on is the directed edge we traverse).
469
- *
470
- * Uses iterative DFS with an explicit stack to avoid call-stack overflows on
471
- * large graphs, but also supports a nested recursive helper for cycle path
472
- * reconstruction.
473
- *
474
- * @param edges - List of dependency edges to check.
475
- * @returns `null` if the graph is acyclic (safe to persist), or a `string[]`
476
- * containing the cycle path with the first and last element being the same
477
- * story key (e.g. `['A', 'B', 'A']`).
478
- */
479
- function detectCycles(edges) {
480
- const adj = new Map();
481
- for (const { story_key, depends_on } of edges) {
482
- if (!adj.has(story_key)) adj.set(story_key, []);
483
- adj.get(story_key).push(depends_on);
484
- }
485
- const visited = new Set();
486
- const visiting = new Set();
487
- const path$3 = [];
488
- function dfs(node) {
489
- if (visiting.has(node)) {
490
- const cycleStart = path$3.indexOf(node);
491
- return [...path$3.slice(cycleStart), node];
492
- }
493
- if (visited.has(node)) return null;
494
- visiting.add(node);
495
- path$3.push(node);
496
- for (const neighbor of adj.get(node) ?? []) {
497
- const cycle = dfs(neighbor);
498
- if (cycle !== null) return cycle;
499
- }
500
- path$3.pop();
501
- visiting.delete(node);
502
- visited.add(node);
503
- return null;
504
- }
505
- const allNodes = new Set([...edges.map((e) => e.story_key), ...edges.map((e) => e.depends_on)]);
506
- for (const node of allNodes) if (!visited.has(node)) {
507
- const cycle = dfs(node);
508
- if (cycle !== null) return cycle;
509
- }
510
- return null;
511
- }
512
-
513
- //#endregion
514
- //#region src/modules/state/work-graph-repository.ts
515
- var WorkGraphRepository = class {
516
- constructor(db) {
517
- this.db = db;
518
- }
519
- /**
520
- * Insert or replace a work-graph story node.
521
- * Uses DELETE + INSERT so it works on InMemoryDatabaseAdapter (which does
522
- * not support ON DUPLICATE KEY UPDATE).
523
- */
524
- async upsertStory(story) {
525
- await this.db.query(`DELETE FROM wg_stories WHERE story_key = ?`, [story.story_key]);
526
- await this.db.query(`INSERT INTO wg_stories (story_key, epic, title, status, spec_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
527
- story.story_key,
528
- story.epic,
529
- story.title ?? null,
530
- story.status,
531
- story.spec_path ?? null,
532
- story.created_at ?? null,
533
- story.updated_at ?? null,
534
- story.completed_at ?? null
535
- ]);
536
- }
537
- /**
538
- * Insert a dependency edge. Idempotent — if a row with the same
539
- * (story_key, depends_on) already exists it is silently skipped.
540
- */
541
- async addDependency(dep) {
542
- const existing = await this.db.query(`SELECT story_key FROM story_dependencies WHERE story_key = ? AND depends_on = ?`, [dep.story_key, dep.depends_on]);
543
- if (existing.length > 0) return;
544
- await this.db.query(`INSERT INTO story_dependencies (story_key, depends_on, dependency_type, source, created_at) VALUES (?, ?, ?, ?, ?)`, [
545
- dep.story_key,
546
- dep.depends_on,
547
- dep.dependency_type,
548
- dep.source,
549
- dep.created_at ?? null
550
- ]);
551
- }
552
- /**
553
- * Persist contract-based dependency edges to `story_dependencies` as
554
- * best-effort, idempotent writes.
555
- *
556
- * - edges where `reason` does NOT start with `'dual export:'` are persisted
557
- * as `dependency_type = 'blocks'` (hard prerequisites).
558
- * - edges where `reason` starts with `'dual export:'` are persisted as
559
- * `dependency_type = 'informs'` (serialization hints, not hard gates).
560
- *
561
- * Idempotency is delegated to `addDependency()`, which skips the INSERT if
562
- * a row with the same `(story_key, depends_on)` already exists.
563
- *
564
- * @param edges - Readonly list of contract dependency edges to persist.
565
- */
566
- async addContractDependencies(edges) {
567
- if (edges.length === 0) return;
568
- for (const edge of edges) {
569
- const dependency_type = edge.reason?.startsWith("dual export:") ? "informs" : "blocks";
570
- await this.addDependency({
571
- story_key: edge.to,
572
- depends_on: edge.from,
573
- dependency_type,
574
- source: "contract",
575
- created_at: new Date().toISOString()
576
- });
577
- }
578
- }
579
- /**
580
- * Return all work-graph stories, optionally filtered by epic and/or status.
581
- */
582
- async listStories(filter) {
583
- if (!filter || !filter.epic && !filter.status) return this.db.query(`SELECT * FROM wg_stories`);
584
- const conditions = [];
585
- const params = [];
586
- if (filter.epic) {
587
- conditions.push(`epic = ?`);
588
- params.push(filter.epic);
589
- }
590
- if (filter.status) {
591
- conditions.push(`status = ?`);
592
- params.push(filter.status);
593
- }
594
- const where = conditions.join(" AND ");
595
- return this.db.query(`SELECT * FROM wg_stories WHERE ${where}`, params);
596
- }
597
- /**
598
- * Update the `status` (and optionally `completed_at`) of an existing
599
- * work-graph story.
600
- *
601
- * This is a read-modify-write operation: SELECT existing row → build
602
- * updated WgStory → upsertStory(). If no row exists for `storyKey` the
603
- * call is a no-op (AC4).
604
- *
605
- * @param storyKey - Story identifier, e.g. "31-4"
606
- * @param status - Target WgStoryStatus value
607
- * @param opts - Optional `completedAt` ISO string for terminal phases
608
- */
609
- async updateStoryStatus(storyKey, status, opts) {
610
- const rows = await this.db.query(`SELECT * FROM wg_stories WHERE story_key = ?`, [storyKey]);
611
- if (rows.length === 0) return;
612
- const existing = rows[0];
613
- const now = new Date().toISOString();
614
- const isTerminal = status === "complete" || status === "escalated";
615
- const updated = {
616
- ...existing,
617
- status,
618
- updated_at: now,
619
- completed_at: isTerminal ? opts?.completedAt ?? now : existing.completed_at
620
- };
621
- await this.upsertStory(updated);
622
- }
623
- /**
624
- * Return stories that are eligible for dispatch.
625
- *
626
- * A story is ready when:
627
- * 1. Its status is 'planned' or 'ready', AND
628
- * 2. It has no 'blocks' dependency whose blocking story is not 'complete'.
629
- *
630
- * Soft ('informs') dependencies never block dispatch.
631
- *
632
- * This is implemented programmatically rather than via the `ready_stories`
633
- * VIEW so that the InMemoryDatabaseAdapter can handle it without VIEW support.
634
- */
635
- async getReadyStories() {
636
- const allStories = await this.db.query(`SELECT * FROM wg_stories`);
637
- const candidates = allStories.filter((s) => s.status === "planned" || s.status === "ready");
638
- if (candidates.length === 0) return [];
639
- const deps = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
640
- if (deps.length === 0) return candidates;
641
- const blockerStatus = new Map(allStories.map((s) => [s.story_key, s.status]));
642
- const depsMap = new Map();
643
- for (const d of deps) {
644
- if (!depsMap.has(d.story_key)) depsMap.set(d.story_key, []);
645
- depsMap.get(d.story_key).push(d.depends_on);
646
- }
647
- return candidates.filter((s) => {
648
- const blocking = depsMap.get(s.story_key) ?? [];
649
- return blocking.every((dep) => blockerStatus.get(dep) === "complete");
650
- });
651
- }
652
- /**
653
- * Return stories that are planned/ready but cannot be dispatched because
654
- * at least one hard-blocking ('blocks') dependency is not yet complete.
655
- *
656
- * For each blocked story, the returned object includes the full WgStory
657
- * record plus the list of unsatisfied blockers (key, title, status).
658
- *
659
- * Soft ('informs') dependencies are ignored here, matching getReadyStories().
660
- */
661
- /**
662
- * Query the database for all 'blocks' dependency rows and run DFS cycle
663
- * detection over them.
664
- *
665
- * Returns an empty array if no cycle is found (consistent with other
666
- * repository methods that return empty arrays rather than null).
667
- *
668
- * Only 'blocks' deps are checked — soft 'informs' deps cannot cause
669
- * dispatch deadlocks (AC5).
670
- */
671
- async detectCycles() {
672
- const rows = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
673
- const cycle = detectCycles(rows);
674
- return cycle ?? [];
675
- }
676
- async getBlockedStories() {
677
- const allStories = await this.db.query(`SELECT * FROM wg_stories`);
678
- const candidates = allStories.filter((s) => s.status === "planned" || s.status === "ready");
679
- if (candidates.length === 0) return [];
680
- const deps = await this.db.query(`SELECT story_key, depends_on FROM story_dependencies WHERE dependency_type = 'blocks'`);
681
- if (deps.length === 0) return [];
682
- const statusMap = new Map(allStories.map((s) => [s.story_key, s]));
683
- const depsMap = new Map();
684
- for (const d of deps) {
685
- if (!depsMap.has(d.story_key)) depsMap.set(d.story_key, []);
686
- depsMap.get(d.story_key).push(d.depends_on);
687
- }
688
- const result = [];
689
- for (const story of candidates) {
690
- const blockerKeys = depsMap.get(story.story_key) ?? [];
691
- const unsatisfied = blockerKeys.filter((key) => statusMap.get(key)?.status !== "complete").map((key) => {
692
- const s = statusMap.get(key);
693
- return {
694
- key,
695
- title: s?.title ?? key,
696
- status: s?.status ?? "unknown"
697
- };
698
- });
699
- if (unsatisfied.length > 0) result.push({
700
- story,
701
- blockers: unsatisfied
702
- });
703
- }
704
- return result;
705
- }
706
- };
707
-
708
- //#endregion
709
- //#region src/modules/state/file-store.ts
710
- /**
711
- * In-memory / file-backed StateStore implementation.
712
- *
713
- * Suitable for CI environments and testing where orchestrator state is
714
- * ephemeral. Use DoltStateStore for branch-per-story isolation and versioned
715
- * history in production.
716
- */
717
- var FileStateStore = class {
718
- _basePath;
719
- _stories = new Map();
720
- _metrics = [];
721
- _contracts = new Map();
722
- _contractVerifications = new Map();
723
- /** Key-value metrics store: outer key = runId, inner key = metric key */
724
- _kvMetrics = new Map();
725
- constructor(options = {}) {
726
- this._basePath = options.basePath;
727
- }
728
- async initialize() {}
729
- async close() {}
730
- async getStoryState(storyKey) {
731
- return this._stories.get(storyKey);
732
- }
733
- async setStoryState(storyKey, state) {
734
- this._stories.set(storyKey, {
735
- ...state,
736
- storyKey
737
- });
738
- }
739
- async queryStories(filter) {
740
- const all = Array.from(this._stories.values());
741
- return all.filter((record) => {
742
- if (filter.phase !== void 0) {
743
- const phases = Array.isArray(filter.phase) ? filter.phase : [filter.phase];
744
- if (!phases.includes(record.phase)) return false;
745
- }
746
- if (filter.sprint !== void 0 && record.sprint !== filter.sprint) return false;
747
- if (filter.storyKey !== void 0 && record.storyKey !== filter.storyKey) return false;
748
- return true;
749
- });
750
- }
751
- async recordMetric(metric) {
752
- const record = {
753
- ...metric,
754
- recordedAt: metric.recordedAt ?? new Date().toISOString()
755
- };
756
- this._metrics.push(record);
757
- }
758
- async queryMetrics(filter) {
759
- const storyKey = filter.storyKey ?? filter.story_key;
760
- const taskType = filter.taskType ?? filter.task_type;
761
- return this._metrics.filter((m) => {
762
- if (storyKey !== void 0 && m.storyKey !== storyKey) return false;
763
- if (taskType !== void 0 && m.taskType !== taskType) return false;
764
- if (filter.sprint !== void 0 && m.sprint !== filter.sprint) return false;
765
- if (filter.dateFrom !== void 0 && m.recordedAt !== void 0 && m.recordedAt < filter.dateFrom) return false;
766
- if (filter.dateTo !== void 0 && m.recordedAt !== void 0 && m.recordedAt > filter.dateTo) return false;
767
- if (filter.since !== void 0 && m.recordedAt !== void 0 && m.recordedAt < filter.since) return false;
768
- return true;
769
- });
770
- }
771
- /**
772
- * Persist an arbitrary key-value metric for a run.
773
- * Stored in memory AND written to `{basePath}/kv-metrics.json` when basePath is set.
774
- */
775
- async setMetric(runId, key, value) {
776
- let runMap = this._kvMetrics.get(runId);
777
- if (runMap === void 0) {
778
- runMap = new Map();
779
- this._kvMetrics.set(runId, runMap);
780
- }
781
- runMap.set(key, value);
782
- if (this._basePath !== void 0) await this._flushKvMetrics();
783
- }
784
- /**
785
- * Retrieve a previously stored key-value metric for a run.
786
- * Reads from in-memory cache, falling back to the JSON file when basePath is set.
787
- */
788
- async getMetric(runId, key) {
789
- const inMemory = this._kvMetrics.get(runId)?.get(key);
790
- if (inMemory !== void 0) return inMemory;
791
- if (this._basePath !== void 0) try {
792
- const filePath = join$1(this._basePath, "kv-metrics.json");
793
- const content = await readFile$1(filePath, "utf-8");
794
- const parsed = JSON.parse(content);
795
- return parsed[runId]?.[key] ?? void 0;
796
- } catch {}
797
- return void 0;
798
- }
799
- /** Serialize the in-memory kv metrics map to JSON on disk. */
800
- async _flushKvMetrics() {
801
- if (this._basePath === void 0) return;
802
- const serialized = {};
803
- for (const [runId, runMap] of this._kvMetrics) {
804
- serialized[runId] = {};
805
- for (const [key, value] of runMap) serialized[runId][key] = value;
806
- }
807
- const filePath = join$1(this._basePath, "kv-metrics.json");
808
- await writeFile$1(filePath, JSON.stringify(serialized, null, 2), "utf-8");
809
- }
810
- async getContracts(storyKey) {
811
- return this._contracts.get(storyKey) ?? [];
812
- }
813
- async setContracts(storyKey, contracts) {
814
- this._contracts.set(storyKey, contracts.map((c) => ({ ...c })));
815
- }
816
- async queryContracts(filter) {
817
- const all = [];
818
- for (const records of this._contracts.values()) for (const r of records) all.push(r);
819
- return all.filter((r) => {
820
- if (filter?.storyKey !== void 0 && r.storyKey !== filter.storyKey) return false;
821
- if (filter?.direction !== void 0 && r.direction !== filter.direction) return false;
822
- return true;
823
- });
824
- }
825
- async setContractVerification(storyKey, results) {
826
- this._contractVerifications.set(storyKey, results.map((r) => ({ ...r })));
827
- if (this._basePath !== void 0) {
828
- const serialized = {};
829
- for (const [key, records] of this._contractVerifications) serialized[key] = records;
830
- const filePath = join$1(this._basePath, "contract-verifications.json");
831
- await writeFile$1(filePath, JSON.stringify(serialized, null, 2), "utf-8");
832
- }
833
- }
834
- async getContractVerification(storyKey) {
835
- return this._contractVerifications.get(storyKey) ?? [];
836
- }
837
- async branchForStory(_storyKey) {}
838
- async mergeStory(_storyKey) {}
839
- async rollbackStory(_storyKey) {}
840
- async diffStory(storyKey) {
841
- return {
842
- storyKey,
843
- tables: []
844
- };
845
- }
846
- async getHistory(_limit) {
847
- return [];
848
- }
849
- };
850
-
851
- //#endregion
852
- //#region src/modules/state/errors.ts
853
- /**
854
- * Typed error classes for the Dolt state store.
855
- */
856
- var StateStoreError = class extends Error {
857
- code;
858
- constructor(code, message) {
859
- super(message);
860
- this.name = "StateStoreError";
861
- this.code = code;
862
- }
863
- };
864
- var DoltMergeConflictError = class extends StateStoreError {
865
- table;
866
- conflictingKeys;
867
- rowKey;
868
- ourValue;
869
- theirValue;
870
- constructor(table, conflictingKeys, options) {
871
- super("DOLT_MERGE_CONFLICT", `Merge conflict in table '${table}' on keys: ${conflictingKeys.join(", ")}`);
872
- this.name = "DoltMergeConflictError";
873
- this.table = table;
874
- this.conflictingKeys = conflictingKeys;
875
- if (options) {
876
- this.rowKey = options.rowKey;
877
- this.ourValue = options.ourValue;
878
- this.theirValue = options.theirValue;
879
- }
880
- }
881
- };
882
- /** Alias for DoltMergeConflictError — used by orchestrator branch lifecycle. */
883
- const DoltMergeConflict = DoltMergeConflictError;
884
-
885
- //#endregion
886
- //#region src/modules/state/dolt-store.ts
887
- const log = createLogger("modules:state:dolt");
888
- /**
889
- * Validate that a story key matches the expected pattern (e.g. "26-7", "1-1a", "NEW-26", "E6").
890
- * Prevents SQL injection via string-interpolated identifiers.
891
- */
892
- const STORY_KEY_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)?$/;
893
- function assertValidStoryKey(storyKey) {
894
- if (!STORY_KEY_PATTERN.test(storyKey)) throw new DoltQueryError("assertValidStoryKey", `Invalid story key: '${storyKey}'. Must match pattern <key> or <epic>-<story> (e.g. "E6", "10-1", "1-1a", "NEW-26").`);
895
- }
896
- /**
897
- * Dolt-backed implementation of the StateStore interface.
898
- *
899
- * Constructor accepts a deps object for DI: `{ repoPath, client }`.
900
- * Call `initialize()` before any CRUD operations.
901
- */
902
- var DoltStateStore = class DoltStateStore {
903
- _repoPath;
904
- _client;
905
- _storyBranches = new Map();
906
- constructor(options) {
907
- this._repoPath = options.repoPath;
908
- this._client = options.client;
909
- }
910
- /**
911
- * Return the branch name for a story if one has been created via branchForStory(),
912
- * or undefined to use the default (main) branch.
913
- */
914
- _branchFor(storyKey) {
915
- return this._storyBranches.get(storyKey);
916
- }
917
- async initialize() {
918
- await this._client.connect();
919
- await this._runMigrations();
920
- await this.flush("substrate: schema migrations");
921
- log.debug("DoltStateStore initialized at %s", this._repoPath);
922
- }
923
- async close() {
924
- await this._client.close();
925
- }
926
- async _runMigrations() {
927
- const ddl = [
928
- `CREATE TABLE IF NOT EXISTS stories (
929
- story_key VARCHAR(100) NOT NULL,
930
- phase VARCHAR(30) NOT NULL DEFAULT 'PENDING',
931
- review_cycles INT NOT NULL DEFAULT 0,
932
- last_verdict VARCHAR(64) NULL,
933
- error TEXT NULL,
934
- started_at VARCHAR(64) NULL,
935
- completed_at VARCHAR(64) NULL,
936
- sprint VARCHAR(50) NULL,
937
- PRIMARY KEY (story_key)
938
- )`,
939
- `CREATE TABLE IF NOT EXISTS metrics (
940
- id BIGINT NOT NULL AUTO_INCREMENT,
941
- story_key VARCHAR(100) NOT NULL,
942
- task_type VARCHAR(100) NOT NULL,
943
- model VARCHAR(100) NULL,
944
- tokens_in BIGINT NULL,
945
- tokens_out BIGINT NULL,
946
- cache_read_tokens BIGINT NULL,
947
- cost_usd DOUBLE NULL,
948
- wall_clock_ms BIGINT NULL,
949
- review_cycles INT NULL,
950
- stall_count INT NULL,
951
- result VARCHAR(30) NULL,
952
- recorded_at VARCHAR(64) NULL,
953
- sprint VARCHAR(50) NULL,
954
- PRIMARY KEY (id)
955
- )`,
956
- `CREATE TABLE IF NOT EXISTS contracts (
957
- story_key VARCHAR(100) NOT NULL,
958
- contract_name VARCHAR(200) NOT NULL,
959
- direction VARCHAR(20) NOT NULL,
960
- schema_path VARCHAR(500) NULL,
961
- transport VARCHAR(200) NULL,
962
- PRIMARY KEY (story_key, contract_name, direction)
963
- )`,
964
- `CREATE TABLE IF NOT EXISTS review_verdicts (
965
- id BIGINT NOT NULL AUTO_INCREMENT,
966
- story_key VARCHAR(100) NOT NULL,
967
- task_type VARCHAR(100) NOT NULL,
968
- verdict VARCHAR(64) NOT NULL,
969
- issues_count INT NULL,
970
- notes TEXT NULL,
971
- timestamp VARCHAR(64) NULL,
972
- PRIMARY KEY (id)
973
- )`
974
- ];
975
- for (const sql of ddl) await this._client.query(sql);
976
- try {
977
- const colRows = await this._client.query(`SHOW COLUMNS FROM repo_map_symbols LIKE 'dependencies'`);
978
- if (colRows.length === 0) {
979
- await this._client.query(`ALTER TABLE repo_map_symbols ADD COLUMN dependencies JSON`);
980
- await this._client.query(`INSERT IGNORE INTO _schema_version (version, description) VALUES (6, 'Add dependencies JSON column to repo_map_symbols (Epic 28-3)')`);
981
- log.info({
982
- component: "dolt-state",
983
- migration: "v5-to-v6",
984
- column: "dependencies",
985
- table: "repo_map_symbols"
986
- }, "Applied migration v5-to-v6: added dependencies column to repo_map_symbols");
987
- }
988
- } catch {
989
- log.debug("Skipping repo_map_symbols migration: table not yet created");
990
- }
991
- log.debug("Schema migrations applied");
992
- }
993
- /**
994
- * Commit pending Dolt changes on the current branch.
995
- * Callers can invoke this after a batch of writes for explicit durability.
996
- */
997
- async flush(message = "substrate: auto-commit") {
998
- try {
999
- await this._client.execArgs(["add", "."]);
1000
- await this._client.execArgs([
1001
- "commit",
1002
- "--allow-empty",
1003
- "-m",
1004
- message
1005
- ]);
1006
- log.debug("Dolt flush committed: %s", message);
1007
- } catch (err) {
1008
- const detail = err instanceof Error ? err.message : String(err);
1009
- log.warn({ detail }, "Dolt flush failed (non-fatal)");
1010
- }
1011
- }
1012
- async getStoryState(storyKey) {
1013
- const rows = await this._client.query("SELECT * FROM stories WHERE story_key = ?", [storyKey]);
1014
- if (rows.length === 0) return void 0;
1015
- return this._rowToStory(rows[0]);
1016
- }
1017
- async setStoryState(storyKey, state) {
1018
- const branch = this._branchFor(storyKey);
1019
- const sql = `REPLACE INTO stories
1020
- (story_key, phase, review_cycles, last_verdict, error, started_at, completed_at, sprint)
1021
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
1022
- await this._client.query(sql, [
1023
- storyKey,
1024
- state.phase,
1025
- state.reviewCycles,
1026
- state.lastVerdict ?? null,
1027
- state.error ?? null,
1028
- state.startedAt ?? null,
1029
- state.completedAt ?? null,
1030
- state.sprint ?? null
1031
- ], branch);
1032
- }
1033
- async queryStories(filter) {
1034
- const conditions = [];
1035
- const params = [];
1036
- if (filter.phase !== void 0) {
1037
- const phases = Array.isArray(filter.phase) ? filter.phase : [filter.phase];
1038
- const placeholders = phases.map(() => "?").join(", ");
1039
- conditions.push(`phase IN (${placeholders})`);
1040
- params.push(...phases);
1041
- }
1042
- if (filter.sprint !== void 0) {
1043
- conditions.push("sprint = ?");
1044
- params.push(filter.sprint);
1045
- }
1046
- if (filter.storyKey !== void 0) {
1047
- conditions.push("story_key = ?");
1048
- params.push(filter.storyKey);
1049
- }
1050
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1051
- const sql = `SELECT * FROM stories ${where} ORDER BY story_key`;
1052
- const rows = await this._client.query(sql, params);
1053
- return rows.map((r) => this._rowToStory(r));
1054
- }
1055
- _rowToStory(row) {
1056
- return {
1057
- storyKey: row.story_key,
1058
- phase: row.phase,
1059
- reviewCycles: Number(row.review_cycles),
1060
- lastVerdict: row.last_verdict ?? void 0,
1061
- error: row.error ?? void 0,
1062
- startedAt: row.started_at ?? void 0,
1063
- completedAt: row.completed_at ?? void 0,
1064
- sprint: row.sprint ?? void 0
1065
- };
1066
- }
1067
- async recordMetric(metric) {
1068
- const branch = this._branchFor(metric.storyKey);
1069
- const recordedAt = metric.recordedAt ?? metric.timestamp ?? new Date().toISOString();
1070
- const sql = `INSERT INTO metrics
1071
- (story_key, task_type, model, tokens_in, tokens_out, cache_read_tokens,
1072
- cost_usd, wall_clock_ms, review_cycles, stall_count, result, recorded_at, sprint)
1073
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
1074
- await this._client.query(sql, [
1075
- metric.storyKey,
1076
- metric.taskType,
1077
- metric.model ?? null,
1078
- metric.tokensIn ?? null,
1079
- metric.tokensOut ?? null,
1080
- metric.cacheReadTokens ?? null,
1081
- metric.costUsd ?? null,
1082
- metric.wallClockMs ?? null,
1083
- metric.reviewCycles ?? null,
1084
- metric.stallCount ?? null,
1085
- metric.result ?? null,
1086
- recordedAt,
1087
- metric.sprint ?? null
1088
- ], branch);
1089
- }
1090
- async queryMetrics(filter) {
1091
- const conditions = [];
1092
- const params = [];
1093
- const storyKey = filter.storyKey ?? filter.story_key;
1094
- const taskType = filter.taskType ?? filter.task_type;
1095
- if (storyKey !== void 0) {
1096
- conditions.push("story_key = ?");
1097
- params.push(storyKey);
1098
- }
1099
- if (taskType !== void 0) {
1100
- conditions.push("task_type = ?");
1101
- params.push(taskType);
1102
- }
1103
- if (filter.sprint !== void 0) {
1104
- conditions.push("sprint = ?");
1105
- params.push(filter.sprint);
1106
- }
1107
- if (filter.dateFrom !== void 0) {
1108
- conditions.push("recorded_at >= ?");
1109
- params.push(filter.dateFrom);
1110
- }
1111
- if (filter.dateTo !== void 0) {
1112
- conditions.push("recorded_at <= ?");
1113
- params.push(filter.dateTo);
1114
- }
1115
- if (filter.since !== void 0) {
1116
- conditions.push("recorded_at >= ?");
1117
- params.push(filter.since);
1118
- }
1119
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1120
- if (filter.aggregate) {
1121
- const sql$1 = `SELECT task_type,
1122
- AVG(cost_usd) AS avg_cost_usd,
1123
- SUM(tokens_in) AS sum_tokens_in,
1124
- SUM(tokens_out) AS sum_tokens_out,
1125
- COUNT(*) AS count
1126
- FROM metrics ${where} GROUP BY task_type ORDER BY task_type`;
1127
- const aggRows = await this._client.query(sql$1, params);
1128
- return aggRows.map((r) => this._aggregateRowToMetric(r));
1129
- }
1130
- const sql = `SELECT * FROM metrics ${where} ORDER BY id`;
1131
- const rows = await this._client.query(sql, params);
1132
- return rows.map((r) => this._rowToMetric(r));
1133
- }
1134
- _aggregateRowToMetric(row) {
1135
- return {
1136
- storyKey: "",
1137
- taskType: row.task_type,
1138
- costUsd: row.avg_cost_usd ?? void 0,
1139
- tokensIn: row.sum_tokens_in ?? void 0,
1140
- tokensOut: row.sum_tokens_out ?? void 0,
1141
- count: row.count,
1142
- result: "aggregate"
1143
- };
1144
- }
1145
- _rowToMetric(row) {
1146
- return {
1147
- storyKey: row.story_key,
1148
- taskType: row.task_type,
1149
- model: row.model ?? void 0,
1150
- tokensIn: row.tokens_in ?? void 0,
1151
- tokensOut: row.tokens_out ?? void 0,
1152
- cacheReadTokens: row.cache_read_tokens ?? void 0,
1153
- costUsd: row.cost_usd ?? void 0,
1154
- wallClockMs: row.wall_clock_ms ?? void 0,
1155
- reviewCycles: row.review_cycles ?? void 0,
1156
- stallCount: row.stall_count ?? void 0,
1157
- result: row.result ?? void 0,
1158
- recordedAt: row.recorded_at ?? void 0,
1159
- sprint: row.sprint ?? void 0,
1160
- timestamp: row.timestamp ?? row.recorded_at ?? void 0
1161
- };
1162
- }
1163
- async getContracts(storyKey) {
1164
- const rows = await this._client.query("SELECT * FROM contracts WHERE story_key = ? ORDER BY contract_name", [storyKey]);
1165
- return rows.map((r) => this._rowToContract(r));
1166
- }
1167
- async setContracts(storyKey, contracts) {
1168
- const branch = this._branchFor(storyKey);
1169
- await this._client.query("DELETE FROM contracts WHERE story_key = ?", [storyKey], branch);
1170
- for (const c of contracts) await this._client.query(`INSERT INTO contracts (story_key, contract_name, direction, schema_path, transport)
1171
- VALUES (?, ?, ?, ?, ?)`, [
1172
- c.storyKey,
1173
- c.contractName,
1174
- c.direction,
1175
- c.schemaPath,
1176
- c.transport ?? null
1177
- ], branch);
1178
- }
1179
- _rowToContract(row) {
1180
- return {
1181
- storyKey: row.story_key,
1182
- contractName: row.contract_name,
1183
- direction: row.direction,
1184
- schemaPath: row.schema_path,
1185
- transport: row.transport ?? void 0
1186
- };
1187
- }
1188
- async queryContracts(filter) {
1189
- const conditions = [];
1190
- const params = [];
1191
- if (filter?.storyKey !== void 0) {
1192
- conditions.push("story_key = ?");
1193
- params.push(filter.storyKey);
1194
- }
1195
- if (filter?.direction !== void 0) {
1196
- conditions.push("direction = ?");
1197
- params.push(filter.direction);
1198
- }
1199
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1200
- const sql = `SELECT * FROM contracts ${where} ORDER BY story_key, contract_name`;
1201
- const rows = await this._client.query(sql, params);
1202
- return rows.map((r) => this._rowToContract(r));
1203
- }
1204
- async setContractVerification(storyKey, results) {
1205
- const branch = this._branchFor(storyKey);
1206
- await this._client.query(`DELETE FROM review_verdicts WHERE story_key = ? AND task_type = 'contract-verification'`, [storyKey], branch);
1207
- const failCount = results.filter((r) => r.verdict === "fail").length;
1208
- for (const r of results) await this._client.query(`INSERT INTO review_verdicts (story_key, task_type, verdict, issues_count, notes, timestamp)
1209
- VALUES (?, 'contract-verification', ?, ?, ?, ?)`, [
1210
- storyKey,
1211
- r.verdict,
1212
- failCount,
1213
- JSON.stringify({
1214
- contractName: r.contractName,
1215
- mismatchDescription: r.mismatchDescription
1216
- }),
1217
- r.verifiedAt
1218
- ], branch);
1219
- }
1220
- async getContractVerification(storyKey) {
1221
- const rows = await this._client.query(`SELECT * FROM review_verdicts WHERE story_key = ? AND task_type = 'contract-verification' ORDER BY timestamp DESC`, [storyKey]);
1222
- return rows.map((row) => {
1223
- let contractName = "";
1224
- let mismatchDescription;
1225
- if (row.notes !== null) try {
1226
- const parsed = JSON.parse(row.notes);
1227
- if (typeof parsed.contractName === "string") contractName = parsed.contractName;
1228
- if (typeof parsed.mismatchDescription === "string") mismatchDescription = parsed.mismatchDescription;
1229
- } catch {}
1230
- return {
1231
- storyKey: row.story_key,
1232
- contractName,
1233
- verdict: row.verdict,
1234
- ...mismatchDescription !== void 0 ? { mismatchDescription } : {},
1235
- verifiedAt: row.timestamp ?? new Date().toISOString()
1236
- };
1237
- });
1238
- }
1239
- async branchForStory(storyKey) {
1240
- assertValidStoryKey(storyKey);
1241
- const branchName = `story/${storyKey}`;
1242
- try {
1243
- await this._client.query(`CALL DOLT_BRANCH('${branchName}')`, [], "main");
1244
- this._storyBranches.set(storyKey, branchName);
1245
- log.debug("Created Dolt branch %s for story %s", branchName, storyKey);
1246
- } catch (err) {
1247
- const detail = err instanceof Error ? err.message : String(err);
1248
- throw new DoltQueryError(`CALL DOLT_BRANCH('${branchName}')`, detail);
1249
- }
1250
- }
1251
- async mergeStory(storyKey) {
1252
- assertValidStoryKey(storyKey);
1253
- const branchName = this._storyBranches.get(storyKey);
1254
- if (branchName === void 0) {
1255
- log.warn({ storyKey }, "mergeStory called but no branch registered — no-op");
1256
- return;
1257
- }
1258
- try {
1259
- try {
1260
- await this._client.query(`CALL DOLT_ADD('-A')`, [], branchName);
1261
- await this._client.query(`CALL DOLT_COMMIT('-m', 'Story ${storyKey}: pre-merge commit', '--allow-empty')`, [], branchName);
1262
- } catch {}
1263
- try {
1264
- await this._client.query(`CALL DOLT_ADD('-A')`, [], "main");
1265
- await this._client.query(`CALL DOLT_COMMIT('-m', 'substrate: pre-merge auto-commit', '--allow-empty')`, [], "main");
1266
- } catch {}
1267
- const mergeRows = await this._client.query(`CALL DOLT_MERGE('${branchName}')`, [], "main");
1268
- const mergeResult = mergeRows[0];
1269
- if (mergeResult && (mergeResult.conflicts ?? 0) > 0) {
1270
- let table = "stories";
1271
- let rowKey = "unknown";
1272
- let ourValue;
1273
- let theirValue;
1274
- try {
1275
- const conflictRows = await this._client.query(`SELECT * FROM dolt_conflicts_stories LIMIT 1`, [], "main");
1276
- if (conflictRows.length > 0) {
1277
- const row = conflictRows[0];
1278
- rowKey = String(row["base_story_key"] ?? row["our_story_key"] ?? "unknown");
1279
- ourValue = JSON.stringify(row["our_status"] ?? row);
1280
- theirValue = JSON.stringify(row["their_status"] ?? row);
1281
- }
1282
- } catch {}
1283
- this._storyBranches.delete(storyKey);
1284
- throw new DoltMergeConflictError(table, [rowKey], {
1285
- rowKey,
1286
- ourValue,
1287
- theirValue
1288
- });
1289
- }
1290
- try {
1291
- await this._client.query(`CALL DOLT_COMMIT('-m', 'Merge story ${storyKey}: COMPLETE')`, [], "main");
1292
- } catch (commitErr) {
1293
- const msg = commitErr instanceof Error ? commitErr.message : String(commitErr);
1294
- if (!msg.includes("nothing to commit")) throw commitErr;
1295
- }
1296
- this._storyBranches.delete(storyKey);
1297
- log.debug("Merged branch %s into main for story %s", branchName, storyKey);
1298
- } catch (err) {
1299
- if (err instanceof DoltMergeConflictError) throw err;
1300
- const detail = err instanceof Error ? err.message : String(err);
1301
- throw new DoltQueryError(`CALL DOLT_MERGE('${branchName}')`, detail);
1302
- }
1303
- }
1304
- async rollbackStory(storyKey) {
1305
- assertValidStoryKey(storyKey);
1306
- const branchName = this._storyBranches.get(storyKey);
1307
- if (branchName === void 0) {
1308
- log.warn({ storyKey }, "rollbackStory called but no branch registered — no-op");
1309
- return;
1310
- }
1311
- try {
1312
- await this._client.query(`CALL DOLT_BRANCH('-D', '${branchName}')`, [], "main");
1313
- this._storyBranches.delete(storyKey);
1314
- log.debug("Rolled back (deleted) branch %s for story %s", branchName, storyKey);
1315
- } catch (err) {
1316
- const detail = err instanceof Error ? err.message : String(err);
1317
- log.warn({
1318
- detail,
1319
- storyKey,
1320
- branchName
1321
- }, "rollbackStory failed (non-fatal)");
1322
- this._storyBranches.delete(storyKey);
1323
- }
1324
- }
1325
- /**
1326
- * Tables queried by diffStory(). Each table is checked for row-level changes
1327
- * via SELECT * FROM DOLT_DIFF('main', branchName, tableName).
1328
- */
1329
- static DIFF_TABLES = [
1330
- "stories",
1331
- "contracts",
1332
- "metrics",
1333
- "dispatch_log",
1334
- "build_results",
1335
- "review_verdicts"
1336
- ];
1337
- async diffStory(storyKey) {
1338
- assertValidStoryKey(storyKey);
1339
- const branchName = this._storyBranches.get(storyKey);
1340
- if (branchName === void 0) return this._diffMergedStory(storyKey);
1341
- try {
1342
- await this._client.query(`CALL DOLT_ADD('-A')`, [], branchName);
1343
- await this._client.query(`CALL DOLT_COMMIT('-m', 'Story ${storyKey}: pre-diff snapshot', '--allow-empty')`, [], branchName);
1344
- } catch {}
1345
- return this._diffRange("main", branchName, storyKey);
1346
- }
1347
- /**
1348
- * Diff a merged story by finding its merge commit in the Dolt log.
1349
- * Queries the `dolt_log` system table for commits referencing the story,
1350
- * then diffs `<hash>~1` vs `<hash>` for row-level changes.
1351
- */
1352
- async _diffMergedStory(storyKey) {
1353
- try {
1354
- const rows = await this._client.query(`SELECT commit_hash FROM dolt_log WHERE message LIKE ? LIMIT 1`, [`%${storyKey}%`]);
1355
- if (rows.length === 0) return {
1356
- storyKey,
1357
- tables: []
1358
- };
1359
- const hash = String(rows[0].commit_hash);
1360
- if (!hash) return {
1361
- storyKey,
1362
- tables: []
1363
- };
1364
- return this._diffRange(`${hash}~1`, hash, storyKey);
1365
- } catch {
1366
- return {
1367
- storyKey,
1368
- tables: []
1369
- };
1370
- }
1371
- }
1372
- /**
1373
- * Compute row-level diffs between two Dolt revisions (branches or commit hashes)
1374
- * across all tracked tables.
1375
- */
1376
- async _diffRange(fromRef, toRef, storyKey) {
1377
- const tableDiffs = [];
1378
- for (const table of DoltStateStore.DIFF_TABLES) try {
1379
- const rows = await this._client.query(`SELECT * FROM DOLT_DIFF('${fromRef}', '${toRef}', '${table}')`, [], "main");
1380
- if (rows.length === 0) continue;
1381
- const added = [];
1382
- const modified = [];
1383
- const deleted = [];
1384
- for (const row of rows) {
1385
- const diffType = row["diff_type"];
1386
- const rowKey = this._extractRowKey(row);
1387
- const before = this._extractPrefixedFields(row, "before_");
1388
- const after = this._extractPrefixedFields(row, "after_");
1389
- const diffRow = {
1390
- rowKey,
1391
- ...before !== void 0 && { before },
1392
- ...after !== void 0 && { after }
1393
- };
1394
- if (diffType === "added") added.push(diffRow);
1395
- else if (diffType === "modified") modified.push(diffRow);
1396
- else if (diffType === "removed") deleted.push(diffRow);
1397
- }
1398
- if (added.length > 0 || modified.length > 0 || deleted.length > 0) tableDiffs.push({
1399
- table,
1400
- added,
1401
- modified,
1402
- deleted
1403
- });
1404
- } catch {}
1405
- return {
1406
- storyKey,
1407
- tables: tableDiffs
1408
- };
1409
- }
1410
- /**
1411
- * Extract a human-readable row key from a DOLT_DIFF result row.
1412
- * Tries after_ fields first (for added/modified rows), then before_ fields
1413
- * (for removed rows). Skips commit_hash pseudo-columns.
1414
- */
1415
- _extractRowKey(row) {
1416
- for (const prefix of ["after_", "before_"]) for (const [key, val] of Object.entries(row)) if (key.startsWith(prefix) && !key.endsWith("_commit_hash") && val !== null && val !== void 0) return String(val);
1417
- return "unknown";
1418
- }
1419
- /**
1420
- * Extract all fields with a given prefix from a DOLT_DIFF result row,
1421
- * stripping the prefix from the key names. Returns undefined if no matching
1422
- * fields are found.
1423
- */
1424
- _extractPrefixedFields(row, prefix) {
1425
- const result = {};
1426
- for (const [key, val] of Object.entries(row)) if (key.startsWith(prefix)) result[key.slice(prefix.length)] = val;
1427
- return Object.keys(result).length > 0 ? result : void 0;
1428
- }
1429
- /** In-memory KV store for per-run arbitrary metrics. Not persisted to Dolt. */
1430
- _kvMetrics = new Map();
1431
- async setMetric(runId, key, value) {
1432
- let runMap = this._kvMetrics.get(runId);
1433
- if (runMap === void 0) {
1434
- runMap = new Map();
1435
- this._kvMetrics.set(runId, runMap);
1436
- }
1437
- runMap.set(key, value);
1438
- }
1439
- async getMetric(runId, key) {
1440
- return this._kvMetrics.get(runId)?.get(key);
1441
- }
1442
- async getHistory(limit) {
1443
- const effectiveLimit = limit ?? 20;
1444
- try {
1445
- const rows = await this._client.query(`SELECT commit_hash, date, message, committer FROM dolt_log LIMIT ?`, [effectiveLimit]);
1446
- const entries = [];
1447
- for (const row of rows) {
1448
- const hash = String(row.commit_hash ?? "");
1449
- const dateVal = row.date;
1450
- const timestamp = dateVal instanceof Date ? dateVal.toISOString() : String(dateVal ?? "");
1451
- const message = String(row.message ?? "");
1452
- const author = row.committer ? String(row.committer) : void 0;
1453
- const storyKeyMatch = /story\/([0-9]+-[0-9]+)/i.exec(message);
1454
- entries.push({
1455
- hash,
1456
- timestamp,
1457
- storyKey: storyKeyMatch ? storyKeyMatch[1] : null,
1458
- message,
1459
- author
1460
- });
1461
- }
1462
- return entries;
1463
- } catch (err) {
1464
- const detail = err instanceof Error ? err.message : String(err);
1465
- throw new DoltQueryError("getHistory", detail);
1466
- }
1467
- }
1468
- };
1469
-
1470
- //#endregion
1471
- //#region src/modules/state/index.ts
1472
- const logger$2 = createLogger("state:factory");
19
+ //#region src/utils/git-root.ts
1473
20
  /**
1474
- * Synchronously check whether Dolt is available and a Dolt repo exists at the
1475
- * canonical state path under `basePath`.
21
+ * Resolve the main git repository root, even from a linked worktree.
1476
22
  *
1477
- * @param basePath - Project root to check (e.g. `process.cwd()`).
1478
- * @returns `{ available: true, reason: '...' }` when both probes pass,
1479
- * `{ available: false, reason: '...' }` otherwise.
1480
- */
1481
- function detectDoltAvailableSync(basePath) {
1482
- const result = spawnSync("dolt", ["version"], { stdio: "ignore" });
1483
- const binaryFound = result.error == null && result.status === 0;
1484
- if (!binaryFound) return {
1485
- available: false,
1486
- reason: "dolt binary not found on PATH"
1487
- };
1488
- const stateDoltDir = join$1(basePath, ".substrate", "state", ".dolt");
1489
- const repoExists = existsSync(stateDoltDir);
1490
- if (!repoExists) return {
1491
- available: false,
1492
- reason: `Dolt repo not initialised at ${stateDoltDir}`
1493
- };
1494
- return {
1495
- available: true,
1496
- reason: "dolt binary found and repo initialised"
1497
- };
1498
- }
1499
- /**
1500
- * Create a StateStore backed by the specified backend.
23
+ * In the main worktree, `--git-common-dir` returns `.git` (relative).
24
+ * In a linked worktree, it returns the absolute path to the main `.git` dir.
25
+ * Either way, `dirname()` of the resolved absolute path yields the repo root.
1501
26
  *
1502
- * @param config - Optional configuration. Defaults to `{ backend: 'auto' }`.
1503
- * @returns A StateStore instance. Call `initialize()` before use.
27
+ * Falls back to `cwd` if not in a git repo or git is unavailable.
1504
28
  */
1505
- function createStateStore(config = {}) {
1506
- const backend = config.backend ?? "auto";
1507
- if (backend === "dolt") {
1508
- const repoPath = config.basePath ?? process.cwd();
1509
- const client = new DoltClient({ repoPath });
1510
- return new DoltStateStore({
1511
- repoPath,
1512
- client
29
+ async function resolveMainRepoRoot(cwd = process.cwd()) {
30
+ return new Promise((res) => {
31
+ let stdout = "";
32
+ const proc = spawn("git", ["rev-parse", "--git-common-dir"], {
33
+ cwd,
34
+ stdio: [
35
+ "ignore",
36
+ "pipe",
37
+ "pipe"
38
+ ]
1513
39
  });
1514
- }
1515
- if (backend === "auto") {
1516
- const repoPath = config.basePath ?? process.cwd();
1517
- const detection = detectDoltAvailableSync(repoPath);
1518
- if (detection.available) {
1519
- logger$2.debug(`Dolt detected, using DoltStateStore (state path: ${join$1(repoPath, ".substrate", "state")})`);
1520
- const client = new DoltClient({ repoPath });
1521
- return new DoltStateStore({
1522
- repoPath,
1523
- client
1524
- });
1525
- } else {
1526
- logger$2.debug(`Dolt not found, using FileStateStore (reason: ${detection.reason})`);
1527
- return new FileStateStore({ basePath: config.basePath });
1528
- }
1529
- }
1530
- return new FileStateStore({ basePath: config.basePath });
40
+ if (proc.stdout !== null) proc.stdout.on("data", (chunk) => {
41
+ stdout += chunk.toString("utf-8");
42
+ });
43
+ proc.on("error", () => {
44
+ res(cwd);
45
+ });
46
+ proc.on("close", (code) => {
47
+ if (code !== 0) {
48
+ res(cwd);
49
+ return;
50
+ }
51
+ const commonDir = stdout.trim();
52
+ if (!commonDir) {
53
+ res(cwd);
54
+ return;
55
+ }
56
+ const absCommonDir = resolve$1(cwd, commonDir);
57
+ res(dirname$1(absCommonDir));
58
+ });
59
+ });
1531
60
  }
1532
61
 
1533
62
  //#endregion
@@ -2766,8 +1295,8 @@ function resolveGraphPath() {
2766
1295
  join$1(__dirname, "graphs/sdlc-pipeline.dot")
2767
1296
  ];
2768
1297
  try {
2769
- const require$1 = createRequire$1(import.meta.url);
2770
- const sdlcPkgPath = require$1.resolve("@substrate-ai/sdlc/package.json");
1298
+ const require = createRequire(import.meta.url);
1299
+ const sdlcPkgPath = require.resolve("@substrate-ai/sdlc/package.json");
2771
1300
  candidates.push(join$1(dirname$1(sdlcPkgPath), "graphs", "sdlc-pipeline.dot"));
2772
1301
  } catch {}
2773
1302
  for (const candidate of candidates) if (existsSync(candidate)) return candidate;
@@ -6040,7 +4569,12 @@ const ProposalSchema = z.object({
6040
4569
  z.string()
6041
4570
  ]),
6042
4571
  story_key: z.string().optional(),
6043
- payload: z.record(z.string(), z.unknown()).optional()
4572
+ payload: z.record(z.string(), z.unknown()).optional(),
4573
+ storyKey: z.string().optional(),
4574
+ rootCause: z.string().optional(),
4575
+ attempts: z.number().int().optional(),
4576
+ suggestedAction: z.string().optional(),
4577
+ blastRadius: z.array(z.string()).optional()
6044
4578
  });
6045
4579
  /**
6046
4580
  * Zod schema for the full run manifest data.
@@ -6519,6 +5053,64 @@ var RunManifest = class RunManifest {
6519
5053
  return this._enqueue(() => this._appendRecoveryEntryImpl(entry));
6520
5054
  }
6521
5055
  /**
5056
+ * Raw implementation — must only be called from within `_enqueue`.
5057
+ */
5058
+ async _appendProposalImpl(proposal) {
5059
+ let existingData;
5060
+ try {
5061
+ const read = await RunManifest.read(this.runId, this.baseDir, this.doltAdapter);
5062
+ const { generation: _gen, updated_at: _ts,...rest } = read;
5063
+ existingData = rest;
5064
+ } catch {
5065
+ const now = new Date().toISOString();
5066
+ existingData = {
5067
+ run_id: this.runId,
5068
+ cli_flags: {},
5069
+ story_scope: [],
5070
+ supervisor_pid: null,
5071
+ supervisor_session_id: null,
5072
+ per_story_state: {},
5073
+ recovery_history: [],
5074
+ cost_accumulation: {
5075
+ per_story: {},
5076
+ run_total: 0
5077
+ },
5078
+ pending_proposals: [],
5079
+ created_at: now
5080
+ };
5081
+ }
5082
+ const targetKey = proposal.storyKey ?? proposal.story_key;
5083
+ if (targetKey !== void 0) {
5084
+ const alreadyPresent = existingData.pending_proposals.some((p) => {
5085
+ const existingKey = p.storyKey ?? p.story_key;
5086
+ return existingKey === targetKey;
5087
+ });
5088
+ if (alreadyPresent) return;
5089
+ }
5090
+ await this._writeImpl({
5091
+ ...existingData,
5092
+ pending_proposals: [...existingData.pending_proposals, proposal]
5093
+ });
5094
+ }
5095
+ /**
5096
+ * Atomically append a proposal to `pending_proposals`, deduplicating by storyKey.
5097
+ *
5098
+ * If a proposal with the same `storyKey` (or `story_key`) is already present in
5099
+ * `pending_proposals`, the call is a silent no-op (idempotent). This guards
5100
+ * against duplicate proposals from concurrent or repeated recovery invocations.
5101
+ *
5102
+ * Enqueues the operation via `_enqueue` so concurrent calls are serialized.
5103
+ * Non-fatal: callers MUST wrap in `.catch((err) => logger.warn(...))`.
5104
+ * The pipeline must never abort due to a manifest write failure.
5105
+ *
5106
+ * Story 73-1 (Recovery Engine). Canonical manifest helper per AC3.
5107
+ *
5108
+ * @param proposal - Proposal to append (id, created_at, description, type + Epic 73 fields)
5109
+ */
5110
+ async appendProposal(proposal) {
5111
+ return this._enqueue(() => this._appendProposalImpl(proposal));
5112
+ }
5113
+ /**
6522
5114
  * Create a new manifest with `generation: 0` and write it.
6523
5115
  * Returns a bound `RunManifest` instance.
6524
5116
  */
@@ -6854,11 +5446,11 @@ var SupervisorLock = class {
6854
5446
  mode = null;
6855
5447
  /** File handle held open to maintain the advisory lock (flock mode only). */
6856
5448
  lockHandle = null;
6857
- constructor(runId, manifest, logger$3) {
5449
+ constructor(runId, manifest, logger$1) {
6858
5450
  this.runId = runId;
6859
5451
  this.manifest = manifest;
6860
5452
  this.baseDir = manifest.baseDir;
6861
- this.logger = logger$3 ?? defaultLogger;
5453
+ this.logger = logger$1 ?? defaultLogger;
6862
5454
  }
6863
5455
  /** Advisory lock file path (primary path). */
6864
5456
  get lockPath() {
@@ -6905,19 +5497,19 @@ var SupervisorLock = class {
6905
5497
  existingPid = data.supervisor_pid;
6906
5498
  } catch {}
6907
5499
  if (existingPid === null) {
6908
- await unlink(this.lockPath).catch(() => void 0);
5500
+ await unlink$1(this.lockPath).catch(() => void 0);
6909
5501
  await this.acquire(pid, sessionId, opts);
6910
5502
  return;
6911
5503
  }
6912
5504
  const isAlive = this.isPidAlive(existingPid);
6913
5505
  if (!isAlive) {
6914
- await unlink(this.lockPath).catch(() => void 0);
5506
+ await unlink$1(this.lockPath).catch(() => void 0);
6915
5507
  await this.acquire(pid, sessionId, opts);
6916
5508
  return;
6917
5509
  }
6918
5510
  if (force) {
6919
5511
  await this.forceKillOwner(existingPid);
6920
- await unlink(this.lockPath).catch(() => void 0);
5512
+ await unlink$1(this.lockPath).catch(() => void 0);
6921
5513
  await this.acquire(pid, sessionId, opts);
6922
5514
  return;
6923
5515
  }
@@ -6939,7 +5531,7 @@ var SupervisorLock = class {
6939
5531
  } catch {}
6940
5532
  this.lockHandle = null;
6941
5533
  this.mode = null;
6942
- await unlink(this.lockPath).catch(() => void 0);
5534
+ await unlink$1(this.lockPath).catch(() => void 0);
6943
5535
  throw postOpenErr;
6944
5536
  }
6945
5537
  }
@@ -6959,7 +5551,7 @@ var SupervisorLock = class {
6959
5551
  } catch {}
6960
5552
  this.lockHandle = null;
6961
5553
  }
6962
- await unlink(this.lockPath).catch(() => void 0);
5554
+ await unlink$1(this.lockPath).catch(() => void 0);
6963
5555
  } else if (this.mode === "pid-file") await this.releaseViaPidFile();
6964
5556
  this.mode = null;
6965
5557
  await this.manifest.update({
@@ -6999,7 +5591,7 @@ var SupervisorLock = class {
6999
5591
  this.mode = "pid-file";
7000
5592
  }
7001
5593
  async releaseViaPidFile() {
7002
- await unlink(this.pidPath).catch(() => void 0);
5594
+ await unlink$1(this.pidPath).catch(() => void 0);
7003
5595
  }
7004
5596
  /**
7005
5597
  * Send SIGTERM to the existing supervisor and wait up to 500ms for it to exit.
@@ -7032,7 +5624,7 @@ var SupervisorLock = class {
7032
5624
 
7033
5625
  //#endregion
7034
5626
  //#region src/cli/commands/manifest-read.ts
7035
- const logger$1 = createLogger("manifest-read");
5627
+ const logger = createLogger("manifest-read");
7036
5628
  /**
7037
5629
  * Read the active run ID from `.substrate/current-run-id`.
7038
5630
  *
@@ -7064,7 +5656,7 @@ async function readCurrentRunId(dbRoot) {
7064
5656
  async function resolveRunManifest(dbRoot, runId) {
7065
5657
  const resolvedRunId = runId ?? await readCurrentRunId(dbRoot);
7066
5658
  if (!resolvedRunId) {
7067
- logger$1.debug("run manifest not found — falling back to Dolt (no current-run-id)");
5659
+ logger.debug("run manifest not found — falling back to Dolt (no current-run-id)");
7068
5660
  return {
7069
5661
  manifest: null,
7070
5662
  runId: null
@@ -7079,7 +5671,7 @@ async function resolveRunManifest(dbRoot, runId) {
7079
5671
  runId: resolvedRunId
7080
5672
  };
7081
5673
  } catch {
7082
- logger$1.debug({ runId: resolvedRunId }, "run manifest not found — falling back to Dolt");
5674
+ logger.debug({ runId: resolvedRunId }, "run manifest not found — falling back to Dolt");
7083
5675
  return {
7084
5676
  manifest: null,
7085
5677
  runId: resolvedRunId
@@ -7088,489 +5680,5 @@ async function resolveRunManifest(dbRoot, runId) {
7088
5680
  }
7089
5681
 
7090
5682
  //#endregion
7091
- //#region src/cli/commands/health.ts
7092
- const logger = createLogger("health-cmd");
7093
- /** Default stall threshold in seconds — also used by supervisor default */
7094
- const DEFAULT_STALL_THRESHOLD_SECONDS = 600;
7095
- /**
7096
- * Determine whether a ps output line represents the substrate pipeline orchestrator.
7097
- * Handles invocation via:
7098
- * - `substrate run` (globally installed)
7099
- * - `substrate-ai run`
7100
- * - `node dist/cli/index.js run` (npm run substrate:dev)
7101
- * - `npx substrate run`
7102
- * - any node process whose command contains `run` with `--events` or `--stories`
7103
- *
7104
- * When `projectRoot` is provided, additionally checks that the command line
7105
- * contains that path (via `--project-root` flag or as part of the binary/CWD path).
7106
- * This ensures multi-project environments match the correct orchestrator.
7107
- */
7108
- function isOrchestratorProcessLine(line, projectRoot) {
7109
- if (line.includes("grep")) return false;
7110
- let isOrchestrator = false;
7111
- if (line.includes("substrate run")) isOrchestrator = true;
7112
- else if (line.includes("substrate-ai run")) isOrchestrator = true;
7113
- else if (line.includes("index.js run")) isOrchestrator = true;
7114
- else if (line.includes("node") && /\srun(\s|$)/.test(line) && (line.includes("substrate") || line.includes("--events") || line.includes("--stories"))) isOrchestrator = true;
7115
- if (!isOrchestrator) return false;
7116
- if (projectRoot !== void 0) return line.includes(projectRoot);
7117
- return true;
7118
- }
7119
- function inspectProcessTree(opts) {
7120
- const { projectRoot, substrateDirPath, execFileSync: execFileSyncOverride, readFileSync: readFileSyncOverride } = opts ?? {};
7121
- const result = {
7122
- orchestrator_pid: null,
7123
- child_pids: [],
7124
- zombies: []
7125
- };
7126
- try {
7127
- let psOutput;
7128
- if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid,stat,command"], {
7129
- encoding: "utf-8",
7130
- timeout: 5e3
7131
- });
7132
- else {
7133
- const { execFileSync: execFileSync$1 } = __require("node:child_process");
7134
- psOutput = execFileSync$1("ps", ["-eo", "pid,ppid,stat,command"], {
7135
- encoding: "utf-8",
7136
- timeout: 5e3
7137
- });
7138
- }
7139
- const lines = psOutput.split("\n");
7140
- if (substrateDirPath !== void 0) try {
7141
- const readFileSyncFn = readFileSyncOverride ?? ((path$3, encoding) => readFileSync(path$3, encoding));
7142
- const pidContent = readFileSyncFn(join(substrateDirPath, "orchestrator.pid"), "utf-8");
7143
- const pid = parseInt(pidContent.trim(), 10);
7144
- if (!isNaN(pid) && pid > 0) {
7145
- const isAlive = lines.some((line) => {
7146
- const parts = line.trim().split(/\s+/);
7147
- if (parts.length < 3) return false;
7148
- return parseInt(parts[0], 10) === pid && !parts[2].includes("Z");
7149
- });
7150
- if (isAlive) result.orchestrator_pid = pid;
7151
- else result.pid_file_dead = true;
7152
- }
7153
- } catch {}
7154
- if (result.orchestrator_pid === null) {
7155
- for (const line of lines) if (isOrchestratorProcessLine(line, projectRoot)) {
7156
- const match = line.trim().match(/^(\d+)/);
7157
- if (match) {
7158
- result.orchestrator_pid = parseInt(match[1], 10);
7159
- break;
7160
- }
7161
- }
7162
- }
7163
- if (result.orchestrator_pid !== null) for (const line of lines) {
7164
- const parts = line.trim().split(/\s+/);
7165
- if (parts.length >= 3) {
7166
- const pid = parseInt(parts[0], 10);
7167
- const ppid = parseInt(parts[1], 10);
7168
- const stat$2 = parts[2];
7169
- if (ppid === result.orchestrator_pid && pid !== result.orchestrator_pid) {
7170
- result.child_pids.push(pid);
7171
- if (stat$2.includes("Z")) result.zombies.push(pid);
7172
- }
7173
- }
7174
- }
7175
- } catch {}
7176
- return result;
7177
- }
7178
- /**
7179
- * Collect all descendant PIDs of the given root PIDs by walking the process
7180
- * tree recursively. This ensures that grandchildren of the orchestrator
7181
- * (e.g. node subprocesses spawned by `claude -p`) are also killed during
7182
- * stall recovery, leaving no orphan processes.
7183
- *
7184
- * Returns only the descendants — the root PIDs themselves are NOT included.
7185
- */
7186
- function getAllDescendantPids(rootPids, execFileSyncOverride) {
7187
- if (rootPids.length === 0) return [];
7188
- try {
7189
- let psOutput;
7190
- if (execFileSyncOverride !== void 0) psOutput = execFileSyncOverride("ps", ["-eo", "pid,ppid"], {
7191
- encoding: "utf-8",
7192
- timeout: 5e3
7193
- });
7194
- else {
7195
- const { execFileSync: execFileSync$1 } = __require("node:child_process");
7196
- psOutput = execFileSync$1("ps", ["-eo", "pid,ppid"], {
7197
- encoding: "utf-8",
7198
- timeout: 5e3
7199
- });
7200
- }
7201
- const childrenOf = new Map();
7202
- for (const line of psOutput.split("\n")) {
7203
- const parts = line.trim().split(/\s+/);
7204
- if (parts.length >= 2) {
7205
- const pid = parseInt(parts[0], 10);
7206
- const ppid = parseInt(parts[1], 10);
7207
- if (!isNaN(pid) && !isNaN(ppid) && pid > 0) {
7208
- if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
7209
- childrenOf.get(ppid).push(pid);
7210
- }
7211
- }
7212
- }
7213
- const descendants = [];
7214
- const seen = new Set(rootPids);
7215
- const queue = [...rootPids];
7216
- while (queue.length > 0) {
7217
- const current = queue.shift();
7218
- const children = childrenOf.get(current) ?? [];
7219
- for (const child of children) if (!seen.has(child)) {
7220
- seen.add(child);
7221
- descendants.push(child);
7222
- queue.push(child);
7223
- }
7224
- }
7225
- return descendants;
7226
- } catch {
7227
- return [];
7228
- }
7229
- }
7230
- /**
7231
- * Derive health story counts from manifest `per_story_state`.
7232
- * Maps manifest status strings to health output buckets.
7233
- */
7234
- function buildHealthStoryCountsFromManifest(perStoryState) {
7235
- const counts = {
7236
- active: 0,
7237
- completed: 0,
7238
- escalated: 0,
7239
- pending: 0,
7240
- failed: 0
7241
- };
7242
- for (const entry of Object.values(perStoryState)) switch (entry.status) {
7243
- case "complete":
7244
- counts.completed++;
7245
- break;
7246
- case "escalated":
7247
- counts.escalated++;
7248
- break;
7249
- case "failed":
7250
- case "verification-failed":
7251
- counts.failed++;
7252
- break;
7253
- case "pending":
7254
- case "gated":
7255
- counts.pending++;
7256
- break;
7257
- case "dispatched":
7258
- case "in-review":
7259
- case "recovered":
7260
- default:
7261
- counts.active++;
7262
- break;
7263
- }
7264
- return counts;
7265
- }
7266
- /**
7267
- * Fetch pipeline health data as a structured object without any stdout side-effects.
7268
- * Used by runSupervisorAction to poll health without formatting overhead.
7269
- *
7270
- * Returns a NO_PIPELINE_RUNNING health object for all graceful "no data" cases
7271
- * (missing DB, missing run, terminal run status). Throws only on unexpected errors.
7272
- */
7273
- async function getAutoHealthData(options) {
7274
- const { runId, projectRoot, stateStore, stateStoreConfig } = options;
7275
- const dbRoot = await resolveMainRepoRoot(projectRoot);
7276
- const dbPath = join(dbRoot, ".substrate", "substrate.db");
7277
- let doltStateInfo;
7278
- if (stateStoreConfig?.backend === "dolt" && stateStore) {
7279
- const repoPath = stateStoreConfig.basePath ?? projectRoot;
7280
- const doltDirPath = join(repoPath, ".dolt");
7281
- const initialized = existsSync(doltDirPath);
7282
- let responsive = false;
7283
- let version;
7284
- let branches;
7285
- let currentBranch;
7286
- try {
7287
- await stateStore.getHistory(1);
7288
- responsive = true;
7289
- try {
7290
- const { execFile: ef } = await import("node:child_process");
7291
- const { promisify: p } = await import("node:util");
7292
- const execFileAsync = p(ef);
7293
- const { stdout } = await execFileAsync("dolt", ["version"]);
7294
- const match = stdout.match(/dolt version (\S+)/);
7295
- if (match) version = match[1];
7296
- } catch {}
7297
- try {
7298
- const { execFile: ef } = await import("node:child_process");
7299
- const { promisify: p } = await import("node:util");
7300
- const execFileAsync = p(ef);
7301
- const { stdout } = await execFileAsync("dolt", ["branch", "--list"], { cwd: repoPath });
7302
- const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
7303
- branches = lines.map((l) => {
7304
- const trimmed = l.trim();
7305
- if (trimmed.startsWith("* ")) {
7306
- currentBranch = trimmed.slice(2).trim();
7307
- return currentBranch;
7308
- }
7309
- return trimmed;
7310
- });
7311
- } catch {}
7312
- } catch {
7313
- responsive = false;
7314
- }
7315
- doltStateInfo = {
7316
- initialized,
7317
- responsive,
7318
- ...version !== void 0 ? { version } : {},
7319
- ...branches !== void 0 ? { branches } : {},
7320
- ...currentBranch !== void 0 ? { current_branch: currentBranch } : {}
7321
- };
7322
- }
7323
- const NO_PIPELINE = {
7324
- verdict: "NO_PIPELINE_RUNNING",
7325
- run_id: null,
7326
- status: null,
7327
- current_phase: null,
7328
- staleness_seconds: 0,
7329
- last_activity: "",
7330
- process: {
7331
- orchestrator_pid: null,
7332
- child_pids: [],
7333
- zombies: []
7334
- },
7335
- stories: {
7336
- active: 0,
7337
- completed: 0,
7338
- escalated: 0,
7339
- details: {}
7340
- },
7341
- ...doltStateInfo !== void 0 ? { dolt_state: doltStateInfo } : {}
7342
- };
7343
- const doltDir = join(dbRoot, ".substrate", "state", ".dolt");
7344
- if (!existsSync(dbPath) && !existsSync(doltDir)) return NO_PIPELINE;
7345
- const adapter = createDatabaseAdapter$1({
7346
- backend: "auto",
7347
- basePath: dbRoot
7348
- });
7349
- try {
7350
- await initSchema(adapter);
7351
- let run;
7352
- if (runId !== void 0) run = await getPipelineRunById(adapter, runId);
7353
- else {
7354
- let currentRunId;
7355
- try {
7356
- const currentRunIdPath = join(dbRoot, ".substrate", "current-run-id");
7357
- const content = readFileSync(currentRunIdPath, "utf-8").trim();
7358
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7359
- if (UUID_RE.test(content)) currentRunId = content;
7360
- } catch {}
7361
- if (currentRunId !== void 0) run = await getPipelineRunById(adapter, currentRunId);
7362
- if (run === void 0) run = await getLatestRun(adapter);
7363
- }
7364
- if (run === void 0) {
7365
- const substrateDirPath$1 = join(dbRoot, ".substrate");
7366
- const fallbackProcessInfo = inspectProcessTree({
7367
- projectRoot: dbRoot,
7368
- substrateDirPath: substrateDirPath$1
7369
- });
7370
- if (fallbackProcessInfo.orchestrator_pid !== null) return {
7371
- verdict: "HEALTHY",
7372
- run_id: null,
7373
- status: "running",
7374
- current_phase: "implementation",
7375
- staleness_seconds: 0,
7376
- last_activity: new Date().toISOString(),
7377
- process: fallbackProcessInfo,
7378
- stories: {
7379
- active: 0,
7380
- completed: 0,
7381
- escalated: 0,
7382
- details: {}
7383
- },
7384
- ...doltStateInfo !== void 0 ? { dolt_state: doltStateInfo } : {}
7385
- };
7386
- return NO_PIPELINE;
7387
- }
7388
- const updatedAt = parseDbTimestampAsUtc(run.updated_at ?? "");
7389
- const stalenessSeconds = Math.round((Date.now() - updatedAt.getTime()) / 1e3);
7390
- let storyDetails = {};
7391
- let active = 0;
7392
- let completed = 0;
7393
- let escalated = 0;
7394
- let pending = 0;
7395
- try {
7396
- if (run.token_usage_json) {
7397
- const state = JSON.parse(run.token_usage_json);
7398
- if (state.stories) for (const [key, s] of Object.entries(state.stories)) {
7399
- storyDetails[key] = {
7400
- phase: s.phase,
7401
- review_cycles: s.reviewCycles
7402
- };
7403
- if (s.phase === "COMPLETE") completed++;
7404
- else if (s.phase === "ESCALATED") escalated++;
7405
- else if (s.phase === "PENDING") pending++;
7406
- else active++;
7407
- }
7408
- }
7409
- } catch {}
7410
- let manifestSupervisor;
7411
- let manifestStoryCounts;
7412
- try {
7413
- const { manifest: resolvedManifest } = await resolveRunManifest(dbRoot, run.id);
7414
- if (resolvedManifest !== null) {
7415
- const manifestData = await resolvedManifest.read();
7416
- manifestSupervisor = {
7417
- pid: manifestData.supervisor_pid,
7418
- session_id: manifestData.supervisor_session_id
7419
- };
7420
- manifestStoryCounts = buildHealthStoryCountsFromManifest(manifestData.per_story_state);
7421
- logger.debug({ runId: run.id }, "health: story counts and supervisor read from manifest");
7422
- }
7423
- } catch {
7424
- logger.debug({ runId: run.id }, "health: manifest read failed — using token_usage_json counts");
7425
- }
7426
- const finalActive = manifestStoryCounts?.active ?? active;
7427
- const finalCompleted = manifestStoryCounts?.completed ?? completed;
7428
- const finalEscalated = manifestStoryCounts?.escalated ?? escalated;
7429
- const finalPending = manifestStoryCounts?.pending ?? pending;
7430
- const finalFailed = manifestStoryCounts?.failed;
7431
- const substrateDirPath = join(dbRoot, ".substrate");
7432
- const processInfo = options._processInfoOverride ?? inspectProcessTree({
7433
- projectRoot,
7434
- substrateDirPath
7435
- });
7436
- let verdict = "NO_PIPELINE_RUNNING";
7437
- if (run.status === "running") if (processInfo.orchestrator_pid !== null) verdict = "HEALTHY";
7438
- else if (processInfo.pid_file_dead === true) verdict = "STALLED";
7439
- else if (processInfo.zombies.length > 0) verdict = "STALLED";
7440
- else if (stalenessSeconds > DEFAULT_STALL_THRESHOLD_SECONDS) verdict = "STALLED";
7441
- else if (finalActive > 0) verdict = "STALLED";
7442
- else verdict = "HEALTHY";
7443
- else if (run.status === "completed" || run.status === "failed" || run.status === "stopped") verdict = "NO_PIPELINE_RUNNING";
7444
- const warnings = [];
7445
- if (doltStateInfo !== void 0 && doltStateInfo.responsive === false) warnings.push("Dolt not connected — decision store queries may fail, story context will be degraded");
7446
- if (finalEscalated > 0) warnings.push(`${finalEscalated} story(ies) escalated — operator intervention may be needed`);
7447
- const healthOutput = {
7448
- verdict,
7449
- run_id: run.id,
7450
- status: run.status,
7451
- current_phase: run.current_phase ?? null,
7452
- staleness_seconds: stalenessSeconds,
7453
- last_activity: run.updated_at ?? "",
7454
- process: processInfo,
7455
- stories: {
7456
- active: finalActive,
7457
- completed: finalCompleted,
7458
- escalated: finalEscalated,
7459
- pending: finalPending,
7460
- ...finalFailed !== void 0 ? { failed: finalFailed } : {},
7461
- details: storyDetails
7462
- },
7463
- ...manifestSupervisor !== void 0 ? { manifest_supervisor: manifestSupervisor } : {},
7464
- ...doltStateInfo !== void 0 ? { dolt_state: doltStateInfo } : {},
7465
- ...warnings.length > 0 ? { warnings } : {}
7466
- };
7467
- return healthOutput;
7468
- } finally {
7469
- try {
7470
- await adapter.close();
7471
- } catch {}
7472
- }
7473
- }
7474
- async function runHealthAction(options) {
7475
- const { outputFormat } = options;
7476
- try {
7477
- const health = await getAutoHealthData(options);
7478
- if (outputFormat === "json") process.stdout.write(formatOutput(health, "json", true) + "\n");
7479
- else {
7480
- const verdictLabel = health.verdict === "HEALTHY" ? "HEALTHY" : health.verdict === "STALLED" ? "STALLED" : "NO PIPELINE RUNNING";
7481
- process.stdout.write(`\nPipeline Health: ${verdictLabel}\n`);
7482
- if (health.run_id !== null) {
7483
- process.stdout.write(` Run: ${health.run_id}\n`);
7484
- process.stdout.write(` Status: ${health.status}\n`);
7485
- process.stdout.write(` Phase: ${health.current_phase ?? "N/A"}\n`);
7486
- process.stdout.write(` Last Active: ${health.last_activity} (${health.staleness_seconds}s ago)\n`);
7487
- const processInfo = health.process;
7488
- if (processInfo.orchestrator_pid !== null) {
7489
- process.stdout.write(` Orchestrator: PID ${processInfo.orchestrator_pid}\n`);
7490
- process.stdout.write(` Children: ${processInfo.child_pids.length} active`);
7491
- if (processInfo.zombies.length > 0) process.stdout.write(` (${processInfo.zombies.length} ZOMBIE)`);
7492
- process.stdout.write("\n");
7493
- } else process.stdout.write(" Orchestrator: not running\n");
7494
- const storyDetails = health.stories.details;
7495
- if (Object.keys(storyDetails).length > 0) {
7496
- process.stdout.write("\n Stories:\n");
7497
- for (const [key, s] of Object.entries(storyDetails)) process.stdout.write(` ${key}: ${s.phase} (${s.review_cycles} review cycles)\n`);
7498
- process.stdout.write(`\n Summary: ${health.stories.active} active, ${health.stories.completed} completed, ${health.stories.escalated} escalated\n`);
7499
- }
7500
- }
7501
- if (health.verdict === "STALLED") {
7502
- process.stdout.write("\n Recommended Actions:\n");
7503
- if (health.process.orchestrator_pid !== null) process.stdout.write(` 1. Kill stalled orchestrator: kill ${health.process.orchestrator_pid}\n`);
7504
- if (health.process.zombies.length > 0) process.stdout.write(` ${health.process.orchestrator_pid !== null ? "2" : "1"}. Kill zombie processes: kill ${health.process.zombies.join(" ")}\n`);
7505
- process.stdout.write(` ${health.process.orchestrator_pid !== null ? "3" : "2"}. Resume the run: substrate resume\n`);
7506
- process.stdout.write(` ${health.process.orchestrator_pid !== null ? "4" : "3"}. Or start fresh: substrate run --events --stories <keys>\n`);
7507
- } else if (health.verdict === "NO_PIPELINE_RUNNING" && health.stories.escalated > 0) {
7508
- process.stdout.write("\n Recommended Actions:\n");
7509
- process.stdout.write(" 1. Retry escalated stories: substrate retry-escalated\n");
7510
- process.stdout.write(" 2. Or start a new run: substrate run --events\n");
7511
- }
7512
- if (health.warnings !== void 0 && health.warnings.length > 0) {
7513
- process.stdout.write("\n Warnings:\n");
7514
- for (const w of health.warnings) process.stdout.write(` ⚠ ${w}\n`);
7515
- }
7516
- if (health.dolt_state !== void 0) {
7517
- const ds = health.dolt_state;
7518
- const initStr = ds.initialized ? "yes" : "no";
7519
- const respStr = ds.responsive ? "yes" : "no";
7520
- const verStr = ds.version !== void 0 ? ` (v${ds.version})` : "";
7521
- process.stdout.write(`\n Dolt State: initialized=${initStr} responsive=${respStr}${verStr}\n`);
7522
- }
7523
- }
7524
- return 0;
7525
- } catch (err) {
7526
- const msg = err instanceof Error ? err.message : String(err);
7527
- if (outputFormat === "json") process.stdout.write(formatOutput(null, "json", false, msg) + "\n");
7528
- else process.stderr.write(`Error: ${msg}\n`);
7529
- logger.error({ err }, "health action failed");
7530
- return 1;
7531
- }
7532
- }
7533
- function registerHealthCommand(program, _version = "0.0.0", projectRoot = process.cwd()) {
7534
- program.command("health").description("Check pipeline health: process status, stall detection, and verdict").option("--run-id <id>", "Pipeline run ID to query (defaults to latest)").option("--project-root <path>", "Project root directory", projectRoot).option("--output-format <format>", "Output format: human (default) or json", "human").action(async (opts) => {
7535
- const outputFormat = opts.outputFormat === "json" ? "json" : "human";
7536
- const root = opts.projectRoot;
7537
- let stateStore;
7538
- let stateStoreConfig;
7539
- const doltStatePath = join(root, ".substrate", "state", ".dolt");
7540
- if (existsSync(doltStatePath)) {
7541
- const basePath = join(root, ".substrate", "state");
7542
- stateStoreConfig = {
7543
- backend: "dolt",
7544
- basePath
7545
- };
7546
- try {
7547
- stateStore = createStateStore({
7548
- backend: "dolt",
7549
- basePath
7550
- });
7551
- await stateStore.initialize();
7552
- } catch {
7553
- stateStore = void 0;
7554
- stateStoreConfig = void 0;
7555
- }
7556
- }
7557
- try {
7558
- const exitCode = await runHealthAction({
7559
- outputFormat,
7560
- runId: opts.runId,
7561
- projectRoot: root,
7562
- stateStore,
7563
- stateStoreConfig
7564
- });
7565
- process.exitCode = exitCode;
7566
- } finally {
7567
- try {
7568
- await stateStore?.close();
7569
- } catch {}
7570
- }
7571
- });
7572
- }
7573
-
7574
- //#endregion
7575
- export { BMAD_BASELINE_TOKENS_FULL, DEFAULT_STALL_THRESHOLD_SECONDS, DoltMergeConflict, FileStateStore, FindingsInjector, RunManifest, RuntimeProbeListSchema, STOP_AFTER_VALID_PHASES, STORY_KEY_PATTERN$1 as STORY_KEY_PATTERN, SUBSTRATE_OWNED_SETTINGS_KEYS, SupervisorLock, VALID_PHASES, WorkGraphRepository, ZERO_FINDINGS_BY_AUTHOR, ZERO_FINDING_COUNTS, ZERO_PROBE_AUTHOR_METRICS, __commonJS, __require, __toESM, aggregateProbeAuthorMetrics, applyConfigToGraph, buildPipelineStatusOutput, createDatabaseAdapter$1 as createDatabaseAdapter, createDefaultVerificationPipeline, createGraphOrchestrator, createSdlcCodeReviewHandler, createSdlcCreateStoryHandler, createSdlcDevStoryHandler, createSdlcPhaseHandler, createStateStore, detectCycles, detectsEventDrivenAC, detectsStateIntegratingAC, extractTargetFilesFromStoryContent, findPackageRoot, formatOutput, formatPipelineStatusHuman, formatPipelineSummary, formatTokenTelemetry, getAllDescendantPids, getAutoHealthData, getSubstrateDefaultSettings, inspectProcessTree, isOrchestratorProcessLine, parseDbTimestampAsUtc, parseRuntimeProbes, readCurrentRunId, registerHealthCommand, renderFindings, resolveBmadMethodSrcPath, resolveBmadMethodVersion, resolveGraphPath, resolveMainRepoRoot, resolveRunManifest, rollupFindingCounts, rollupFindingsByAuthor, rollupProbeAuthorByClass, rollupProbeAuthorMetrics, runHealthAction, runStaleVerificationRecovery, validateStoryKey };
7576
- //# sourceMappingURL=health-DudlnqXd.js.map
5683
+ export { FindingsInjector, RunManifest, RuntimeProbeListSchema, SupervisorLock, ZERO_FINDINGS_BY_AUTHOR, ZERO_FINDING_COUNTS, ZERO_PROBE_AUTHOR_METRICS, aggregateProbeAuthorMetrics, applyConfigToGraph, createDefaultVerificationPipeline, createGraphOrchestrator, createSdlcCodeReviewHandler, createSdlcCreateStoryHandler, createSdlcDevStoryHandler, createSdlcPhaseHandler, detectsEventDrivenAC, detectsStateIntegratingAC, extractTargetFilesFromStoryContent, parseRuntimeProbes, readCurrentRunId, renderFindings, resolveGraphPath, resolveMainRepoRoot, resolveRunManifest, rollupFindingCounts, rollupFindingsByAuthor, rollupProbeAuthorByClass, rollupProbeAuthorMetrics, runStaleVerificationRecovery };
5684
+ //# sourceMappingURL=manifest-read-DDkXC3L_.js.map