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