@hyperdrive.bot/bmad-workflow 1.0.25 → 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,58 @@
1
+ /**
2
+ * GitOps — Git operations abstraction
3
+ *
4
+ * Wraps git CLI commands for pull, add, commit, push, and remove operations.
5
+ * All methods use execSync with { cwd, encoding: 'utf8', stdio: 'pipe' }.
6
+ */
7
+ /**
8
+ * Result of a git push operation
9
+ */
10
+ export interface PushResult {
11
+ isNonFastForward: boolean;
12
+ stderr: string;
13
+ success: boolean;
14
+ }
15
+ /**
16
+ * Git operations class for repository management
17
+ *
18
+ * All methods accept a `cwd` parameter to scope operations to a specific directory.
19
+ */
20
+ export declare class GitOps {
21
+ /**
22
+ * Pull latest changes from remote with rebase and autostash
23
+ *
24
+ * @param cwd - Working directory for the git command
25
+ */
26
+ pull(cwd: string): void;
27
+ /**
28
+ * Stage a file for commit
29
+ *
30
+ * @param filePath - Path to the file to stage (relative to cwd)
31
+ * @param cwd - Working directory for the git command
32
+ */
33
+ add(filePath: string, cwd: string): void;
34
+ /**
35
+ * Create a commit with the [lock] prefix prepended to the message
36
+ *
37
+ * @param message - Commit message suffix (e.g., "acquire: story-1.001.md")
38
+ * @param cwd - Working directory for the git command
39
+ */
40
+ commit(message: string, cwd: string): void;
41
+ /**
42
+ * Push commits to remote
43
+ *
44
+ * Returns a typed PushResult. NEVER throws on non-fast-forward rejection.
45
+ * Throws PushRejectedError only on unexpected errors (e.g., no remote, network timeout).
46
+ *
47
+ * @param cwd - Working directory for the git command
48
+ * @returns Push result with success status and error details
49
+ */
50
+ push(cwd: string): PushResult;
51
+ /**
52
+ * Remove a file from git index (safe even if file is already deleted from disk)
53
+ *
54
+ * @param filePath - Path to the file to remove (relative to cwd)
55
+ * @param cwd - Working directory for the git command
56
+ */
57
+ remove(filePath: string, cwd: string): void;
58
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * GitOps — Git operations abstraction
3
+ *
4
+ * Wraps git CLI commands for pull, add, commit, push, and remove operations.
5
+ * All methods use execSync with { cwd, encoding: 'utf8', stdio: 'pipe' }.
6
+ */
7
+ import { execSync } from 'node:child_process';
8
+ import { PushRejectedError } from '../../utils/errors.js';
9
+ /**
10
+ * Git operations class for repository management
11
+ *
12
+ * All methods accept a `cwd` parameter to scope operations to a specific directory.
13
+ */
14
+ export class GitOps {
15
+ /**
16
+ * Pull latest changes from remote with rebase and autostash
17
+ *
18
+ * @param cwd - Working directory for the git command
19
+ */
20
+ pull(cwd) {
21
+ execSync('git pull --rebase --autostash', { cwd, encoding: 'utf8', stdio: 'pipe' });
22
+ }
23
+ /**
24
+ * Stage a file for commit
25
+ *
26
+ * @param filePath - Path to the file to stage (relative to cwd)
27
+ * @param cwd - Working directory for the git command
28
+ */
29
+ add(filePath, cwd) {
30
+ execSync(`git add ${filePath}`, { cwd, encoding: 'utf8', stdio: 'pipe' });
31
+ }
32
+ /**
33
+ * Create a commit with the [lock] prefix prepended to the message
34
+ *
35
+ * @param message - Commit message suffix (e.g., "acquire: story-1.001.md")
36
+ * @param cwd - Working directory for the git command
37
+ */
38
+ commit(message, cwd) {
39
+ execSync(`git commit -m "[lock] ${message}"`, { cwd, encoding: 'utf8', stdio: 'pipe' });
40
+ }
41
+ /**
42
+ * Push commits to remote
43
+ *
44
+ * Returns a typed PushResult. NEVER throws on non-fast-forward rejection.
45
+ * Throws PushRejectedError only on unexpected errors (e.g., no remote, network timeout).
46
+ *
47
+ * @param cwd - Working directory for the git command
48
+ * @returns Push result with success status and error details
49
+ */
50
+ push(cwd) {
51
+ try {
52
+ execSync('git push', { cwd, encoding: 'utf8', stdio: 'pipe' });
53
+ return { isNonFastForward: false, stderr: '', success: true };
54
+ }
55
+ catch (error) {
56
+ const err = error;
57
+ const stderr = err.stderr ? err.stderr.toString() : err.message;
58
+ if (/non-fast-forward|rejected/i.test(stderr)) {
59
+ return { isNonFastForward: true, stderr, success: false };
60
+ }
61
+ throw new PushRejectedError(`Git push failed: ${stderr}`, stderr);
62
+ }
63
+ }
64
+ /**
65
+ * Remove a file from git index (safe even if file is already deleted from disk)
66
+ *
67
+ * @param filePath - Path to the file to remove (relative to cwd)
68
+ * @param cwd - Working directory for the git command
69
+ */
70
+ remove(filePath, cwd) {
71
+ execSync(`git rm --cached --ignore-unmatch ${filePath}`, { cwd, encoding: 'utf8', stdio: 'pipe' });
72
+ }
73
+ }
@@ -0,0 +1,3 @@
1
+ export { GitOps } from './git-ops.js';
2
+ export type { PushResult } from './git-ops.js';
3
+ export { PushConflictHandler } from './push-conflict-handler.js';
@@ -0,0 +1,2 @@
1
+ export { GitOps } from './git-ops.js';
2
+ export { PushConflictHandler } from './push-conflict-handler.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * PushConflictHandler — Handles non-fast-forward push rejections
3
+ *
4
+ * When a git push is rejected due to a non-fast-forward error (race condition),
5
+ * this handler pulls the remote state, checks if a competing lock file exists,
6
+ * and either throws a LockConflictError (competing lock found) or retries the push.
7
+ */
8
+ import type { GitOps } from './git-ops.js';
9
+ /**
10
+ * Handles non-fast-forward push rejections during lock acquisition
11
+ */
12
+ export declare class PushConflictHandler {
13
+ private readonly gitOps;
14
+ constructor(gitOps: GitOps);
15
+ /**
16
+ * Handle a non-fast-forward push rejection
17
+ *
18
+ * Pulls remote state, checks for competing lock files, and either:
19
+ * 1. Throws LockConflictError if a competing lock was found
20
+ * 2. Retries the push if the conflict was unrelated
21
+ * 3. Throws PushRejectedError if the retry also fails
22
+ *
23
+ * @param storyPath - Path to the story file being locked
24
+ * @param localLockPath - Path to the .lock file on disk
25
+ * @param cwd - Working directory for git operations
26
+ */
27
+ handleConflict(storyPath: string, localLockPath: string, cwd: string): void;
28
+ /**
29
+ * Clean up local lock file state
30
+ */
31
+ private cleanup;
32
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * PushConflictHandler — Handles non-fast-forward push rejections
3
+ *
4
+ * When a git push is rejected due to a non-fast-forward error (race condition),
5
+ * this handler pulls the remote state, checks if a competing lock file exists,
6
+ * and either throws a LockConflictError (competing lock found) or retries the push.
7
+ */
8
+ import { execSync } from 'node:child_process';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import fsExtra from 'fs-extra';
11
+ const { removeSync } = fsExtra;
12
+ import { LockConflictError, lockFileSchema } from '../../models/lock.js';
13
+ import { PushRejectedError } from '../../utils/errors.js';
14
+ /**
15
+ * Handles non-fast-forward push rejections during lock acquisition
16
+ */
17
+ export class PushConflictHandler {
18
+ gitOps;
19
+ constructor(gitOps) {
20
+ this.gitOps = gitOps;
21
+ }
22
+ /**
23
+ * Handle a non-fast-forward push rejection
24
+ *
25
+ * Pulls remote state, checks for competing lock files, and either:
26
+ * 1. Throws LockConflictError if a competing lock was found
27
+ * 2. Retries the push if the conflict was unrelated
28
+ * 3. Throws PushRejectedError if the retry also fails
29
+ *
30
+ * @param storyPath - Path to the story file being locked
31
+ * @param localLockPath - Path to the .lock file on disk
32
+ * @param cwd - Working directory for git operations
33
+ */
34
+ handleConflict(storyPath, localLockPath, cwd) {
35
+ // Step 1: Pull remote state that caused the non-fast-forward
36
+ this.gitOps.pull(cwd);
37
+ // Step 2: Check if the pull brought in a competing .lock file
38
+ if (existsSync(localLockPath)) {
39
+ // Competing lock found — read and validate
40
+ const raw = readFileSync(localLockPath, 'utf8');
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ }
45
+ catch {
46
+ // Invalid JSON — corrupt lock
47
+ this.cleanup(localLockPath, cwd);
48
+ throw new PushRejectedError('Corrupt lock file found after pull — manual resolution required', raw);
49
+ }
50
+ const result = lockFileSchema.safeParse(parsed);
51
+ if (!result.success) {
52
+ // Schema validation failed — corrupt lock
53
+ this.cleanup(localLockPath, cwd);
54
+ throw new PushRejectedError('Corrupt lock file found after pull — manual resolution required', JSON.stringify(result.error));
55
+ }
56
+ const lockContent = result.data;
57
+ // Clean up losing agent's local state before throwing
58
+ this.cleanup(localLockPath, cwd);
59
+ // Throw with the winner's identity
60
+ throw new LockConflictError(lockContent);
61
+ }
62
+ // No competing lock — conflict was on a different file. Retry push.
63
+ const retryResult = this.gitOps.push(cwd);
64
+ if (retryResult.success) {
65
+ return;
66
+ }
67
+ // Retry also failed — clean up and throw
68
+ this.cleanup(localLockPath, cwd);
69
+ throw new PushRejectedError('Push rejected after retry — manual resolution required', retryResult.stderr);
70
+ }
71
+ /**
72
+ * Clean up local lock file state
73
+ */
74
+ cleanup(localLockPath, cwd) {
75
+ try {
76
+ removeSync(localLockPath);
77
+ }
78
+ catch { /* ignore */ }
79
+ try {
80
+ execSync('git reset HEAD -- ' + localLockPath, { cwd, stdio: 'pipe' });
81
+ }
82
+ catch { /* ignore */ }
83
+ }
84
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * GitBackedLockService
3
+ *
4
+ * Orchestrates lock acquisition with git persistence: pull → check → create → add → commit → push.
5
+ * Ensures distributed agents on different machines see each other's locks by persisting
6
+ * lock files to the remote git branch.
7
+ *
8
+ * Delegates to:
9
+ * - LockService for filesystem lock operations (acquire, release, isLocked, isStale, getLockInfo)
10
+ * - GitOps for git operations (pull, add, commit, push)
11
+ * - PushConflictHandler for non-fast-forward push resolution
12
+ */
13
+ import type { GitOps } from '../git/git-ops.js';
14
+ import type { PushConflictHandler } from '../git/push-conflict-handler.js';
15
+ import type { LockService } from './lock-service.js';
16
+ /**
17
+ * Git-backed lock service that persists locks to remote via git
18
+ *
19
+ * Wraps LockService with a git pull → check → create → add → commit → push flow
20
+ * to ensure distributed lock visibility across machines.
21
+ */
22
+ export declare class GitBackedLockService {
23
+ private readonly lockService;
24
+ private readonly gitOps;
25
+ private readonly conflictHandler;
26
+ /**
27
+ * Create a new GitBackedLockService
28
+ *
29
+ * @param lockService - LockService instance for filesystem lock operations
30
+ * @param gitOps - GitOps instance for git operations
31
+ * @param conflictHandler - PushConflictHandler for non-fast-forward resolution
32
+ */
33
+ constructor(lockService: LockService, gitOps: GitOps, conflictHandler: PushConflictHandler);
34
+ /**
35
+ * Acquire a lock on a story file with full git persistence
36
+ *
37
+ * Executes: pull → isLocked check → acquire → git add → git commit → git push
38
+ *
39
+ * On push non-fast-forward, delegates to PushConflictHandler.
40
+ * On any failure after lock file creation, cleans up the lock file from disk and git state.
41
+ *
42
+ * @param storyPath - Path to the story file to lock (relative to cwd)
43
+ * @param agentName - Name of the agent acquiring the lock
44
+ * @param sessionId - Unique session ID of the agent
45
+ * @param cwd - Working directory (git repository root)
46
+ * @throws {LockConflictError} If an active (non-stale) lock exists or another agent won the race
47
+ */
48
+ acquire(storyPath: string, agentName: string, sessionId: string, cwd: string): Promise<void>;
49
+ /**
50
+ * Release a lock on a story file with git persistence
51
+ *
52
+ * Executes: LockService.release() → GitOps.remove() → GitOps.commit() → GitOps.push()
53
+ *
54
+ * On push failure, logs a warning but does NOT throw — the lock file is already
55
+ * deleted locally, and the story is considered unlocked for the releasing agent.
56
+ *
57
+ * @param storyPath - Path to the story file to unlock (relative to cwd)
58
+ * @param cwd - Working directory (git repository root)
59
+ */
60
+ release(storyPath: string, cwd: string): Promise<void>;
61
+ /**
62
+ * Clean up a lock file from disk and git staging area
63
+ *
64
+ * Handles 3 possible states:
65
+ * 1. File on disk, not staged → removeSync is sufficient
66
+ * 2. File on disk, staged → removeSync + git reset HEAD
67
+ * 3. File on disk, committed but not pushed → removeSync + git reset HEAD
68
+ *
69
+ * Each cleanup step is wrapped in its own try/catch — a failure in one
70
+ * should not prevent the others from executing.
71
+ *
72
+ * @param lockFilePath - Path to the lock file (relative to cwd)
73
+ * @param cwd - Working directory (git repository root)
74
+ */
75
+ private cleanupLockFile;
76
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * GitBackedLockService
3
+ *
4
+ * Orchestrates lock acquisition with git persistence: pull → check → create → add → commit → push.
5
+ * Ensures distributed agents on different machines see each other's locks by persisting
6
+ * lock files to the remote git branch.
7
+ *
8
+ * Delegates to:
9
+ * - LockService for filesystem lock operations (acquire, release, isLocked, isStale, getLockInfo)
10
+ * - GitOps for git operations (pull, add, commit, push)
11
+ * - PushConflictHandler for non-fast-forward push resolution
12
+ */
13
+ import { execSync } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import fsExtra from 'fs-extra';
16
+ const { removeSync } = fsExtra;
17
+ import { LockConflictError, PushRejectedError } from '../../models/lock.js';
18
+ /**
19
+ * Git-backed lock service that persists locks to remote via git
20
+ *
21
+ * Wraps LockService with a git pull → check → create → add → commit → push flow
22
+ * to ensure distributed lock visibility across machines.
23
+ */
24
+ export class GitBackedLockService {
25
+ lockService;
26
+ gitOps;
27
+ conflictHandler;
28
+ /**
29
+ * Create a new GitBackedLockService
30
+ *
31
+ * @param lockService - LockService instance for filesystem lock operations
32
+ * @param gitOps - GitOps instance for git operations
33
+ * @param conflictHandler - PushConflictHandler for non-fast-forward resolution
34
+ */
35
+ constructor(lockService, gitOps, conflictHandler) {
36
+ this.lockService = lockService;
37
+ this.gitOps = gitOps;
38
+ this.conflictHandler = conflictHandler;
39
+ }
40
+ /**
41
+ * Acquire a lock on a story file with full git persistence
42
+ *
43
+ * Executes: pull → isLocked check → acquire → git add → git commit → git push
44
+ *
45
+ * On push non-fast-forward, delegates to PushConflictHandler.
46
+ * On any failure after lock file creation, cleans up the lock file from disk and git state.
47
+ *
48
+ * @param storyPath - Path to the story file to lock (relative to cwd)
49
+ * @param agentName - Name of the agent acquiring the lock
50
+ * @param sessionId - Unique session ID of the agent
51
+ * @param cwd - Working directory (git repository root)
52
+ * @throws {LockConflictError} If an active (non-stale) lock exists or another agent won the race
53
+ */
54
+ async acquire(storyPath, agentName, sessionId, cwd) {
55
+ const lockFilePath = storyPath + '.lock';
56
+ // Step 1: Sync latest remote state
57
+ this.gitOps.pull(cwd);
58
+ // Step 2: Check for existing lock
59
+ const locked = await this.lockService.isLocked(storyPath);
60
+ if (locked) {
61
+ // Check if the existing lock is stale
62
+ const stale = await this.lockService.isStale(storyPath);
63
+ if (!stale) {
64
+ // Active lock — throw conflict with owner details
65
+ const lockInfo = await this.lockService.getLockInfo(storyPath);
66
+ if (lockInfo) {
67
+ throw new LockConflictError(lockInfo);
68
+ }
69
+ }
70
+ // Stale lock — release it before proceeding
71
+ await this.lockService.release(storyPath);
72
+ }
73
+ // Steps 3–8: Create lock, stage, commit, push (with cleanup on failure)
74
+ try {
75
+ // Step 3: Create .lock file on disk
76
+ await this.lockService.acquire(storyPath, agentName, sessionId);
77
+ // Step 5: Stage the lock file
78
+ this.gitOps.add(lockFilePath, cwd);
79
+ // Step 6: Commit
80
+ this.gitOps.commit('[lock] acquire: ' + path.basename(storyPath), cwd);
81
+ // Step 7: Push
82
+ const pushResult = this.gitOps.push(cwd);
83
+ // Step 8: Handle push result
84
+ if (!pushResult.success) {
85
+ if (pushResult.isNonFastForward) {
86
+ // Delegate to conflict handler (Story 2.3)
87
+ this.conflictHandler.handleConflict(storyPath, lockFilePath, cwd);
88
+ }
89
+ else {
90
+ // Non-recoverable push error
91
+ throw new PushRejectedError({ storyFile: storyPath, reason: pushResult.stderr || 'unknown error' });
92
+ }
93
+ }
94
+ }
95
+ catch (error) {
96
+ // Cleanup: remove lock file from disk and git state
97
+ this.cleanupLockFile(lockFilePath, cwd);
98
+ // Re-throw the original error
99
+ throw error;
100
+ }
101
+ }
102
+ /**
103
+ * Release a lock on a story file with git persistence
104
+ *
105
+ * Executes: LockService.release() → GitOps.remove() → GitOps.commit() → GitOps.push()
106
+ *
107
+ * On push failure, logs a warning but does NOT throw — the lock file is already
108
+ * deleted locally, and the story is considered unlocked for the releasing agent.
109
+ *
110
+ * @param storyPath - Path to the story file to unlock (relative to cwd)
111
+ * @param cwd - Working directory (git repository root)
112
+ */
113
+ async release(storyPath, cwd) {
114
+ const lockFilePath = storyPath + '.lock';
115
+ const storyFileName = path.basename(storyPath);
116
+ // Step 1: Delete lock file from disk
117
+ await this.lockService.release(storyPath);
118
+ // Step 2: Stage the removal in git
119
+ this.gitOps.remove(lockFilePath, cwd);
120
+ // Step 3: Commit the removal
121
+ this.gitOps.commit('release: ' + storyFileName, cwd);
122
+ // Step 4: Push — non-fatal on failure
123
+ try {
124
+ const pushResult = this.gitOps.push(cwd);
125
+ if (!pushResult.success) {
126
+ // Log warning but don't throw — lock is already deleted locally
127
+ console.warn(`[lock] Push after release failed for ${storyFileName}: ${pushResult.stderr || 'unknown error'}`);
128
+ }
129
+ }
130
+ catch (error) {
131
+ // Network error or other unexpected failure — still non-fatal
132
+ console.warn(`[lock] Push after release failed for ${storyFileName}: ${error.message}`);
133
+ }
134
+ }
135
+ /**
136
+ * Clean up a lock file from disk and git staging area
137
+ *
138
+ * Handles 3 possible states:
139
+ * 1. File on disk, not staged → removeSync is sufficient
140
+ * 2. File on disk, staged → removeSync + git reset HEAD
141
+ * 3. File on disk, committed but not pushed → removeSync + git reset HEAD
142
+ *
143
+ * Each cleanup step is wrapped in its own try/catch — a failure in one
144
+ * should not prevent the others from executing.
145
+ *
146
+ * @param lockFilePath - Path to the lock file (relative to cwd)
147
+ * @param cwd - Working directory (git repository root)
148
+ */
149
+ cleanupLockFile(lockFilePath, cwd) {
150
+ const absoluteLockPath = path.resolve(cwd, lockFilePath);
151
+ // Remove file from disk
152
+ try {
153
+ removeSync(absoluteLockPath);
154
+ }
155
+ catch {
156
+ // File may not exist — that's OK
157
+ }
158
+ // Unstage from git index (git checkout)
159
+ try {
160
+ execSync('git checkout -- ' + lockFilePath, { cwd, stdio: 'pipe' });
161
+ }
162
+ catch {
163
+ // May fail if file wasn't staged — that's OK
164
+ }
165
+ // Reset from staging area
166
+ try {
167
+ execSync('git reset HEAD -- ' + lockFilePath, { cwd, stdio: 'pipe' });
168
+ }
169
+ catch {
170
+ // May fail if nothing to reset — that's OK
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * LockCleanupService
3
+ *
4
+ * Batch cleanup of stale lock files with git persistence.
5
+ * Pulls latest state, scans for stale locks, deletes them all,
6
+ * then commits and pushes in a single atomic operation.
7
+ */
8
+ import type pino from 'pino';
9
+ import type { GitOps } from '../git/git-ops.js';
10
+ import type { LockService } from './lock-service.js';
11
+ /**
12
+ * Service for batch cleanup of stale locks with git persistence
13
+ *
14
+ * Orchestrates: pull → findStaleLocks → release each → stage each → single commit → push
15
+ */
16
+ export declare class LockCleanupService {
17
+ private readonly lockService;
18
+ private readonly gitOps;
19
+ private readonly logger;
20
+ private readonly storiesDir;
21
+ /**
22
+ * Create a new LockCleanupService
23
+ *
24
+ * @param lockService - LockService instance for filesystem lock operations
25
+ * @param gitOps - GitOps instance for git operations
26
+ * @param logger - Pino logger instance
27
+ * @param storiesDir - Directory to scan for lock files (relative to cwd)
28
+ */
29
+ constructor(lockService: LockService, gitOps: GitOps, logger: pino.Logger, storiesDir: string);
30
+ /**
31
+ * Clean up all stale locks with git persistence
32
+ *
33
+ * Flow:
34
+ * 1. Pull latest remote state
35
+ * 2. Find all stale locks via LockService.findStaleLocks()
36
+ * 3. If none found, return { cleaned: 0 } with no git operations
37
+ * 4. Delete each stale lock from disk and stage for removal
38
+ * 5. Commit all removals in a single commit
39
+ * 6. Push to remote — throws PushRejectedError on failure
40
+ *
41
+ * @param staleThresholdMs - Threshold in milliseconds for considering a lock stale
42
+ * @param cwd - Working directory (git repository root)
43
+ * @returns Object with count of cleaned locks
44
+ * @throws {PushRejectedError} If git push fails after cleanup
45
+ */
46
+ cleanupStaleLocks(staleThresholdMs: number, cwd: string): Promise<{
47
+ cleaned: number;
48
+ }>;
49
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * LockCleanupService
3
+ *
4
+ * Batch cleanup of stale lock files with git persistence.
5
+ * Pulls latest state, scans for stale locks, deletes them all,
6
+ * then commits and pushes in a single atomic operation.
7
+ */
8
+ import { PushRejectedError } from '../../utils/errors.js';
9
+ /**
10
+ * Service for batch cleanup of stale locks with git persistence
11
+ *
12
+ * Orchestrates: pull → findStaleLocks → release each → stage each → single commit → push
13
+ */
14
+ export class LockCleanupService {
15
+ lockService;
16
+ gitOps;
17
+ logger;
18
+ storiesDir;
19
+ /**
20
+ * Create a new LockCleanupService
21
+ *
22
+ * @param lockService - LockService instance for filesystem lock operations
23
+ * @param gitOps - GitOps instance for git operations
24
+ * @param logger - Pino logger instance
25
+ * @param storiesDir - Directory to scan for lock files (relative to cwd)
26
+ */
27
+ constructor(lockService, gitOps, logger, storiesDir) {
28
+ this.lockService = lockService;
29
+ this.gitOps = gitOps;
30
+ this.logger = logger;
31
+ this.storiesDir = storiesDir;
32
+ }
33
+ /**
34
+ * Clean up all stale locks with git persistence
35
+ *
36
+ * Flow:
37
+ * 1. Pull latest remote state
38
+ * 2. Find all stale locks via LockService.findStaleLocks()
39
+ * 3. If none found, return { cleaned: 0 } with no git operations
40
+ * 4. Delete each stale lock from disk and stage for removal
41
+ * 5. Commit all removals in a single commit
42
+ * 6. Push to remote — throws PushRejectedError on failure
43
+ *
44
+ * @param staleThresholdMs - Threshold in milliseconds for considering a lock stale
45
+ * @param cwd - Working directory (git repository root)
46
+ * @returns Object with count of cleaned locks
47
+ * @throws {PushRejectedError} If git push fails after cleanup
48
+ */
49
+ async cleanupStaleLocks(staleThresholdMs, cwd) {
50
+ // Step 1: Get latest remote state
51
+ this.gitOps.pull(cwd);
52
+ // Step 2: Find all stale locks
53
+ const staleLocks = await this.lockService.findStaleLocks(this.storiesDir, staleThresholdMs);
54
+ // Step 3: No stale locks — early return, no git operations
55
+ if (staleLocks.length === 0) {
56
+ this.logger.info('No stale locks found');
57
+ return { cleaned: 0 };
58
+ }
59
+ this.logger.info({ count: staleLocks.length }, 'Found stale locks, cleaning up');
60
+ // Step 4: Delete each stale lock from disk and stage the removal
61
+ for (const lockFilePath of staleLocks) {
62
+ const storyPath = lockFilePath.replace(/\.lock$/, '');
63
+ await this.lockService.release(storyPath);
64
+ this.gitOps.remove(lockFilePath, cwd);
65
+ }
66
+ // Step 5: Single commit for all removals
67
+ this.gitOps.commit(`cleanup: ${staleLocks.length} stale locks`, cwd);
68
+ // Step 6: Push — throws on failure
69
+ try {
70
+ const pushResult = this.gitOps.push(cwd);
71
+ if (!pushResult.success) {
72
+ throw new PushRejectedError(`Push failed after stale lock cleanup: ${pushResult.stderr || 'unknown error'}`, pushResult.stderr || '');
73
+ }
74
+ }
75
+ catch (error) {
76
+ if (error instanceof PushRejectedError) {
77
+ throw error;
78
+ }
79
+ // Unexpected error — wrap in PushRejectedError
80
+ throw new PushRejectedError(`Push failed after stale lock cleanup: ${error.message}`, error.message);
81
+ }
82
+ this.logger.info({ cleaned: staleLocks.length }, 'Stale lock cleanup complete');
83
+ return { cleaned: staleLocks.length };
84
+ }
85
+ }