@hyperdrive.bot/bmad-workflow 1.0.26 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/epics/create.d.ts +1 -0
- package/dist/commands/lock/acquire.d.ts +54 -0
- package/dist/commands/lock/acquire.js +193 -0
- package/dist/commands/lock/cleanup.d.ts +38 -0
- package/dist/commands/lock/cleanup.js +148 -0
- package/dist/commands/lock/list.d.ts +31 -0
- package/dist/commands/lock/list.js +123 -0
- package/dist/commands/lock/release.d.ts +42 -0
- package/dist/commands/lock/release.js +134 -0
- package/dist/commands/lock/status.d.ts +34 -0
- package/dist/commands/lock/status.js +109 -0
- package/dist/commands/stories/create.d.ts +1 -0
- package/dist/commands/stories/develop.d.ts +4 -0
- package/dist/commands/stories/develop.js +55 -5
- package/dist/commands/stories/qa.d.ts +1 -0
- package/dist/commands/stories/qa.js +31 -0
- package/dist/commands/stories/review.d.ts +1 -0
- package/dist/commands/workflow.d.ts +11 -0
- package/dist/commands/workflow.js +120 -4
- package/dist/models/agent-options.d.ts +33 -0
- package/dist/models/agent-result.d.ts +10 -1
- package/dist/models/dispatch.d.ts +16 -0
- package/dist/models/dispatch.js +8 -0
- package/dist/models/index.d.ts +3 -0
- package/dist/models/index.js +2 -0
- package/dist/models/lock.d.ts +80 -0
- package/dist/models/lock.js +69 -0
- package/dist/models/phase-result.d.ts +8 -0
- package/dist/models/provider.js +1 -1
- package/dist/models/workflow-callbacks.d.ts +37 -0
- package/dist/models/workflow-config.d.ts +50 -0
- package/dist/services/agents/agent-runner-factory.d.ts +14 -15
- package/dist/services/agents/agent-runner-factory.js +56 -15
- package/dist/services/agents/channel-agent-runner.d.ts +76 -0
- package/dist/services/agents/channel-agent-runner.js +246 -0
- package/dist/services/agents/channel-session-manager.d.ts +119 -0
- package/dist/services/agents/channel-session-manager.js +250 -0
- package/dist/services/agents/claude-agent-runner.d.ts +9 -50
- package/dist/services/agents/claude-agent-runner.js +221 -199
- package/dist/services/agents/gemini-agent-runner.js +3 -0
- package/dist/services/agents/index.d.ts +1 -0
- package/dist/services/agents/index.js +1 -0
- package/dist/services/agents/opencode-agent-runner.js +3 -0
- package/dist/services/file-system/file-manager.d.ts +11 -0
- package/dist/services/file-system/file-manager.js +26 -0
- package/dist/services/git/git-ops.d.ts +58 -0
- package/dist/services/git/git-ops.js +73 -0
- package/dist/services/git/index.d.ts +3 -0
- package/dist/services/git/index.js +2 -0
- package/dist/services/git/push-conflict-handler.d.ts +32 -0
- package/dist/services/git/push-conflict-handler.js +84 -0
- package/dist/services/lock/git-backed-lock-service.d.ts +76 -0
- package/dist/services/lock/git-backed-lock-service.js +173 -0
- package/dist/services/lock/lock-cleanup.d.ts +49 -0
- package/dist/services/lock/lock-cleanup.js +85 -0
- package/dist/services/lock/lock-service.d.ts +143 -0
- package/dist/services/lock/lock-service.js +290 -0
- package/dist/services/orchestration/locked-story-dispatcher.d.ts +40 -0
- package/dist/services/orchestration/locked-story-dispatcher.js +84 -0
- package/dist/services/orchestration/workflow-orchestrator.d.ts +31 -0
- package/dist/services/orchestration/workflow-orchestrator.js +181 -31
- package/dist/services/review/ai-review-scanner.js +1 -0
- package/dist/services/review/review-phase-executor.js +3 -0
- package/dist/services/review/self-heal-loop.js +1 -0
- package/dist/services/review/types.d.ts +2 -0
- package/dist/utils/errors.d.ts +17 -1
- package/dist/utils/errors.js +18 -0
- package/dist/utils/session-naming.d.ts +23 -0
- package/dist/utils/session-naming.js +30 -0
- package/dist/utils/shared-flags.d.ts +1 -0
- package/dist/utils/shared-flags.js +5 -0
- package/package.json +3 -2
|
@@ -35,6 +35,7 @@ export default class EpicsCreate extends Command {
|
|
|
35
35
|
prefix: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
36
36
|
reference: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
37
37
|
start: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
38
|
+
stream: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
38
39
|
agent: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
39
40
|
cwd: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
40
41
|
model: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock Acquire Command
|
|
3
|
+
*
|
|
4
|
+
* Acquires exclusive ownership of a story file via a git-committed `.lock` file.
|
|
5
|
+
* Prevents parallel agents from working on the same story simultaneously.
|
|
6
|
+
*
|
|
7
|
+
* Flow: validate → git pull → check existing lock → create .lock → git add+commit+push
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```bash
|
|
11
|
+
* bmad-workflow lock acquire --story docs/qa/stories/X.md --agent pirlo --session 550e8400
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { Command } from '@oclif/core';
|
|
15
|
+
export default class LockAcquireCommand extends Command {
|
|
16
|
+
static description: string;
|
|
17
|
+
static examples: {
|
|
18
|
+
command: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}[];
|
|
21
|
+
static flags: {
|
|
22
|
+
agent: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
23
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
24
|
+
session: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
25
|
+
'stale-threshold': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
26
|
+
story: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
27
|
+
};
|
|
28
|
+
private gitOps;
|
|
29
|
+
private lockService;
|
|
30
|
+
private logger;
|
|
31
|
+
private pushConflictHandler;
|
|
32
|
+
run(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Clean up a lock file from disk and git state after failure
|
|
35
|
+
*/
|
|
36
|
+
private cleanupLockFile;
|
|
37
|
+
/**
|
|
38
|
+
* Output failure message in JSON or human-readable format, then exit 1
|
|
39
|
+
*/
|
|
40
|
+
private fail;
|
|
41
|
+
/**
|
|
42
|
+
* Get current git branch name
|
|
43
|
+
*/
|
|
44
|
+
private getCurrentBranch;
|
|
45
|
+
private initServices;
|
|
46
|
+
/**
|
|
47
|
+
* Parse duration string (e.g., '2h', '30m', '1d') to milliseconds
|
|
48
|
+
*/
|
|
49
|
+
private parseDuration;
|
|
50
|
+
/**
|
|
51
|
+
* Output success message in JSON or human-readable format
|
|
52
|
+
*/
|
|
53
|
+
private succeed;
|
|
54
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock Acquire Command
|
|
3
|
+
*
|
|
4
|
+
* Acquires exclusive ownership of a story file via a git-committed `.lock` file.
|
|
5
|
+
* Prevents parallel agents from working on the same story simultaneously.
|
|
6
|
+
*
|
|
7
|
+
* Flow: validate → git pull → check existing lock → create .lock → git add+commit+push
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```bash
|
|
11
|
+
* bmad-workflow lock acquire --story docs/qa/stories/X.md --agent pirlo --session 550e8400
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { Command, Flags } from '@oclif/core';
|
|
16
|
+
import fs from 'fs-extra';
|
|
17
|
+
import { LockConflictError } from '../../models/lock.js';
|
|
18
|
+
import { FileManager } from '../../services/file-system/file-manager.js';
|
|
19
|
+
import { GlobMatcher } from '../../services/file-system/glob-matcher.js';
|
|
20
|
+
import { GitOps } from '../../services/git/git-ops.js';
|
|
21
|
+
import { PushConflictHandler } from '../../services/git/push-conflict-handler.js';
|
|
22
|
+
import { LockService } from '../../services/lock/lock-service.js';
|
|
23
|
+
import { createLogger } from '../../utils/logger.js';
|
|
24
|
+
const DURATION_MULTIPLIERS = {
|
|
25
|
+
d: 86_400_000,
|
|
26
|
+
h: 3_600_000,
|
|
27
|
+
m: 60_000,
|
|
28
|
+
s: 1000,
|
|
29
|
+
};
|
|
30
|
+
export default class LockAcquireCommand extends Command {
|
|
31
|
+
static description = 'Acquire a lock on a story file';
|
|
32
|
+
static examples = [
|
|
33
|
+
{
|
|
34
|
+
command: '<%= config.bin %> lock acquire --story docs/qa/stories/X.md --agent pirlo --session 550e8400-e29b-41d4-a716-446655440000',
|
|
35
|
+
description: 'Acquire lock on a story for the pirlo agent',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
static flags = {
|
|
39
|
+
agent: Flags.string({ description: 'Agent name claiming the lock', required: true }),
|
|
40
|
+
json: Flags.boolean({ description: 'Output as JSON' }),
|
|
41
|
+
session: Flags.string({ description: 'Session UUID', required: true }),
|
|
42
|
+
'stale-threshold': Flags.string({
|
|
43
|
+
default: '2h',
|
|
44
|
+
description: 'Duration before lock is stale (e.g., 2h, 30m, 1d)',
|
|
45
|
+
}),
|
|
46
|
+
story: Flags.string({ description: 'Path to story file', required: true }),
|
|
47
|
+
};
|
|
48
|
+
gitOps;
|
|
49
|
+
lockService;
|
|
50
|
+
logger;
|
|
51
|
+
pushConflictHandler;
|
|
52
|
+
async run() {
|
|
53
|
+
this.initServices();
|
|
54
|
+
const { flags } = await this.parse(LockAcquireCommand);
|
|
55
|
+
const cwd = process.cwd();
|
|
56
|
+
const lockFilePath = flags.story + '.lock';
|
|
57
|
+
// AC 3: Validate story file exists
|
|
58
|
+
const storyExists = await fs.pathExists(flags.story);
|
|
59
|
+
if (!storyExists) {
|
|
60
|
+
return this.fail(flags, 'Story file not found: ' + flags.story);
|
|
61
|
+
}
|
|
62
|
+
// AC 4: Parse stale threshold
|
|
63
|
+
const thresholdMs = this.parseDuration(flags['stale-threshold']);
|
|
64
|
+
if (thresholdMs === null) {
|
|
65
|
+
return this.fail(flags, 'Invalid duration format: ' + flags['stale-threshold'] + ', expected e.g. 2h, 30m, 1d');
|
|
66
|
+
}
|
|
67
|
+
// AC 2: Pull latest and check existing lock
|
|
68
|
+
try {
|
|
69
|
+
this.gitOps.pull(cwd);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
this.logger.warn('git pull failed — proceeding with local state');
|
|
73
|
+
}
|
|
74
|
+
const locked = await this.lockService.isLocked(flags.story);
|
|
75
|
+
if (locked) {
|
|
76
|
+
const stale = await this.lockService.isStale(flags.story, thresholdMs);
|
|
77
|
+
if (!stale) {
|
|
78
|
+
const lockInfo = await this.lockService.getLockInfo(flags.story);
|
|
79
|
+
if (lockInfo) {
|
|
80
|
+
return this.fail(flags, `Lock already held by ${lockInfo.agent_name} since ${lockInfo.started_at}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// AC 4: Stale lock — release and proceed
|
|
84
|
+
this.logger.info('Removing stale lock');
|
|
85
|
+
await this.lockService.release(flags.story);
|
|
86
|
+
}
|
|
87
|
+
// AC 1, 5, 6: Acquire lock with cleanup on failure
|
|
88
|
+
let lockFileCreated = false;
|
|
89
|
+
try {
|
|
90
|
+
const branch = this.getCurrentBranch(cwd);
|
|
91
|
+
await this.lockService.acquire(flags.story, flags.agent, flags.session, branch);
|
|
92
|
+
lockFileCreated = true;
|
|
93
|
+
this.gitOps.add(lockFilePath, cwd);
|
|
94
|
+
this.gitOps.commit('acquire: ' + flags.story + ' (' + flags.agent + ')', cwd);
|
|
95
|
+
const pushResult = this.gitOps.push(cwd);
|
|
96
|
+
if (!pushResult.success) {
|
|
97
|
+
if (pushResult.isNonFastForward) {
|
|
98
|
+
// AC 5: Handle race condition via PushConflictHandler
|
|
99
|
+
this.pushConflictHandler.handleConflict(flags.story, lockFilePath, cwd);
|
|
100
|
+
// If handleConflict returns (didn't throw), push succeeded on retry
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
throw new Error('Git push failed: ' + (pushResult.stderr || 'unknown error'));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// AC 6: Cleanup — no partial lock files remain
|
|
109
|
+
if (lockFileCreated) {
|
|
110
|
+
this.cleanupLockFile(lockFilePath, cwd);
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof LockConflictError) {
|
|
113
|
+
return this.fail(flags, `Lock claimed by ${error.lockContent.agent_name} during race — backing off`);
|
|
114
|
+
}
|
|
115
|
+
return this.fail(flags, error.message);
|
|
116
|
+
}
|
|
117
|
+
// AC 7, 8: Success output
|
|
118
|
+
this.succeed(flags);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Clean up a lock file from disk and git state after failure
|
|
122
|
+
*/
|
|
123
|
+
cleanupLockFile(lockFilePath, cwd) {
|
|
124
|
+
try {
|
|
125
|
+
fs.removeSync(lockFilePath);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* ignore */
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
this.gitOps.remove(lockFilePath, cwd);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* ignore */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Output failure message in JSON or human-readable format, then exit 1
|
|
139
|
+
*/
|
|
140
|
+
fail(flags, message) {
|
|
141
|
+
if (flags.json) {
|
|
142
|
+
this.log(JSON.stringify({ error: message, story: flags.story, success: false }));
|
|
143
|
+
this.exit(1);
|
|
144
|
+
}
|
|
145
|
+
this.error(message, { exit: 1 });
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get current git branch name
|
|
149
|
+
*/
|
|
150
|
+
getCurrentBranch(cwd) {
|
|
151
|
+
try {
|
|
152
|
+
return execSync('git branch --show-current', { cwd, encoding: 'utf8' }).trim() || 'unknown';
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return 'unknown';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
initServices() {
|
|
159
|
+
this.logger = createLogger({ namespace: 'commands:lock:acquire' });
|
|
160
|
+
const fileManager = new FileManager(this.logger);
|
|
161
|
+
const globMatcher = new GlobMatcher(fileManager, this.logger);
|
|
162
|
+
this.lockService = new LockService(fileManager, globMatcher, this.logger);
|
|
163
|
+
this.gitOps = new GitOps();
|
|
164
|
+
this.pushConflictHandler = new PushConflictHandler(this.gitOps);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Parse duration string (e.g., '2h', '30m', '1d') to milliseconds
|
|
168
|
+
*/
|
|
169
|
+
parseDuration(duration) {
|
|
170
|
+
const match = /^(\d+)(h|m|d|s)$/.exec(duration);
|
|
171
|
+
if (!match)
|
|
172
|
+
return null;
|
|
173
|
+
const [, value, unit] = match;
|
|
174
|
+
return Number.parseInt(value, 10) * DURATION_MULTIPLIERS[unit];
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Output success message in JSON or human-readable format
|
|
178
|
+
*/
|
|
179
|
+
succeed(flags) {
|
|
180
|
+
if (flags.json) {
|
|
181
|
+
this.log(JSON.stringify({
|
|
182
|
+
agent: flags.agent,
|
|
183
|
+
session: flags.session,
|
|
184
|
+
story: flags.story,
|
|
185
|
+
success: true,
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
this.log('✓ Lock acquired: ' + flags.story + ' (agent: ' + flags.agent + ', session: ' + flags.session + ')');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock Cleanup Command
|
|
3
|
+
*
|
|
4
|
+
* Finds and removes all stale story locks in a single git commit+push.
|
|
5
|
+
* Supports dry-run mode, custom stale thresholds, and JSON output.
|
|
6
|
+
*
|
|
7
|
+
* Flow: parse flags → git pull → find stale locks → (dry-run | delete+commit+push) → output
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```bash
|
|
11
|
+
* bmad-workflow lock cleanup
|
|
12
|
+
* bmad-workflow lock cleanup --stale-threshold 30m --dry-run
|
|
13
|
+
* bmad-workflow lock cleanup --json
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from '@oclif/core';
|
|
17
|
+
export default class LockCleanupCommand extends Command {
|
|
18
|
+
static description: string;
|
|
19
|
+
static examples: {
|
|
20
|
+
command: string;
|
|
21
|
+
description: string;
|
|
22
|
+
}[];
|
|
23
|
+
static flags: {
|
|
24
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
25
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
26
|
+
'stale-threshold': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
27
|
+
};
|
|
28
|
+
private cleanupService;
|
|
29
|
+
private gitOps;
|
|
30
|
+
private lockService;
|
|
31
|
+
private logger;
|
|
32
|
+
run(): Promise<void>;
|
|
33
|
+
private initServices;
|
|
34
|
+
/**
|
|
35
|
+
* Check if a lock's started_at is older than threshold
|
|
36
|
+
*/
|
|
37
|
+
private isOverThreshold;
|
|
38
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock Cleanup Command
|
|
3
|
+
*
|
|
4
|
+
* Finds and removes all stale story locks in a single git commit+push.
|
|
5
|
+
* Supports dry-run mode, custom stale thresholds, and JSON output.
|
|
6
|
+
*
|
|
7
|
+
* Flow: parse flags → git pull → find stale locks → (dry-run | delete+commit+push) → output
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```bash
|
|
11
|
+
* bmad-workflow lock cleanup
|
|
12
|
+
* bmad-workflow lock cleanup --stale-threshold 30m --dry-run
|
|
13
|
+
* bmad-workflow lock cleanup --json
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import { Command, Flags } from '@oclif/core';
|
|
17
|
+
import { FileManager } from '../../services/file-system/file-manager.js';
|
|
18
|
+
import { GlobMatcher } from '../../services/file-system/glob-matcher.js';
|
|
19
|
+
import { GitOps } from '../../services/git/git-ops.js';
|
|
20
|
+
import { LockCleanupService } from '../../services/lock/lock-cleanup.js';
|
|
21
|
+
import { LockService } from '../../services/lock/lock-service.js';
|
|
22
|
+
import { parseDuration } from '../../utils/duration.js';
|
|
23
|
+
import { createLogger } from '../../utils/logger.js';
|
|
24
|
+
/** Default directory to scan for lock files */
|
|
25
|
+
const STORIES_DIR = 'docs';
|
|
26
|
+
export default class LockCleanupCommand extends Command {
|
|
27
|
+
static description = 'Remove all stale story locks in a single commit';
|
|
28
|
+
static examples = [
|
|
29
|
+
{
|
|
30
|
+
command: '<%= config.bin %> lock cleanup',
|
|
31
|
+
description: 'Remove all stale locks (default 2h threshold)',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
command: '<%= config.bin %> lock cleanup --stale-threshold 30m --dry-run',
|
|
35
|
+
description: 'Preview locks that would be cleaned with 30m threshold',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
command: '<%= config.bin %> lock cleanup --json',
|
|
39
|
+
description: 'Output cleanup results as JSON',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
static flags = {
|
|
43
|
+
'dry-run': Flags.boolean({ description: 'Show what would be cleaned without acting' }),
|
|
44
|
+
json: Flags.boolean({ description: 'Output as JSON' }),
|
|
45
|
+
'stale-threshold': Flags.string({
|
|
46
|
+
default: '2h',
|
|
47
|
+
description: 'Duration before lock is stale (e.g., 2h, 30m, 1d)',
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
cleanupService;
|
|
51
|
+
gitOps;
|
|
52
|
+
lockService;
|
|
53
|
+
logger;
|
|
54
|
+
async run() {
|
|
55
|
+
this.initServices();
|
|
56
|
+
const { flags } = await this.parse(LockCleanupCommand);
|
|
57
|
+
// AC 4: Parse stale threshold — exit 1 on invalid format
|
|
58
|
+
let thresholdMs;
|
|
59
|
+
try {
|
|
60
|
+
thresholdMs = parseDuration(flags['stale-threshold']);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
const msg = `Invalid duration format: ${flags['stale-threshold']}, expected e.g. 2h, 30m, 1d`;
|
|
64
|
+
if (flags.json) {
|
|
65
|
+
this.log(JSON.stringify({ error: msg, removed: 0 }));
|
|
66
|
+
}
|
|
67
|
+
this.error(msg, { exit: 1 });
|
|
68
|
+
}
|
|
69
|
+
// AC 7: Pull latest before scanning
|
|
70
|
+
const cwd = process.cwd();
|
|
71
|
+
try {
|
|
72
|
+
this.gitOps.pull(cwd);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
this.logger.warn('git pull failed — proceeding with local state');
|
|
76
|
+
}
|
|
77
|
+
// Find stale locks with metadata for output
|
|
78
|
+
const allLocks = await this.lockService.list(STORIES_DIR);
|
|
79
|
+
const staleLocks = allLocks.filter((entry) => entry.isStale || this.isOverThreshold(entry.lock.started_at, thresholdMs));
|
|
80
|
+
// Also check for locks that list() skipped (malformed) — use findStaleLocks for completeness
|
|
81
|
+
const staleLockPaths = await this.lockService.findStaleLocks(STORIES_DIR, thresholdMs);
|
|
82
|
+
// Build lock details from the metadata we have
|
|
83
|
+
const lockDetails = staleLocks.map((entry) => ({
|
|
84
|
+
agent: entry.lock.agent_name,
|
|
85
|
+
started_at: entry.lock.started_at,
|
|
86
|
+
story: entry.storyPath,
|
|
87
|
+
}));
|
|
88
|
+
// If findStaleLocks found more than list() (malformed locks), count them
|
|
89
|
+
const totalStaleCount = Math.max(staleLockPaths.length, staleLocks.length);
|
|
90
|
+
// AC 1, 5, 6: No stale locks
|
|
91
|
+
if (totalStaleCount === 0) {
|
|
92
|
+
if (flags.json) {
|
|
93
|
+
if (flags['dry-run']) {
|
|
94
|
+
this.log(JSON.stringify({ dry_run: true, locks: [], would_remove: 0 }));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.log(JSON.stringify({ locks: [], removed: 0 }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.log('No stale locks found');
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// AC 5, 9: Dry-run mode — list but don't act
|
|
106
|
+
if (flags['dry-run']) {
|
|
107
|
+
if (flags.json) {
|
|
108
|
+
this.log(JSON.stringify({ dry_run: true, locks: lockDetails, would_remove: totalStaleCount }));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
this.log(`Would remove ${totalStaleCount} stale lock(s):`);
|
|
112
|
+
for (const detail of lockDetails) {
|
|
113
|
+
this.log(` ${detail.story} — agent: ${detail.agent}, started_at: ${detail.started_at}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// AC 2: Batch cleanup with single commit+push via LockCleanupService
|
|
119
|
+
const result = await this.cleanupService.cleanupStaleLocks(thresholdMs, cwd);
|
|
120
|
+
// AC 8: Output
|
|
121
|
+
if (flags.json) {
|
|
122
|
+
this.log(JSON.stringify({ locks: lockDetails, removed: result.cleaned }));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.log(`✓ Cleaned up ${result.cleaned} stale lock(s):`);
|
|
126
|
+
for (const detail of lockDetails) {
|
|
127
|
+
this.log(` ${detail.story} — agent: ${detail.agent}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
initServices() {
|
|
132
|
+
this.logger = createLogger({ namespace: 'commands:lock:cleanup' });
|
|
133
|
+
const fileManager = new FileManager(this.logger);
|
|
134
|
+
const globMatcher = new GlobMatcher(fileManager, this.logger);
|
|
135
|
+
this.lockService = new LockService(fileManager, globMatcher, this.logger);
|
|
136
|
+
this.gitOps = new GitOps();
|
|
137
|
+
this.cleanupService = new LockCleanupService(this.lockService, this.gitOps, this.logger, STORIES_DIR);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Check if a lock's started_at is older than threshold
|
|
141
|
+
*/
|
|
142
|
+
isOverThreshold(startedAt, thresholdMs) {
|
|
143
|
+
const ts = new Date(startedAt).getTime();
|
|
144
|
+
if (Number.isNaN(ts))
|
|
145
|
+
return true;
|
|
146
|
+
return ts + thresholdMs < Date.now();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock List Command
|
|
3
|
+
*
|
|
4
|
+
* Lists all current locks across all story directories.
|
|
5
|
+
* Displays results in a table or JSON format.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```bash
|
|
9
|
+
* bmad-workflow lock list
|
|
10
|
+
* bmad-workflow lock list --stale-only
|
|
11
|
+
* bmad-workflow lock list --json
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { Command } from '@oclif/core';
|
|
15
|
+
export default class LockListCommand extends Command {
|
|
16
|
+
static description: string;
|
|
17
|
+
static examples: {
|
|
18
|
+
command: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}[];
|
|
21
|
+
static flags: {
|
|
22
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
23
|
+
'stale-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
24
|
+
};
|
|
25
|
+
private lockService;
|
|
26
|
+
private logger;
|
|
27
|
+
private pathResolver;
|
|
28
|
+
run(): Promise<void>;
|
|
29
|
+
private initServices;
|
|
30
|
+
private truncate;
|
|
31
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock List Command
|
|
3
|
+
*
|
|
4
|
+
* Lists all current locks across all story directories.
|
|
5
|
+
* Displays results in a table or JSON format.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```bash
|
|
9
|
+
* bmad-workflow lock list
|
|
10
|
+
* bmad-workflow lock list --stale-only
|
|
11
|
+
* bmad-workflow lock list --json
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
import { Command, Flags } from '@oclif/core';
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { FileManager } from '../../services/file-system/file-manager.js';
|
|
18
|
+
import { GlobMatcher } from '../../services/file-system/glob-matcher.js';
|
|
19
|
+
import { PathResolver } from '../../services/file-system/path-resolver.js';
|
|
20
|
+
import { LockService } from '../../services/lock/lock-service.js';
|
|
21
|
+
import { createLogger } from '../../utils/logger.js';
|
|
22
|
+
export default class LockListCommand extends Command {
|
|
23
|
+
static description = 'List all current story locks';
|
|
24
|
+
static examples = [
|
|
25
|
+
{
|
|
26
|
+
command: '<%= config.bin %> lock list',
|
|
27
|
+
description: 'List all locks across story directories',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
command: '<%= config.bin %> lock list --stale-only',
|
|
31
|
+
description: 'Show only stale locks',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
command: '<%= config.bin %> lock list --json',
|
|
35
|
+
description: 'Output all locks as JSON',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
static flags = {
|
|
39
|
+
json: Flags.boolean({ description: 'Output as JSON' }),
|
|
40
|
+
'stale-only': Flags.boolean({ description: 'Show only stale locks' }),
|
|
41
|
+
};
|
|
42
|
+
lockService;
|
|
43
|
+
logger;
|
|
44
|
+
pathResolver;
|
|
45
|
+
async run() {
|
|
46
|
+
this.initServices();
|
|
47
|
+
const { flags } = await this.parse(LockListCommand);
|
|
48
|
+
// AC 9: Pull latest before reading lock state
|
|
49
|
+
try {
|
|
50
|
+
execSync('git pull --ff-only', { cwd: process.cwd(), stdio: 'pipe' });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
this.logger.warn('git pull failed — proceeding with local state');
|
|
54
|
+
}
|
|
55
|
+
// Get all story directories
|
|
56
|
+
const allDirs = this.pathResolver.getAllStoryDirs();
|
|
57
|
+
// Get all locks with stale status
|
|
58
|
+
let locks = await this.lockService.listLocks(allDirs);
|
|
59
|
+
// AC 6: Filter stale-only if requested
|
|
60
|
+
if (flags['stale-only']) {
|
|
61
|
+
locks = locks.filter((l) => l.stale);
|
|
62
|
+
}
|
|
63
|
+
// AC 8: No locks found
|
|
64
|
+
if (locks.length === 0) {
|
|
65
|
+
if (flags.json) {
|
|
66
|
+
this.log(JSON.stringify({ locks: [] }, null, 2));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.log(chalk.yellow('No locks found'));
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// AC 7: JSON output
|
|
74
|
+
if (flags.json) {
|
|
75
|
+
const output = {
|
|
76
|
+
locks: locks.map((l) => ({
|
|
77
|
+
agent: l.agent_name,
|
|
78
|
+
branch: l.branch,
|
|
79
|
+
session: l.agent_session_id,
|
|
80
|
+
stale: l.stale,
|
|
81
|
+
started_at: l.started_at,
|
|
82
|
+
story: l.story_file,
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
this.log(JSON.stringify(output, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// AC 5: Table output
|
|
89
|
+
this.log('');
|
|
90
|
+
this.log(chalk.cyan.bold('Story'.padEnd(40)) +
|
|
91
|
+
chalk.cyan.bold('Agent'.padEnd(20)) +
|
|
92
|
+
chalk.cyan.bold('Session'.padEnd(38)) +
|
|
93
|
+
chalk.cyan.bold('Started At'.padEnd(24)) +
|
|
94
|
+
chalk.cyan.bold('Branch'.padEnd(20)) +
|
|
95
|
+
chalk.cyan.bold('Stale'));
|
|
96
|
+
this.log(chalk.gray('─'.repeat(150)));
|
|
97
|
+
for (const lock of locks) {
|
|
98
|
+
const staleLabel = lock.stale ? chalk.red('yes') : chalk.green('no');
|
|
99
|
+
const story = this.truncate(lock.story_file, 38);
|
|
100
|
+
const agent = this.truncate(lock.agent_name, 18);
|
|
101
|
+
const branch = this.truncate(lock.branch, 18);
|
|
102
|
+
this.log(story.padEnd(40) +
|
|
103
|
+
agent.padEnd(20) +
|
|
104
|
+
lock.agent_session_id.padEnd(38) +
|
|
105
|
+
lock.started_at.padEnd(24) +
|
|
106
|
+
branch.padEnd(20) +
|
|
107
|
+
staleLabel);
|
|
108
|
+
}
|
|
109
|
+
this.log('');
|
|
110
|
+
this.log(chalk.white(`Found ${locks.length} lock(s)`));
|
|
111
|
+
this.log('');
|
|
112
|
+
}
|
|
113
|
+
initServices() {
|
|
114
|
+
this.logger = createLogger({ namespace: 'commands:lock:list' });
|
|
115
|
+
const fileManager = new FileManager(this.logger);
|
|
116
|
+
const globMatcher = new GlobMatcher(fileManager, this.logger);
|
|
117
|
+
this.pathResolver = new PathResolver(fileManager, this.logger);
|
|
118
|
+
this.lockService = new LockService(fileManager, globMatcher, this.logger);
|
|
119
|
+
}
|
|
120
|
+
truncate(str, maxLength) {
|
|
121
|
+
return str.length > maxLength ? str.slice(0, maxLength - 3) + '...' : str;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock Release Command
|
|
3
|
+
*
|
|
4
|
+
* Releases an existing lock on a story file by deleting the `.lock` file,
|
|
5
|
+
* committing the removal, and pushing to remote.
|
|
6
|
+
*
|
|
7
|
+
* Flow: git pull → validate lock exists → delete .lock → git add+commit+push
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```bash
|
|
11
|
+
* bmad-workflow lock release --story docs/qa/stories/X.md
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { Command } from '@oclif/core';
|
|
15
|
+
export default class LockReleaseCommand extends Command {
|
|
16
|
+
static description: string;
|
|
17
|
+
static examples: {
|
|
18
|
+
command: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}[];
|
|
21
|
+
static flags: {
|
|
22
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
23
|
+
story: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
|
+
};
|
|
25
|
+
private gitOps;
|
|
26
|
+
private lockService;
|
|
27
|
+
private logger;
|
|
28
|
+
run(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Output failure message in JSON or human-readable format, then exit 1
|
|
31
|
+
*/
|
|
32
|
+
private fail;
|
|
33
|
+
private initServices;
|
|
34
|
+
/**
|
|
35
|
+
* Output idempotent success when lock was already released by another agent (AC 4)
|
|
36
|
+
*/
|
|
37
|
+
private succeedAlreadyReleased;
|
|
38
|
+
/**
|
|
39
|
+
* Output success message in JSON or human-readable format
|
|
40
|
+
*/
|
|
41
|
+
private succeed;
|
|
42
|
+
}
|