@hyperdrive.bot/bmad-workflow 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/commands/epics/create.d.ts +1 -0
  2. package/dist/commands/lock/acquire.d.ts +54 -0
  3. package/dist/commands/lock/acquire.js +193 -0
  4. package/dist/commands/lock/cleanup.d.ts +38 -0
  5. package/dist/commands/lock/cleanup.js +148 -0
  6. package/dist/commands/lock/list.d.ts +31 -0
  7. package/dist/commands/lock/list.js +123 -0
  8. package/dist/commands/lock/release.d.ts +42 -0
  9. package/dist/commands/lock/release.js +134 -0
  10. package/dist/commands/lock/status.d.ts +34 -0
  11. package/dist/commands/lock/status.js +109 -0
  12. package/dist/commands/stories/create.d.ts +1 -0
  13. package/dist/commands/stories/develop.d.ts +4 -0
  14. package/dist/commands/stories/develop.js +55 -5
  15. package/dist/commands/stories/qa.d.ts +1 -0
  16. package/dist/commands/stories/qa.js +31 -0
  17. package/dist/commands/stories/review.d.ts +1 -0
  18. package/dist/commands/workflow.d.ts +11 -0
  19. package/dist/commands/workflow.js +120 -4
  20. package/dist/models/agent-options.d.ts +33 -0
  21. package/dist/models/agent-result.d.ts +10 -1
  22. package/dist/models/dispatch.d.ts +16 -0
  23. package/dist/models/dispatch.js +8 -0
  24. package/dist/models/index.d.ts +3 -0
  25. package/dist/models/index.js +2 -0
  26. package/dist/models/lock.d.ts +80 -0
  27. package/dist/models/lock.js +69 -0
  28. package/dist/models/phase-result.d.ts +8 -0
  29. package/dist/models/provider.js +1 -1
  30. package/dist/models/workflow-callbacks.d.ts +37 -0
  31. package/dist/models/workflow-config.d.ts +50 -0
  32. package/dist/services/agents/agent-runner-factory.d.ts +24 -15
  33. package/dist/services/agents/agent-runner-factory.js +95 -15
  34. package/dist/services/agents/channel-agent-runner.d.ts +76 -0
  35. package/dist/services/agents/channel-agent-runner.js +256 -0
  36. package/dist/services/agents/channel-session-manager.d.ts +126 -0
  37. package/dist/services/agents/channel-session-manager.js +260 -0
  38. package/dist/services/agents/claude-agent-runner.d.ts +9 -50
  39. package/dist/services/agents/claude-agent-runner.js +221 -199
  40. package/dist/services/agents/gemini-agent-runner.js +3 -0
  41. package/dist/services/agents/index.d.ts +1 -0
  42. package/dist/services/agents/index.js +1 -0
  43. package/dist/services/agents/opencode-agent-runner.js +3 -0
  44. package/dist/services/file-system/file-manager.d.ts +11 -0
  45. package/dist/services/file-system/file-manager.js +26 -0
  46. package/dist/services/git/git-ops.d.ts +58 -0
  47. package/dist/services/git/git-ops.js +73 -0
  48. package/dist/services/git/index.d.ts +3 -0
  49. package/dist/services/git/index.js +2 -0
  50. package/dist/services/git/push-conflict-handler.d.ts +32 -0
  51. package/dist/services/git/push-conflict-handler.js +84 -0
  52. package/dist/services/lock/git-backed-lock-service.d.ts +76 -0
  53. package/dist/services/lock/git-backed-lock-service.js +173 -0
  54. package/dist/services/lock/lock-cleanup.d.ts +49 -0
  55. package/dist/services/lock/lock-cleanup.js +85 -0
  56. package/dist/services/lock/lock-service.d.ts +143 -0
  57. package/dist/services/lock/lock-service.js +290 -0
  58. package/dist/services/orchestration/locked-story-dispatcher.d.ts +40 -0
  59. package/dist/services/orchestration/locked-story-dispatcher.js +84 -0
  60. package/dist/services/orchestration/workflow-orchestrator.d.ts +31 -0
  61. package/dist/services/orchestration/workflow-orchestrator.js +181 -31
  62. package/dist/services/review/ai-review-scanner.js +1 -0
  63. package/dist/services/review/review-phase-executor.js +3 -0
  64. package/dist/services/review/self-heal-loop.js +1 -0
  65. package/dist/services/review/types.d.ts +2 -0
  66. package/dist/utils/errors.d.ts +17 -1
  67. package/dist/utils/errors.js +18 -0
  68. package/dist/utils/session-naming.d.ts +23 -0
  69. package/dist/utils/session-naming.js +30 -0
  70. package/dist/utils/shared-flags.d.ts +1 -0
  71. package/dist/utils/shared-flags.js +5 -0
  72. package/package.json +3 -2
