@magnusekdahl/parallix 1.2.0 → 1.3.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.
Files changed (127) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +737 -838
  3. package/lib/agents/agents.ts +930 -0
  4. package/lib/agents/claude-telemetry.js +149 -154
  5. package/lib/agents/claude-telemetry.ts +233 -0
  6. package/lib/agents/claude.js +121 -131
  7. package/lib/agents/claude.ts +156 -0
  8. package/lib/agents/codex-telemetry.js +176 -164
  9. package/lib/agents/codex-telemetry.ts +205 -0
  10. package/lib/agents/codex.js +168 -201
  11. package/lib/agents/codex.ts +236 -0
  12. package/lib/agents/limit-hit.js +216 -226
  13. package/lib/agents/limit-hit.ts +262 -0
  14. package/lib/agents/mistral-telemetry.js +7 -10
  15. package/lib/agents/mistral-telemetry.ts +44 -0
  16. package/lib/agents/mistral.js +40 -58
  17. package/lib/agents/mistral.ts +84 -0
  18. package/lib/agents/opencode-export.js +126 -127
  19. package/lib/agents/opencode-export.ts +160 -0
  20. package/lib/agents/opencode-telemetry.js +282 -261
  21. package/lib/agents/opencode-telemetry.ts +350 -0
  22. package/lib/agents/opencode.js +263 -305
  23. package/lib/agents/opencode.ts +370 -0
  24. package/lib/agents/stage-telemetry.js +23 -24
  25. package/lib/agents/stage-telemetry.ts +47 -0
  26. package/lib/commands/active.js +502 -579
  27. package/lib/commands/active.ts +665 -0
  28. package/lib/commands/checkpoint.js +98 -74
  29. package/lib/commands/checkpoint.ts +79 -0
  30. package/lib/commands/config.js +61 -31
  31. package/lib/commands/config.ts +47 -0
  32. package/lib/commands/coverage-gate.js +271 -246
  33. package/lib/commands/coverage-gate.ts +362 -0
  34. package/lib/commands/diff.js +140 -119
  35. package/lib/commands/diff.ts +125 -0
  36. package/lib/commands/draft.js +770 -923
  37. package/lib/commands/draft.ts +1021 -0
  38. package/lib/commands/handoff.js +588 -608
  39. package/lib/commands/handoff.ts +694 -0
  40. package/lib/commands/integrate.js +1332 -1463
  41. package/lib/commands/integrate.ts +1688 -0
  42. package/lib/commands/mission-start.js +279 -237
  43. package/lib/commands/mission-start.ts +263 -0
  44. package/lib/commands/rebase.js +606 -582
  45. package/lib/commands/rebase.ts +635 -0
  46. package/lib/commands/repair-handoff.js +181 -179
  47. package/lib/commands/repair-handoff.ts +228 -0
  48. package/lib/commands/resolve-conflict.js +121 -102
  49. package/lib/commands/resolve-conflict.ts +112 -0
  50. package/lib/commands/review.js +3 -4
  51. package/lib/commands/review.ts +14 -0
  52. package/lib/commands/setup-review.js +4 -5
  53. package/lib/commands/setup-review.ts +14 -0
  54. package/lib/commands/setup.js +3 -3
  55. package/lib/commands/setup.ts +2 -0
  56. package/lib/commands/stats-backfill.js +394 -359
  57. package/lib/commands/stats-backfill.ts +418 -0
  58. package/lib/commands/stats.js +1424 -1665
  59. package/lib/commands/stats.ts +2186 -0
  60. package/lib/commands/status.js +219 -200
  61. package/lib/commands/status.ts +219 -0
  62. package/lib/commands/verify.js +3 -1
  63. package/lib/commands/verify.ts +2 -0
  64. package/lib/core/fmt.js +140 -211
  65. package/lib/core/fmt.ts +189 -0
  66. package/lib/core/git.js +121 -102
  67. package/lib/core/git.ts +146 -0
  68. package/lib/core/gitignore.js +59 -106
  69. package/lib/core/gitignore.ts +103 -0
  70. package/lib/core/mission-utils.js +833 -875
  71. package/lib/core/mission-utils.ts +998 -0
  72. package/lib/core/nels.js +112 -127
  73. package/lib/core/nels.ts +189 -0
  74. package/lib/core/persistent-data-migration.js +247 -205
  75. package/lib/core/persistent-data-migration.ts +255 -0
  76. package/lib/core/product-config.js +400 -476
  77. package/lib/core/product-config.ts +518 -0
  78. package/lib/core/runtime-matrix.js +41 -87
  79. package/lib/core/runtime-matrix.ts +84 -0
  80. package/lib/core/spawn-tee.js +152 -192
  81. package/lib/core/spawn-tee.ts +202 -0
  82. package/lib/core/state-map.js +92 -92
  83. package/lib/core/state-map.ts +115 -0
  84. package/lib/core/storage.js +101 -129
  85. package/lib/core/storage.ts +182 -0
  86. package/lib/core/subagent-limit.js +15 -16
  87. package/lib/core/subagent-limit.ts +25 -0
  88. package/lib/core/verification.js +148 -138
  89. package/lib/core/verification.ts +200 -0
  90. package/lib/index.js +142 -59
  91. package/lib/index.ts +134 -0
  92. package/lib/review/rebase.js +142 -145
  93. package/lib/review/rebase.ts +188 -0
  94. package/lib/review/review-adapter.js +99 -87
  95. package/lib/review/review-adapter.ts +198 -0
  96. package/lib/review/review-artifacts.js +486 -629
  97. package/lib/review/review-artifacts.ts +622 -0
  98. package/lib/review/review-commands.js +1220 -1373
  99. package/lib/review/review-commands.ts +1579 -0
  100. package/lib/review/review-events.js +544 -886
  101. package/lib/review/review-events.ts +954 -0
  102. package/lib/review/review-loop.js +945 -1020
  103. package/lib/review/review-loop.ts +1174 -0
  104. package/lib/review/review-polling.js +86 -115
  105. package/lib/review/review-polling.ts +194 -0
  106. package/lib/review/review-prompts.js +154 -189
  107. package/lib/review/review-prompts.ts +182 -0
  108. package/lib/review/review-state.js +246 -225
  109. package/lib/review/review-state.ts +353 -0
  110. package/lib/review/review.js +63 -84
  111. package/lib/review/review.ts +138 -0
  112. package/lib/tools/backlog.js +694 -680
  113. package/lib/tools/backlog.ts +835 -0
  114. package/lib/tools/forgejo.js +1270 -1405
  115. package/lib/tools/forgejo.ts +1793 -0
  116. package/lib/tools/gatekeeper.js +106 -96
  117. package/lib/tools/gatekeeper.ts +124 -0
  118. package/lib/tools/redgreen.js +200 -181
  119. package/lib/tools/redgreen.ts +221 -0
  120. package/lib/tools/sessions.js +58 -52
  121. package/lib/tools/sessions.ts +81 -0
  122. package/lib/tools/setup-review.js +902 -975
  123. package/lib/tools/setup-review.ts +1152 -0
  124. package/package.json +7 -5
  125. package/prompts/draft.md +1 -0
  126. package/prompts/portfolio.md +1 -1
  127. package/px.js +226 -210
