@magnusekdahl/parallix 1.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,680 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { git } = require('../core/git');
4
+ const { WORKFLOW_AGENT_NAMES } = require('../agents/agents');
5
+ const fmt = require('../core/fmt');
6
+
7
+ function getSupportedAgents() {
8
+ return WORKFLOW_AGENT_NAMES;
9
+ }
10
+
11
+ function findTaskFiles(slug, rootDir = process.cwd()) {
12
+ const tasksDir = path.join(rootDir, 'backlog', 'tasks');
13
+ const completedDir = path.join(rootDir, 'backlog', 'completed');
14
+ const archivedDir = path.join(rootDir, 'backlog', 'archive', 'tasks');
15
+ const sortByBacklogState = (filePath) => {
16
+ if (filePath.startsWith(tasksDir + path.sep)) return 0;
17
+ if (filePath.startsWith(completedDir + path.sep)) return 1;
18
+ if (filePath.startsWith(archivedDir + path.sep)) return 2;
19
+ return 3;
20
+ };
21
+
22
+ const scan = (dir) => {
23
+ if (!fs.existsSync(dir)) return [];
24
+ const files = fs.readdirSync(dir);
25
+ const normalizedSlug = slug.toLowerCase();
26
+ return files
27
+ .filter(f => f.toLowerCase().startsWith(normalizedSlug))
28
+ .map(f => path.join(dir, f));
29
+ };
30
+
31
+ const matches = [...scan(tasksDir), ...scan(completedDir), ...scan(archivedDir)];
32
+ return matches.sort((a, b) => sortByBacklogState(a) - sortByBacklogState(b) || a.localeCompare(b));
33
+ }
34
+
35
+ function resolveTaskFile(slug, rootDir = process.cwd()) {
36
+ let matches = findTaskFiles(slug, rootDir);
37
+ const normalizedId = slug.toUpperCase();
38
+ const archivedDir = path.join(rootDir, 'backlog', 'archive', 'tasks');
39
+ const preferSameTaskInHigherPriorityDir = (candidateMatches) => {
40
+ if (candidateMatches.length < 2) return null;
41
+ const [preferred, ...rest] = candidateMatches;
42
+ return rest.every(match => path.basename(match) === path.basename(preferred)) ? preferred : null;
43
+ };
44
+
45
+ const findById = (candidateFiles, targetId) => {
46
+ return candidateFiles.filter(f => {
47
+ try {
48
+ const content = fs.readFileSync(f, 'utf8');
49
+ const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
50
+ return idMatch && idMatch[1].trim().toUpperCase() === targetId;
51
+ } catch (_) {
52
+ return false;
53
+ }
54
+ });
55
+ };
56
+
57
+ if (matches.length === 0) {
58
+ // Hardening: If no prefix match, search ALL task files for an exact ID match
59
+ const tasksDir = path.join(rootDir, 'backlog', 'tasks');
60
+ const completedDir = path.join(rootDir, 'backlog', 'completed');
61
+ const allFiles = [
62
+ ...(fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir).map(f => path.join(tasksDir, f)) : []),
63
+ ...(fs.existsSync(completedDir) ? fs.readdirSync(completedDir).map(f => path.join(completedDir, f)) : []),
64
+ ...(fs.existsSync(archivedDir) ? fs.readdirSync(archivedDir).map(f => path.join(archivedDir, f)) : []),
65
+ ].filter(f => f.endsWith('.md'));
66
+
67
+ const idMatches = findById(allFiles, normalizedId);
68
+ if (idMatches.length === 1) {
69
+ return { ok: true, taskFile: idMatches[0], matches: idMatches };
70
+ }
71
+
72
+ // Still no match? Try base task ID if slug has a suffix (e.g., task-1004-modern -> TASK-1004)
73
+ const baseTaskMatch = slug.match(/^(task-\d+)/i);
74
+ if (baseTaskMatch) {
75
+ const baseId = baseTaskMatch[1].toUpperCase();
76
+ const baseMatches = findById(allFiles, baseId);
77
+ if (baseMatches.length === 1) {
78
+ return { ok: true, taskFile: baseMatches[0], matches: baseMatches };
79
+ }
80
+ if (baseMatches.length > 1) {
81
+ const preferred = preferSameTaskInHigherPriorityDir(baseMatches);
82
+ if (preferred) {
83
+ return { ok: true, taskFile: preferred, matches: baseMatches };
84
+ }
85
+ return { ok: false, reason: 'ambiguous', matches: baseMatches };
86
+ }
87
+ }
88
+
89
+ return { ok: false, reason: 'missing', matches: idMatches };
90
+ }
91
+
92
+ if (matches.length === 1) {
93
+ // Even if one prefix match exists, verify it doesn't conflict with another ID match
94
+ // or just return it if it's the only one.
95
+ return { ok: true, taskFile: matches[0], matches };
96
+ }
97
+
98
+ // Preference 1: Exact frontmatter id: match (e.g., id: TASK-093)
99
+ const idMatches = findById(matches, normalizedId);
100
+
101
+ if (idMatches.length === 1) {
102
+ return { ok: true, taskFile: idMatches[0], matches: idMatches };
103
+ }
104
+ if (idMatches.length > 1) {
105
+ const preferred = preferSameTaskInHigherPriorityDir(idMatches);
106
+ if (preferred) {
107
+ return { ok: true, taskFile: preferred, matches: idMatches };
108
+ }
109
+ return { ok: false, reason: 'ambiguous', matches: idMatches };
110
+ }
111
+
112
+ // Fallback: If no ID matches but we have filename-prefix matches,
113
+ // we only allow it if it's unambiguous.
114
+ return { ok: false, reason: 'ambiguous', matches };
115
+ }
116
+
117
+ function findTaskFile(slug, rootDir = process.cwd()) {
118
+ const result = resolveTaskFile(slug, rootDir);
119
+ return result.ok ? result.taskFile : null;
120
+ }
121
+
122
+ function reportTaskResolution(result, slug, log = fmt.log.plain) {
123
+ if (result.ok) return;
124
+
125
+ if (result.reason === 'ambiguous') {
126
+ log(fmt.status('FAIL', `Backlog task resolution is ambiguous for slug: ${fmt.slug(slug)}`));
127
+ log(fmt.status('INFO', 'Multiple candidates found:'));
128
+ result.matches.forEach(m => log(` - ${m}`));
129
+ log(fmt.status('INFO', 'Repair: Ensure only one task has the filename prefix or matching "id:" frontmatter.'));
130
+ } else {
131
+ log(fmt.status('FAIL', `Backlog task for ${fmt.slug(slug)} not found in backlog/tasks/ or backlog/completed/.`));
132
+ log(fmt.status('INFO', 'Create the task file first.'));
133
+ log(fmt.status('INFO', 'Repair: px draft requires an existing Backlog.md-style task file.'));
134
+ log(fmt.status('INFO', `If Backlog.md is installed, create one with ${fmt.command(`backlog_task_create --title "Mission: ${slug}"`)}.`));
135
+ log(fmt.status('INFO', `Without Backlog.md, create ${fmt.path(`backlog/tasks/${slug} - <title>.md`)} with frontmatter id ${fmt.bold(slug.toUpperCase())}.`));
136
+ }
137
+ }
138
+
139
+ function checkBacklogIntegrity(rootDir = process.cwd(), slug = null) {
140
+ const tasksDir = path.join(rootDir, 'backlog', 'tasks');
141
+ const completedDir = path.join(rootDir, 'backlog', 'completed');
142
+ const issues = [];
143
+ const normalizedSlug = slug ? slug.toLowerCase() : null;
144
+
145
+ const scan = (dir) => {
146
+ if (!fs.existsSync(dir)) return;
147
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
148
+ for (const file of files) {
149
+ if (normalizedSlug && !file.toLowerCase().startsWith(normalizedSlug)) {
150
+ continue;
151
+ }
152
+ const filePath = path.join(dir, file);
153
+ try {
154
+ const content = fs.readFileSync(filePath, 'utf8');
155
+
156
+ // Extract ID from filename prefix (e.g., task-093 or task-093.01)
157
+ const filenameMatch = file.match(/^(task-\d+(?:\.\d+)?)/i);
158
+ if (!filenameMatch) continue;
159
+
160
+ const filenameId = filenameMatch[1].toUpperCase();
161
+
162
+ // Extract ID from frontmatter
163
+ const idMatch = content.match(/^id:\s*([^\r\n]+)/m);
164
+ if (idMatch) {
165
+ const frontmatterId = idMatch[1].trim().toUpperCase();
166
+ if (frontmatterId !== filenameId) {
167
+ issues.push({
168
+ file: path.relative(rootDir, filePath),
169
+ type: 'id-mismatch',
170
+ filenameId,
171
+ frontmatterId
172
+ });
173
+ }
174
+ }
175
+ } catch (_) {
176
+ // ignore read errors
177
+ }
178
+ }
179
+ };
180
+
181
+ scan(tasksDir);
182
+ scan(completedDir);
183
+ return issues;
184
+ }
185
+
186
+ function getTaskStatus(taskFilePath) {
187
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
188
+ const content = fs.readFileSync(taskFilePath, 'utf8');
189
+
190
+ // Try YAML first
191
+ const yamlMatch = content.match(/^status:\s*([^\r\n]+)/m);
192
+ if (yamlMatch) {
193
+ return yamlMatch[1].trim().toLowerCase();
194
+ }
195
+
196
+ // Try rendered format
197
+ const statusMatch = content.match(/^Status:\s*(.*)$/m);
198
+ if (statusMatch) {
199
+ return statusMatch[1].replace(/^[○●\(\)\s]+/, '').trim().toLowerCase();
200
+ }
201
+
202
+ return null;
203
+ }
204
+
205
+ function setTaskStatus(taskFilePath, newStatus) {
206
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
207
+ let content = fs.readFileSync(taskFilePath, 'utf8');
208
+
209
+ // Update YAML
210
+ const yamlMatch = content.match(/^status:\s*([^\r\n]+)/m);
211
+ if (yamlMatch) {
212
+ content = content.replace(/^status:\s*.*$/m, `status: ${newStatus}`);
213
+ }
214
+
215
+ // Update rendered status line (e.g., "Status: ○ backlog")
216
+ // Map internal status to display markers if needed, but for now just text update
217
+ const statusMatch = content.match(/^Status:\s*(.*)$/m);
218
+ if (statusMatch) {
219
+ const original = statusMatch[1];
220
+ // Keep markers like ○ if they exist
221
+ const markerMatch = original.match(/^([○●\(\)\s]+)/);
222
+ const marker = markerMatch ? markerMatch[1] : '';
223
+ content = content.replace(/^Status:\s*.*$/m, `Status: ${marker}${newStatus}`);
224
+ }
225
+
226
+ fs.writeFileSync(taskFilePath, content, 'utf8');
227
+ return true;
228
+ }
229
+
230
+ function completeTask(slug, rootDir = process.cwd()) {
231
+ const resolution = resolveTaskFile(slug, rootDir);
232
+ if (!resolution.ok) return false;
233
+
234
+ const taskFilePath = resolution.taskFile;
235
+ const fileName = path.basename(taskFilePath);
236
+ const tasksDir = path.join(rootDir, 'backlog', 'tasks');
237
+ const completedDir = path.join(rootDir, 'backlog', 'completed');
238
+
239
+ if (!taskFilePath.includes(tasksDir)) {
240
+ // Already in completed or somewhere else
241
+ setTaskStatus(taskFilePath, 'done');
242
+ return true;
243
+ }
244
+
245
+ if (!fs.existsSync(completedDir)) {
246
+ fs.mkdirSync(completedDir, { recursive: true });
247
+ }
248
+
249
+ const targetPath = path.join(completedDir, fileName);
250
+
251
+ // Set status before moving
252
+ setTaskStatus(taskFilePath, 'done');
253
+
254
+ fs.renameSync(taskFilePath, targetPath);
255
+ return true;
256
+ }
257
+
258
+ /**
259
+ * Internal helper to parse assignee families from YAML frontmatter content.
260
+ * Supports inline array, simple inline, and block formats.
261
+ */
262
+ function parseAssigneeFamilies(content) {
263
+ let families = [];
264
+ let matched = false;
265
+
266
+ const lineMatch = content.match(/^assignee:[ \t]*(.*)$/m);
267
+ if (lineMatch) {
268
+ const rest = lineMatch[1].trim();
269
+ if (rest) {
270
+ matched = true;
271
+ // It's some kind of inline form
272
+ const rawValues = rest.startsWith('[') && rest.endsWith(']')
273
+ ? rest.slice(1, -1)
274
+ : rest;
275
+
276
+ families = rawValues.split(',')
277
+ .map(s => s.trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
278
+ .filter(s => s.length > 0);
279
+ }
280
+ }
281
+
282
+ if (!matched) {
283
+ // Try block form
284
+ const blockMatch = content.match(/^assignee:[ \t]*[\r\n]+((?:\s+-\s+.+[\r\n]*)+)/m);
285
+ if (blockMatch) {
286
+ matched = true;
287
+ families = blockMatch[1].split(/[\r\n]+/)
288
+ .map(line => line.trim())
289
+ .filter(line => line.startsWith('-'))
290
+ .map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, '').replace(/^@/, ''))
291
+ .filter(s => s.length > 0);
292
+ }
293
+ }
294
+
295
+ return { matched, families };
296
+ }
297
+
298
+ function commitTaskFileUpdate(taskFilePath, message, rootDir = process.cwd()) {
299
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return;
300
+ // Run `git add/commit` against the worktree the caller intended (rootDir),
301
+ // not whichever branch process.cwd() happens to have checked out. All
302
+ // worktrees share one git object store, so the only thing that pins the
303
+ // commit to the correct mission branch is the `-C <rootDir>` working dir.
304
+ // Fall back to the task file's directory if rootDir isn't supplied or the
305
+ // task file lives outside it (defensive: keeps the add/commit consistent).
306
+ const gitDir = (rootDir && taskFilePath.startsWith(path.resolve(rootDir) + path.sep))
307
+ ? rootDir
308
+ : path.dirname(taskFilePath);
309
+ const relativeTaskPath = path.relative(gitDir, taskFilePath);
310
+ try {
311
+ git(['-C', gitDir, 'add', relativeTaskPath]);
312
+ const result = git(['-C', gitDir, 'commit', '-m', message]);
313
+ if (result.status !== 0) {
314
+ // If there's nothing to commit (e.g. no change), git commit exits with status 1
315
+ // but we should check if it was really a failure or just no-op.
316
+ const statusResult = git(['-C', gitDir, 'status', '--porcelain', relativeTaskPath]);
317
+ if (statusResult.stdout.trim() === '') {
318
+ return true; // No-op is success
319
+ }
320
+ fmt.log.warn(`Failed to commit task update: ${result.stderr}`);
321
+ return false;
322
+ }
323
+ return true;
324
+ } catch (e) {
325
+ fmt.log.fail(`Git error during task update: ${e.message}`);
326
+ return false;
327
+ }
328
+ }
329
+
330
+ function clearTaskAgentAssignee(taskFilePath) {
331
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
332
+ let content = fs.readFileSync(taskFilePath, 'utf8');
333
+ if (!content.match(/^assignee:/m)) return false;
334
+
335
+ const { families } = parseAssigneeFamilies(content);
336
+ const supportedAgents = getSupportedAgents();
337
+
338
+ // Separate agent families from human assignees
339
+ const agentFamilies = families.filter(f => supportedAgents.includes(f.toLowerCase()));
340
+ const humanFamilies = families.filter(f => !supportedAgents.includes(f.toLowerCase()));
341
+
342
+ // If there are no agent families to clear, nothing to do — preserve human assignees
343
+ if (agentFamilies.length === 0) {
344
+ // Write back the human-only assignee list
345
+ if (humanFamilies.length > 0) {
346
+ const hasBlockForm = content.match(/^assignee:[ \t]*[\r\n]+/);
347
+ if (humanFamilies.length === families.length) {
348
+ // All families were human; no change needed
349
+ return false;
350
+ }
351
+ // Remove agent families and write back human-only
352
+ let newAssigneeLine;
353
+ if (hasBlockForm) {
354
+ newAssigneeLine = 'assignee:\n' + humanFamilies.map(f => ` - ${f}`).join('\n') + '\n';
355
+ } else {
356
+ newAssigneeLine = `assignee: [${humanFamilies.join(', ')}]`;
357
+ }
358
+
359
+ if (content.match(/^assignee:\s*\[.*?\]/m)) {
360
+ content = content.replace(/^assignee:\s*\[.*?\]/m, newAssigneeLine);
361
+ } else if (content.match(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m)) {
362
+ content = content.replace(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m, newAssigneeLine);
363
+ } else {
364
+ content = content.replace(/^assignee:[ \t]*.*$/m, newAssigneeLine);
365
+ }
366
+
367
+ fs.writeFileSync(taskFilePath, content, 'utf8');
368
+ return true;
369
+ }
370
+ return false;
371
+ }
372
+
373
+ const hasBlockForm = content.match(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m);
374
+
375
+ if (!hasBlockForm) {
376
+ // Inline array form: replace with human-only agents
377
+ const newAssignee = humanFamilies.length > 0 ? `[${humanFamilies.join(', ')}]` : '[]';
378
+ content = content.replace(/^assignee:\s*\[.*?\]/m, `assignee: ${newAssignee}`);
379
+ } else {
380
+ // Block form: remove agent lines, keep human lines
381
+ let newBlock = content.replace(/^assignee:[ \t]*[\r\n]+/m, 'assignee:\n');
382
+ const blockLines = newBlock.match(/^assignee:\n((?:\s+-\s+.+\n?)*)/m);
383
+ if (blockLines) {
384
+ const keptLines = blockLines[1].split('\n').filter(line => {
385
+ const m = line.match(/^\s+-\s+(.+)/);
386
+ if (!m) return line.trim() === '';
387
+ const family = m[1].trim().replace(/^['"]|['"]$/g, '');
388
+ return !supportedAgents.includes(family.toLowerCase());
389
+ }).join('\n');
390
+ newBlock = newBlock.replace(/^assignee:\n((?:\s+-\s+.+\n?)*)/m, 'assignee:\n' + keptLines);
391
+ if (newBlock.endsWith('assignee:\n') || newBlock.endsWith('assignee: \n') || newBlock.endsWith('assignee:\n\n')) {
392
+ newBlock = newBlock.replace(/assignee:\s*\n\s*$/, 'assignee: []\n');
393
+ }
394
+ content = newBlock;
395
+ } else {
396
+ content = content.replace(/^assignee:\s*\[.*?\]/m, `assignee: []`);
397
+ }
398
+ }
399
+
400
+ fs.writeFileSync(taskFilePath, content, 'utf8');
401
+ return true;
402
+ }
403
+
404
+ /**
405
+ * Transition a task to a new status, optionally enforcing an implementer,
406
+ * restoring a prior assignee, or clearing agent assignees,
407
+ * and commit the change to the mission branch.
408
+ */
409
+ function transitionTask(slug, newStatus, { implementer = null, clearAssignee = false, rootDir = process.cwd(), log = fmt.log.plain } = {}) {
410
+ const resolution = resolveTaskFile(slug, rootDir);
411
+ if (!resolution.ok) {
412
+ log(fmt.status('WARN', `Could not transition task ${fmt.slug(slug)}: ${resolution.reason}`));
413
+ return false;
414
+ }
415
+
416
+ const taskFile = resolution.taskFile;
417
+
418
+ // Guard: reject suffixed slugs (e.g. "task-1048-regress") to prevent
419
+ // resolveTaskFile's base-ID fallback from silently committing to the
420
+ // wrong task file when the slug's base ID does not match the resolved
421
+ // file's frontmatter id. See TASK-1265 for the original incident.
422
+ const slugHasSuffix = /^(task-\d+)-/i.test(slug);
423
+ if (slugHasSuffix) {
424
+ log(fmt.status('WARN',
425
+ `Task ${fmt.slug(slug)} rejected: slug "${slug}" has a suffix; ` +
426
+ 'use the exact task id instead to avoid committing to the wrong file.'));
427
+ return false;
428
+ }
429
+
430
+ let changed = false;
431
+
432
+ if (implementer) {
433
+ if (enforceTaskAssignee(taskFile, implementer)) {
434
+ changed = true;
435
+ }
436
+ } else if (clearAssignee) {
437
+ if (clearTaskAgentAssignee(taskFile)) {
438
+ changed = true;
439
+ }
440
+ }
441
+
442
+ const currentStatus = getTaskStatus(taskFile);
443
+ if (currentStatus !== newStatus) {
444
+ if (setTaskStatus(taskFile, newStatus)) {
445
+ changed = true;
446
+ }
447
+ }
448
+
449
+ if (changed) {
450
+ let msg = `backlog(${slug}): transition to ${newStatus}`;
451
+ if (implementer) msg += ` and implementer=${implementer}`;
452
+
453
+ if (commitTaskFileUpdate(taskFile, msg, rootDir)) {
454
+ log(fmt.status('PASS', `Task ${fmt.slug(slug)} transitioned to ${newStatus}${implementer ? ' (assignee=' + fmt.agent(implementer) + ')' : ''} and committed.`));
455
+ return true;
456
+ }
457
+ return false;
458
+ }
459
+
460
+ return true; // Already in the desired state
461
+ }
462
+
463
+ function getTaskAssignee(taskFilePath) {
464
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
465
+ const content = fs.readFileSync(taskFilePath, 'utf8');
466
+ const { families } = parseAssigneeFamilies(content);
467
+ return families.length > 0 ? families[0] : null;
468
+ }
469
+
470
+ function getTaskImplementer(taskFilePath) {
471
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
472
+ const content = fs.readFileSync(taskFilePath, 'utf8');
473
+ const { families } = parseAssigneeFamilies(content);
474
+
475
+ const normalizedFamilies = families.map(f => f.toLowerCase());
476
+ const supportedAgents = getSupportedAgents();
477
+ return normalizedFamilies.find(f => supportedAgents.includes(f)) || null;
478
+ }
479
+
480
+ function getTaskFrontmatterValue(taskFilePath, field) {
481
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
482
+ const content = fs.readFileSync(taskFilePath, 'utf8');
483
+ const pattern = new RegExp(`^${field}:\\s*([^\\r\\n]+)`, 'mi');
484
+ const match = content.match(pattern);
485
+ if (!match) return null;
486
+ const value = match[1].trim().replace(/^['"]|['"]$/g, '');
487
+ return value || null;
488
+ }
489
+
490
+ function getTaskClassification(taskFilePath) {
491
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return null;
492
+ const content = fs.readFileSync(taskFilePath, 'utf8');
493
+ const matches = new Set();
494
+
495
+ const blockMatch = content.match(/^labels:[ \t]*[\r\n]+((?:\s+-\s+.+[\r\n]*)+)/m);
496
+ if (blockMatch) {
497
+ const labels = blockMatch[1].split(/[\r\n]+/)
498
+ .map(line => line.trim())
499
+ .filter(line => line.startsWith('-'))
500
+ .map(line => line.substring(1).trim().replace(/^['"]|['"]$/g, ''))
501
+ .map(s => s.toLowerCase());
502
+ if (labels.includes('ai_sdlc')) matches.add('ai_sdlc');
503
+ if (labels.includes('user_value')) matches.add('user_value');
504
+ }
505
+
506
+ const inlineMatch = content.match(/^labels:[ \t]*\[(.*?)\]/m);
507
+ if (inlineMatch) {
508
+ const labels = inlineMatch[1].split(',')
509
+ .map(s => s.trim().replace(/^['"]|['"]$/g, ''))
510
+ .map(s => s.toLowerCase());
511
+ if (labels.includes('ai_sdlc')) matches.add('ai_sdlc');
512
+ if (labels.includes('user_value')) matches.add('user_value');
513
+ }
514
+
515
+ return matches.size === 1 ? [...matches][0] : null;
516
+ }
517
+
518
+
519
+ function setTaskAssignee(taskFilePath, agentFamily, { promote = true } = {}) {
520
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
521
+ let content = fs.readFileSync(taskFilePath, 'utf8');
522
+
523
+ const { matched, families } = parseAssigneeFamilies(content);
524
+
525
+ if (matched) {
526
+ const lowerAgentFamily = agentFamily.toLowerCase();
527
+ const existingIndex = families.findIndex(f => f.toLowerCase() === lowerAgentFamily);
528
+
529
+ if (existingIndex !== 0) {
530
+ if (existingIndex !== -1) {
531
+ if (!promote) return false; // Already in the list, and we don't want to move it
532
+ // Remove existing to promote to first
533
+ families.splice(existingIndex, 1);
534
+ }
535
+
536
+ if (promote) {
537
+ families.unshift(agentFamily);
538
+ } else {
539
+ families.push(agentFamily);
540
+ }
541
+
542
+ const newAssignees = `assignee: [${families.join(', ')}]`;
543
+
544
+ // Replace whatever form was there with a normalized inline array form
545
+ if (content.match(/^assignee:\s*\[.*?\]/m)) {
546
+ content = content.replace(/^assignee:\s*\[.*?\]/m, newAssignees);
547
+ } else if (content.match(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m)) {
548
+ content = content.replace(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m, newAssignees + '\n');
549
+ } else {
550
+ // Fallback for simple form
551
+ content = content.replace(/^assignee:\s*.*$/m, newAssignees);
552
+ }
553
+
554
+ fs.writeFileSync(taskFilePath, content, 'utf8');
555
+ return true;
556
+ }
557
+ return false; // Already authoritative (at index 0)
558
+ }
559
+
560
+ // No assignee line exists — insert one after the id frontmatter line
561
+ const insertMatch = content.match(/^(id:.*)/m);
562
+ if (insertMatch) {
563
+ const insertPos = insertMatch.index + insertMatch[0].length;
564
+ const newLine = '\nassignee: [' + agentFamily + ']';
565
+ content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
566
+ fs.writeFileSync(taskFilePath, content, 'utf8');
567
+ return true;
568
+ }
569
+
570
+ return false;
571
+ }
572
+
573
+ function setTaskImplementer(taskFilePath, agentFamily) {
574
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
575
+ let content = fs.readFileSync(taskFilePath, 'utf8');
576
+
577
+ const { matched, families } = parseAssigneeFamilies(content);
578
+ // Separate humans from recognized agents
579
+ const supportedAgents = getSupportedAgents();
580
+ const preservedFamilies = families.filter(f => !supportedAgents.includes(f.toLowerCase()));
581
+ // To ensure the new implementer is authoritative for getTaskImplementer(),
582
+ // it MUST be the first recognized agent in the list.
583
+ const nextFamilies = [agentFamily, ...preservedFamilies];
584
+
585
+ if (matched) {
586
+ const normalizedCurrent = families.map(f => f.toLowerCase());
587
+ const normalizedNext = nextFamilies.map(f => f.toLowerCase());
588
+ // Check if the first agent is already the one we want to set
589
+ if (normalizedCurrent.length > 0 && normalizedCurrent[0] === agentFamily.toLowerCase()) {
590
+ // If the rest of the list is also the same, it's a no-op
591
+ if (normalizedCurrent.length === normalizedNext.length &&
592
+ normalizedCurrent.every((f, i) => f === normalizedNext[i])) {
593
+ return false;
594
+ }
595
+ }
596
+
597
+ const newAssignees = `assignee: [${nextFamilies.join(', ')}]`;
598
+
599
+ if (content.match(/^assignee:\s*\[.*?\]/m)) {
600
+ content = content.replace(/^assignee:\s*\[.*?\]/m, newAssignees);
601
+ } else if (content.match(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m)) {
602
+ content = content.replace(/^assignee:\s*\n((?:\s+-\s+.+\r?\n?)+)/m, newAssignees + '\n');
603
+ } else {
604
+ content = content.replace(/^assignee:\s*.*$/m, newAssignees);
605
+ }
606
+
607
+ fs.writeFileSync(taskFilePath, content, 'utf8');
608
+ return true;
609
+ }
610
+
611
+ const insertMatch = content.match(/^(id:.*)/m);
612
+ if (insertMatch) {
613
+ const insertPos = insertMatch.index + insertMatch[0].length;
614
+ const newLine = '\nassignee: [' + agentFamily + ']';
615
+ content = content.slice(0, insertPos) + newLine + content.slice(insertPos);
616
+ fs.writeFileSync(taskFilePath, content, 'utf8');
617
+ return true;
618
+ }
619
+
620
+ return false;
621
+ }
622
+
623
+ function enforceTaskAssignee(taskFilePath, agentFamily) {
624
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return false;
625
+ let content = fs.readFileSync(taskFilePath, 'utf8');
626
+
627
+ const { families } = parseAssigneeFamilies(content);
628
+ if (families.length === 1 && families[0] === agentFamily) return true;
629
+
630
+ const newAssignee = `assignee: [${agentFamily}]`;
631
+
632
+ if (content.match(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m)) {
633
+ content = content.replace(/^assignee:[ \t]*[\r\n]+((?:[ \t]+-[ \t]+.+[\r\n]*)+)/m, newAssignee + '\n');
634
+ } else if (content.match(/^assignee:[ \t]*.*$/m)) {
635
+ content = content.replace(/^assignee:[ \t]*.*$/m, newAssignee);
636
+ } else {
637
+ const insertMatch = content.match(/^(id:.*)/m);
638
+ if (!insertMatch) return false;
639
+ const insertPos = insertMatch.index + insertMatch[0].length;
640
+ content = content.slice(0, insertPos) + '\n' + newAssignee + content.slice(insertPos);
641
+ }
642
+
643
+ fs.writeFileSync(taskFilePath, content, 'utf8');
644
+ return true;
645
+ }
646
+
647
+ function getAcceptanceCriteria(taskFilePath) {
648
+ if (!taskFilePath || !fs.existsSync(taskFilePath)) return [];
649
+ const content = fs.readFileSync(taskFilePath, 'utf8');
650
+ const sectionMatch = content.match(/## Acceptance Criteria\s*\n([\s\S]*?)(?:\n## |\nDefinition of Done:|\n---\n|$)/);
651
+ if (!sectionMatch) return [];
652
+
653
+ return sectionMatch[1]
654
+ .split('\n')
655
+ .map(line => line.trim())
656
+ .filter(line => /^- \[[ xX]\]/.test(line));
657
+ }
658
+
659
+ module.exports = {
660
+ findTaskFiles,
661
+ findTaskFile,
662
+ resolveTaskFile,
663
+ reportTaskResolution,
664
+ checkBacklogIntegrity,
665
+ getTaskStatus,
666
+ setTaskStatus,
667
+ transitionTask,
668
+ commitTaskFileUpdate,
669
+ completeTask,
670
+ getTaskAssignee,
671
+ getTaskImplementer,
672
+ getTaskFrontmatterValue,
673
+ getTaskClassification,
674
+ setTaskAssignee,
675
+ setTaskImplementer,
676
+ enforceTaskAssignee,
677
+ getAcceptanceCriteria,
678
+ parseAssigneeFamilies,
679
+ clearTaskAgentAssignee
680
+ };