@magnusekdahl/parallix 1.1.0 → 1.2.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/config/integration-pipelines.json +9 -0
- package/config/workflow.config.schema.json +12 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
- package/docs/adr/index.md +1 -0
- package/docs/use-cases.md +44 -0
- package/lib/agents/agents.js +149 -100
- package/lib/agents/claude-telemetry.js +14 -10
- package/lib/agents/claude.js +32 -11
- package/lib/agents/codex-telemetry.js +32 -19
- package/lib/agents/codex.js +36 -11
- package/lib/agents/limit-hit.js +42 -25
- package/lib/agents/mistral-telemetry.js +1 -1
- package/lib/agents/mistral.js +13 -3
- package/lib/agents/opencode-export.js +11 -5
- package/lib/agents/opencode-telemetry.js +75 -90
- package/lib/agents/opencode.js +86 -29
- package/lib/agents/stage-telemetry.js +3 -7
- package/lib/commands/active.js +139 -94
- package/lib/commands/checkpoint.js +3 -1
- package/lib/commands/config.js +5 -3
- package/lib/commands/coverage-gate.js +17 -10
- package/lib/commands/diff.js +11 -5
- package/lib/commands/draft.js +95 -55
- package/lib/commands/handoff.js +253 -65
- package/lib/commands/integrate.js +288 -188
- package/lib/commands/mission-start.js +39 -34
- package/lib/commands/rebase.js +32 -21
- package/lib/commands/repair-handoff.js +21 -18
- package/lib/commands/resolve-conflict.js +5 -2
- package/lib/commands/review.js +1 -0
- package/lib/commands/setup-review.js +1 -0
- package/lib/commands/stats-backfill.js +50 -41
- package/lib/commands/stats.js +653 -204
- package/lib/commands/status.js +33 -28
- package/lib/core/fmt.js +58 -15
- package/lib/core/git.js +7 -5
- package/lib/core/gitignore.js +6 -4
- package/lib/core/mission-utils.js +161 -88
- package/lib/core/nels.js +199 -0
- package/lib/core/persistent-data-migration.js +55 -23
- package/lib/core/product-config.js +44 -23
- package/lib/core/runtime-matrix.js +15 -2
- package/lib/core/spawn-tee.js +40 -13
- package/lib/core/state-map.js +32 -13
- package/lib/core/storage.js +19 -5
- package/lib/core/subagent-limit.js +28 -0
- package/lib/core/verification.js +29 -12
- package/lib/review/rebase.js +11 -3
- package/lib/review/review-adapter.js +93 -6
- package/lib/review/review-artifacts.js +89 -23
- package/lib/review/review-commands.js +139 -45
- package/lib/review/review-events.js +103 -52
- package/lib/review/review-loop.js +117 -87
- package/lib/review/review-polling.js +25 -5
- package/lib/review/review-prompts.js +36 -5
- package/lib/review/review-state.js +39 -15
- package/lib/tools/backlog.js +162 -71
- package/lib/tools/forgejo.js +290 -129
- package/lib/tools/gatekeeper.js +15 -0
- package/lib/tools/redgreen.js +220 -0
- package/lib/tools/sessions.js +12 -5
- package/lib/tools/setup-review.js +170 -70
- package/package.json +11 -3
- package/prompts/draft.md +6 -0
- package/templates/mission-scaffold.md +1 -1
package/lib/tools/backlog.js
CHANGED
|
@@ -5,48 +5,63 @@ const { WORKFLOW_AGENT_NAMES } = require('../agents/agents');
|
|
|
5
5
|
const fmt = require('../core/fmt');
|
|
6
6
|
const { resolveTaskStorage } = require('../core/product-config');
|
|
7
7
|
|
|
8
|
+
/** @returns {readonly string[]} */
|
|
8
9
|
function getSupportedAgents() {
|
|
9
10
|
return WORKFLOW_AGENT_NAMES;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** @param {string} [rootDir] @returns {{tasksDir: string, completedDir: string, archiveTasksDir: string}} */
|
|
12
14
|
function getTaskStorage(rootDir = process.cwd()) {
|
|
13
15
|
return resolveTaskStorage(rootDir);
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} slug
|
|
20
|
+
* @param {string} [rootDir]
|
|
21
|
+
* @returns {string[]}
|
|
22
|
+
*/
|
|
16
23
|
function findTaskFiles(slug, rootDir = process.cwd()) {
|
|
17
24
|
const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
|
|
18
|
-
const sortByBacklogState = (filePath) => {
|
|
19
|
-
if (filePath.startsWith(tasksDir + path.sep)) return 0;
|
|
20
|
-
if (filePath.startsWith(completedDir + path.sep)) return 1;
|
|
21
|
-
if (filePath.startsWith(archivedDir + path.sep)) return 2;
|
|
25
|
+
const sortByBacklogState = /** @param {string} filePath */ (filePath) => {
|
|
26
|
+
if (filePath.startsWith(tasksDir + path.sep)) {return 0;}
|
|
27
|
+
if (filePath.startsWith(completedDir + path.sep)) {return 1;}
|
|
28
|
+
if (filePath.startsWith(archivedDir + path.sep)) {return 2;}
|
|
22
29
|
return 3;
|
|
23
30
|
};
|
|
24
31
|
|
|
32
|
+
/** @param {string} dir */
|
|
25
33
|
const scan = (dir) => {
|
|
26
|
-
if (!fs.existsSync(dir)) return [];
|
|
34
|
+
if (!fs.existsSync(dir)) {return [];}
|
|
27
35
|
const files = fs.readdirSync(dir);
|
|
28
36
|
const normalizedSlug = slug.toLowerCase();
|
|
29
37
|
return files
|
|
30
|
-
.filter(f => f.toLowerCase().startsWith(normalizedSlug))
|
|
31
|
-
.map(f => path.join(dir, f));
|
|
38
|
+
.filter(/** @param {string} f */ f => f.toLowerCase().startsWith(normalizedSlug))
|
|
39
|
+
.map(/** @param {string} f */ f => path.join(dir, f));
|
|
32
40
|
};
|
|
33
41
|
|
|
34
42
|
const matches = [...scan(tasksDir), ...scan(completedDir), ...scan(archivedDir)];
|
|
35
43
|
return matches.sort((a, b) => sortByBacklogState(a) - sortByBacklogState(b) || a.localeCompare(b));
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} slug
|
|
48
|
+
* @param {string} [rootDir]
|
|
49
|
+
* @returns {{ok: boolean, taskFile?: string, matches: string[], reason?: string}}
|
|
50
|
+
*/
|
|
38
51
|
function resolveTaskFile(slug, rootDir = process.cwd()) {
|
|
39
52
|
let matches = findTaskFiles(slug, rootDir);
|
|
40
53
|
const normalizedId = slug.toUpperCase();
|
|
41
54
|
const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
|
|
55
|
+
/** @param {string[]} candidateMatches */
|
|
42
56
|
const preferSameTaskInHigherPriorityDir = (candidateMatches) => {
|
|
43
|
-
if (candidateMatches.length < 2) return null;
|
|
57
|
+
if (candidateMatches.length < 2) {return null;}
|
|
44
58
|
const [preferred, ...rest] = candidateMatches;
|
|
45
|
-
return rest.every(match => path.basename(match) === path.basename(preferred)) ? preferred : null;
|
|
59
|
+
return rest.every(/** @param {string} match */ match => path.basename(match) === path.basename(preferred)) ? preferred : null;
|
|
46
60
|
};
|
|
47
61
|
|
|
62
|
+
/** @param {string[]} candidateFiles @param {string} targetId */
|
|
48
63
|
const findById = (candidateFiles, targetId) => {
|
|
49
|
-
return candidateFiles.filter(f => {
|
|
64
|
+
return candidateFiles.filter(/** @param {string} f */ f => {
|
|
50
65
|
try {
|
|
51
66
|
const content = fs.readFileSync(f, 'utf8');
|
|
52
67
|
const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
|
|
@@ -115,13 +130,23 @@ function resolveTaskFile(slug, rootDir = process.cwd()) {
|
|
|
115
130
|
return { ok: false, reason: 'ambiguous', matches };
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} slug
|
|
135
|
+
* @param {string} [rootDir]
|
|
136
|
+
* @returns {string|undefined|null}
|
|
137
|
+
*/
|
|
118
138
|
function findTaskFile(slug, rootDir = process.cwd()) {
|
|
119
139
|
const result = resolveTaskFile(slug, rootDir);
|
|
120
140
|
return result.ok ? result.taskFile : null;
|
|
121
141
|
}
|
|
122
142
|
|
|
143
|
+
/**
|
|
144
|
+
* @param {{ok: boolean, taskFile?: string, matches: string[], reason?: string}} result
|
|
145
|
+
* @param {string} slug
|
|
146
|
+
* @param {Function} [log]
|
|
147
|
+
*/
|
|
123
148
|
function reportTaskResolution(result, slug, log = fmt.log.plain) {
|
|
124
|
-
if (result.ok) return;
|
|
149
|
+
if (result.ok) {return;}
|
|
125
150
|
const { tasksDir, completedDir } = getTaskStorage(process.cwd());
|
|
126
151
|
const taskHint = path.relative(process.cwd(), tasksDir).split(path.sep).join('/');
|
|
127
152
|
const completedHint = path.relative(process.cwd(), completedDir).split(path.sep).join('/');
|
|
@@ -139,13 +164,19 @@ function reportTaskResolution(result, slug, log = fmt.log.plain) {
|
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
166
|
|
|
167
|
+
/** @param {string} file @returns {string|null} */
|
|
142
168
|
function taskIdFromFilename(file) {
|
|
143
169
|
// Extract ID from filename prefix (e.g., task-093 or task-093.01)
|
|
144
170
|
const filenameMatch = file.match(/^(task-\d+(?:\.\d+)?)/i);
|
|
145
171
|
return filenameMatch ? filenameMatch[1].toUpperCase() : null;
|
|
146
172
|
}
|
|
147
173
|
|
|
148
|
-
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} [rootDir]
|
|
176
|
+
* @param {string} [slug]
|
|
177
|
+
* @returns {{file: string, type: string, taskId?: string, canonicalFile?: string}[]}
|
|
178
|
+
*/
|
|
179
|
+
function checkBacklogIntegrity(rootDir = process.cwd(), /** @type {string|null} */ slug = null) {
|
|
149
180
|
const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
|
|
150
181
|
const issues = [];
|
|
151
182
|
const normalizedSlug = slug ? slug.toLowerCase() : null;
|
|
@@ -157,20 +188,21 @@ function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
|
|
|
157
188
|
const tasksLocations = new Map(); // id -> rel path in tasks/
|
|
158
189
|
const canonicalLocations = new Map(); // id -> rel path in completed|archive
|
|
159
190
|
|
|
191
|
+
/** @param {string} dir @param {{canonical?: boolean}} [opts] */
|
|
160
192
|
const scan = (dir, { canonical = false } = {}) => {
|
|
161
|
-
if (!fs.existsSync(dir)) return;
|
|
162
|
-
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
193
|
+
if (!fs.existsSync(dir)) {return;}
|
|
194
|
+
const files = fs.readdirSync(dir).filter(/** @param {string} f */ f => f.endsWith('.md'));
|
|
163
195
|
for (const file of files) {
|
|
164
196
|
if (normalizedSlug && !file.toLowerCase().startsWith(normalizedSlug)) {
|
|
165
197
|
continue;
|
|
166
198
|
}
|
|
167
199
|
const filePath = path.join(dir, file);
|
|
168
200
|
const filenameId = taskIdFromFilename(file);
|
|
169
|
-
if (!filenameId) continue;
|
|
201
|
+
if (!filenameId) {continue;}
|
|
170
202
|
const relPath = path.relative(rootDir, filePath);
|
|
171
203
|
|
|
172
204
|
if (canonical) {
|
|
173
|
-
if (!canonicalLocations.has(filenameId)) canonicalLocations.set(filenameId, relPath);
|
|
205
|
+
if (!canonicalLocations.has(filenameId)) {canonicalLocations.set(filenameId, relPath);}
|
|
174
206
|
} else if (!tasksLocations.has(filenameId)) {
|
|
175
207
|
tasksLocations.set(filenameId, relPath);
|
|
176
208
|
}
|
|
@@ -243,8 +275,9 @@ function pruneStaleBacklogDuplicates(rootDir = process.cwd()) {
|
|
|
243
275
|
return removed;
|
|
244
276
|
}
|
|
245
277
|
|
|
278
|
+
/** @param {string} taskFilePath @returns {string|null} */
|
|
246
279
|
function getTaskStatus(taskFilePath) {
|
|
247
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
|
|
280
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
|
|
248
281
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
249
282
|
|
|
250
283
|
// Try YAML first
|
|
@@ -262,8 +295,9 @@ function getTaskStatus(taskFilePath) {
|
|
|
262
295
|
return null;
|
|
263
296
|
}
|
|
264
297
|
|
|
298
|
+
/** @param {string} taskFilePath @param {string} newStatus @returns {boolean} */
|
|
265
299
|
function setTaskStatus(taskFilePath, newStatus) {
|
|
266
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
|
|
300
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
|
|
267
301
|
let content = fs.readFileSync(taskFilePath, 'utf8');
|
|
268
302
|
|
|
269
303
|
// Update YAML
|
|
@@ -287,11 +321,16 @@ function setTaskStatus(taskFilePath, newStatus) {
|
|
|
287
321
|
return true;
|
|
288
322
|
}
|
|
289
323
|
|
|
324
|
+
/**
|
|
325
|
+
* @param {string} slug
|
|
326
|
+
* @param {string} [rootDir]
|
|
327
|
+
* @returns {boolean}
|
|
328
|
+
*/
|
|
290
329
|
function completeTask(slug, rootDir = process.cwd()) {
|
|
291
330
|
const resolution = resolveTaskFile(slug, rootDir);
|
|
292
|
-
if (!resolution.ok) return false;
|
|
331
|
+
if (!resolution.ok) {return false;}
|
|
293
332
|
|
|
294
|
-
const taskFilePath = resolution.taskFile;
|
|
333
|
+
const taskFilePath = /** @type {string} */ (resolution.taskFile);
|
|
295
334
|
const fileName = path.basename(taskFilePath);
|
|
296
335
|
const { tasksDir, completedDir } = getTaskStorage(rootDir);
|
|
297
336
|
|
|
@@ -318,8 +357,12 @@ function completeTask(slug, rootDir = process.cwd()) {
|
|
|
318
357
|
* Internal helper to parse assignee families from YAML frontmatter content.
|
|
319
358
|
* Supports inline array, simple inline, and block formats.
|
|
320
359
|
*/
|
|
360
|
+
/**
|
|
361
|
+
* @param {string} content
|
|
362
|
+
* @returns {{matched: boolean, families: string[]}}
|
|
363
|
+
*/
|
|
321
364
|
function parseAssigneeFamilies(content) {
|
|
322
|
-
let families = [];
|
|
365
|
+
let families = /** @type {string[]} */ ([]);
|
|
323
366
|
let matched = false;
|
|
324
367
|
|
|
325
368
|
const lineMatch = content.match(/^assignee:[ \t]*(.*)$/m);
|
|
@@ -333,8 +376,8 @@ function parseAssigneeFamilies(content) {
|
|
|
333
376
|
: rest;
|
|
334
377
|
|
|
335
378
|
families = rawValues.split(',')
|
|
336
|
-
.map(s => s.trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
|
|
337
|
-
.filter(s => s.length > 0);
|
|
379
|
+
.map(/** @param {string} s */ s => s.trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
|
|
380
|
+
.filter(/** @param {string} s */ s => s.length > 0);
|
|
338
381
|
}
|
|
339
382
|
}
|
|
340
383
|
|
|
@@ -344,18 +387,24 @@ function parseAssigneeFamilies(content) {
|
|
|
344
387
|
if (blockMatch) {
|
|
345
388
|
matched = true;
|
|
346
389
|
families = blockMatch[1].split(/[\r\n]+/)
|
|
347
|
-
.map(line => line.trim())
|
|
348
|
-
.filter(line => line.startsWith('-'))
|
|
349
|
-
.map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
|
|
350
|
-
.filter(s => s.length > 0);
|
|
390
|
+
.map(/** @param {string} line */ line => line.trim())
|
|
391
|
+
.filter(/** @param {string} line */ line => line.startsWith('-'))
|
|
392
|
+
.map(/** @param {string} line */ line => line.substring(1).trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
|
|
393
|
+
.filter(/** @param {string} s */ s => s.length > 0);
|
|
351
394
|
}
|
|
352
395
|
}
|
|
353
396
|
|
|
354
397
|
return { matched, families };
|
|
355
398
|
}
|
|
356
399
|
|
|
400
|
+
/**
|
|
401
|
+
* @param {string} taskFilePath
|
|
402
|
+
* @param {string} message
|
|
403
|
+
* @param {string} [rootDir]
|
|
404
|
+
* @returns {boolean|null|void}
|
|
405
|
+
*/
|
|
357
406
|
function commitTaskFileUpdate(taskFilePath, message, rootDir = process.cwd()) {
|
|
358
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return;
|
|
407
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return;}
|
|
359
408
|
// Run `git add/commit` against the worktree the caller intended (rootDir),
|
|
360
409
|
// not whichever branch process.cwd() happens to have checked out. All
|
|
361
410
|
// worktrees share one git object store, so the only thing that pins the
|
|
@@ -380,23 +429,24 @@ function commitTaskFileUpdate(taskFilePath, message, rootDir = process.cwd()) {
|
|
|
380
429
|
return false;
|
|
381
430
|
}
|
|
382
431
|
return true;
|
|
383
|
-
} catch (e) {
|
|
384
|
-
fmt.log.fail(`Git error during task update: ${e.message}`);
|
|
432
|
+
} catch (/** @type {unknown} */ e) {
|
|
433
|
+
fmt.log.fail(`Git error during task update: ${(/** @type {Error} */ (e)).message}`);
|
|
385
434
|
return false;
|
|
386
435
|
}
|
|
387
436
|
}
|
|
388
437
|
|
|
438
|
+
/** @param {string} taskFilePath @returns {boolean} */
|
|
389
439
|
function clearTaskAgentAssignee(taskFilePath) {
|
|
390
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
|
|
440
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
|
|
391
441
|
let content = fs.readFileSync(taskFilePath, 'utf8');
|
|
392
|
-
if (!content.match(/^assignee:/m)) return false;
|
|
442
|
+
if (!content.match(/^assignee:/m)) {return false;}
|
|
393
443
|
|
|
394
444
|
const { families } = parseAssigneeFamilies(content);
|
|
395
445
|
const supportedAgents = getSupportedAgents();
|
|
396
446
|
|
|
397
447
|
// Separate agent families from human assignees
|
|
398
|
-
const agentFamilies = families.filter(f => supportedAgents.includes(f.toLowerCase()));
|
|
399
|
-
const humanFamilies = families.filter(f => !supportedAgents.includes(f.toLowerCase()));
|
|
448
|
+
const agentFamilies = families.filter(/** @param {string} f */ f => supportedAgents.includes(f.toLowerCase()));
|
|
449
|
+
const humanFamilies = families.filter(/** @param {string} f */ f => !supportedAgents.includes(f.toLowerCase()));
|
|
400
450
|
|
|
401
451
|
// If there are no agent families to clear, nothing to do — preserve human assignees
|
|
402
452
|
if (agentFamilies.length === 0) {
|
|
@@ -410,7 +460,7 @@ function clearTaskAgentAssignee(taskFilePath) {
|
|
|
410
460
|
// Remove agent families and write back human-only
|
|
411
461
|
let newAssigneeLine;
|
|
412
462
|
if (hasBlockForm) {
|
|
413
|
-
newAssigneeLine = 'assignee:\n' + humanFamilies.map(f => ` - ${f}`).join('\n') + '\n';
|
|
463
|
+
newAssigneeLine = 'assignee:\n' + humanFamilies.map(/** @param {string} f */ f => ` - ${f}`).join('\n') + '\n';
|
|
414
464
|
} else {
|
|
415
465
|
newAssigneeLine = `assignee: [${humanFamilies.join(', ')}]`;
|
|
416
466
|
}
|
|
@@ -442,7 +492,7 @@ function clearTaskAgentAssignee(taskFilePath) {
|
|
|
442
492
|
if (blockLines) {
|
|
443
493
|
const keptLines = blockLines[1].split('\n').filter(line => {
|
|
444
494
|
const m = line.match(/^\s+-\s+(.+)/);
|
|
445
|
-
if (!m) return line.trim() === '';
|
|
495
|
+
if (!m) {return line.trim() === '';}
|
|
446
496
|
const family = m[1].trim().replace(/^['"]|['"]$/g, '');
|
|
447
497
|
return !supportedAgents.includes(family.toLowerCase());
|
|
448
498
|
}).join('\n');
|
|
@@ -465,6 +515,12 @@ function clearTaskAgentAssignee(taskFilePath) {
|
|
|
465
515
|
* restoring a prior assignee, or clearing agent assignees,
|
|
466
516
|
* and commit the change to the mission branch.
|
|
467
517
|
*/
|
|
518
|
+
/**
|
|
519
|
+
* @param {string} slug
|
|
520
|
+
* @param {string} newStatus
|
|
521
|
+
* @param {{implementer?: string|null, clearAssignee?: boolean, rootDir?: string, log?: Function}} [opts]
|
|
522
|
+
* @returns {boolean}
|
|
523
|
+
*/
|
|
468
524
|
function transitionTask(slug, newStatus, { implementer = null, clearAssignee = false, rootDir = process.cwd(), log = fmt.log.plain } = {}) {
|
|
469
525
|
const resolution = resolveTaskFile(slug, rootDir);
|
|
470
526
|
if (!resolution.ok) {
|
|
@@ -472,7 +528,7 @@ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = f
|
|
|
472
528
|
return false;
|
|
473
529
|
}
|
|
474
530
|
|
|
475
|
-
const taskFile = resolution.taskFile;
|
|
531
|
+
const taskFile = /** @type {string} */ (resolution.taskFile);
|
|
476
532
|
|
|
477
533
|
// Guard: reject suffixed slugs (e.g. "task-1048-regress") to prevent
|
|
478
534
|
// resolveTaskFile's base-ID fallback from silently committing to the
|
|
@@ -507,7 +563,7 @@ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = f
|
|
|
507
563
|
|
|
508
564
|
if (changed) {
|
|
509
565
|
let msg = `backlog(${slug}): transition to ${newStatus}`;
|
|
510
|
-
if (implementer) msg += ` and implementer=${implementer}`;
|
|
566
|
+
if (implementer) {msg += ` and implementer=${implementer}`;}
|
|
511
567
|
|
|
512
568
|
if (commitTaskFileUpdate(taskFile, msg, rootDir)) {
|
|
513
569
|
log(fmt.status('PASS', `Task ${fmt.slug(slug)} transitioned to ${newStatus}${implementer ? ' (assignee=' + fmt.agent(implementer) + ')' : ''} and committed.`));
|
|
@@ -519,77 +575,107 @@ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = f
|
|
|
519
575
|
return true; // Already in the desired state
|
|
520
576
|
}
|
|
521
577
|
|
|
578
|
+
/** @param {string} taskFilePath @returns {string|null} */
|
|
522
579
|
function getTaskAssignee(taskFilePath) {
|
|
523
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
|
|
580
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
|
|
524
581
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
525
582
|
const { families } = parseAssigneeFamilies(content);
|
|
526
583
|
return families.length > 0 ? families[0] : null;
|
|
527
584
|
}
|
|
528
585
|
|
|
586
|
+
/** @param {string} taskFilePath @returns {string|null} */
|
|
529
587
|
function getTaskImplementer(taskFilePath) {
|
|
530
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
|
|
588
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
|
|
531
589
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
532
590
|
const { families } = parseAssigneeFamilies(content);
|
|
533
591
|
|
|
534
|
-
const normalizedFamilies = families.map(f => f.toLowerCase());
|
|
592
|
+
const normalizedFamilies = families.map(/** @param {string} f */ f => f.toLowerCase());
|
|
535
593
|
const supportedAgents = getSupportedAgents();
|
|
536
|
-
return normalizedFamilies.find(f => supportedAgents.includes(f)) || null;
|
|
594
|
+
return normalizedFamilies.find(/** @param {string} f */ f => supportedAgents.includes(f)) || null;
|
|
537
595
|
}
|
|
538
596
|
|
|
597
|
+
/** @param {string} taskFilePath @param {string} field @returns {string|null} */
|
|
539
598
|
function getTaskFrontmatterValue(taskFilePath, field) {
|
|
540
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
|
|
599
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
|
|
541
600
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
542
601
|
const pattern = new RegExp(`^${field}:\\s*([^\\r\\n]+)`, 'mi');
|
|
543
602
|
const match = content.match(pattern);
|
|
544
|
-
if (!match) return null;
|
|
603
|
+
if (!match) {return null;}
|
|
545
604
|
const value = match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
546
605
|
return value || null;
|
|
547
606
|
}
|
|
548
607
|
|
|
549
|
-
|
|
550
|
-
|
|
608
|
+
const CLASSIFICATION_LABELS = new Set(['ai_sdlc', 'user_value', 'unknown']);
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Parse all labels from a task file's frontmatter, supporting both block
|
|
612
|
+
* and inline YAML formats. Returns a lowercased array of label strings.
|
|
613
|
+
* When both formats are present, block labels take precedence (inline is
|
|
614
|
+
* treated as a fallback when no block labels are found).
|
|
615
|
+
*/
|
|
616
|
+
/** @param {string} taskFilePath */
|
|
617
|
+
function getTaskLabels(taskFilePath) {
|
|
618
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return [];}
|
|
551
619
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
552
|
-
const matches = new Set();
|
|
553
620
|
|
|
554
621
|
const blockMatch = content.match(/^labels:[ \t]*[\r\n]+((?:\s+-\s+.+[\r\n]*)+)/m);
|
|
555
622
|
if (blockMatch) {
|
|
556
|
-
|
|
623
|
+
return blockMatch[1].split(/[\r\n]+/)
|
|
557
624
|
.map(line => line.trim())
|
|
558
625
|
.filter(line => line.startsWith('-'))
|
|
559
626
|
.map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, ''))
|
|
560
|
-
.map(s => s.toLowerCase())
|
|
561
|
-
|
|
562
|
-
if (labels.includes('user_value')) matches.add('user_value');
|
|
563
|
-
if (labels.includes('unknown')) matches.add('unknown');
|
|
627
|
+
.map(s => s.toLowerCase())
|
|
628
|
+
.filter(s => s.length > 0);
|
|
564
629
|
}
|
|
565
630
|
|
|
566
631
|
const inlineMatch = content.match(/^labels:[ \t]*\[(.*?)\]/m);
|
|
567
632
|
if (inlineMatch) {
|
|
568
|
-
|
|
633
|
+
return inlineMatch[1].split(',')
|
|
569
634
|
.map(s => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
570
|
-
.map(s => s.toLowerCase())
|
|
571
|
-
|
|
572
|
-
if (labels.includes('user_value')) matches.add('user_value');
|
|
573
|
-
if (labels.includes('unknown')) matches.add('unknown');
|
|
635
|
+
.map(s => s.toLowerCase())
|
|
636
|
+
.filter(s => s.length > 0);
|
|
574
637
|
}
|
|
575
638
|
|
|
639
|
+
return [];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** @param {string} taskFilePath */
|
|
643
|
+
function getTaskClassification(taskFilePath) {
|
|
644
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
|
|
645
|
+
const labels = getTaskLabels(taskFilePath);
|
|
646
|
+
const matches = new Set();
|
|
647
|
+
for (const label of labels) {
|
|
648
|
+
if (CLASSIFICATION_LABELS.has(label)) {matches.add(label);}
|
|
649
|
+
}
|
|
576
650
|
return matches.size === 1 ? [...matches][0] : null;
|
|
577
651
|
}
|
|
578
652
|
|
|
653
|
+
/** @param {string} taskFilePath */
|
|
654
|
+
function hasBugLabel(taskFilePath) {
|
|
655
|
+
const labels = getTaskLabels(taskFilePath);
|
|
656
|
+
return labels.includes('bug');
|
|
657
|
+
}
|
|
658
|
+
|
|
579
659
|
|
|
660
|
+
/**
|
|
661
|
+
* @param {string} taskFilePath
|
|
662
|
+
* @param {string} agentFamily
|
|
663
|
+
* @param {{promote?: boolean}} [opts]
|
|
664
|
+
* @returns {boolean}
|
|
665
|
+
*/
|
|
580
666
|
function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
|
|
581
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
|
|
667
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
|
|
582
668
|
let content = fs.readFileSync(taskFilePath, 'utf8');
|
|
583
669
|
|
|
584
670
|
const { matched, families } = parseAssigneeFamilies(content);
|
|
585
671
|
|
|
586
672
|
if (matched) {
|
|
587
673
|
const lowerAgentFamily = agentFamily.toLowerCase();
|
|
588
|
-
const existingIndex = families.findIndex(f => f.toLowerCase() === lowerAgentFamily);
|
|
674
|
+
const existingIndex = families.findIndex(/** @param {string} f */ f => f.toLowerCase() === lowerAgentFamily);
|
|
589
675
|
|
|
590
676
|
if (existingIndex !== 0) {
|
|
591
677
|
if (existingIndex !== -1) {
|
|
592
|
-
if (!promote) return false; // Already in the list, and we don't want to move it
|
|
678
|
+
if (!promote) {return false;} // Already in the list, and we don't want to move it
|
|
593
679
|
// Remove existing to promote to first
|
|
594
680
|
families.splice(existingIndex, 1);
|
|
595
681
|
}
|
|
@@ -620,7 +706,7 @@ function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
|
|
|
620
706
|
|
|
621
707
|
// No assignee line exists — insert one after the id frontmatter line
|
|
622
708
|
const insertMatch = content.match(/^(id:.*)/m);
|
|
623
|
-
if (insertMatch) {
|
|
709
|
+
if (insertMatch && insertMatch.index !== undefined) {
|
|
624
710
|
const insertPos = insertMatch.index + insertMatch[0].length;
|
|
625
711
|
const newLine = '\nassignee: [' + agentFamily + ']';
|
|
626
712
|
content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
|
|
@@ -631,8 +717,9 @@ function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
|
|
|
631
717
|
return false;
|
|
632
718
|
}
|
|
633
719
|
|
|
720
|
+
/** @param {string} taskFilePath @param {string} agentFamily @returns {boolean} */
|
|
634
721
|
function setTaskImplementer(taskFilePath, agentFamily) {
|
|
635
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
|
|
722
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
|
|
636
723
|
let content = fs.readFileSync(taskFilePath, 'utf8');
|
|
637
724
|
|
|
638
725
|
const { matched, families } = parseAssigneeFamilies(content);
|
|
@@ -644,13 +731,13 @@ function setTaskImplementer(taskFilePath, agentFamily) {
|
|
|
644
731
|
const nextFamilies = [agentFamily, ...preservedFamilies];
|
|
645
732
|
|
|
646
733
|
if (matched) {
|
|
647
|
-
const normalizedCurrent = families.map(f => f.toLowerCase());
|
|
648
|
-
const normalizedNext = nextFamilies.map(f => f.toLowerCase());
|
|
734
|
+
const normalizedCurrent = families.map(/** @param {string} f */ f => f.toLowerCase());
|
|
735
|
+
const normalizedNext = nextFamilies.map(/** @param {string} f */ f => f.toLowerCase());
|
|
649
736
|
// Check if the first agent is already the one we want to set
|
|
650
737
|
if (normalizedCurrent.length > 0 && normalizedCurrent[0] === agentFamily.toLowerCase()) {
|
|
651
738
|
// If the rest of the list is also the same, it's a no-op
|
|
652
739
|
if (normalizedCurrent.length === normalizedNext.length &&
|
|
653
|
-
normalizedCurrent.every((f, i) => f === normalizedNext[i])) {
|
|
740
|
+
normalizedCurrent.every((f, /** @type {number} */ i) => f === normalizedNext[i])) {
|
|
654
741
|
return false;
|
|
655
742
|
}
|
|
656
743
|
}
|
|
@@ -670,7 +757,7 @@ function setTaskImplementer(taskFilePath, agentFamily) {
|
|
|
670
757
|
}
|
|
671
758
|
|
|
672
759
|
const insertMatch = content.match(/^(id:.*)/m);
|
|
673
|
-
if (insertMatch) {
|
|
760
|
+
if (insertMatch && insertMatch.index !== undefined) {
|
|
674
761
|
const insertPos = insertMatch.index + insertMatch[0].length;
|
|
675
762
|
const newLine = '\nassignee: [' + agentFamily + ']';
|
|
676
763
|
content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
|
|
@@ -681,12 +768,13 @@ function setTaskImplementer(taskFilePath, agentFamily) {
|
|
|
681
768
|
return false;
|
|
682
769
|
}
|
|
683
770
|
|
|
771
|
+
/** @param {string} taskFilePath @param {string} agentFamily @returns {boolean} */
|
|
684
772
|
function enforceTaskAssignee(taskFilePath, agentFamily) {
|
|
685
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
|
|
773
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
|
|
686
774
|
let content = fs.readFileSync(taskFilePath, 'utf8');
|
|
687
775
|
|
|
688
776
|
const { families } = parseAssigneeFamilies(content);
|
|
689
|
-
if (families.length === 1 && families[0] === agentFamily) return true;
|
|
777
|
+
if (families.length === 1 && families[0] === agentFamily) {return true;}
|
|
690
778
|
|
|
691
779
|
const newAssignee = `assignee: [${agentFamily}]`;
|
|
692
780
|
|
|
@@ -696,7 +784,7 @@ function enforceTaskAssignee(taskFilePath, agentFamily) {
|
|
|
696
784
|
content = content.replace(/^assignee:[ \t]*.*$/m, newAssignee);
|
|
697
785
|
} else {
|
|
698
786
|
const insertMatch = content.match(/^(id:.*)/m);
|
|
699
|
-
if (!insertMatch) return false;
|
|
787
|
+
if (!insertMatch || insertMatch.index === undefined) {return false;}
|
|
700
788
|
const insertPos = insertMatch.index + insertMatch[0].length;
|
|
701
789
|
content = content.slice(0, insertPos) + '\n' + newAssignee + content.slice(insertPos);
|
|
702
790
|
}
|
|
@@ -705,11 +793,12 @@ function enforceTaskAssignee(taskFilePath, agentFamily) {
|
|
|
705
793
|
return true;
|
|
706
794
|
}
|
|
707
795
|
|
|
796
|
+
/** @param {string} taskFilePath @returns {string[]} */
|
|
708
797
|
function getAcceptanceCriteria(taskFilePath) {
|
|
709
|
-
if (!taskFilePath || !fs.existsSync(taskFilePath)) return [];
|
|
798
|
+
if (!taskFilePath || !fs.existsSync(taskFilePath)) {return [];}
|
|
710
799
|
const content = fs.readFileSync(taskFilePath, 'utf8');
|
|
711
800
|
const sectionMatch = content.match(/## Acceptance Criteria\s*\n([\s\S]*?)(?:\n## |\nDefinition of Done:|\n---\n|$)/);
|
|
712
|
-
if (!sectionMatch) return [];
|
|
801
|
+
if (!sectionMatch) {return [];}
|
|
713
802
|
|
|
714
803
|
return sectionMatch[1]
|
|
715
804
|
.split('\n')
|
|
@@ -734,6 +823,8 @@ module.exports = {
|
|
|
734
823
|
getTaskImplementer,
|
|
735
824
|
getTaskFrontmatterValue,
|
|
736
825
|
getTaskClassification,
|
|
826
|
+
getTaskLabels,
|
|
827
|
+
hasBugLabel,
|
|
737
828
|
setTaskAssignee,
|
|
738
829
|
setTaskImplementer,
|
|
739
830
|
enforceTaskAssignee,
|