@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
@@ -1,6 +1,7 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import { execSync } from 'node:child_process';
3
- import { basename } from 'node:path';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
4
5
  import { parseDuration } from '../utils/duration.js';
5
6
  import { AssetResolver } from '../services/file-system/asset-resolver.js';
6
7
  import { mergeExecutionConfig } from '../utils/config-merge.js';
@@ -8,7 +9,12 @@ import { createAgentRunner, isProviderSupported } from '../services/agents/agent
8
9
  import { getRegisteredScannerNames } from '../services/review/scanner-factory.js';
9
10
  import { Severity } from '../services/review/types.js';
10
11
  import { FileManager } from '../services/file-system/file-manager.js';
12
+ import { GlobMatcher } from '../services/file-system/glob-matcher.js';
11
13
  import { PathResolver } from '../services/file-system/path-resolver.js';
14
+ import { GitOps } from '../services/git/git-ops.js';
15
+ import { PushConflictHandler } from '../services/git/push-conflict-handler.js';
16
+ import { GitBackedLockService } from '../services/lock/git-backed-lock-service.js';
17
+ import { LockService } from '../services/lock/lock-service.js';
12
18
  import { BatchProcessor } from '../services/orchestration/batch-processor.js';
13
19
  import { InputDetector } from '../services/orchestration/input-detector.js';
14
20
  import { StoryTypeDetector } from '../services/orchestration/story-type-detector.js';
