@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.
- package/index.js +190 -185
- package/lib/agents/agents.js +34 -4
- package/lib/agents/agents.ts +40 -5
- package/lib/commands/active.js +502 -579
- package/lib/commands/active.ts +665 -0
- package/lib/commands/checkpoint.js +98 -74
- package/lib/commands/checkpoint.ts +79 -0
- package/lib/commands/config.js +61 -31
- package/lib/commands/config.ts +47 -0
- package/lib/commands/coverage-gate.js +271 -246
- package/lib/commands/coverage-gate.ts +362 -0
- package/lib/commands/diff.js +140 -119
- package/lib/commands/diff.ts +125 -0
- package/lib/commands/draft.js +770 -923
- package/lib/commands/draft.ts +1021 -0
- package/lib/commands/handoff.js +588 -608
- package/lib/commands/handoff.ts +694 -0
- package/lib/commands/integrate.js +1331 -1468
- package/lib/commands/integrate.ts +1688 -0
- package/lib/commands/mission-start.js +279 -237
- package/lib/commands/mission-start.ts +263 -0
- package/lib/commands/rebase.js +606 -582
- package/lib/commands/rebase.ts +635 -0
- package/lib/commands/repair-handoff.js +181 -179
- package/lib/commands/repair-handoff.ts +228 -0
- package/lib/commands/resolve-conflict.js +121 -102
- package/lib/commands/resolve-conflict.ts +112 -0
- package/lib/commands/review.js +3 -4
- package/lib/commands/review.ts +14 -0
- package/lib/commands/setup-review.js +4 -5
- package/lib/commands/setup-review.ts +14 -0
- package/lib/commands/setup.js +3 -3
- package/lib/commands/setup.ts +2 -0
- package/lib/commands/stats-backfill.js +394 -359
- package/lib/commands/stats-backfill.ts +418 -0
- package/lib/commands/stats.js +1424 -1665
- package/lib/commands/stats.ts +2186 -0
- package/lib/commands/status.js +219 -200
- package/lib/commands/status.ts +219 -0
- package/lib/commands/verify.js +3 -1
- package/lib/commands/verify.ts +2 -0
- package/lib/core/nels.js +112 -127
- package/lib/core/nels.ts +189 -0
- package/lib/core/subagent-limit.js +15 -16
- package/lib/core/subagent-limit.ts +25 -0
- package/lib/index.js +142 -59
- package/lib/index.ts +134 -0
- package/lib/review/rebase.js +142 -145
- package/lib/review/rebase.ts +188 -0
- package/lib/review/review-adapter.js +99 -87
- package/lib/review/review-adapter.ts +198 -0
- package/lib/review/review-artifacts.js +486 -629
- package/lib/review/review-artifacts.ts +622 -0
- package/lib/review/review-commands.js +1220 -1373
- package/lib/review/review-commands.ts +1579 -0
- package/lib/review/review-events.js +544 -886
- package/lib/review/review-events.ts +954 -0
- package/lib/review/review-loop.js +945 -1020
- package/lib/review/review-loop.ts +1174 -0
- package/lib/review/review-polling.js +86 -115
- package/lib/review/review-polling.ts +194 -0
- package/lib/review/review-prompts.js +154 -189
- package/lib/review/review-prompts.ts +182 -0
- package/lib/review/review-state.js +246 -225
- package/lib/review/review-state.ts +353 -0
- package/lib/review/review.js +63 -84
- package/lib/review/review.ts +138 -0
- package/lib/tools/backlog.js +694 -680
- package/lib/tools/backlog.ts +835 -0
- package/lib/tools/forgejo.js +1270 -1405
- package/lib/tools/forgejo.ts +1793 -0
- package/lib/tools/gatekeeper.js +106 -96
- package/lib/tools/gatekeeper.ts +124 -0
- package/lib/tools/redgreen.js +200 -181
- package/lib/tools/redgreen.ts +221 -0
- package/lib/tools/sessions.js +58 -52
- package/lib/tools/sessions.ts +81 -0
- package/lib/tools/setup-review.js +902 -975
- package/lib/tools/setup-review.ts +1152 -0
- package/package.json +4 -4
- package/px.js +226 -208
package/lib/tools/backlog.js
CHANGED
|
@@ -1,253 +1,308 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
182
|
+
return { ok: true, taskFile: preferred, matches: idMatches };
|
|
100
183
|
}
|
|
101
|
-
return { ok: false, reason: 'ambiguous', matches:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return { ok: false, reason: '
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(),
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
297
|
+
file: taskPath,
|
|
298
|
+
type: 'duplicate-completed',
|
|
299
|
+
taskId: id,
|
|
300
|
+
canonicalFile: canonicalPath
|
|
223
301
|
});
|
|
224
|
-
}
|
|
225
302
|
}
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
+
;
|