agileflow 2.99.8 → 3.0.0
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/CHANGELOG.md +5 -0
- package/lib/cache-provider.js +155 -0
- package/lib/codebase-indexer.js +1 -1
- package/lib/content-sanitizer.js +1 -0
- package/lib/dashboard-protocol.js +25 -0
- package/lib/dashboard-server.js +184 -133
- package/lib/errors.js +18 -0
- package/lib/file-cache.js +1 -1
- package/lib/flag-detection.js +11 -20
- package/lib/git-operations.js +15 -33
- package/lib/merge-operations.js +40 -34
- package/lib/process-executor.js +199 -0
- package/lib/registry-cache.js +13 -47
- package/lib/skill-loader.js +206 -0
- package/lib/smart-json-file.js +2 -4
- package/package.json +1 -1
- package/scripts/agileflow-configure.js +13 -12
- package/scripts/agileflow-statusline.sh +30 -0
- package/scripts/agileflow-welcome.js +181 -212
- package/scripts/auto-self-improve.js +3 -3
- package/scripts/claude-smart.sh +67 -0
- package/scripts/claude-tmux.sh +248 -161
- package/scripts/damage-control-multi-agent.js +227 -0
- package/scripts/lib/bus-utils.js +471 -0
- package/scripts/lib/configure-detect.js +5 -6
- package/scripts/lib/configure-features.js +44 -0
- package/scripts/lib/configure-repair.js +5 -6
- package/scripts/lib/configure-utils.js +2 -3
- package/scripts/lib/context-formatter.js +87 -8
- package/scripts/lib/damage-control-utils.js +37 -3
- package/scripts/lib/file-lock.js +392 -0
- package/scripts/lib/ideation-index.js +2 -5
- package/scripts/lib/lifecycle-detector.js +123 -0
- package/scripts/lib/process-cleanup.js +55 -81
- package/scripts/lib/scale-detector.js +357 -0
- package/scripts/lib/signal-detectors.js +779 -0
- package/scripts/lib/story-state-machine.js +1 -1
- package/scripts/lib/sync-ideation-status.js +2 -3
- package/scripts/lib/task-registry.js +7 -1
- package/scripts/lib/team-events.js +357 -0
- package/scripts/messaging-bridge.js +79 -36
- package/scripts/migrate-ideation-index.js +37 -14
- package/scripts/obtain-context.js +37 -19
- package/scripts/ralph-loop.js +3 -4
- package/scripts/smart-detect.js +390 -0
- package/scripts/team-manager.js +174 -30
- package/src/core/commands/audit.md +13 -11
- package/src/core/commands/babysit.md +162 -115
- package/src/core/commands/changelog.md +21 -4
- package/src/core/commands/configure.md +105 -2
- package/src/core/commands/debt.md +12 -2
- package/src/core/commands/feedback.md +7 -6
- package/src/core/commands/ideate/history.md +1 -1
- package/src/core/commands/ideate/new.md +5 -5
- package/src/core/commands/logic/audit.md +2 -2
- package/src/core/commands/pr.md +7 -6
- package/src/core/commands/research/analyze.md +28 -20
- package/src/core/commands/research/ask.md +43 -0
- package/src/core/commands/research/import.md +29 -21
- package/src/core/commands/research/list.md +8 -7
- package/src/core/commands/research/synthesize.md +356 -20
- package/src/core/commands/research/view.md +8 -5
- package/src/core/commands/review.md +24 -6
- package/src/core/commands/skill/create.md +34 -0
- package/tools/cli/lib/docs-setup.js +4 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-lock.js - Atomic file writing with locking for multi-agent safety
|
|
3
|
+
*
|
|
4
|
+
* FAIL-OPEN SEMANTICS:
|
|
5
|
+
* ====================
|
|
6
|
+
* This module prioritizes availability over strict consistency.
|
|
7
|
+
* On lock contention, timeout, or errors, operations proceed
|
|
8
|
+
* with best-effort semantics rather than blocking or crashing.
|
|
9
|
+
*
|
|
10
|
+
* USAGE:
|
|
11
|
+
* ======
|
|
12
|
+
* // Simple atomic JSON write
|
|
13
|
+
* atomicWriteJSON('docs/09-agents/status.json', { stories: {...} });
|
|
14
|
+
*
|
|
15
|
+
* // Read-modify-write (handles concurrency gracefully)
|
|
16
|
+
* atomicReadModifyWrite('docs/09-agents/status.json', (data) => {
|
|
17
|
+
* data.stories['US-0040'].status = 'in-review';
|
|
18
|
+
* return data;
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // Manual lock management (use for complex sequences)
|
|
22
|
+
* const lock = acquireLock('docs/09-agents/status.json');
|
|
23
|
+
* if (lock.acquired) {
|
|
24
|
+
* try {
|
|
25
|
+
* // ... do work ...
|
|
26
|
+
* } finally {
|
|
27
|
+
* releaseLock(lock.lockPath);
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* IMPLEMENTATION:
|
|
32
|
+
* ===============
|
|
33
|
+
* Lock file + temp file + rename pattern for atomic writes:
|
|
34
|
+
* 1. Create lock file (filePath + '.lock') with PID
|
|
35
|
+
* 2. If lock exists, check if PID is alive. If stale, remove and retry.
|
|
36
|
+
* 3. Write to temp file (filePath + '.tmp.' + random)
|
|
37
|
+
* 4. Rename temp to target (atomic on POSIX)
|
|
38
|
+
* 5. Remove lock file
|
|
39
|
+
*
|
|
40
|
+
* NO EXTERNAL DEPENDENCIES - only Node.js built-ins
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const fs = require('fs');
|
|
44
|
+
const path = require('path');
|
|
45
|
+
const os = require('os');
|
|
46
|
+
const crypto = require('crypto');
|
|
47
|
+
// Inline colors (no external dependency)
|
|
48
|
+
const c = {
|
|
49
|
+
dim: '\x1b[2m',
|
|
50
|
+
reset: '\x1b[0m',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Default timeout for lock acquisition
|
|
54
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 5000;
|
|
55
|
+
|
|
56
|
+
// Retry configuration
|
|
57
|
+
const LOCK_RETRY_INTERVAL_MS = 50;
|
|
58
|
+
const MAX_LOCK_RETRIES = Math.ceil(DEFAULT_LOCK_TIMEOUT_MS / LOCK_RETRY_INTERVAL_MS);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a process with given PID is alive
|
|
62
|
+
* Uses process.kill(pid, 0) which is safe (sends no signal, just checks existence)
|
|
63
|
+
*
|
|
64
|
+
* @param {number} pid - Process ID
|
|
65
|
+
* @returns {boolean} True if process exists
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
function isPidAlive(pid) {
|
|
69
|
+
if (typeof pid !== 'number' || isNaN(pid) || pid <= 0) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
// process.kill with signal 0 checks if process exists without sending a signal
|
|
74
|
+
process.kill(pid, 0);
|
|
75
|
+
return true;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// ESRCH = no such process, EPERM = process exists but no permission
|
|
78
|
+
return e.code === 'EPERM';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Acquire a lock file for a given file path
|
|
84
|
+
* Returns immediately with success/failure - does not wait on lock contention
|
|
85
|
+
*
|
|
86
|
+
* @param {string} filePath - File to lock
|
|
87
|
+
* @param {number} [timeoutMs=5000] - Timeout in milliseconds
|
|
88
|
+
* @returns {{ acquired: boolean, lockPath: string, error?: string }}
|
|
89
|
+
* @public
|
|
90
|
+
*/
|
|
91
|
+
function acquireLock(filePath, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
|
|
92
|
+
try {
|
|
93
|
+
const lockPath = filePath + '.lock';
|
|
94
|
+
const startTime = Date.now();
|
|
95
|
+
let retries = 0;
|
|
96
|
+
|
|
97
|
+
while (retries < MAX_LOCK_RETRIES) {
|
|
98
|
+
try {
|
|
99
|
+
// Try to create lock file exclusively
|
|
100
|
+
// fs.openSync with 'wx' flag fails if file exists
|
|
101
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
102
|
+
fs.writeSync(fd, `${process.pid}\n`);
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
acquired: true,
|
|
107
|
+
lockPath: lockPath,
|
|
108
|
+
};
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e.code !== 'EEXIST') {
|
|
111
|
+
// Real error (not lock contention)
|
|
112
|
+
return {
|
|
113
|
+
acquired: false,
|
|
114
|
+
lockPath: lockPath,
|
|
115
|
+
error: `Failed to create lock: ${e.message}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Lock file exists - check if PID is alive
|
|
120
|
+
try {
|
|
121
|
+
const lockContent = fs.readFileSync(lockPath, 'utf8').trim();
|
|
122
|
+
const lockPid = parseInt(lockContent, 10);
|
|
123
|
+
|
|
124
|
+
if (isNaN(lockPid)) {
|
|
125
|
+
// Corrupted lock file - try to remove and retry
|
|
126
|
+
try {
|
|
127
|
+
fs.unlinkSync(lockPath);
|
|
128
|
+
} catch (unlinkErr) {
|
|
129
|
+
// Ignore unlink errors
|
|
130
|
+
}
|
|
131
|
+
} else if (!isPidAlive(lockPid)) {
|
|
132
|
+
// PID is stale - remove lock and retry
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(lockPath);
|
|
135
|
+
} catch (unlinkErr) {
|
|
136
|
+
// Ignore unlink errors
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Lock is held by live process
|
|
140
|
+
// Check timeout
|
|
141
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
142
|
+
return {
|
|
143
|
+
acquired: false,
|
|
144
|
+
lockPath: lockPath,
|
|
145
|
+
error: `Lock timeout after ${timeoutMs}ms (held by PID ${lockPid})`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wait and retry
|
|
150
|
+
const delay = Math.min(LOCK_RETRY_INTERVAL_MS, 10 + Math.random() * 40);
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
while (Date.now() - now < delay) {
|
|
153
|
+
// Busy-wait for short delays (avoid complexity of setTimeout)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (readErr) {
|
|
157
|
+
// Could not read lock file - assume stale, retry
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
retries++;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Timeout reached
|
|
165
|
+
return {
|
|
166
|
+
acquired: false,
|
|
167
|
+
lockPath: lockPath,
|
|
168
|
+
error: `Could not acquire lock within ${timeoutMs}ms`,
|
|
169
|
+
};
|
|
170
|
+
} catch (e) {
|
|
171
|
+
// Unexpected error - fail open
|
|
172
|
+
return {
|
|
173
|
+
acquired: false,
|
|
174
|
+
lockPath: filePath + '.lock',
|
|
175
|
+
error: `Unexpected error: ${e.message}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Release a lock file
|
|
182
|
+
* Best-effort - does not throw on errors
|
|
183
|
+
*
|
|
184
|
+
* @param {string} lockPath - Path to lock file (from acquireLock result)
|
|
185
|
+
* @returns {boolean} True if lock was removed, false if already gone or error
|
|
186
|
+
* @public
|
|
187
|
+
*/
|
|
188
|
+
function releaseLock(lockPath) {
|
|
189
|
+
try {
|
|
190
|
+
if (fs.existsSync(lockPath)) {
|
|
191
|
+
fs.unlinkSync(lockPath);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return true; // Already gone is success
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// Fail open - don't throw
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Generate random string for temp file
|
|
203
|
+
* @returns {string} Random hex string
|
|
204
|
+
* @private
|
|
205
|
+
*/
|
|
206
|
+
function generateRandomSuffix() {
|
|
207
|
+
return crypto.randomBytes(8).toString('hex');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Write JSON data atomically to a file
|
|
212
|
+
* Uses temp file + rename pattern for safety
|
|
213
|
+
*
|
|
214
|
+
* @param {string} filePath - Target file path
|
|
215
|
+
* @param {object} data - Data to write
|
|
216
|
+
* @param {object} [options={}] - Options
|
|
217
|
+
* @param {boolean} [options.force=false] - Skip lock (for non-critical files)
|
|
218
|
+
* @param {number} [options.lockTimeoutMs=5000] - Lock timeout
|
|
219
|
+
* @returns {{ success: boolean, error?: string }}
|
|
220
|
+
* @public
|
|
221
|
+
*/
|
|
222
|
+
function atomicWriteJSON(filePath, data, options = {}) {
|
|
223
|
+
const { force = false, lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = options;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const dir = path.dirname(filePath);
|
|
227
|
+
const tempPath = filePath + '.tmp.' + generateRandomSuffix();
|
|
228
|
+
const lock = force ? null : acquireLock(filePath, lockTimeoutMs);
|
|
229
|
+
|
|
230
|
+
if (!force && !lock.acquired) {
|
|
231
|
+
// Fall back to direct write without lock
|
|
232
|
+
// Log warning in dim text to not clutter output
|
|
233
|
+
if (process.stderr && process.stderr.isTTY) {
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
`${c.dim}[file-lock] Write without lock: ${path.basename(filePath)}${c.reset}\n`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// Ensure directory exists
|
|
242
|
+
if (!fs.existsSync(dir)) {
|
|
243
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Write to temp file
|
|
247
|
+
const jsonStr = JSON.stringify(data, null, 2) + '\n';
|
|
248
|
+
fs.writeFileSync(tempPath, jsonStr, 'utf8');
|
|
249
|
+
|
|
250
|
+
// Atomic rename
|
|
251
|
+
fs.renameSync(tempPath, filePath);
|
|
252
|
+
|
|
253
|
+
return { success: true };
|
|
254
|
+
} finally {
|
|
255
|
+
// Clean up temp file if rename failed
|
|
256
|
+
if (fs.existsSync(tempPath)) {
|
|
257
|
+
try {
|
|
258
|
+
fs.unlinkSync(tempPath);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
// Ignore cleanup errors
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Release lock
|
|
265
|
+
if (!force && lock && lock.acquired) {
|
|
266
|
+
releaseLock(lock.lockPath);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
// Fail open - return error but don't throw
|
|
271
|
+
return {
|
|
272
|
+
success: false,
|
|
273
|
+
error: e.message,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Read, modify, and write JSON file atomically
|
|
280
|
+
* Handles concurrent access gracefully with retry logic
|
|
281
|
+
*
|
|
282
|
+
* @param {string} filePath - Target file path
|
|
283
|
+
* @param {function} modifyFn - Function that takes data, returns modified data
|
|
284
|
+
* @param {object} [options={}] - Options
|
|
285
|
+
* @param {number} [options.lockTimeoutMs=5000] - Lock timeout
|
|
286
|
+
* @param {number} [options.maxRetries=3] - Max retries on write conflict
|
|
287
|
+
* @returns {{ success: boolean, data?: object, error?: string }}
|
|
288
|
+
* @public
|
|
289
|
+
*/
|
|
290
|
+
function atomicReadModifyWrite(filePath, modifyFn, options = {}) {
|
|
291
|
+
const { lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS, maxRetries = 3 } = options;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
let retries = 0;
|
|
295
|
+
|
|
296
|
+
while (retries < maxRetries) {
|
|
297
|
+
const lock = acquireLock(filePath, lockTimeoutMs);
|
|
298
|
+
|
|
299
|
+
if (!lock.acquired) {
|
|
300
|
+
// Could not acquire lock - fail open and return last known good data
|
|
301
|
+
if (fs.existsSync(filePath)) {
|
|
302
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
303
|
+
const data = JSON.parse(content);
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
data: data,
|
|
307
|
+
error: `Could not acquire lock (${lock.error || 'unknown'}), returning last known good data`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
success: false,
|
|
313
|
+
error: `Could not acquire lock: ${lock.error || 'unknown'}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
// Read current data
|
|
319
|
+
if (!fs.existsSync(filePath)) {
|
|
320
|
+
return {
|
|
321
|
+
success: false,
|
|
322
|
+
error: `File does not exist: ${filePath}`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
327
|
+
const data = JSON.parse(content);
|
|
328
|
+
|
|
329
|
+
// Apply modification
|
|
330
|
+
const modifiedData = modifyFn(data);
|
|
331
|
+
|
|
332
|
+
// Write atomically
|
|
333
|
+
const dir = path.dirname(filePath);
|
|
334
|
+
const tempPath = filePath + '.tmp.' + generateRandomSuffix();
|
|
335
|
+
|
|
336
|
+
// Ensure directory exists
|
|
337
|
+
if (!fs.existsSync(dir)) {
|
|
338
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Write to temp
|
|
342
|
+
const jsonStr = JSON.stringify(modifiedData, null, 2) + '\n';
|
|
343
|
+
fs.writeFileSync(tempPath, jsonStr, 'utf8');
|
|
344
|
+
|
|
345
|
+
// Atomic rename
|
|
346
|
+
fs.renameSync(tempPath, filePath);
|
|
347
|
+
|
|
348
|
+
return { success: true, data: modifiedData };
|
|
349
|
+
} catch (e) {
|
|
350
|
+
// Clean up temp file if it exists
|
|
351
|
+
const tempPath = filePath + '.tmp.' + generateRandomSuffix();
|
|
352
|
+
if (fs.existsSync(tempPath)) {
|
|
353
|
+
try {
|
|
354
|
+
fs.unlinkSync(tempPath);
|
|
355
|
+
} catch (cleanupErr) {
|
|
356
|
+
// Ignore cleanup errors
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
throw e;
|
|
361
|
+
} finally {
|
|
362
|
+
// Release lock
|
|
363
|
+
if (lock && lock.acquired) {
|
|
364
|
+
releaseLock(lock.lockPath);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
success: false,
|
|
371
|
+
error: `Max retries (${maxRetries}) exceeded`,
|
|
372
|
+
};
|
|
373
|
+
} catch (e) {
|
|
374
|
+
// Fail open - return error but don't throw
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
error: e.message,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Export public API
|
|
383
|
+
module.exports = {
|
|
384
|
+
acquireLock,
|
|
385
|
+
releaseLock,
|
|
386
|
+
atomicWriteJSON,
|
|
387
|
+
atomicReadModifyWrite,
|
|
388
|
+
|
|
389
|
+
// For testing only
|
|
390
|
+
_generateRandomSuffix: generateRandomSuffix,
|
|
391
|
+
_isPidAlive: isPidAlive,
|
|
392
|
+
};
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
const fs = require('fs');
|
|
30
30
|
const path = require('path');
|
|
31
31
|
const crypto = require('crypto');
|
|
32
|
+
const { tryOptional } = require('../../lib/errors');
|
|
32
33
|
|
|
33
34
|
// Default index file location
|
|
34
35
|
const DEFAULT_INDEX_PATH = 'docs/00-meta/ideation-index.json';
|
|
@@ -151,11 +152,7 @@ function saveIdeationIndex(rootDir, index) {
|
|
|
151
152
|
return { ok: true };
|
|
152
153
|
} catch (err) {
|
|
153
154
|
// Clean up temp file if it exists
|
|
154
|
-
|
|
155
|
-
if (fs.existsSync(tempPath)) {
|
|
156
|
-
fs.unlinkSync(tempPath);
|
|
157
|
-
}
|
|
158
|
-
} catch {}
|
|
155
|
+
tryOptional(() => fs.unlinkSync(tempPath), 'cleanup temp');
|
|
159
156
|
return { ok: false, error: `Failed to save ideation index: ${err.message}` };
|
|
160
157
|
}
|
|
161
158
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lifecycle-detector.js
|
|
4
|
+
*
|
|
5
|
+
* Determines the current workflow phase based on project signals.
|
|
6
|
+
* Used by smart-detect.js to filter feature recommendations by phase.
|
|
7
|
+
*
|
|
8
|
+
* Phases (in order):
|
|
9
|
+
* pre-story → No active story, selecting what to work on
|
|
10
|
+
* planning → Story selected, planning implementation approach
|
|
11
|
+
* implementation → Actively writing code, files changed
|
|
12
|
+
* post-impl → Code done, reviewing/testing/documenting
|
|
13
|
+
* pre-pr → Ready to create PR, final checks
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const PHASES = ['pre-story', 'planning', 'implementation', 'post-impl', 'pre-pr'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect the current lifecycle phase from project signals.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} signals - Gathered project signals
|
|
24
|
+
* @param {Object} signals.story - Current story info { id, status, owner }
|
|
25
|
+
* @param {Object} signals.git - Git state { branch, filesChanged, isClean, onFeatureBranch }
|
|
26
|
+
* @param {Object} signals.session - Session state { planModeActive, activeCommands }
|
|
27
|
+
* @param {Object} signals.tests - Test state { passing, hasTestSetup }
|
|
28
|
+
* @returns {{ phase: string, confidence: number, reason: string }}
|
|
29
|
+
*/
|
|
30
|
+
function detectLifecyclePhase(signals = {}) {
|
|
31
|
+
const { story: rawStory, git: rawGit, session: rawSession, tests: rawTests } = signals;
|
|
32
|
+
const story = rawStory || {};
|
|
33
|
+
const git = rawGit || {};
|
|
34
|
+
const session = rawSession || {};
|
|
35
|
+
const tests = rawTests || {};
|
|
36
|
+
|
|
37
|
+
// Phase 5: pre-pr
|
|
38
|
+
// Story in-progress, tests passing, git clean (or nearly), on feature branch
|
|
39
|
+
if (
|
|
40
|
+
story.status === 'in-progress' &&
|
|
41
|
+
tests.passing === true &&
|
|
42
|
+
git.isClean &&
|
|
43
|
+
git.onFeatureBranch
|
|
44
|
+
) {
|
|
45
|
+
return {
|
|
46
|
+
phase: 'pre-pr',
|
|
47
|
+
confidence: 0.9,
|
|
48
|
+
reason: 'Tests passing, clean git, on feature branch',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Phase 4: post-impl
|
|
53
|
+
// Story in-progress, tests passing (or test files exist), still has some changes
|
|
54
|
+
if (story.status === 'in-progress' && tests.passing === true && git.filesChanged > 0) {
|
|
55
|
+
return {
|
|
56
|
+
phase: 'post-impl',
|
|
57
|
+
confidence: 0.8,
|
|
58
|
+
reason: 'Tests passing but uncommitted changes remain',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Phase 3: implementation
|
|
63
|
+
// Story in-progress AND files changed (git status non-empty)
|
|
64
|
+
if (story.status === 'in-progress' && git.filesChanged > 0) {
|
|
65
|
+
return {
|
|
66
|
+
phase: 'implementation',
|
|
67
|
+
confidence: 0.85,
|
|
68
|
+
reason: `Actively coding (${git.filesChanged} files changed)`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Phase 2: planning
|
|
73
|
+
// Story selected/in-progress but no files changed yet, OR plan mode active
|
|
74
|
+
if (session.planModeActive) {
|
|
75
|
+
return {
|
|
76
|
+
phase: 'planning',
|
|
77
|
+
confidence: 0.9,
|
|
78
|
+
reason: 'Plan mode is active',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (story.status === 'in-progress' && (git.filesChanged || 0) === 0) {
|
|
83
|
+
return {
|
|
84
|
+
phase: 'planning',
|
|
85
|
+
confidence: 0.7,
|
|
86
|
+
reason: 'Story in-progress but no files changed yet',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Phase 1: pre-story (default)
|
|
91
|
+
// No current story OR story is ready (not yet started)
|
|
92
|
+
return {
|
|
93
|
+
phase: 'pre-story',
|
|
94
|
+
confidence: story.id ? 0.6 : 0.9,
|
|
95
|
+
reason: story.id ? `Story ${story.id} not yet started (status: ${story.status || 'unknown'})` : 'No active story',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get features relevant to a given phase.
|
|
101
|
+
* Returns the set of feature categories that apply.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} phase - Lifecycle phase name
|
|
104
|
+
* @returns {string[]} Array of phase names whose features are relevant
|
|
105
|
+
*/
|
|
106
|
+
function getRelevantPhases(phase) {
|
|
107
|
+
// Each phase also includes adjacent phase features (for smooth transitions)
|
|
108
|
+
const phaseMap = {
|
|
109
|
+
'pre-story': ['pre-story'],
|
|
110
|
+
'planning': ['pre-story', 'planning'],
|
|
111
|
+
'implementation': ['planning', 'implementation'],
|
|
112
|
+
'post-impl': ['implementation', 'post-impl'],
|
|
113
|
+
'pre-pr': ['post-impl', 'pre-pr'],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return phaseMap[phase] || ['pre-story'];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
PHASES,
|
|
121
|
+
detectLifecyclePhase,
|
|
122
|
+
getRelevantPhases,
|
|
123
|
+
};
|