@@ -0,0 +1,835 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { git } from '../core/git.js';
4
+ import { WORKFLOW_AGENT_NAMES } from '../agents/agents.js';
5
+ import * as fmt from '../core/fmt.js';
6
+ import { resolveTaskStorage } from '../core/product-config.js';
7
+
8
+ /** @returns {readonly string[]} */
9
+ function getSupportedAgents() {
10
+ return WORKFLOW_AGENT_NAMES;
11
+ }
12
+
13
+ /** @param {string} [rootDir] @returns {{tasksDir: string, completedDir: string, archiveTasksDir: string}} */
14
+ function getTaskStorage(rootDir = process.cwd()) {
15
+ return resolveTaskStorage(rootDir);
16
+ }
17
+
18
+ /**
19
+ * @param {string} slug
20
+ * @param {string} [rootDir]
21
+ * @returns {string[]}
22
+ */
23
+ function findTaskFiles(slug: string, rootDir: string = process.cwd()): string[] {
24
+ const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
25
+ const sortByBacklogState = (filePath: string) => {
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;}
29
+ return 3;
30
+ };
31
+
32
+ /** @param {string} dir */
33
+ const scan = (dir: string) => {
34
+ if (!fs.existsSync(dir)) {return [];}
35
+ const files = fs.readdirSync(dir);
36
+ const normalizedSlug = slug.toLowerCase();
37
+ return files
38
+ .filter((f: string) => f.toLowerCase().startsWith(normalizedSlug))
39
+ .map((f: string) => path.join(dir, f));
40
+ };
41
+
42
+ const matches = [...scan(tasksDir), ...scan(completedDir), ...scan(archivedDir)];
43
+ return matches.sort((a, b) => sortByBacklogState(a) - sortByBacklogState(b) || a.localeCompare(b));
44
+ }
45
+
46
+ /**
47
+ * @param {string} slug
48
+ * @param {string} [rootDir]
49
+ * @returns {{ok: boolean, taskFile?: string, matches: string[], reason?: string}}
50
+ */
51
+ function resolveTaskFile(slug: string, rootDir: string = process.cwd()) {
52
+ let matches = findTaskFiles(slug, rootDir);
53
+ const normalizedId = slug.toUpperCase();
54
+ const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
55
+ /** @param {string[]} candidateMatches */
56
+ const preferSameTaskInHigherPriorityDir = (candidateMatches: string[]) => {
57
+ if (candidateMatches.length < 2) {return null;}
58
+ const [preferred, ...rest] = candidateMatches;
59
+ return rest.every((match: string) => path.basename(match) === path.basename(preferred)) ? preferred : null;
60
+ };
61
+
62
+ /** @param {string[]} candidateFiles @param {string} targetId */
63
+ const findById = (candidateFiles: string[], targetId: string) => {
64
+ return candidateFiles.filter((f: string) => {
65
+ try {
66
+ const content = fs.readFileSync(f, 'utf8');
67
+ const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
68
+ return idMatch && idMatch[1].trim().toUpperCase() === targetId;
69
+ } catch (_) {
70
+ return false;
71
+ }
72
+ });
73
+ };
74
+
75
+ if (matches.length === 0) {
76
+ // Hardening: If no prefix match, search ALL task files for an exact ID match
77
+ const allFiles = [
78
+ ...(fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir).map(f => path.join(tasksDir, f)) : []),
79
+ ...(fs.existsSync(completedDir) ? fs.readdirSync(completedDir).map(f => path.join(completedDir, f)) : []),
80
+ ...(fs.existsSync(archivedDir) ? fs.readdirSync(archivedDir).map(f => path.join(archivedDir, f)) : []),
81
+ ].filter(f => f.endsWith('.md'));
82
+
83
+ const idMatches = findById(allFiles, normalizedId);
84
+ if (idMatches.length === 1) {
85
+ return { ok: true, taskFile: idMatches[0], matches: idMatches };
86
+ }
87
+
88
+ // Still no match? Try base task ID if slug has a suffix (e.g., task-1004-modern -> TASK-1004)
89
+ const baseTaskMatch = slug.match(/^(task-\d+)/i);
90
+ if (baseTaskMatch) {
91
+ const baseId = baseTaskMatch[1].toUpperCase();
92
+ const baseMatches = findById(allFiles, baseId);
93
+ if (baseMatches.length === 1) {
94
+ return { ok: true, taskFile: baseMatches[0], matches: baseMatches };
95
+ }
96
+ if (baseMatches.length > 1) {
97
+ const preferred = preferSameTaskInHigherPriorityDir(baseMatches);
98
+ if (preferred) {
99
+ return { ok: true, taskFile: preferred, matches: baseMatches };
100
+ }
101
+ return { ok: false, reason: 'ambiguous', matches: baseMatches };
102
+ }
103
+ }
104
+
105
+ return { ok: false, reason: 'missing', matches: idMatches };
106
+ }
107
+
108
+ if (matches.length === 1) {
109
+ // Even if one prefix match exists, verify it doesn't conflict with another ID match
110
+ // or just return it if it's the only one.
111
+ return { ok: true, taskFile: matches[0], matches };
112
+ }
113
+
114
+ // Preference 1: Exact frontmatter id: match (e.g., id: TASK-093)
115
+ const idMatches = findById(matches, normalizedId);
116
+
117
+ if (idMatches.length === 1) {
118
+ return { ok: true, taskFile: idMatches[0], matches: idMatches };
119
+ }
120
+ if (idMatches.length > 1) {
121
+ const preferred = preferSameTaskInHigherPriorityDir(idMatches);
122
+ if (preferred) {
123
+ return { ok: true, taskFile: preferred, matches: idMatches };
124
+ }
125
+ return { ok: false, reason: 'ambiguous', matches: idMatches };
126
+ }
127
+
128
+ // Fallback: If no ID matches but we have filename-prefix matches,
129
+ // we only allow it if it's unambiguous.
130
+ return { ok: false, reason: 'ambiguous', matches };
131
+ }
132
+
133
+ /**
134
+ * @param {string} slug
135
+ * @param {string} [rootDir]
136
+ * @returns {string|undefined|null}
137
+ */
138
+ function findTaskFile(slug: string, rootDir: string = process.cwd()) {
139
+ const result = resolveTaskFile(slug, rootDir);
140
+ return result.ok ? result.taskFile : null;
141
+ }
142
+
143
+ /**
144
+ * @param {{ok: boolean, taskFile?: string, matches: string[], reason?: string}} result
145
+ * @param {string} slug
146
+ * @param {Function} [log]
147
+ */
148
+ function reportTaskResolution(result: {ok: boolean, taskFile?: string, matches: string[], reason?: string}, slug: string, log: Function = fmt.log.plain) {
149
+ if (result.ok) {return;}
150
+ const { tasksDir, completedDir } = getTaskStorage(process.cwd());
151
+ const taskHint = path.relative(process.cwd(), tasksDir).split(path.sep).join('/');
152
+ const completedHint = path.relative(process.cwd(), completedDir).split(path.sep).join('/');
153
+
154
+ if (result.reason === 'ambiguous') {
155
+ log(fmt.status('FAIL', `Backlog task resolution is ambiguous for slug: ${fmt.slug(slug)}`));
156
+ log(fmt.status('INFO', 'Multiple candidates found:'));
157
+ result.matches.forEach((m: string) => log(` - ${m}`));
158
+ log(fmt.status('INFO', 'Repair: Ensure only one task has the filename prefix or matching "id:" frontmatter.'));
159
+ } else {
160
+ log(fmt.status('FAIL', `Backlog task for ${fmt.slug(slug)} not found in ${fmt.path(taskHint)}/ or ${fmt.path(completedHint)}/.`));
161
+ log(fmt.status('INFO', 'Create the task file first.'));
162
+ log(fmt.status('INFO', 'Repair: create the task with your task adapter, or add a markdown task file manually.'));
163
+ log(fmt.status('INFO', `Manual fallback: create ${fmt.path(`${taskHint}/${slug} - <title>.md`)} with frontmatter id ${fmt.bold(slug.toUpperCase())}.`));
164
+ }
165
+ }
166
+
167
+ /** @param {string} file @returns {string|null} */
168
+ function taskIdFromFilename(file: string) {
169
+ // Extract ID from filename prefix (e.g., task-093 or task-093.01)
170
+ const filenameMatch = file.match(/^(task-\d+(?:\.\d+)?)/i);
171
+ return filenameMatch ? filenameMatch[1].toUpperCase() : null;
172
+ }
173
+
174
+ /**
175
+ * @param {string} [rootDir]
176
+ * @param {string} [slug]
177
+ * @returns {{file: string, type: string, taskId?: string, canonicalFile?: string}[]}
178
+ */
179
+ function checkBacklogIntegrity(rootDir: string = process.cwd(), slug: string | null = null) {
180
+ const { tasksDir, completedDir, archiveTasksDir: archivedDir } = getTaskStorage(rootDir);
181
+ const issues = [];
182
+ const normalizedSlug = slug ? slug.toLowerCase() : null;
183
+
184
+ // Track where each task id lives so we can detect the same id appearing in
185
+ // both backlog/tasks/ and backlog/completed/ (or backlog/archive/) — the
186
+ // reorder-recreates-completed-task defect (TASK-1343). The completed/archive
187
+ // copy is canonical; a backlog/tasks/ copy alongside it is stale.
188
+ const tasksLocations = new Map(); // id -> rel path in tasks/
189
+ const canonicalLocations = new Map(); // id -> rel path in completed|archive
190
+
191
+ /** @param {string} dir @param {{canonical?: boolean}} [opts] */
192
+ const scan = (dir: string, { canonical = false }: { canonical?: boolean } = {}) => {
193
+ if (!fs.existsSync(dir)) {return;}
194
+ const files = fs.readdirSync(dir).filter((f: string) => f.endsWith('.md'));
195
+ for (const file of files) {
196
+ if (normalizedSlug && !file.toLowerCase().startsWith(normalizedSlug)) {
197
+ continue;
198
+ }
199
+ const filePath = path.join(dir, file);
200
+ const filenameId = taskIdFromFilename(file);
201
+ if (!filenameId) {continue;}
202
+ const relPath = path.relative(rootDir, filePath);
203
+
204
+ if (canonical) {
205
+ if (!canonicalLocations.has(filenameId)) {canonicalLocations.set(filenameId, relPath);}
206
+ } else if (!tasksLocations.has(filenameId)) {
207
+ tasksLocations.set(filenameId, relPath);
208
+ }
209
+
210
+ try {
211
+ const content = fs.readFileSync(filePath, 'utf8');
212
+
213
+ // Extract ID from frontmatter
214
+ const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
215
+ if (idMatch) {
216
+ const frontmatterId = idMatch[1].trim().toUpperCase();
217
+ if (frontmatterId !== filenameId) {
218
+ issues.push({
219
+ file: relPath,
220
+ type: 'id-mismatch',
221
+ filenameId,
222
+ frontmatterId
223
+ });
224
+ }
225
+ }
226
+ } catch (_) {
227
+ // ignore read errors
228
+ }
229
+ }
230
+ };
231
+
232
+ scan(tasksDir);
233
+ scan(completedDir, { canonical: true });
234
+ scan(archivedDir, { canonical: true });
235
+
236
+ for (const [id, taskPath] of tasksLocations) {
237
+ const canonicalPath = canonicalLocations.get(id);
238
+ if (canonicalPath) {
239
+ issues.push({
240
+ file: taskPath,
241
+ type: 'duplicate-completed',
242
+ taskId: id,
243
+ canonicalFile: canonicalPath
244
+ });
245
+ }
246
+ }
247
+
248
+ return issues;
249
+ }
250
+
251
+ /**
252
+ * Drop backlog/tasks/ copies of task ids whose canonical record already lives
253
+ * in backlog/completed/ or backlog/archive/. Used to make any board mutation
254
+ * (reorder / ordinal write) completed- and archive-aware: the completed copy is
255
+ * treated as canonical and the stale backlog/tasks/ copy is removed. Returns
256
+ * the list of removed { taskId, file, canonicalFile } records. See TASK-1343.
257
+ */
258
+ function pruneStaleBacklogDuplicates(rootDir = process.cwd()) {
259
+ const removed = [];
260
+ const duplicates = checkBacklogIntegrity(rootDir)
261
+ .filter(issue => issue.type === 'duplicate-completed');
262
+
263
+ for (const dup of duplicates) {
264
+ const absPath = path.join(rootDir, dup.file);
265
+ try {
266
+ if (fs.existsSync(absPath)) {
267
+ fs.rmSync(absPath);
268
+ removed.push({ taskId: dup.taskId, file: dup.file, canonicalFile: dup.canonicalFile });
269
+ }
270
+ } catch (_) {
271
+ // ignore removal errors; the integrity gate will still surface the duplicate
272
+ }
273
+ }
274
+
275
+ return removed;
276
+ }
277
+
278
+ /** @param {string} taskFilePath @returns {string|null} */
279
+ function getTaskStatus(taskFilePath: string) {
280
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
281
+ const content = fs.readFileSync(taskFilePath, 'utf8');
282
+
283
+ // Try YAML first
284
+ const yamlMatch = content.match(/^status:\s*([^\r\n]+)/m);
285
+ if (yamlMatch) {
286
+ return yamlMatch[1].trim().toLowerCase();
287
+ }
288
+
289
+ // Try rendered format
290
+ const statusMatch = content.match(/^Status:\s*(.*)$/m);
291
+ if (statusMatch) {
292
+ return statusMatch[1].replace(/^[○●\(\)\s]+/, '').trim().toLowerCase();
293
+ }
294
+
295
+ return null;
296
+ }
297
+
298
+ /** @param {string} taskFilePath @param {string} newStatus @returns {boolean} */
299
+ function setTaskStatus(taskFilePath: string, newStatus: string) {
300
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
301
+ let content = fs.readFileSync(taskFilePath, 'utf8');
302
+
303
+ // Update YAML
304
+ const yamlMatch = content.match(/^status:\s*([^\r\n]+)/m);
305
+ if (yamlMatch) {
306
+ content = content.replace(/^status:\s*.*$/m, `status: ${newStatus}`);
307
+ }
308
+
309
+ // Update rendered status line (e.g., "Status: ○ backlog")
310
+ // Map internal status to display markers if needed, but for now just text update
311
+ const statusMatch = content.match(/^Status:\s*(.*)$/m);
312
+ if (statusMatch) {
313
+ const original = statusMatch[1];
314
+ // Keep markers like ○ if they exist
315
+ const markerMatch = original.match(/^([○●\(\)\s]+)/);
316
+ const marker = markerMatch ? markerMatch[1] : '';
317
+ content = content.replace(/^Status:\s*.*$/m, `Status: ${marker}${newStatus}`);
318
+ }
319
+
320
+ fs.writeFileSync(taskFilePath, content, 'utf8');
321
+ return true;
322
+ }
323
+
324
+ /**
325
+ * @param {string} slug
326
+ * @param {string} [rootDir]
327
+ * @returns {boolean}
328
+ */
329
+ function completeTask(slug: string, rootDir: string = process.cwd()): boolean {
330
+ const resolution = resolveTaskFile(slug, rootDir);
331
+ if (!resolution.ok) {return false;}
332
+
333
+ const taskFilePath = /** @type {string} */ (resolution.taskFile);
334
+ if (!taskFilePath) {return false;}
335
+ const fileName = path.basename(taskFilePath);
336
+ const { tasksDir, completedDir } = getTaskStorage(rootDir);
337
+
338
+ if (!taskFilePath.includes(tasksDir)) {
339
+ // Already in completed or somewhere else
340
+ setTaskStatus(taskFilePath, 'done');
341
+ return true;
342
+ }
343
+
344
+ if (!fs.existsSync(completedDir)) {
345
+ fs.mkdirSync(completedDir, { recursive: true });
346
+ }
347
+
348
+ const targetPath = path.join(completedDir, fileName);
349
+
350
+ // Set status before moving
351
+ setTaskStatus(taskFilePath, 'done');
352
+
353
+ fs.renameSync(taskFilePath, targetPath);
354
+ return true;
355
+ }
356
+
357
+ /**
358
+ * Internal helper to parse assignee families from YAML frontmatter content.
359
+ * Supports inline array, simple inline, and block formats.
360
+ */
361
+ /**
362
+ * @param {string} content
363
+ * @returns {{matched: boolean, families: string[]}}
364
+ */
365
+ function parseAssigneeFamilies(content: string) {
366
+ let families: string[] = /** @type {string[]} */ ([]);
367
+ let matched = false;
368
+
369
+ const lineMatch = content.match(/^assignee:[ \t]*(.*)$/m);
370
+ if (lineMatch) {
371
+ const rest = lineMatch[1].trim();
372
+ if (rest) {
373
+ matched = true;
374
+ // It's some kind of inline form
375
+ const rawValues = rest.startsWith('[') && rest.endsWith(']')
376
+ ? rest.slice(1, -1)
377
+ : rest;
378
+
379
+ families = rawValues.split(',')
380
+ .map((s: string) => s.trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
381
+ .filter((s: string) => s.length > 0);
382
+ }
383
+ }
384
+
385
+ if (!matched) {
386
+ // Try block form
387
+ const blockMatch = content.match(/^assignee:[ \t]*[\r\n]+((?:\s+-\s+.+[\r\n]*)+)/m);
388
+ if (blockMatch) {
389
+ matched = true;
390
+ families = blockMatch[1].split(/[\r\n]+/)
391
+ .map((line: string) => line.trim())
392
+ .filter((line: string) => line.startsWith('-'))
393
+ .map((line: string) => line.substring(1).trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
394
+ .filter((s: string) => s.length > 0);
395
+ }
396
+ }
397
+
398
+ return { matched, families };
399
+ }
400
+
401
+ /**
402
+ * @param {string} taskFilePath
403
+ * @param {string} message
404
+ * @param {string} [rootDir]
405
+ * @returns {boolean|null|void}
406
+ */
407
+ function commitTaskFileUpdate(taskFilePath: string, message: string, rootDir: string = process.cwd()) {
408
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return;}
409
+ // Run `git add/commit` against the worktree the caller intended (rootDir),
410
+ // not whichever branch process.cwd() happens to have checked out. All
411
+ // worktrees share one git object store, so the only thing that pins the
412
+ // commit to the correct mission branch is the `-C <rootDir>` working dir.
413
+ // Fall back to the task file's directory if rootDir isn't supplied or the
414
+ // task file lives outside it (defensive: keeps the add/commit consistent).
415
+ const gitDir = (rootDir && taskFilePath.startsWith(path.resolve(rootDir) + path.sep))
416
+ ? rootDir
417
+ : path.dirname(taskFilePath);
418
+ const relativeTaskPath = path.relative(gitDir, taskFilePath);
419
+ try {
420
+ git(['-C', gitDir, 'add', relativeTaskPath]);
421
+ const result = git(['-C', gitDir, 'commit', '-m', message]);
422
+ if (result.status !== 0) {
423
+ // If there's nothing to commit (e.g. no change), git commit exits with status 1
424
+ // but we should check if it was really a failure or just no-op.
425
+ const statusResult = git(['-C', gitDir, 'status', '--porcelain', relativeTaskPath]);
426
+ if (statusResult.stdout.trim() === '') {
427
+ return true; // No-op is success
428
+ }
429
+ fmt.log.warn(`Failed to commit task update: ${result.stderr}`);
430
+ return false;
431
+ }
432
+ return true;
433
+ } catch (/** @type {unknown} */ e) {
434
+ fmt.log.fail(`Git error during task update: ${(e as Error).message}`);
435
+ return false;
436
+ }
437
+ }
438
+
439
+ /** @param {string} taskFilePath @returns {boolean} */
440
+ function clearTaskAgentAssignee(taskFilePath: string) {
441
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
442
+ let content = fs.readFileSync(taskFilePath, 'utf8');
443
+ if (!content.match(/^assignee:/m)) {return false;}
444
+
445
+ const { families } = parseAssigneeFamilies(content);
446
+ const supportedAgents = getSupportedAgents();
447
+
448
+ // Separate agent families from human assignees
449
+ const agentFamilies = families.filter((f: string) => supportedAgents.includes(f.toLowerCase()));
450
+ const humanFamilies = families.filter((f: string) => !supportedAgents.includes(f.toLowerCase()));
451
+
452
+ // If there are no agent families to clear, nothing to do — preserve human assignees
453
+ if (agentFamilies.length === 0) {
454
+ // Write back the human-only assignee list
455
+ if (humanFamilies.length > 0) {
456
+ const hasBlockForm = content.match(/^assignee:[ \t]*[\r\n]+/);
457
+ if (humanFamilies.length === families.length) {
458
+ // All families were human; no change needed
459
+ return false;
460
+ }
461
+ // Remove agent families and write back human-only
462
+ let newAssigneeLine;
463
+ if (hasBlockForm) {
464
+ newAssigneeLine = 'assignee:\n' + humanFamilies.map((f: string) => ` - ${f}`).join('\n') + '\n';
465
+ } else {
466
+ newAssigneeLine = `assignee: [${humanFamilies.join(', ')}]`;
467
+ }
468
+
469
+ if (content.match(/^assignee:\s*\[.*?\]/m)) {
470
+ content = content.replace(/^assignee:\s*\[.*?\]/m, newAssigneeLine);
471
+ } else if (content.match(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m)) {
472
+ content = content.replace(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m, newAssigneeLine);
473
+ } else {
474
+ content = content.replace(/^assignee:[ \t]*.*$/m, newAssigneeLine);
475
+ }
476
+
477
+ fs.writeFileSync(taskFilePath, content, 'utf8');
478
+ return true;
479
+ }
480
+ return false;
481
+ }
482
+
483
+ const hasBlockForm = content.match(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m);
484
+
485
+ if (!hasBlockForm) {
486
+ // Inline array form: replace with human-only agents
487
+ const newAssignee = humanFamilies.length > 0 ? `[${humanFamilies.join(', ')}]` : '[]';
488
+ content = content.replace(/^assignee:\s*\[.*?\]/m, `assignee: ${newAssignee}`);
489
+ } else {
490
+ // Block form: remove agent lines, keep human lines
491
+ let newBlock = content.replace(/^assignee:[ \t]*[\r\n]+/m, 'assignee:\n');
492
+ const blockLines = newBlock.match(/^assignee:\n((?:\s+-\s+.+\n?)*)/m);
493
+ if (blockLines) {
494
+ const keptLines = blockLines[1].split('\n').filter(line => {
495
+ const m = line.match(/^\s+-\s+(.+)/);
496
+ if (!m) {return line.trim() === '';}
497
+ const family = m[1].trim().replace(/^['"]|['"]$/g, '');
498
+ return !supportedAgents.includes(family.toLowerCase());
499
+ }).join('\n');
500
+ newBlock = newBlock.replace(/^assignee:\n((?:\s+-\s+.+\n?)*)/m, 'assignee:\n' + keptLines);
501
+ if (newBlock.endsWith('assignee:\n') || newBlock.endsWith('assignee: \n') || newBlock.endsWith('assignee:\n\n')) {
502
+ newBlock = newBlock.replace(/assignee:\s*\n\s*$/, 'assignee: []\n');
503
+ }
504
+ content = newBlock;
505
+ } else {
506
+ content = content.replace(/^assignee:\s*\[.*?\]/m, `assignee: []`);
507
+ }
508
+ }
509
+
510
+ fs.writeFileSync(taskFilePath, content, 'utf8');
511
+ return true;
512
+ }
513
+
514
+ /**
515
+ * Transition a task to a new status, optionally enforcing an implementer,
516
+ * restoring a prior assignee, or clearing agent assignees,
517
+ * and commit the change to the mission branch.
518
+ */
519
+ /**
520
+ * @param {string} slug
521
+ * @param {string} newStatus
522
+ * @param {{implementer?: string|null, clearAssignee?: boolean, rootDir?: string, log?: Function}} [opts]
523
+ * @returns {boolean}
524
+ */
525
+ function transitionTask(slug: string, newStatus: string, { implementer = null, clearAssignee = false, rootDir = process.cwd(), log = fmt.log.plain }: { implementer?: string | null | undefined, clearAssignee?: boolean, rootDir?: string, log?: Function } = {} as any) {
526
+ const resolution = resolveTaskFile(slug, rootDir);
527
+ if (!resolution.ok) {
528
+ log(fmt.status('WARN', `Could not transition task ${fmt.slug(slug)}: ${resolution.reason}`));
529
+ return false;
530
+ }
531
+
532
+ const taskFile = resolution.taskFile as string;
533
+
534
+ // Guard: reject suffixed slugs (e.g. "task-1048-regress") to prevent
535
+ // resolveTaskFile's base-ID fallback from silently committing to the
536
+ // wrong task file when the slug's base ID does not match the resolved
537
+ // file's frontmatter id. See TASK-1265 for the original incident.
538
+ const slugHasSuffix = /^(task-\d+)-/i.test(slug);
539
+ if (slugHasSuffix) {
540
+ log(fmt.status('WARN',
541
+ `Task ${fmt.slug(slug)} rejected: slug "${slug}" has a suffix; ` +
542
+ 'use the exact task id instead to avoid committing to the wrong file.'));
543
+ return false;
544
+ }
545
+
546
+ let changed = false;
547
+
548
+ if (implementer) {
549
+ if (enforceTaskAssignee(taskFile, implementer)) {
550
+ changed = true;
551
+ }
552
+ } else if (clearAssignee) {
553
+ if (clearTaskAgentAssignee(taskFile)) {
554
+ changed = true;
555
+ }
556
+ }
557
+
558
+ const currentStatus = getTaskStatus(taskFile);
559
+ if (currentStatus !== newStatus) {
560
+ if (setTaskStatus(taskFile, newStatus)) {
561
+ changed = true;
562
+ }
563
+ }
564
+
565
+ if (changed) {
566
+ let msg = `backlog(${slug}): transition to ${newStatus}`;
567
+ if (implementer) {msg += ` and implementer=${implementer}`;}
568
+
569
+ if (commitTaskFileUpdate(taskFile, msg, rootDir)) {
570
+ log(fmt.status('PASS', `Task ${fmt.slug(slug)} transitioned to ${newStatus}${implementer ? ' (assignee=' + fmt.agent(implementer) + ')' : ''} and committed.`));
571
+ return true;
572
+ }
573
+ return false;
574
+ }
575
+
576
+ return true; // Already in the desired state
577
+ }
578
+
579
+ /** @param {string} taskFilePath @returns {string|null} */
580
+ function getTaskAssignee(taskFilePath: string) {
581
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
582
+ const content = fs.readFileSync(taskFilePath, 'utf8');
583
+ const { families } = parseAssigneeFamilies(content);
584
+ return families.length > 0 ? families[0] : null;
585
+ }
586
+
587
+ /** @param {string} taskFilePath @returns {string|null} */
588
+ function getTaskImplementer(taskFilePath: string) {
589
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
590
+ const content = fs.readFileSync(taskFilePath, 'utf8');
591
+ const { families } = parseAssigneeFamilies(content);
592
+
593
+ const normalizedFamilies = families.map((f: string) => f.toLowerCase());
594
+ const supportedAgents = getSupportedAgents();
595
+ return normalizedFamilies.find((f: string) => supportedAgents.includes(f)) || null;
596
+ }
597
+
598
+ /** @param {string} taskFilePath @param {string} field @returns {string|null} */
599
+ function getTaskFrontmatterValue(taskFilePath: string, field: string) {
600
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
601
+ const content = fs.readFileSync(taskFilePath, 'utf8');
602
+ const pattern = new RegExp(`^${field}:\\s*([^\\r\\n]+)`, 'mi');
603
+ const match = content.match(pattern);
604
+ if (!match) {return null;}
605
+ const value = match[1].trim().replace(/^['"]|['"]$/g, '');
606
+ return value || null;
607
+ }
608
+
609
+ const CLASSIFICATION_LABELS = new Set(['ai_sdlc', 'user_value', 'unknown']);
610
+
611
+ /**
612
+ * Parse all labels from a task file's frontmatter, supporting both block
613
+ * and inline YAML formats. Returns a lowercased array of label strings.
614
+ * When both formats are present, block labels take precedence (inline is
615
+ * treated as a fallback when no block labels are found).
616
+ */
617
+ /** @param {string} taskFilePath */
618
+ function getTaskLabels(taskFilePath: string) {
619
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return [];}
620
+ const content = fs.readFileSync(taskFilePath, 'utf8');
621
+
622
+ const blockMatch = content.match(/^labels:[ \t]*[\r\n]+((?:\s+-\s+.+[\r\n]*)+)/m);
623
+ if (blockMatch) {
624
+ return blockMatch[1].split(/[\r\n]+/)
625
+ .map(line => line.trim())
626
+ .filter(line => line.startsWith('-'))
627
+ .map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, ''))
628
+ .map(s => s.toLowerCase())
629
+ .filter(s => s.length > 0);
630
+ }
631
+
632
+ const inlineMatch = content.match(/^labels:[ \t]*\[(.*?)\]/m);
633
+ if (inlineMatch) {
634
+ return inlineMatch[1].split(',')
635
+ .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
636
+ .map(s => s.toLowerCase())
637
+ .filter(s => s.length > 0);
638
+ }
639
+
640
+ return [];
641
+ }
642
+
643
+ /** @param {string} taskFilePath */
644
+ function getTaskClassification(taskFilePath: string) {
645
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return null;}
646
+ const labels = getTaskLabels(taskFilePath);
647
+ const matches = new Set();
648
+ for (const label of labels) {
649
+ if (CLASSIFICATION_LABELS.has(label)) {matches.add(label);}
650
+ }
651
+ return matches.size === 1 ? [...matches][0] : null;
652
+ }
653
+
654
+ /** @param {string} taskFilePath */
655
+ function hasBugLabel(taskFilePath: string) {
656
+ const labels = getTaskLabels(taskFilePath);
657
+ return labels.includes('bug');
658
+ }
659
+
660
+
661
+ /**
662
+ * @param {string} taskFilePath
663
+ * @param {string} agentFamily
664
+ * @param {{promote?: boolean}} [opts]
665
+ * @returns {boolean}
666
+ */
667
+ function setTaskAssignee(taskFilePath: string, agentFamily: string, { promote = true }: { promote?: boolean } = {} as any) {
668
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
669
+ let content = fs.readFileSync(taskFilePath, 'utf8');
670
+
671
+ const { matched, families } = parseAssigneeFamilies(content);
672
+
673
+ if (matched) {
674
+ const lowerAgentFamily = agentFamily.toLowerCase();
675
+ const existingIndex = families.findIndex((f: string) => f.toLowerCase() === lowerAgentFamily);
676
+
677
+ if (existingIndex !== 0) {
678
+ if (existingIndex !== -1) {
679
+ if (!promote) {return false;} // Already in the list, and we don't want to move it
680
+ // Remove existing to promote to first
681
+ families.splice(existingIndex, 1);
682
+ }
683
+
684
+ if (promote) {
685
+ families.unshift(agentFamily);
686
+ } else {
687
+ families.push(agentFamily);
688
+ }
689
+
690
+ const newAssignees = `assignee: [${families.join(', ')}]`;
691
+
692
+ // Replace whatever form was there with a normalized inline array form
693
+ if (content.match(/^assignee:\s*\[.*?\]/m)) {
694
+ content = content.replace(/^assignee:\s*\[.*?\]/m, newAssignees);
695
+ } else if (content.match(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m)) {
696
+ content = content.replace(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m, newAssignees + '\n');
697
+ } else {
698
+ // Fallback for simple form
699
+ content = content.replace(/^assignee:\s*.*$/m, newAssignees);
700
+ }
701
+
702
+ fs.writeFileSync(taskFilePath, content, 'utf8');
703
+ return true;
704
+ }
705
+ return false; // Already authoritative (at index 0)
706
+ }
707
+
708
+ // No assignee line exists — insert one after the id frontmatter line
709
+ const insertMatch = content.match(/^(id:.*)/m);
710
+ if (insertMatch && insertMatch.index !== undefined) {
711
+ const insertPos = insertMatch.index + insertMatch[0].length;
712
+ const newLine = '\nassignee: [' + agentFamily + ']';
713
+ content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
714
+ fs.writeFileSync(taskFilePath, content, 'utf8');
715
+ return true;
716
+ }
717
+
718
+ return false;
719
+ }
720
+
721
+ /** @param {string} taskFilePath @param {string} agentFamily @returns {boolean} */
722
+ function setTaskImplementer(taskFilePath: string, agentFamily: string) {
723
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
724
+ let content = fs.readFileSync(taskFilePath, 'utf8');
725
+
726
+ const { matched, families } = parseAssigneeFamilies(content);
727
+ // Separate humans from recognized agents
728
+ const supportedAgents = getSupportedAgents();
729
+ const preservedFamilies = families.filter(f => !supportedAgents.includes(f.toLowerCase()));
730
+ // To ensure the new implementer is authoritative for getTaskImplementer(),
731
+ // it MUST be the first recognized agent in the list.
732
+ const nextFamilies = [agentFamily, ...preservedFamilies];
733
+
734
+ if (matched) {
735
+ const normalizedCurrent = families.map((f: string) => f.toLowerCase());
736
+ const normalizedNext = nextFamilies.map((f: string) => f.toLowerCase());
737
+ // Check if the first agent is already the one we want to set
738
+ if (normalizedCurrent.length > 0 && normalizedCurrent[0] === agentFamily.toLowerCase()) {
739
+ // If the rest of the list is also the same, it's a no-op
740
+ if (normalizedCurrent.length === normalizedNext.length &&
741
+ normalizedCurrent.every((f, /** @type {number} */ i) => f === normalizedNext[i])) {
742
+ return false;
743
+ }
744
+ }
745
+
746
+ const newAssignees = `assignee: [${nextFamilies.join(', ')}]`;
747
+
748
+ if (content.match(/^assignee:\s*\[.*?\]/m)) {
749
+ content = content.replace(/^assignee:\s*\[.*?\]/m, newAssignees);
750
+ } else if (content.match(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m)) {
751
+ content = content.replace(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m, newAssignees + '\n');
752
+ } else {
753
+ content = content.replace(/^assignee:\s*.*$/m, newAssignees);
754
+ }
755
+
756
+ fs.writeFileSync(taskFilePath, content, 'utf8');
757
+ return true;
758
+ }
759
+
760
+ const insertMatch = content.match(/^(id:.*)/m);
761
+ if (insertMatch && insertMatch.index !== undefined) {
762
+ const insertPos = insertMatch.index + insertMatch[0].length;
763
+ const newLine = '\nassignee: [' + agentFamily + ']';
764
+ content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
765
+ fs.writeFileSync(taskFilePath, content, 'utf8');
766
+ return true;
767
+ }
768
+
769
+ return false;
770
+ }
771
+
772
+ /** @param {string} taskFilePath @param {string} agentFamily @returns {boolean} */
773
+ function enforceTaskAssignee(taskFilePath: string, agentFamily: string) {
774
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return false;}
775
+ let content = fs.readFileSync(taskFilePath, 'utf8');
776
+
777
+ const { families } = parseAssigneeFamilies(content);
778
+ if (families.length === 1 && families[0] === agentFamily) {return true;}
779
+
780
+ const newAssignee = `assignee: [${agentFamily}]`;
781
+
782
+ if (content.match(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m)) {
783
+ content = content.replace(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m, newAssignee + '\n');
784
+ } else if (content.match(/^assignee:[ \t]*.*$/m)) {
785
+ content = content.replace(/^assignee:[ \t]*.*$/m, newAssignee);
786
+ } else {
787
+ const insertMatch = content.match(/^(id:.*)/m);
788
+ if (!insertMatch || insertMatch.index === undefined) {return false;}
789
+ const insertPos = insertMatch.index + insertMatch[0].length;
790
+ content = content.slice(0, insertPos) + '\n' + newAssignee + content.slice(insertPos);
791
+ }
792
+
793
+ fs.writeFileSync(taskFilePath, content, 'utf8');
794
+ return true;
795
+ }
796
+
797
+ /** @param {string} taskFilePath @returns {string[]} */
798
+ function getAcceptanceCriteria(taskFilePath: string) {
799
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) {return [];}
800
+ const content = fs.readFileSync(taskFilePath, 'utf8');
801
+ const sectionMatch = content.match(/## Acceptance Criteria\s*\n([\s\S]*?)(?:\n## |\nDefinition of Done:|\n---\n|$)/);
802
+ if (!sectionMatch) {return [];}
803
+
804
+ return sectionMatch[1]
805
+ .split('\n')
806
+ .map(line => line.trim())
807
+ .filter(line => /^- \[[ xX]\]/.test(line));
808
+ }
809
+
810
+
811
+ export { findTaskFiles };
812
+ export { findTaskFile };
813
+ export { resolveTaskFile };
814
+ export { reportTaskResolution };
815
+ export { checkBacklogIntegrity };
816
+ export { pruneStaleBacklogDuplicates };
817
+ export { getTaskStorage };
818
+ export { getTaskStatus };
819
+ export { setTaskStatus };
820
+ export { transitionTask };
821
+ export { commitTaskFileUpdate };
822
+ export { completeTask };
823
+ export { getTaskAssignee };
824
+ export { getTaskImplementer };
825
+ export { getTaskFrontmatterValue };
826
+ export { getTaskClassification };
827
+ export { getTaskLabels };
828
+ export { hasBugLabel };
829
+ export { setTaskAssignee };
830
+ export { setTaskImplementer };
831
+ export { enforceTaskAssignee };
832
+ export { getAcceptanceCriteria };
833
+ export { parseAssigneeFamilies };
834
+ export { clearTaskAgentAssignee };
835
+ ;