@magnusekdahl/parallix 1.2.1 → 1.3.1

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