@@ -102,6 +108,11 @@ export default class Workflow extends Command {
102
108
  model: Flags.string({
103
109
  description: 'Model to use for AI provider. Format varies by provider: Claude (claude-opus-4-5-20251101), Gemini (gemini-2.5-pro), OpenCode (anthropic/claude-3-5-sonnet)',
104
110
  }),
111
+ 'session-name': Flags.string({
112
+ char: 'n',
113
+ description: 'Session display name template. Supports {prefix}, {phase}, {story} placeholders. ' +
114
+ 'Auto-generated from input filename if not provided.',
115
+ }),
105
116
  prefix: Flags.string({
106
117
  default: '',
107
118
  description: 'Filename prefix for generated files and session directory name',
@@ -170,6 +181,10 @@ export default class Workflow extends Command {
170
181
  default: false,
171
182
  description: 'Skip epic creation phase',
172
183
  }),
184
+ 'skip-locking': Flags.boolean({
185
+ default: false,
186
+ description: 'Disable story locking (for single-agent runs)',
187
+ }),
173
188
  'skip-stories': Flags.boolean({
174
189
  default: false,
175
190
  description: 'Skip story creation phase',
@@ -197,6 +212,16 @@ export default class Workflow extends Command {
197
212
  default: 5000,
198
213
  description: 'Backoff delay between retries in milliseconds',
199
214
  }),
215
+ 'use-channels': Flags.boolean({
216
+ default: false,
217
+ description: 'Use Channel transport for agent communication (requires pre-started Channel agents)',
218
+ helpGroup: 'Channel Transport',
219
+ }),
220
+ stream: Flags.boolean({
221
+ default: false,
222
+ description: 'Stream full Claude output to stdout in real-time (verbose passthrough of everything Claude does)',
223
+ helpGroup: 'Output',
224
+ }),
200
225
  verbose: Flags.boolean({
201
226
  char: 'v',
202
227
  default: false,
@@ -234,6 +259,8 @@ export default class Workflow extends Command {
234
259
  this.error('--cwd and --worktree are mutually exclusive. --worktree sets cwd automatically.', { exit: 1 });
235
260
  }
236
261
  const entities = flags['gut-entities'].split(',').map((e) => e.trim()).filter(Boolean);
262
+ // Preflight: validate gut entities exist before attempting worktree creation
263
+ this.validateGutEntities(entities);
237
264
  const branchSlug = `workflow/${basename(inputPath, '.md').replace(/^PRD-/, '').toLowerCase()}-${Date.now()}`;
238
265
  const installFlag = flags['worktree-install'] ? '--install' : '';
239
266
  this.log(colors.info(`Creating isolated worktree: ${branchSlug}`));
@@ -296,8 +323,8 @@ export default class Workflow extends Command {
296
323
  if (!isProviderSupported(merged.provider)) {
297
324
  this.error(`Unsupported provider: ${merged.provider}. Use 'claude', 'gemini', or 'opencode'.`, { exit: 1 });
298
325
  }
299
- // Initialize services with parallel concurrency and provider
300
- await this.initializeServices(merged.parallel, merged.provider, flags.verbose);
326
+ // Initialize services with parallel concurrency, provider, and locking
327
+ await this.initializeServices(merged.parallel, merged.provider, flags.verbose, !flags['skip-locking']);
301
328
  // Register signal handlers
302
329
  this.registerSignalHandlers();
303
330
  // Initialize session scaffolder and create session structure
@@ -343,6 +370,7 @@ export default class Workflow extends Command {
343
370
  dryRun: flags['dry-run'],
344
371
  epicInterval: merged.epicInterval,
345
372
  input: args.input,
373
+ lockingEnabled: !flags['skip-locking'],
346
374
  maxRetries: merged.maxRetries,
347
375
  mcp: flags.mcp || undefined,
348
376
  mcpPhases: flags['mcp-phases'] ? flags['mcp-phases'].split(',').map((s) => s.trim()) : undefined,
@@ -369,6 +397,10 @@ export default class Workflow extends Command {
369
397
  skipStories: flags['skip-stories'],
370
398
  storyInterval: merged.storyInterval,
371
399
  timeout: merged.timeout,
400
+ sessionName: flags['session-name'],
401
+ inputPath: args.input,
402
+ stream: flags.stream,
403
+ useChannels: flags['use-channels'],
372
404
  verbose: flags.verbose,
373
405
  };
374
406
  // Validate --mcp-phases when --mcp is enabled
@@ -898,7 +930,7 @@ export default class Workflow extends Command {
898
930
  * @param verbose - Enable verbose output mode
899
931
  * @private
900
932
  */
901
- async initializeServices(maxConcurrency = 3, provider = 'claude', verbose = false) {
933
+ async initializeServices(maxConcurrency = 3, provider = 'claude', verbose = false, lockingEnabled = false) {
902
934
  // Create logger — suppress INFO logs in non-verbose mode to keep terminal clean
903
935
  this.logger = createLogger({ level: verbose ? 'info' : 'warn', namespace: 'commands:workflow' });
904
936
  this.logger.info({ provider }, 'Initializing services with AI provider');
@@ -923,6 +955,16 @@ export default class Workflow extends Command {
923
955
  nonTTY: !process.stdout.isTTY,
924
956
  verbose,
925
957
  });
958
+ // Conditionally instantiate lock service for multi-session coordination
959
+ let lockService;
960
+ if (lockingEnabled) {
961
+ const globMatcher = new GlobMatcher(fileManager, this.logger);
962
+ const gitOps = new GitOps();
963
+ const conflictHandler = new PushConflictHandler(gitOps);
964
+ const lockSvc = new LockService(fileManager, globMatcher, this.logger);
965
+ lockService = new GitBackedLockService(lockSvc, gitOps, conflictHandler);
966
+ this.logger.info('GitBackedLockService instantiated for multi-session coordination');
967
+ }
926
968
  // Merge scaffolder callbacks with reporter callbacks for dual-channel output (AC: #1)
927
969
  const callbacks = this.mergeCallbacks(this.createScaffolderCallbacks(), this.reporter.getCallbacks());
928
970
  // Create orchestrator with merged callbacks
@@ -933,6 +975,7 @@ export default class Workflow extends Command {
933
975
  epicParser,
934
976
  fileManager,
935
977
  inputDetector,
978
+ lockService,
936
979
  logger: this.logger,
937
980
  pathResolver,
938
981
  prdParser,
@@ -975,4 +1018,77 @@ export default class Workflow extends Command {
975
1018
  this.log('\n' + colors.warning('⚠️ Cancellation requested... completing current operation'));
976
1019
  });
977
1020
  }
1021
+ /**
1022
+ * Validate that all requested gut entities exist before attempting worktree creation.
1023
+ * Reads .gut/config.json directly for fast validation without subprocess overhead.
1024
+ * Fails fast with clear error message listing valid entities and fuzzy suggestions.
1025
+ */
1026
+ validateGutEntities(entityNames) {
1027
+ const gutConfigPath = join(process.cwd(), '.gut', 'config.json');
1028
+ if (!existsSync(gutConfigPath)) {
1029
+ this.error('gut is not initialized in this workspace. Run \'gut init\' first.', { exit: 1 });
1030
+ }
1031
+ let allEntities;
1032
+ try {
1033
+ const content = readFileSync(gutConfigPath, 'utf8');
1034
+ const config = JSON.parse(content);
1035
+ allEntities = config.entities || [];
1036
+ }
1037
+ catch {
1038
+ this.error('Failed to read .gut/config.json. Ensure the file is valid JSON.', { exit: 1 });
1039
+ }
1040
+ const entityMap = new Map(allEntities.map((e) => [e.name, e]));
1041
+ const invalidEntities = entityNames.filter((name) => !entityMap.has(name));
1042
+ if (invalidEntities.length > 0) {
1043
+ // Entity name doesn't exist in config — show suggestions and bail
1044
+ this.showEntityError(invalidEntities, allEntities);
1045
+ }
1046
+ // Entity names are valid — now check if paths actually exist (uncloned submodules, missing repos)
1047
+ const uninitializedEntities = [];
1048
+ for (const name of entityNames) {
1049
+ const entity = entityMap.get(name);
1050
+ const entityPath = join(process.cwd(), entity.path);
1051
+ const hasContent = existsSync(join(entityPath, 'package.json')) ||
1052
+ existsSync(join(entityPath, '.git')) ||
1053
+ existsSync(join(entityPath, 'Cargo.toml')) ||
1054
+ existsSync(join(entityPath, 'go.mod'));
1055
+ if (!hasContent) {
1056
+ uninitializedEntities.push({ name, path: entity.path });
1057
+ }
1058
+ }
1059
+ if (uninitializedEntities.length > 0) {
1060
+ const details = uninitializedEntities
1061
+ .map((e) => ` • ${e.name} @ ${e.path}`)
1062
+ .join('\n');
1063
+ this.error(`These gut entities are registered but their paths are empty or uninitialized:\n${details}\n\n` +
1064
+ 'This usually means the git submodule was not cloned. Try:\n' +
1065
+ uninitializedEntities.map((e) => ` git submodule update --init ${e.path}`).join('\n') +
1066
+ '\n\nOr clone via gut:\n' +
1067
+ uninitializedEntities.map((e) => ` gut entity clone ${e.name}`).join('\n'), { exit: 1 });
1068
+ }
1069
+ this.log(colors.info(`Preflight: all ${entityNames.length} gut entities validated ✓`));
1070
+ }
1071
+ showEntityError(invalidEntities, allEntities) {
1072
+ // Build helpful error with suggestions
1073
+ const suggestions = allEntities
1074
+ .filter((e) => invalidEntities.some((invalid) => e.name.includes(invalid) ||
1075
+ invalid.includes(e.name) ||
1076
+ invalid.split('-').some((part) => part.length > 2 && e.name.includes(part))))
1077
+ .map((e) => e.name)
1078
+ .slice(0, 5);
1079
+ let msg = `Invalid --gut-entities: ${invalidEntities.join(', ')}\n\n`;
1080
+ msg += `Available entities (${allEntities.length} total):\n`;
1081
+ msg += allEntities
1082
+ .slice(0, 15)
1083
+ .map((e) => ` • ${e.name} (${e.type}) @ ${e.path}`)
1084
+ .join('\n');
1085
+ if (allEntities.length > 15) {
1086
+ msg += `\n ... and ${allEntities.length - 15} more (run 'gut entity list' to see all)`;
1087
+ }
1088
+ if (suggestions.length > 0) {
1089
+ msg += `\n\nDid you mean: ${suggestions.join(', ')}?`;
1090
+ }
1091
+ msg += '\n\nTo add a missing entity: gut entity add <name> <type> -p <path>';
1092
+ this.error(msg, { exit: 1 });
1093
+ }
978
1094
  }
@@ -15,6 +15,17 @@ export type OnPromptCallback = (prompt: string, options: AgentOptionsWithoutProm
15
15
  * Callback invoked when a response is received from Claude
16
16
  */
17
17
  export type OnResponseCallback = (result: AgentResult) => Promise<void> | void;
18
+ /**
19
+ * Callback invoked for each meaningful stream event during agent execution.
20
+ * Only fired when the provider supports streaming (Claude with stream-json).
21
+ * Receives a human-readable summary of what the agent is doing.
22
+ */
23
+ export type OnStreamCallback = (summary: string) => void;
24
+ /**
25
+ * Callback invoked for every raw stream-json line (verbose mode).
26
+ * Receives the parsed assistant text or tool_result output verbatim.
27
+ */
28
+ export type OnStreamVerboseCallback = (text: string) => void;
18
29
  /**
19
30
  * Options for executing a Claude AI agent
20
31
  */
@@ -46,6 +57,17 @@ export interface AgentOptions {
46
57
  * Callback invoked when response is received (for logging)
47
58
  */
48
59
  onResponse?: OnResponseCallback;
60
+ /**
61
+ * Callback invoked for each meaningful stream event during execution.
62
+ * Receives a human-readable summary like "🔧 Edit: src/foo.ts" or "💬 Running tests..."
63
+ * Only works with Claude provider (stream-json output mode).
64
+ */
65
+ onStream?: OnStreamCallback;
66
+ /**
67
+ * Callback invoked with full raw text from every assistant message and tool result.
68
+ * Used for verbose/passthrough mode where the caller wants to see everything Claude outputs.
69
+ */
70
+ onStreamVerbose?: OnStreamVerboseCallback;
49
71
  /**
50
72
  * The prompt to execute with the agent
51
73
  */
@@ -63,4 +85,15 @@ export interface AgentOptions {
63
85
  * Timeout in milliseconds (default: 1800000ms = 30 minutes)
64
86
  */
65
87
  timeout?: number;
88
+ /**
89
+ * Phase name context (used by Channel transport for callback reporting)
90
+ */
91
+ phaseName?: string;
92
+ /**
93
+ * Session display name passed to Claude via --name flag
94
+ *
95
+ * When set, the spawned Claude session gets this name, making it
96
+ * visible in /resume and terminal title. Claude-only feature.
97
+ */
98
+ sessionName?: string;
66
99
  }
@@ -1,5 +1,9 @@
1
1
  /**
2
- * Result of executing a Claude AI agent
2
+ * Transport mechanism used to execute the agent
3
+ */
4
+ export type TransportType = 'subprocess' | 'channel';
5
+ /**
6
+ * Result of executing an AI agent
3
7
  */
4
8
  export interface AgentResult {
5
9
  /**
@@ -26,4 +30,9 @@ export interface AgentResult {
26
30
  * Whether the agent execution was successful
27
31
  */
28
32
  success: boolean;
33
+ /**
34
+ * Transport mechanism used to execute the agent.
35
+ * Optional for backward compatibility — defaults to 'subprocess' when omitted.
36
+ */
37
+ transport?: TransportType;
29
38
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Dispatch Result Model
3
+ *
4
+ * Represents the outcome of a lock-aware story dispatch operation.
5
+ * Used by LockedStoryDispatcher to communicate whether a story was
6
+ * processed, skipped (locked), or failed.
7
+ */
8
+ /**
9
+ * Result of dispatching a story through the lock-aware dispatcher
10
+ */
11
+ export interface DispatchResult {
12
+ /** Outcome of the dispatch operation */
13
+ status: 'failed' | 'processed' | 'skipped';
14
+ /** Human-readable reason (populated for skipped/failed) */
15
+ reason?: string;
16
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Dispatch Result Model
3
+ *
4
+ * Represents the outcome of a lock-aware story dispatch operation.
5
+ * Used by LockedStoryDispatcher to communicate whether a story was
6
+ * processed, skipped (locked), or failed.
7
+ */
8
+ export {};
@@ -3,9 +3,12 @@
3
3
  */
4
4
  export * from './agent-options.js';
5
5
  export * from './agent-result.js';
6
+ export * from './dispatch.js';
6
7
  export * from './phase-result.js';
7
8
  export * from './provider.js';
8
9
  export * from './story.js';
9
10
  export * from './workflow-callbacks.js';
10
11
  export * from './workflow-config.js';
11
12
  export * from './workflow-result.js';
13
+ export type { SessionConfig, SessionHandle } from '../services/agents/channel-session-manager.js';
14
+ export { SessionStartTimeoutError } from '../services/agents/channel-session-manager.js';
@@ -3,9 +3,11 @@
3
3
  */
4
4
  export * from './agent-options.js';
5
5
  export * from './agent-result.js';
6
+ export * from './dispatch.js';
6
7
  export * from './phase-result.js';
7
8
  export * from './provider.js';
8
9
  export * from './story.js';
9
10
  export * from './workflow-callbacks.js';
10
11
  export * from './workflow-config.js';
11
12
  export * from './workflow-result.js';
13
+ export { SessionStartTimeoutError } from '../services/agents/channel-session-manager.js';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Lock File Model
3
+ *
4
+ * Defines the structure and validation schema for story lock files.
5
+ * Lock files prevent concurrent agent sessions from modifying the same story.
6
+ */
7
+ import { z } from 'zod';
8
+ import { BaseError } from '../utils/errors.js';
9
+ /**
10
+ * Lock file content interface
11
+ *
12
+ * Represents the JSON structure stored in `.lock` files alongside story files.
13
+ */
14
+ export interface LockFileContent {
15
+ /** The unique session ID of the agent */
16
+ agent_session_id: string;
17
+ /** The agent name that acquired the lock */
18
+ agent_name: string;
19
+ /** ISO 8601 timestamp when the lock was acquired */
20
+ started_at: string;
21
+ /** The git branch the agent is working on */
22
+ branch: string;
23
+ /** Path to the story file being locked */
24
+ story_file: string;
25
+ }
26
+ /**
27
+ * Zod schema for runtime validation of lock file content
28
+ *
29
+ * Validates all 5 fields with strict typing:
30
+ * - agent_session_id must be a valid UUID
31
+ * - started_at must be a valid ISO 8601 datetime
32
+ * - All other string fields must be non-empty
33
+ * - Unknown keys are rejected (.strict())
34
+ */
35
+ export declare const lockFileSchema: z.ZodObject<{
36
+ agent_session_id: z.ZodString;
37
+ agent_name: z.ZodString;
38
+ started_at: z.ZodString;
39
+ branch: z.ZodString;
40
+ story_file: z.ZodString;
41
+ }, z.core.$strict>;
42
+ /**
43
+ * Lock conflict error for story locking failures
44
+ *
45
+ * Used when an agent attempts to acquire a lock on a story that is already locked
46
+ * by another agent session.
47
+ */
48
+ /**
49
+ * Push rejected error for lock acquire failures
50
+ *
51
+ * Used when a git push is rejected during lock acquisition (non-fast-forward
52
+ * after retry exhaustion, network errors, etc.).
53
+ */
54
+ export declare class PushRejectedError extends BaseError {
55
+ /**
56
+ * Create a new PushRejectedError
57
+ *
58
+ * @param params - Object with storyFile and reason
59
+ * @example
60
+ * throw new PushRejectedError({ storyFile: 'docs/stories/STORY-1.001.md', reason: 'non-fast-forward' })
61
+ */
62
+ constructor({ storyFile, reason }: {
63
+ storyFile: string;
64
+ reason: string;
65
+ });
66
+ }
67
+ export declare class LockConflictError extends BaseError {
68
+ /**
69
+ * The existing lock content that caused the conflict
70
+ */
71
+ readonly lockContent: LockFileContent;
72
+ /**
73
+ * Create a new LockConflictError
74
+ *
75
+ * @param lockContent - The existing lock file content causing the conflict
76
+ * @example
77
+ * throw new LockConflictError(existingLock)
78
+ */
79
+ constructor(lockContent: LockFileContent);
80
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Lock File Model
3
+ *
4
+ * Defines the structure and validation schema for story lock files.
5
+ * Lock files prevent concurrent agent sessions from modifying the same story.
6
+ */
7
+ import { z } from 'zod';
8
+ import { BaseError, ERR_LOCK } from '../utils/errors.js';
9
+ /**
10
+ * Zod schema for runtime validation of lock file content
11
+ *
12
+ * Validates all 5 fields with strict typing:
13
+ * - agent_session_id must be a valid UUID
14
+ * - started_at must be a valid ISO 8601 datetime
15
+ * - All other string fields must be non-empty
16
+ * - Unknown keys are rejected (.strict())
17
+ */
18
+ export const lockFileSchema = z.object({
19
+ agent_session_id: z.string().uuid(),
20
+ agent_name: z.string().min(1),
21
+ started_at: z.string().datetime(),
22
+ branch: z.string().min(1),
23
+ story_file: z.string().min(1),
24
+ }).strict();
25
+ /**
26
+ * Lock conflict error for story locking failures
27
+ *
28
+ * Used when an agent attempts to acquire a lock on a story that is already locked
29
+ * by another agent session.
30
+ */
31
+ /**
32
+ * Push rejected error for lock acquire failures
33
+ *
34
+ * Used when a git push is rejected during lock acquisition (non-fast-forward
35
+ * after retry exhaustion, network errors, etc.).
36
+ */
37
+ export class PushRejectedError extends BaseError {
38
+ /**
39
+ * Create a new PushRejectedError
40
+ *
41
+ * @param params - Object with storyFile and reason
42
+ * @example
43
+ * throw new PushRejectedError({ storyFile: 'docs/stories/STORY-1.001.md', reason: 'non-fast-forward' })
44
+ */
45
+ constructor({ storyFile, reason }) {
46
+ super(`Push rejected while acquiring lock for "${storyFile}": ${reason}`, ERR_LOCK, { reason, story_file: storyFile }, 'A git push was rejected during lock acquisition. This may indicate a concurrent operation or network issue.');
47
+ }
48
+ }
49
+ export class LockConflictError extends BaseError {
50
+ /**
51
+ * The existing lock content that caused the conflict
52
+ */
53
+ lockContent;
54
+ /**
55
+ * Create a new LockConflictError
56
+ *
57
+ * @param lockContent - The existing lock file content causing the conflict
58
+ * @example
59
+ * throw new LockConflictError(existingLock)
60
+ */
61
+ constructor(lockContent) {
62
+ super(`Story "${lockContent.story_file}" is locked by agent "${lockContent.agent_name}" (session: ${lockContent.agent_session_id})`, ERR_LOCK, {
63
+ agent_name: lockContent.agent_name,
64
+ story_file: lockContent.story_file,
65
+ session_id: lockContent.agent_session_id,
66
+ }, `Wait for agent "${lockContent.agent_name}" to finish or release the lock manually.`);
67
+ this.lockContent = lockContent;
68
+ }
69
+ }
@@ -37,6 +37,14 @@ export interface PhaseResult {
37
37
  * @default false
38
38
  */
39
39
  skipped: boolean;
40
+ /**
41
+ * Count of stories skipped due to lock conflicts
42
+ *
43
+ * Only populated when lockingEnabled is true and another session
44
+ * held the lock on a story at dispatch time.
45
+ * @default 0
46
+ */
47
+ skippedCount?: number;
40
48
  /**
41
49
  * Count of successful operations in this phase
42
50
  *
@@ -7,7 +7,7 @@
7
7
  export const PROVIDER_CONFIGS = {
8
8
  claude: {
9
9
  command: 'claude',
10
- flags: ['--dangerously-skip-permissions', '--print'],
10
+ flags: ['--dangerously-skip-permissions', '-p', '--output-format', 'stream-json', '--verbose'],
11
11
  modelFlag: '--model',
12
12
  supportsFileReferences: true,
13
13
  },
@@ -98,6 +98,27 @@ export interface LayerContext {
98
98
  */
99
99
  failureCount?: number;
100
100
  }
101
+ /**
102
+ * Reason why the factory fell back from Channel transport to subprocess.
103
+ */
104
+ export type ChannelFallbackReason = 'not_discovered' | 'connection_refused' | 'timeout' | 'health_check_failed';
105
+ /**
106
+ * Context for Channel connect/disconnect lifecycle events.
107
+ */
108
+ export interface ChannelContext {
109
+ sessionId: string;
110
+ port: number;
111
+ agentType: string;
112
+ phaseName: string;
113
+ }
114
+ /**
115
+ * Context for Channel fallback events (Channel → subprocess).
116
+ */
117
+ export interface ChannelFallbackContext {
118
+ agentType: string;
119
+ reason: ChannelFallbackReason;
120
+ phaseName: string;
121
+ }
101
122
  /**
102
123
  * Context for spawn (agent execution) lifecycle events
103
124
  *
@@ -160,6 +181,10 @@ export interface SpawnContext {
160
181
  * Worker ID for pipelined execution
161
182
  */
162
183
  workerId?: number;
184
+ /**
185
+ * Transport mechanism used: 'subprocess' (claude -p) or 'channel' (persistent session)
186
+ */
187
+ transport?: 'subprocess' | 'channel';
163
188
  /**
164
189
  * Additional metadata
165
190
  */
@@ -248,4 +273,16 @@ export interface WorkflowCallbacks {
248
273
  * Called when an error occurs
249
274
  */
250
275
  onError?: (context: ErrorContext) => void;
276
+ /**
277
+ * Called when a Channel agent connection is established (first successful envelope exchange)
278
+ */
279
+ onChannelConnect?: (context: ChannelContext) => void;
280
+ /**
281
+ * Called when a Channel agent connection is terminated
282
+ */
283
+ onChannelDisconnect?: (context: ChannelContext) => void;
284
+ /**
285
+ * Called when the factory falls back from Channel transport to subprocess
286
+ */
287
+ onChannelFallback?: (context: ChannelFallbackContext) => void;
251
288
  }
@@ -70,6 +70,14 @@ export interface WorkflowConfig {
70
70
  * @default 3
71
71
  */
72
72
  parallel: number;
73
+ /**
74
+ * Enable git-backed story locking for multi-session coordination
75
+ *
76
+ * When true, stories are locked before processing and unlocked after completion,
77
+ * preventing parallel sessions from working the same story simultaneously.
78
+ * @default false
79
+ */
80
+ lockingEnabled?: boolean;
73
81
  /**
74
82
  * Enable pipelined workflow execution
75
83
  *
@@ -255,6 +263,40 @@ export interface WorkflowConfig {
255
263
  * @default 5000 (5 seconds)
256
264
  */
257
265
  retryBackoffMs?: number;
266
+ /**
267
+ * Enable Channel transport for agent execution
268
+ *
269
+ * When true, the AgentRunnerFactory discovers Channel-capable agents
270
+ * via listAgents() and returns a ChannelAgentRunner when a match is found.
271
+ * Falls back to subprocess runner when no Channel agent is discovered.
272
+ * @default false
273
+ */
274
+ useChannels?: boolean;
275
+ /**
276
+ * Timeout for Channel agent requests in milliseconds
277
+ *
278
+ * Applied to ChannelAgentRunner HTTP requests when useChannels is true.
279
+ * @default 300_000 (5 minutes)
280
+ */
281
+ channelTimeout?: number;
282
+ /**
283
+ * Session display name template for spawned Claude sessions
284
+ *
285
+ * When set, each spawned Claude subprocess gets a named session
286
+ * visible in /resume and terminal title. Supports placeholders:
287
+ * - {story} — replaced with story number (e.g., "1.001")
288
+ * - {phase} — replaced with phase name (e.g., "dev", "epic", "story")
289
+ * - {prefix} — replaced with the derived prefix from inputPath
290
+ */
291
+ sessionName?: string;
292
+ /**
293
+ * Original input file path — used for auto-generating session names
294
+ *
295
+ * When set, resolveSessionName() derives a compact prefix from this path
296
+ * and auto-generates session names when no explicit --session-name is given.
297
+ * Absent for programmatic callers that don't supply a file path.
298
+ */
299
+ inputPath?: string;
258
300
  /**
259
301
  * Detailed output mode
260
302
  *
@@ -262,4 +304,12 @@ export interface WorkflowConfig {
262
304
  * @default false
263
305
  */
264
306
  verbose: boolean;
307
+ /**
308
+ * Stream full Claude output to stdout in real-time
309
+ *
310
+ * When true, raw assistant text from Claude's stream-json output is
311
+ * piped directly to stdout so callers can see exactly what's happening.
312
+ * @default false
313
+ */
314
+ stream?: boolean;
265
315
  }