@@ -0,0 +1,143 @@
1
+ /**
2
+ * LockService
3
+ *
4
+ * Manages story file locks to prevent concurrent agent sessions from
5
+ * modifying the same story. Supports acquiring, checking, releasing,
6
+ * and stale detection of locks.
7
+ */
8
+ import type pino from 'pino';
9
+ import type { LockFileContent } from '../../models/lock.js';
10
+ import type { FileManager } from '../file-system/file-manager.js';
11
+ import type { GlobMatcher } from '../file-system/glob-matcher.js';
12
+ /**
13
+ * Service for managing story file locks
14
+ *
15
+ * Locks are stored as `<storyPath>.lock` JSON files alongside the story files.
16
+ * Each lock contains the agent session ID, agent name, timestamp, and branch.
17
+ */
18
+ export declare class LockService {
19
+ /**
20
+ * Default stale threshold in milliseconds (2 hours)
21
+ */
22
+ static readonly DEFAULT_STALE_THRESHOLD_MS = 7200000;
23
+ private readonly fileManager;
24
+ private readonly globMatcher;
25
+ private readonly logger;
26
+ /**
27
+ * Create a new LockService instance
28
+ *
29
+ * @param fileManager - FileManager instance for file operations
30
+ * @param globMatcher - GlobMatcher instance for file pattern matching
31
+ * @param logger - Pino logger instance
32
+ */
33
+ constructor(fileManager: FileManager, globMatcher: GlobMatcher, logger: pino.Logger);
34
+ /**
35
+ * Acquire a lock on a story file
36
+ *
37
+ * If a lock already exists, checks if it's stale. Stale locks are auto-released
38
+ * and re-acquired. Fresh locks cause a LockConflictError.
39
+ *
40
+ * @param storyPath - Path to the story file to lock
41
+ * @param agentName - Name of the agent acquiring the lock
42
+ * @param sessionId - Unique session ID of the agent
43
+ * @param branch - Git branch the agent is working on (defaults to 'unknown')
44
+ * @throws {LockConflictError} If an active (non-stale) lock exists
45
+ */
46
+ acquire(storyPath: string, agentName: string, sessionId: string, branch?: string): Promise<void>;
47
+ /**
48
+ * Get lock file content for a story
49
+ *
50
+ * @param storyPath - Path to the story file
51
+ * @returns Lock file content if valid, null if missing or malformed
52
+ */
53
+ getLockInfo(storyPath: string): Promise<LockFileContent | null>;
54
+ /**
55
+ * Check if a story file is currently locked
56
+ *
57
+ * @param storyPath - Path to the story file
58
+ * @returns True if a valid lock exists
59
+ */
60
+ isLocked(storyPath: string): Promise<boolean>;
61
+ /**
62
+ * Check if an existing lock is stale (expired or corrupt)
63
+ *
64
+ * A lock is stale when:
65
+ * - The lock file exists but contains malformed/unparseable JSON
66
+ * - The lock's started_at timestamp + thresholdMs is less than Date.now()
67
+ * - The lock's started_at is an invalid date
68
+ *
69
+ * A lock is NOT stale when:
70
+ * - No lock file exists
71
+ * - The lock is within the threshold window
72
+ *
73
+ * @param storyPath - Path to the story file
74
+ * @param thresholdMs - Stale threshold in milliseconds (default: 2 hours)
75
+ * @returns True if the lock is stale
76
+ */
77
+ isStale(storyPath: string, thresholdMs?: number): Promise<boolean>;
78
+ /**
79
+ * Release a lock on a story file
80
+ *
81
+ * Idempotent — calling on a non-existent lock does not throw.
82
+ *
83
+ * @param storyPath - Path to the story file
84
+ */
85
+ release(storyPath: string): Promise<void>;
86
+ /**
87
+ * Find stale lock files in a directory without releasing them
88
+ *
89
+ * Scans the given directory for `.lock` files, checks each for staleness,
90
+ * and returns paths of stale ones. Malformed lock files are treated as stale.
91
+ *
92
+ * @param storiesDir - Directory to scan for lock files
93
+ * @param thresholdMs - Optional stale threshold in milliseconds
94
+ * @returns Array of stale lock file paths (the .lock file paths, not story paths)
95
+ */
96
+ findStaleLocks(storiesDir: string, thresholdMs?: number): Promise<string[]>;
97
+ /**
98
+ * Batch cleanup of stale lock files in a directory
99
+ *
100
+ * Scans the given directory for `.lock` files, checks each for staleness,
101
+ * and removes stale ones. Malformed lock files are treated as stale.
102
+ *
103
+ * @param storiesDir - Directory to scan for lock files
104
+ * @param thresholdMs - Optional stale threshold in milliseconds
105
+ * @returns Array of removed lock file paths
106
+ */
107
+ cleanup(storiesDir: string, thresholdMs?: number): Promise<string[]>;
108
+ /**
109
+ * List all active locks in a directory with metadata
110
+ *
111
+ * Scans the given directory for `.lock` files, reads each lock,
112
+ * and returns metadata including stale status. Malformed locks are skipped.
113
+ *
114
+ * @param storiesDir - Directory to scan for lock files
115
+ * @returns Array of lock metadata objects
116
+ */
117
+ list(storiesDir: string): Promise<Array<{
118
+ storyPath: string;
119
+ lock: LockFileContent;
120
+ isStale: boolean;
121
+ }>>;
122
+ /**
123
+ * List all locks across multiple story directories with stale status
124
+ *
125
+ * Scans each directory for `.lock` files, reads and validates each lock,
126
+ * and returns metadata including stale status. Malformed locks are skipped
127
+ * with a warning. Results are sorted by started_at descending (newest first).
128
+ *
129
+ * @param storyDirs - Array of directories to scan for lock files
130
+ * @param thresholdMs - Optional stale threshold in milliseconds (default: DEFAULT_STALE_THRESHOLD_MS)
131
+ * @returns Array of lock metadata with stale boolean, sorted newest first
132
+ */
133
+ listLocks(storyDirs: string[], thresholdMs?: number): Promise<Array<LockFileContent & {
134
+ stale: boolean;
135
+ }>>;
136
+ /**
137
+ * Resolve the lock file path for a given story path
138
+ *
139
+ * @param storyPath - Path to the story file
140
+ * @returns Path to the corresponding lock file
141
+ */
142
+ private resolveLockPath;
143
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * LockService
3
+ *
4
+ * Manages story file locks to prevent concurrent agent sessions from
5
+ * modifying the same story. Supports acquiring, checking, releasing,
6
+ * and stale detection of locks.
7
+ */
8
+ import path from 'node:path';
9
+ import { LockConflictError, lockFileSchema } from '../../models/lock.js';
10
+ /**
11
+ * Service for managing story file locks
12
+ *
13
+ * Locks are stored as `<storyPath>.lock` JSON files alongside the story files.
14
+ * Each lock contains the agent session ID, agent name, timestamp, and branch.
15
+ */
16
+ export class LockService {
17
+ /**
18
+ * Default stale threshold in milliseconds (2 hours)
19
+ */
20
+ static DEFAULT_STALE_THRESHOLD_MS = 7_200_000;
21
+ fileManager;
22
+ globMatcher;
23
+ logger;
24
+ /**
25
+ * Create a new LockService instance
26
+ *
27
+ * @param fileManager - FileManager instance for file operations
28
+ * @param globMatcher - GlobMatcher instance for file pattern matching
29
+ * @param logger - Pino logger instance
30
+ */
31
+ constructor(fileManager, globMatcher, logger) {
32
+ this.fileManager = fileManager;
33
+ this.globMatcher = globMatcher;
34
+ this.logger = logger;
35
+ }
36
+ /**
37
+ * Acquire a lock on a story file
38
+ *
39
+ * If a lock already exists, checks if it's stale. Stale locks are auto-released
40
+ * and re-acquired. Fresh locks cause a LockConflictError.
41
+ *
42
+ * @param storyPath - Path to the story file to lock
43
+ * @param agentName - Name of the agent acquiring the lock
44
+ * @param sessionId - Unique session ID of the agent
45
+ * @param branch - Git branch the agent is working on (defaults to 'unknown')
46
+ * @throws {LockConflictError} If an active (non-stale) lock exists
47
+ */
48
+ async acquire(storyPath, agentName, sessionId, branch = 'unknown') {
49
+ this.logger.info({ agentName, sessionId, storyPath }, 'Attempting to acquire lock');
50
+ const lockPath = this.resolveLockPath(storyPath);
51
+ const exists = await this.fileManager.fileExists(lockPath);
52
+ if (exists) {
53
+ const existingLock = await this.getLockInfo(storyPath);
54
+ if (existingLock) {
55
+ const stale = await this.isStale(storyPath);
56
+ if (stale) {
57
+ this.logger.info({ storyPath }, 'Stale lock auto-released, re-acquiring');
58
+ await this.release(storyPath);
59
+ }
60
+ else {
61
+ throw new LockConflictError(existingLock);
62
+ }
63
+ }
64
+ else {
65
+ // Lock file exists but is malformed — treat as stale and release
66
+ this.logger.info({ storyPath }, 'Malformed lock detected, auto-releasing and re-acquiring');
67
+ await this.release(storyPath);
68
+ }
69
+ }
70
+ const lockContent = {
71
+ agent_name: agentName,
72
+ agent_session_id: sessionId,
73
+ branch,
74
+ started_at: new Date().toISOString(),
75
+ story_file: storyPath,
76
+ };
77
+ await this.fileManager.writeFile(lockPath, JSON.stringify(lockContent, null, 2));
78
+ this.logger.info({ agentName, sessionId, storyPath }, 'Lock acquired successfully');
79
+ }
80
+ /**
81
+ * Get lock file content for a story
82
+ *
83
+ * @param storyPath - Path to the story file
84
+ * @returns Lock file content if valid, null if missing or malformed
85
+ */
86
+ async getLockInfo(storyPath) {
87
+ const lockPath = this.resolveLockPath(storyPath);
88
+ const exists = await this.fileManager.fileExists(lockPath);
89
+ if (!exists) {
90
+ return null;
91
+ }
92
+ try {
93
+ const content = await this.fileManager.readFile(lockPath);
94
+ const parsed = JSON.parse(content);
95
+ const result = lockFileSchema.safeParse(parsed);
96
+ if (!result.success) {
97
+ this.logger.warn({ lockPath }, 'Lock file contains invalid schema');
98
+ return null;
99
+ }
100
+ return result.data;
101
+ }
102
+ catch {
103
+ this.logger.warn({ lockPath }, 'Lock file contains malformed JSON');
104
+ return null;
105
+ }
106
+ }
107
+ /**
108
+ * Check if a story file is currently locked
109
+ *
110
+ * @param storyPath - Path to the story file
111
+ * @returns True if a valid lock exists
112
+ */
113
+ async isLocked(storyPath) {
114
+ const lockInfo = await this.getLockInfo(storyPath);
115
+ return lockInfo !== null;
116
+ }
117
+ /**
118
+ * Check if an existing lock is stale (expired or corrupt)
119
+ *
120
+ * A lock is stale when:
121
+ * - The lock file exists but contains malformed/unparseable JSON
122
+ * - The lock's started_at timestamp + thresholdMs is less than Date.now()
123
+ * - The lock's started_at is an invalid date
124
+ *
125
+ * A lock is NOT stale when:
126
+ * - No lock file exists
127
+ * - The lock is within the threshold window
128
+ *
129
+ * @param storyPath - Path to the story file
130
+ * @param thresholdMs - Stale threshold in milliseconds (default: 2 hours)
131
+ * @returns True if the lock is stale
132
+ */
133
+ async isStale(storyPath, thresholdMs = LockService.DEFAULT_STALE_THRESHOLD_MS) {
134
+ const lockInfo = await this.getLockInfo(storyPath);
135
+ const lockPath = this.resolveLockPath(storyPath);
136
+ if (lockInfo === null) {
137
+ // getLockInfo returns null for BOTH "missing file" and "malformed JSON"
138
+ // Distinguish: if file exists but getLockInfo is null → malformed → stale
139
+ const exists = await this.fileManager.fileExists(lockPath);
140
+ return exists; // exists + malformed = stale; !exists = not stale
141
+ }
142
+ const startedAt = new Date(lockInfo.started_at).getTime();
143
+ if (Number.isNaN(startedAt))
144
+ return true; // invalid date = stale
145
+ return startedAt + thresholdMs < Date.now();
146
+ }
147
+ /**
148
+ * Release a lock on a story file
149
+ *
150
+ * Idempotent — calling on a non-existent lock does not throw.
151
+ *
152
+ * @param storyPath - Path to the story file
153
+ */
154
+ async release(storyPath) {
155
+ const lockPath = this.resolveLockPath(storyPath);
156
+ await this.fileManager.deleteFile(lockPath);
157
+ this.logger.debug({ storyPath }, 'Lock released');
158
+ }
159
+ /**
160
+ * Find stale lock files in a directory without releasing them
161
+ *
162
+ * Scans the given directory for `.lock` files, checks each for staleness,
163
+ * and returns paths of stale ones. Malformed lock files are treated as stale.
164
+ *
165
+ * @param storiesDir - Directory to scan for lock files
166
+ * @param thresholdMs - Optional stale threshold in milliseconds
167
+ * @returns Array of stale lock file paths (the .lock file paths, not story paths)
168
+ */
169
+ async findStaleLocks(storiesDir, thresholdMs) {
170
+ const pattern = path.join(storiesDir, '**/*.lock');
171
+ const lockFiles = await this.globMatcher.expandPattern(pattern);
172
+ if (lockFiles.length === 0) {
173
+ return [];
174
+ }
175
+ const staleLocks = [];
176
+ for (const lockFile of lockFiles) {
177
+ const storyPath = lockFile.replace(/\.lock$/, '');
178
+ const stale = await this.isStale(storyPath, thresholdMs);
179
+ if (stale) {
180
+ staleLocks.push(lockFile);
181
+ }
182
+ }
183
+ this.logger.info({ found: staleLocks.length, scanned: lockFiles.length, storiesDir }, 'Stale lock scan complete');
184
+ return staleLocks;
185
+ }
186
+ /**
187
+ * Batch cleanup of stale lock files in a directory
188
+ *
189
+ * Scans the given directory for `.lock` files, checks each for staleness,
190
+ * and removes stale ones. Malformed lock files are treated as stale.
191
+ *
192
+ * @param storiesDir - Directory to scan for lock files
193
+ * @param thresholdMs - Optional stale threshold in milliseconds
194
+ * @returns Array of removed lock file paths
195
+ */
196
+ async cleanup(storiesDir, thresholdMs) {
197
+ const pattern = path.join(storiesDir, '**/*.lock');
198
+ const lockFiles = await this.globMatcher.expandPattern(pattern);
199
+ if (lockFiles.length === 0) {
200
+ return [];
201
+ }
202
+ const removed = [];
203
+ for (const lockFile of lockFiles) {
204
+ const storyPath = lockFile.replace(/\.lock$/, '');
205
+ const stale = await this.isStale(storyPath, thresholdMs);
206
+ if (stale) {
207
+ await this.release(storyPath);
208
+ removed.push(lockFile);
209
+ }
210
+ }
211
+ this.logger.info({ removed: removed.length, scanned: lockFiles.length, storiesDir }, 'Lock cleanup complete');
212
+ return removed;
213
+ }
214
+ /**
215
+ * List all active locks in a directory with metadata
216
+ *
217
+ * Scans the given directory for `.lock` files, reads each lock,
218
+ * and returns metadata including stale status. Malformed locks are skipped.
219
+ *
220
+ * @param storiesDir - Directory to scan for lock files
221
+ * @returns Array of lock metadata objects
222
+ */
223
+ async list(storiesDir) {
224
+ const pattern = path.join(storiesDir, '**/*.lock');
225
+ const lockFiles = await this.globMatcher.expandPattern(pattern);
226
+ if (lockFiles.length === 0) {
227
+ return [];
228
+ }
229
+ const results = [];
230
+ for (const lockFile of lockFiles) {
231
+ const storyPath = lockFile.replace(/\.lock$/, '');
232
+ const lockInfo = await this.getLockInfo(storyPath);
233
+ if (lockInfo === null) {
234
+ this.logger.warn({ lockFile }, 'Skipping malformed lock file');
235
+ continue;
236
+ }
237
+ const stale = await this.isStale(storyPath);
238
+ results.push({ isStale: stale, lock: lockInfo, storyPath });
239
+ }
240
+ return results;
241
+ }
242
+ /**
243
+ * List all locks across multiple story directories with stale status
244
+ *
245
+ * Scans each directory for `.lock` files, reads and validates each lock,
246
+ * and returns metadata including stale status. Malformed locks are skipped
247
+ * with a warning. Results are sorted by started_at descending (newest first).
248
+ *
249
+ * @param storyDirs - Array of directories to scan for lock files
250
+ * @param thresholdMs - Optional stale threshold in milliseconds (default: DEFAULT_STALE_THRESHOLD_MS)
251
+ * @returns Array of lock metadata with stale boolean, sorted newest first
252
+ */
253
+ async listLocks(storyDirs, thresholdMs = LockService.DEFAULT_STALE_THRESHOLD_MS) {
254
+ const allLocks = [];
255
+ for (const dir of storyDirs) {
256
+ const pattern = path.join(dir, '**/*.lock');
257
+ let lockFiles;
258
+ try {
259
+ lockFiles = await this.globMatcher.expandPattern(pattern);
260
+ }
261
+ catch {
262
+ this.logger.warn({ dir }, 'Could not scan directory for locks — skipping');
263
+ continue;
264
+ }
265
+ for (const lockFile of lockFiles) {
266
+ const storyPath = lockFile.replace(/\.lock$/, '');
267
+ const lockInfo = await this.getLockInfo(storyPath);
268
+ if (lockInfo === null) {
269
+ this.logger.warn({ lockFile }, 'Skipping malformed lock file in listLocks');
270
+ continue;
271
+ }
272
+ const stale = await this.isStale(storyPath, thresholdMs);
273
+ allLocks.push({ ...lockInfo, stale });
274
+ }
275
+ }
276
+ // Sort by started_at descending (newest first)
277
+ allLocks.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
278
+ this.logger.info({ count: allLocks.length, dirs: storyDirs.length }, 'listLocks scan complete');
279
+ return allLocks;
280
+ }
281
+ /**
282
+ * Resolve the lock file path for a given story path
283
+ *
284
+ * @param storyPath - Path to the story file
285
+ * @returns Path to the corresponding lock file
286
+ */
287
+ resolveLockPath(storyPath) {
288
+ return `${storyPath}.lock`;
289
+ }
290
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * LockedStoryDispatcher
3
+ *
4
+ * Lock-aware story processing wrapper that acquires a git-backed lock
5
+ * before processing a story and releases it after completion.
6
+ * When locking is disabled (lockService is null), acts as a passthrough.
7
+ */
8
+ import type pino from 'pino';
9
+ import type { DispatchResult } from '../../models/dispatch.js';
10
+ import type { Story } from '../../models/story.js';
11
+ import type { GitBackedLockService } from '../lock/git-backed-lock-service.js';
12
+ /**
13
+ * Dispatches stories with optional lock coordination
14
+ *
15
+ * When a lockService is provided, acquires a lock before processing
16
+ * and releases it in a finally block. When null, calls processFn directly.
17
+ */
18
+ export declare class LockedStoryDispatcher {
19
+ private readonly lockService;
20
+ private readonly logger;
21
+ private readonly agentName;
22
+ private readonly sessionId;
23
+ /**
24
+ * Create a new LockedStoryDispatcher
25
+ *
26
+ * @param lockService - GitBackedLockService instance, or null to disable locking
27
+ * @param logger - Pino logger instance
28
+ * @param agentName - Name of the agent processing stories
29
+ * @param sessionId - Unique session ID for this pipeline run
30
+ */
31
+ constructor(lockService: GitBackedLockService | null, logger: pino.Logger, agentName: string, sessionId: string);
32
+ /**
33
+ * Dispatch a story with optional lock coordination
34
+ *
35
+ * @param story - Story to process
36
+ * @param processFn - Async function that processes the story
37
+ * @returns DispatchResult indicating outcome
38
+ */
39
+ dispatch(story: Story, processFn: (story: Story) => Promise<void>): Promise<DispatchResult>;
40
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * LockedStoryDispatcher
3
+ *
4
+ * Lock-aware story processing wrapper that acquires a git-backed lock
5
+ * before processing a story and releases it after completion.
6
+ * When locking is disabled (lockService is null), acts as a passthrough.
7
+ */
8
+ import { LockConflictError, PushRejectedError } from '../../models/lock.js';
9
+ /**
10
+ * Dispatches stories with optional lock coordination
11
+ *
12
+ * When a lockService is provided, acquires a lock before processing
13
+ * and releases it in a finally block. When null, calls processFn directly.
14
+ */
15
+ export class LockedStoryDispatcher {
16
+ lockService;
17
+ logger;
18
+ agentName;
19
+ sessionId;
20
+ /**
21
+ * Create a new LockedStoryDispatcher
22
+ *
23
+ * @param lockService - GitBackedLockService instance, or null to disable locking
24
+ * @param logger - Pino logger instance
25
+ * @param agentName - Name of the agent processing stories
26
+ * @param sessionId - Unique session ID for this pipeline run
27
+ */
28
+ constructor(lockService, logger, agentName, sessionId) {
29
+ this.lockService = lockService;
30
+ this.logger = logger;
31
+ this.agentName = agentName;
32
+ this.sessionId = sessionId;
33
+ }
34
+ /**
35
+ * Dispatch a story with optional lock coordination
36
+ *
37
+ * @param story - Story to process
38
+ * @param processFn - Async function that processes the story
39
+ * @returns DispatchResult indicating outcome
40
+ */
41
+ async dispatch(story, processFn) {
42
+ // Passthrough mode when locking is disabled
43
+ if (!this.lockService) {
44
+ await processFn(story);
45
+ return { status: 'processed' };
46
+ }
47
+ const storyPath = story.filePath;
48
+ const cwd = process.cwd();
49
+ // Attempt to acquire lock
50
+ try {
51
+ await this.lockService.acquire(storyPath, this.agentName, this.sessionId, cwd);
52
+ }
53
+ catch (error) {
54
+ if (error instanceof LockConflictError) {
55
+ this.logger.info({ story: story.fullNumber, reason: error.message }, 'Story locked, skipping');
56
+ return { reason: error.message, status: 'skipped' };
57
+ }
58
+ if (error instanceof PushRejectedError) {
59
+ this.logger.warn({ error, storyId: story.fullNumber }, 'Lock acquire push failed, skipping story');
60
+ return { reason: `Lock acquire push failed for story "${story.fullNumber}"`, status: 'skipped' };
61
+ }
62
+ // Catch-all: any other error — skip (never crash the pipeline)
63
+ const err = error;
64
+ this.logger.error({ error: err.stack, storyId: story.fullNumber }, 'Unexpected error during lock acquire, skipping story');
65
+ return { reason: err.message, status: 'skipped' };
66
+ }
67
+ // Lock acquired — process and release in finally
68
+ try {
69
+ await processFn(story);
70
+ return { status: 'processed' };
71
+ }
72
+ catch (error) {
73
+ return { reason: error.message, status: 'failed' };
74
+ }
75
+ finally {
76
+ try {
77
+ await this.lockService.release(storyPath, cwd);
78
+ }
79
+ catch (releaseError) {
80
+ this.logger.error({ error: releaseError.message, story: story.fullNumber }, 'Failed to release lock');
81
+ }
82
+ }
83
+ }
84
+ }
@@ -46,7 +46,9 @@
46
46
  import type pino from 'pino';
47
47
  import type { InputDetectionResult, WorkflowCallbacks, WorkflowConfig, WorkflowResult } from '../../models/index.js';
48
48
  import type { AIProviderRunner } from '../agents/agent-runner.js';
49
+ import type { AgentRunnerFactory } from '../agents/agent-runner-factory.js';
49
50
  import type { WorkflowLogger } from '../logging/workflow-logger.js';
51
+ import type { GitBackedLockService } from '../lock/git-backed-lock-service.js';
50
52
  import { AssetResolver } from '../file-system/asset-resolver.js';
51
53
  import { FileManager } from '../file-system/file-manager.js';
52
54
  import { PathResolver } from '../file-system/path-resolver.js';
@@ -80,6 +82,8 @@ export interface InputDetector {
80
82
  export interface WorkflowOrchestratorConfig {
81
83
  /** Service to execute AI agents (Claude or Gemini) */
82
84
  agentRunner: AIProviderRunner;
85
+ /** Optional factory for discovery-based transport selection (Channel vs subprocess) */
86
+ agentRunnerFactory?: AgentRunnerFactory;
83
87
  /** Optional AssetResolver for three-level path resolution (CLI flag → config → bundled) */
84
88
  assetResolver?: AssetResolver;
85
89
  /** Service to handle parallel batch processing */
@@ -92,6 +96,8 @@ export interface WorkflowOrchestratorConfig {
92
96
  inputDetector: InputDetector;
93
97
  /** Logger instance for structured logging */
94
98
  logger: pino.Logger;
99
+ /** Optional GitBackedLockService for multi-session story locking */
100
+ lockService?: GitBackedLockService;
95
101
  /** Optional MCP context injector for tool discovery in agent prompts */
96
102
  mcpContextInjector?: McpContextInjector;
97
103
  /** Service to resolve file paths from config */
@@ -150,6 +156,7 @@ export interface StoryPromptOptions {
150
156
  */
151
157
  export declare class WorkflowOrchestrator {
152
158
  private readonly agentRunner;
159
+ private readonly agentRunnerFactory?;
153
160
  private readonly assetResolver;
154
161
  private readonly batchProcessor;
155
162
  private readonly callbacks?;
@@ -157,6 +164,7 @@ export declare class WorkflowOrchestrator {
157
164
  private readonly fileManager;
158
165
  private readonly fileScaffolder;
159
166
  private readonly inputDetector;
167
+ private readonly lockService?;
160
168
  private readonly logger;
161
169
  private readonly mcpContextInjector?;
162
170
  private readonly pathResolver;
@@ -171,6 +179,29 @@ export declare class WorkflowOrchestrator {
171
179
  * @param config - Configuration object containing all service dependencies
172
180
  */
173
181
  constructor(config: WorkflowOrchestratorConfig);
182
+ /**
183
+ * Select the appropriate runner for the given agent type and config.
184
+ *
185
+ * When agentRunnerFactory is set and config.useChannels is true, delegates
186
+ * to the factory for discovery-based transport selection. Otherwise returns
187
+ * the default subprocess runner.
188
+ */
189
+ private getRunner;
190
+ /**
191
+ * Build stream callback options from workflow config.
192
+ * Returns onStream (spinner summary) and/or onStreamVerbose (raw passthrough)
193
+ * based on config.stream flag.
194
+ */
195
+ private getStreamCallbacks;
196
+ /**
197
+ * Resolve session name template with placeholders.
198
+ *
199
+ * @param config - Workflow config containing the sessionName template
200
+ * @param phase - Current phase name (e.g., "dev", "epic", "story")
201
+ * @param storyId - Optional story/epic identifier for {story} placeholder
202
+ * @returns Resolved session name string, or undefined if no template
203
+ */
204
+ private resolveSessionName;
174
205
  /**
175
206
  * Execute the complete workflow
176
207
  *