kanbango 3.6.2 → 5.1.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/AGENTS.md +25 -18
- package/API.md +58 -170
- package/CHANGELOG.md +76 -0
- package/LLM_AGENTS.md +21 -502
- package/README.md +45 -12
- package/agent-playbook.js +27 -49
- package/bin/kanban.js +395 -300
- package/config-wizard.js +441 -0
- package/index.html +124 -36
- package/kanban.js +892 -263
- package/mcp-server.js +319 -196
- package/package.json +1 -1
- package/plan.js +39 -40
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +368 -21
- package/.ai/lessons.jsonl +0 -9
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -25
- package/bin/kanban-cmd.js +0 -40
- package/kan2.md +0 -76
- package/kanbango.md +0 -48
- package/planv2.md +0 -317
- package/tests/fixtures/fake-opencode.js +0 -69
- package/tests/index.js +0 -19
- package/tests/kanban-cli.js +0 -118
- package/tests/kanban.js +0 -104
- package/tests/run.js +0 -33
package/kanban.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
const fs = require('fs').promises;
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
function backlogDir() {
|
|
5
|
+
return path.join(process.cwd(), 'backlog');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function epicsDir() {
|
|
9
|
+
return path.join(backlogDir(), 'epics');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BOARD_LOCK_NAME = '.board.lock';
|
|
13
|
+
const BOARD_LOCK_TIMEOUT_MS = 2000;
|
|
14
|
+
const BOARD_LOCK_POLL_MS = 50;
|
|
15
|
+
/** All physical column dirs that may exist on disk (including disabled gates). */
|
|
16
|
+
const KNOWN_COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
|
|
17
|
+
/** Active columns for validation / MCP / create — mutated by applyBoardLayout. */
|
|
18
|
+
let COLS = KNOWN_COLS.slice();
|
|
7
19
|
const STATUS_MAP = {
|
|
8
20
|
active: 'in_progress',
|
|
9
21
|
planned: 'planned',
|
|
@@ -12,11 +24,12 @@ const STATUS_MAP = {
|
|
|
12
24
|
review: 'review',
|
|
13
25
|
done: 'done'
|
|
14
26
|
};
|
|
15
|
-
|
|
27
|
+
/** Gate stages that may spawn agents — subset of active COLS. */
|
|
28
|
+
let WORKFLOW_STAGES = ['testing', 'review'];
|
|
16
29
|
const WORKFLOW_STATUSES = ['idle', 'running', 'pass', 'fail', 'blocked'];
|
|
17
30
|
const EVIDENCE_VERDICTS = ['pass', 'fail', 'blocked', ''];
|
|
18
|
-
// Agent/human move contract. Same column is always a no-op.
|
|
19
|
-
|
|
31
|
+
// Agent/human move contract. Same column is always a no-op. Mutated by applyBoardLayout.
|
|
32
|
+
let COLUMN_TRANSITIONS = {
|
|
20
33
|
icebox: ['planned'],
|
|
21
34
|
planned: ['active', 'icebox', 'testing'],
|
|
22
35
|
active: ['planned', 'testing', 'icebox'],
|
|
@@ -24,8 +37,31 @@ const COLUMN_TRANSITIONS = {
|
|
|
24
37
|
review: ['active', 'done'],
|
|
25
38
|
done: ['active']
|
|
26
39
|
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Apply project board layout from backlog/kanbango.json (via workflow.ensureBoardConfig).
|
|
43
|
+
* Does not create/delete dirs; only changes which columns are legal and which transitions apply.
|
|
44
|
+
*/
|
|
45
|
+
function applyBoardLayout({ cols, transitions, workflowStages } = {}) {
|
|
46
|
+
if (Array.isArray(cols) && cols.length > 0) {
|
|
47
|
+
COLS = cols.slice();
|
|
48
|
+
}
|
|
49
|
+
if (transitions && typeof transitions === 'object' && !Array.isArray(transitions)) {
|
|
50
|
+
COLUMN_TRANSITIONS = {};
|
|
51
|
+
for (const [from, targets] of Object.entries(transitions)) {
|
|
52
|
+
COLUMN_TRANSITIONS[from] = Array.isArray(targets) ? targets.slice() : [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(workflowStages)) {
|
|
56
|
+
WORKFLOW_STAGES = workflowStages.slice();
|
|
57
|
+
}
|
|
58
|
+
// Keep module.exports live references in sync for consumers that cached the export object.
|
|
59
|
+
module.exports.COLS = COLS;
|
|
60
|
+
module.exports.COLUMN_TRANSITIONS = COLUMN_TRANSITIONS;
|
|
61
|
+
module.exports.WORKFLOW_STAGES = WORKFLOW_STAGES;
|
|
62
|
+
}
|
|
27
63
|
const VIEW_FIELDS = {
|
|
28
|
-
summary: ['task_number', 'title', 'column', 'epic_id', '
|
|
64
|
+
summary: ['id', 'task_number', 'title', 'column', 'epic_id', 'progress', 'blocked'],
|
|
29
65
|
planning: [
|
|
30
66
|
'task_number',
|
|
31
67
|
'title',
|
|
@@ -40,28 +76,24 @@ const VIEW_FIELDS = {
|
|
|
40
76
|
'in_scope',
|
|
41
77
|
'out_of_scope',
|
|
42
78
|
'acceptance_criteria',
|
|
43
|
-
'test_cases'
|
|
79
|
+
'test_cases',
|
|
80
|
+
'depends_on',
|
|
81
|
+
'blocked',
|
|
82
|
+
'unmet_dependencies',
|
|
83
|
+
'blocks'
|
|
44
84
|
],
|
|
45
85
|
execution: [
|
|
86
|
+
'id',
|
|
46
87
|
'task_number',
|
|
47
88
|
'title',
|
|
48
89
|
'column',
|
|
49
90
|
'epic_id',
|
|
50
|
-
'epic_group',
|
|
51
|
-
'epic_goals',
|
|
52
|
-
'created',
|
|
53
91
|
'progress',
|
|
54
|
-
'
|
|
55
|
-
'specs',
|
|
56
|
-
'in_scope',
|
|
57
|
-
'out_of_scope',
|
|
58
|
-
'acceptance_criteria',
|
|
59
|
-
'test_cases',
|
|
92
|
+
'current_subtask',
|
|
60
93
|
'subtasks',
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'workflow'
|
|
94
|
+
'workflow',
|
|
95
|
+
'files',
|
|
96
|
+
'blocked'
|
|
65
97
|
],
|
|
66
98
|
full: [
|
|
67
99
|
'task_number',
|
|
@@ -83,7 +115,12 @@ const VIEW_FIELDS = {
|
|
|
83
115
|
'notes',
|
|
84
116
|
'evidence',
|
|
85
117
|
'plan',
|
|
86
|
-
'workflow'
|
|
118
|
+
'workflow',
|
|
119
|
+
'depends_on',
|
|
120
|
+
'files',
|
|
121
|
+
'blocked',
|
|
122
|
+
'unmet_dependencies',
|
|
123
|
+
'blocks'
|
|
87
124
|
]
|
|
88
125
|
};
|
|
89
126
|
|
|
@@ -148,10 +185,18 @@ function createKanbanError(code, message, hint, details = {}, retryable = false,
|
|
|
148
185
|
}
|
|
149
186
|
|
|
150
187
|
// Serialize board mutations so concurrent create/move/update cannot race on ids or paths.
|
|
188
|
+
// mutationTail = in-process; backlog/.board.lock = cross-process.
|
|
151
189
|
let mutationTail = Promise.resolve();
|
|
152
190
|
|
|
153
191
|
function withBoardLock(fn) {
|
|
154
|
-
const run = mutationTail.then(() =>
|
|
192
|
+
const run = mutationTail.then(async () => {
|
|
193
|
+
const owner = await acquireBoardLock();
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
196
|
+
} finally {
|
|
197
|
+
await releaseBoardLock(owner);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
155
200
|
mutationTail = run.then(() => undefined, () => undefined);
|
|
156
201
|
return run;
|
|
157
202
|
}
|
|
@@ -184,6 +229,119 @@ async function writeFileAtomic(filePath, payload, { exclusive = false } = {}) {
|
|
|
184
229
|
}
|
|
185
230
|
}
|
|
186
231
|
|
|
232
|
+
function boardLockPath() {
|
|
233
|
+
return path.join(backlogDir(), BOARD_LOCK_NAME);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function newBoardLockOwner() {
|
|
237
|
+
return {
|
|
238
|
+
pid: process.pid,
|
|
239
|
+
token: `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`,
|
|
240
|
+
started: new Date().toISOString()
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sleep(ms) {
|
|
245
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isPidAlive(pid) {
|
|
249
|
+
try {
|
|
250
|
+
process.kill(pid, 0);
|
|
251
|
+
return true;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error.code === 'ESRCH') return false;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function boardLockedError(details = {}) {
|
|
259
|
+
return createKanbanError(
|
|
260
|
+
'BOARD_LOCKED',
|
|
261
|
+
'Board is locked by another process',
|
|
262
|
+
'Retry the mutation after the other process finishes writing',
|
|
263
|
+
details,
|
|
264
|
+
true,
|
|
265
|
+
409
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readBoardLockOwner() {
|
|
270
|
+
let raw;
|
|
271
|
+
try {
|
|
272
|
+
raw = await fs.readFile(boardLockPath(), 'utf-8');
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (error.code === 'ENOENT') return undefined;
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const data = JSON.parse(raw);
|
|
279
|
+
const pid = Number(data && data.pid);
|
|
280
|
+
const token = data && typeof data.token === 'string' ? data.token : '';
|
|
281
|
+
if (!Number.isInteger(pid) || pid <= 0 || !token) return null;
|
|
282
|
+
return { pid, token, started: data.started };
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function tryPublishBoardLock(owner) {
|
|
289
|
+
await ensureBacklogDir();
|
|
290
|
+
try {
|
|
291
|
+
await writeFileAtomic(boardLockPath(), JSON.stringify(owner) + '\n', { exclusive: true });
|
|
292
|
+
return true;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error.code === 'EEXIST') return false;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function stealDeadBoardLock(expectedToken) {
|
|
300
|
+
const current = await readBoardLockOwner();
|
|
301
|
+
if (!current || current.token !== expectedToken) return false;
|
|
302
|
+
if (isPidAlive(current.pid)) return false;
|
|
303
|
+
const again = await readBoardLockOwner();
|
|
304
|
+
if (!again || again.token !== expectedToken) return false;
|
|
305
|
+
if (isPidAlive(again.pid)) return false;
|
|
306
|
+
try {
|
|
307
|
+
await fs.unlink(boardLockPath());
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error.code !== 'ENOENT') throw error;
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function acquireBoardLock() {
|
|
315
|
+
const owner = newBoardLockOwner();
|
|
316
|
+
const deadline = Date.now() + BOARD_LOCK_TIMEOUT_MS;
|
|
317
|
+
while (Date.now() <= deadline) {
|
|
318
|
+
if (await tryPublishBoardLock(owner)) return owner;
|
|
319
|
+
await sleep(BOARD_LOCK_POLL_MS);
|
|
320
|
+
}
|
|
321
|
+
const observed = await readBoardLockOwner();
|
|
322
|
+
if (observed && observed.token && !isPidAlive(observed.pid)) {
|
|
323
|
+
await stealDeadBoardLock(observed.token);
|
|
324
|
+
if (await tryPublishBoardLock(owner)) return owner;
|
|
325
|
+
} else if (observed === undefined) {
|
|
326
|
+
if (await tryPublishBoardLock(owner)) return owner;
|
|
327
|
+
}
|
|
328
|
+
throw boardLockedError({
|
|
329
|
+
timeout_ms: BOARD_LOCK_TIMEOUT_MS,
|
|
330
|
+
holder_pid: observed && observed.pid
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function releaseBoardLock(owner) {
|
|
335
|
+
if (!owner || !owner.token) return;
|
|
336
|
+
const current = await readBoardLockOwner();
|
|
337
|
+
if (!current || current.token !== owner.token) return;
|
|
338
|
+
try {
|
|
339
|
+
await fs.unlink(boardLockPath());
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error.code !== 'ENOENT') throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
187
345
|
function todayIso() {
|
|
188
346
|
return new Date().toISOString().split('T')[0];
|
|
189
347
|
}
|
|
@@ -208,6 +366,276 @@ function normalizeStringArray(value) {
|
|
|
208
366
|
.filter(Boolean);
|
|
209
367
|
}
|
|
210
368
|
|
|
369
|
+
function assertIsArray(value, field, message, hint) {
|
|
370
|
+
if (Array.isArray(value)) return;
|
|
371
|
+
throw createKanbanError(
|
|
372
|
+
'VALIDATION_ERROR',
|
|
373
|
+
message,
|
|
374
|
+
hint,
|
|
375
|
+
{ field },
|
|
376
|
+
false,
|
|
377
|
+
400
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function requireStringArray(value, field) {
|
|
382
|
+
assertIsArray(
|
|
383
|
+
value,
|
|
384
|
+
field,
|
|
385
|
+
`${field} must be an array of strings`,
|
|
386
|
+
`Send ${field} as an array`
|
|
387
|
+
);
|
|
388
|
+
return normalizeStringArray(value);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function requireNonEmptyTitle(value) {
|
|
392
|
+
const title = normalizeString(value);
|
|
393
|
+
if (!title) {
|
|
394
|
+
throw createKanbanError(
|
|
395
|
+
'VALIDATION_ERROR',
|
|
396
|
+
'title must be a non-empty string',
|
|
397
|
+
'Send a non-empty title or omit the field',
|
|
398
|
+
{ field: 'title' },
|
|
399
|
+
false,
|
|
400
|
+
400
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return title;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function applyAdrPatch(existing, adr) {
|
|
407
|
+
if (Array.isArray(adr)) return normalizeAdr(adr);
|
|
408
|
+
return appendAdrEntry(existing, adr);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeTaskIdRef(value) {
|
|
412
|
+
const raw = normalizeString(value);
|
|
413
|
+
if (!raw) return '';
|
|
414
|
+
if (/^\d+$/.test(raw)) return raw.padStart(3, '0');
|
|
415
|
+
return raw;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function uniqueNormalizedStrings(value, mapItem) {
|
|
419
|
+
if (!Array.isArray(value)) return [];
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const item of value) {
|
|
423
|
+
const next = mapItem(item);
|
|
424
|
+
if (!next || seen.has(next)) continue;
|
|
425
|
+
seen.add(next);
|
|
426
|
+
out.push(next);
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizeDependsOn(value) {
|
|
432
|
+
return uniqueNormalizedStrings(value, normalizeTaskIdRef);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeFiles(value) {
|
|
436
|
+
return uniqueNormalizedStrings(value, normalizeString);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isWorkColumn(column) {
|
|
440
|
+
return column === 'active' || WORKFLOW_STAGES.includes(column);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function unmetDependencies(task, allTasks) {
|
|
444
|
+
const deps = Array.isArray(task && task.depends_on) ? task.depends_on : [];
|
|
445
|
+
if (deps.length === 0) return [];
|
|
446
|
+
const byId = new Map();
|
|
447
|
+
for (const other of allTasks || []) {
|
|
448
|
+
byId.set(other.id, other);
|
|
449
|
+
}
|
|
450
|
+
const unmet = [];
|
|
451
|
+
for (const depId of deps) {
|
|
452
|
+
const dep = byId.get(depId);
|
|
453
|
+
if (!dep || dep.column !== 'done') {
|
|
454
|
+
unmet.push({
|
|
455
|
+
id: depId,
|
|
456
|
+
column: dep ? dep.column : null,
|
|
457
|
+
missing: !dep
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return unmet;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function assertUnblockedForColumn(task, targetColumn, allTasks) {
|
|
465
|
+
if (!isWorkColumn(targetColumn)) return;
|
|
466
|
+
const unmet = unmetDependencies(task, allTasks);
|
|
467
|
+
if (unmet.length === 0) return;
|
|
468
|
+
const labels = unmet.map((item) => (
|
|
469
|
+
item.missing ? `${item.id} (missing)` : `${item.id} (${item.column})`
|
|
470
|
+
));
|
|
471
|
+
throw createKanbanError(
|
|
472
|
+
'TASK_BLOCKED',
|
|
473
|
+
`Task ${task.id} is blocked by incomplete tasks: ${labels.join(', ')}`,
|
|
474
|
+
'Finish dependency tasks (move them to done) or remove them from depends_on',
|
|
475
|
+
{
|
|
476
|
+
task_id: task.id,
|
|
477
|
+
unmet_dependencies: unmet.map((item) => item.id),
|
|
478
|
+
blocked_by: unmet
|
|
479
|
+
},
|
|
480
|
+
false,
|
|
481
|
+
400
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function computeBlocks(taskId, allTasks) {
|
|
486
|
+
if (!taskId) return [];
|
|
487
|
+
return (allTasks || [])
|
|
488
|
+
.filter((other) => other.id !== taskId && (other.depends_on || []).includes(taskId))
|
|
489
|
+
.map((other) => other.id)
|
|
490
|
+
.sort();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function findDependencyCycle(taskId, dependsOn, allTasks) {
|
|
494
|
+
const graph = new Map();
|
|
495
|
+
for (const other of allTasks || []) {
|
|
496
|
+
graph.set(other.id, (other.depends_on || []).slice());
|
|
497
|
+
}
|
|
498
|
+
graph.set(taskId, Array.isArray(dependsOn) ? dependsOn.slice() : []);
|
|
499
|
+
|
|
500
|
+
const visiting = new Set();
|
|
501
|
+
const visited = new Set();
|
|
502
|
+
const stack = [];
|
|
503
|
+
|
|
504
|
+
function dfs(id) {
|
|
505
|
+
if (visiting.has(id)) {
|
|
506
|
+
const start = stack.indexOf(id);
|
|
507
|
+
return stack.slice(start).concat(id);
|
|
508
|
+
}
|
|
509
|
+
if (visited.has(id)) return null;
|
|
510
|
+
visiting.add(id);
|
|
511
|
+
stack.push(id);
|
|
512
|
+
for (const depId of graph.get(id) || []) {
|
|
513
|
+
const cycle = dfs(depId);
|
|
514
|
+
if (cycle) return cycle;
|
|
515
|
+
}
|
|
516
|
+
stack.pop();
|
|
517
|
+
visiting.delete(id);
|
|
518
|
+
visited.add(id);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return dfs(taskId);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function assertNoDependencyCycle(taskId, dependsOn, allTasks) {
|
|
526
|
+
const cycle = findDependencyCycle(taskId, dependsOn, allTasks);
|
|
527
|
+
if (!cycle) return;
|
|
528
|
+
throw createKanbanError(
|
|
529
|
+
'CIRCULAR_DEPENDENCY',
|
|
530
|
+
`Circular dependency: ${cycle.join(' → ')}`,
|
|
531
|
+
'Remove one of the depends_on links that closes the loop',
|
|
532
|
+
{ cycle },
|
|
533
|
+
false,
|
|
534
|
+
400
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function newlyUnblockedTasks(doneTaskId, allTasks) {
|
|
539
|
+
return (allTasks || [])
|
|
540
|
+
.filter((other) => other.id !== doneTaskId && (other.depends_on || []).includes(doneTaskId))
|
|
541
|
+
.filter((other) => unmetDependencies(other, allTasks).length === 0)
|
|
542
|
+
.map((other) => other.id)
|
|
543
|
+
.sort();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function firstEnabledGateColumn() {
|
|
547
|
+
if (COLS.includes('testing')) return 'testing';
|
|
548
|
+
if (COLS.includes('review')) return 'review';
|
|
549
|
+
return 'done';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function evidenceHasProof(entry) {
|
|
553
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
554
|
+
return Boolean(
|
|
555
|
+
normalizeString(entry.diff)
|
|
556
|
+
|| normalizeString(entry.summary)
|
|
557
|
+
|| normalizeString(entry.test_command)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function hasProofEvidence(evidence) {
|
|
562
|
+
return normalizeEvidence(evidence).some(evidenceHasProof);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function requiresEvidenceForColumn(toColumn) {
|
|
566
|
+
if (toColumn === 'testing') return COLS.includes('testing');
|
|
567
|
+
if (toColumn === 'review') return COLS.includes('review');
|
|
568
|
+
if (toColumn === 'done') return COLS.includes('testing') || COLS.includes('review');
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function assertEvidenceForGate(fromTask, toTask) {
|
|
573
|
+
const fromColumn = fromTask && fromTask.column;
|
|
574
|
+
const toColumn = toTask && toTask.column;
|
|
575
|
+
if (!toColumn || fromColumn === toColumn) return;
|
|
576
|
+
if (!requiresEvidenceForColumn(toColumn)) return;
|
|
577
|
+
|
|
578
|
+
const status = toTask.workflow && toTask.workflow.status;
|
|
579
|
+
const fromGate = fromColumn === 'testing' || fromColumn === 'review';
|
|
580
|
+
if (fromGate && (status === 'fail' || status === 'blocked')) {
|
|
581
|
+
throw createKanbanError(
|
|
582
|
+
'EVIDENCE_REQUIRED',
|
|
583
|
+
`Cannot move task ${toTask.id} from ${fromColumn} to ${toColumn} while workflow is ${status}`,
|
|
584
|
+
'Move back to active, add new evidence (diff|summary|test_command), then re-enter the gate',
|
|
585
|
+
{
|
|
586
|
+
task_id: toTask.id,
|
|
587
|
+
from: fromColumn,
|
|
588
|
+
to: toColumn,
|
|
589
|
+
workflow_status: status
|
|
590
|
+
},
|
|
591
|
+
false,
|
|
592
|
+
400
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!hasProofEvidence(toTask.evidence)) {
|
|
597
|
+
throw createKanbanError(
|
|
598
|
+
'EVIDENCE_REQUIRED',
|
|
599
|
+
`Cannot move task ${toTask.id} from ${fromColumn} to ${toColumn} without evidence`,
|
|
600
|
+
'Add evidence with at least one of diff, summary, or test_command before entering the gate',
|
|
601
|
+
{
|
|
602
|
+
task_id: toTask.id,
|
|
603
|
+
from: fromColumn,
|
|
604
|
+
to: toColumn,
|
|
605
|
+
missing: ['evidence'],
|
|
606
|
+
need: 'diff|summary|test_command'
|
|
607
|
+
},
|
|
608
|
+
false,
|
|
609
|
+
400
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function nextMoveAction() {
|
|
615
|
+
const gate = firstEnabledGateColumn();
|
|
616
|
+
if (gate === 'testing') return 'move_testing';
|
|
617
|
+
if (gate === 'review') return 'move_review';
|
|
618
|
+
return 'move_done';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function resolveNextAction(task, mode) {
|
|
622
|
+
if (mode === 'start') return 'start';
|
|
623
|
+
if (mode === 'idle') return 'idle';
|
|
624
|
+
const status = task.workflow && task.workflow.status;
|
|
625
|
+
if (
|
|
626
|
+
(task.column === 'testing' || task.column === 'review')
|
|
627
|
+
&& (status === 'fail' || status === 'blocked')
|
|
628
|
+
) {
|
|
629
|
+
return 'fix_gate';
|
|
630
|
+
}
|
|
631
|
+
if (currentSubtask(task)) return 'advance';
|
|
632
|
+
if (!hasProofEvidence(task.evidence)) return 'evidence';
|
|
633
|
+
if (task.column === 'active') return nextMoveAction();
|
|
634
|
+
if (task.column === 'testing' && COLS.includes('review')) return 'move_review';
|
|
635
|
+
if (task.column === 'testing' || task.column === 'review') return 'move_done';
|
|
636
|
+
return 'implement';
|
|
637
|
+
}
|
|
638
|
+
|
|
211
639
|
function isPresentCreateField(field, value) {
|
|
212
640
|
if (
|
|
213
641
|
field === 'description'
|
|
@@ -385,7 +813,8 @@ function normalizeTask(task) {
|
|
|
385
813
|
const normalized = {
|
|
386
814
|
id,
|
|
387
815
|
title: stripTitlePrefix(task.title || id),
|
|
388
|
-
|
|
816
|
+
// Accept any known physical column so disabled-gate cards stay readable until migrated.
|
|
817
|
+
column: KNOWN_COLS.includes(task.column) ? task.column : 'planned',
|
|
389
818
|
epic_id: epicId,
|
|
390
819
|
epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
|
|
391
820
|
created: normalizeString(task.created) || todayIso(),
|
|
@@ -401,6 +830,8 @@ function normalizeTask(task) {
|
|
|
401
830
|
plan: normalizePlan(task.plan),
|
|
402
831
|
evidence: normalizeEvidence(task.evidence),
|
|
403
832
|
workflow: normalizeWorkflow(task.workflow),
|
|
833
|
+
depends_on: normalizeDependsOn(task.depends_on).filter((depId) => depId !== id),
|
|
834
|
+
files: normalizeFiles(task.files),
|
|
404
835
|
task_number: extractTaskNumber(id)
|
|
405
836
|
};
|
|
406
837
|
|
|
@@ -428,6 +859,8 @@ function serializeTask(task) {
|
|
|
428
859
|
plan: normalized.plan,
|
|
429
860
|
evidence: normalized.evidence,
|
|
430
861
|
workflow: normalized.workflow,
|
|
862
|
+
depends_on: normalized.depends_on,
|
|
863
|
+
files: normalized.files,
|
|
431
864
|
task_number: normalized.task_number
|
|
432
865
|
};
|
|
433
866
|
}
|
|
@@ -507,9 +940,11 @@ function getEpicProgress(tasks) {
|
|
|
507
940
|
function pickEpicFields(epicPayload, fieldNames) {
|
|
508
941
|
const picked = {};
|
|
509
942
|
for (const field of fieldNames) {
|
|
510
|
-
if (field in epicPayload)
|
|
511
|
-
|
|
512
|
-
|
|
943
|
+
if (!(field in epicPayload)) continue;
|
|
944
|
+
const value = epicPayload[field];
|
|
945
|
+
if (value === null || value === '') continue;
|
|
946
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
947
|
+
picked[field] = value;
|
|
513
948
|
}
|
|
514
949
|
return picked;
|
|
515
950
|
}
|
|
@@ -539,7 +974,7 @@ function shapeEpic(epic, tasks = [], options = {}) {
|
|
|
539
974
|
...normalized,
|
|
540
975
|
status: deriveEpicStatus(childTasks, normalized),
|
|
541
976
|
progress: getEpicProgress(childTasks),
|
|
542
|
-
tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' })),
|
|
977
|
+
tasks: childTasks.map((task) => shapeTask(task, { view: 'summary', allTasks: tasks })),
|
|
543
978
|
adrs: collectEpicAdrs(childTasks)
|
|
544
979
|
};
|
|
545
980
|
const fields = Array.isArray(options.fields) && options.fields.length > 0
|
|
@@ -596,7 +1031,7 @@ function nonLiveEpicIdSet(epics, tasks, options = {}) {
|
|
|
596
1031
|
|
|
597
1032
|
function filterTasksForList(tasks, epics, options = {}) {
|
|
598
1033
|
// Explicit epic filter (show that epic's tasks) is applied by caller after this.
|
|
599
|
-
// Default agent list: hide tasks under done/archived epics.
|
|
1034
|
+
// Default agent list: hide tasks under done/archived epics and column=done.
|
|
600
1035
|
if (options.include_archived && options.include_done) return tasks;
|
|
601
1036
|
// GUI path: hide only archived-epic tasks (done epics still show done cards)
|
|
602
1037
|
if (options.live_only === false) {
|
|
@@ -606,9 +1041,19 @@ function filterTasksForList(tasks, epics, options = {}) {
|
|
|
606
1041
|
return tasks.filter((task) => !task.epic_id || !archivedIds.has(task.epic_id));
|
|
607
1042
|
}
|
|
608
1043
|
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
1044
|
+
const skipDoneOmit = Boolean(options.include_done) || options.col === 'done';
|
|
1045
|
+
const hiddenIds = nonLiveEpicIdSet(
|
|
1046
|
+
epics,
|
|
1047
|
+
tasks,
|
|
1048
|
+
skipDoneOmit ? { ...options, include_done: true } : options
|
|
1049
|
+
);
|
|
1050
|
+
let filtered = hiddenIds.size === 0
|
|
1051
|
+
? tasks
|
|
1052
|
+
: tasks.filter((task) => !task.epic_id || !hiddenIds.has(task.epic_id));
|
|
1053
|
+
if (!skipDoneOmit) {
|
|
1054
|
+
filtered = filtered.filter((task) => task.column !== 'done');
|
|
1055
|
+
}
|
|
1056
|
+
return filtered;
|
|
612
1057
|
}
|
|
613
1058
|
|
|
614
1059
|
function getProgress(task) {
|
|
@@ -617,6 +1062,105 @@ function getProgress(task) {
|
|
|
617
1062
|
return { done, total };
|
|
618
1063
|
}
|
|
619
1064
|
|
|
1065
|
+
function currentSubtask(task) {
|
|
1066
|
+
const subtasks = Array.isArray(task && task.subtasks) ? task.subtasks : [];
|
|
1067
|
+
const open = subtasks.find((subtask) => !subtask.done);
|
|
1068
|
+
if (!open) return null;
|
|
1069
|
+
return { id: open.id, text: open.text };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function evidenceProofText(entry) {
|
|
1073
|
+
return normalizeString(entry && entry.summary)
|
|
1074
|
+
|| normalizeString(entry && entry.test_command)
|
|
1075
|
+
|| normalizeString(entry && entry.diff);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function lastContextResult(task) {
|
|
1079
|
+
const evidence = Array.isArray(task && task.evidence) ? task.evidence : [];
|
|
1080
|
+
for (let i = evidence.length - 1; i >= 0; i--) {
|
|
1081
|
+
const proof = evidenceProofText(evidence[i]);
|
|
1082
|
+
if (proof) return proof;
|
|
1083
|
+
}
|
|
1084
|
+
const notes = normalizeString(task && task.notes);
|
|
1085
|
+
if (notes) return notes;
|
|
1086
|
+
const adr = Array.isArray(task && task.adr) ? task.adr : [];
|
|
1087
|
+
return normalizeString(adr[adr.length - 1] && adr[adr.length - 1].decision);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function compactContextTask(task, allTasks, epicLookup, mode) {
|
|
1091
|
+
const unmet = unmetDependencies(task, allTasks);
|
|
1092
|
+
const subtask = currentSubtask(task);
|
|
1093
|
+
const payload = {
|
|
1094
|
+
mode,
|
|
1095
|
+
next_action: resolveNextAction(task, mode),
|
|
1096
|
+
task_id: task.id,
|
|
1097
|
+
title: task.title,
|
|
1098
|
+
column: task.column,
|
|
1099
|
+
epic_id: task.epic_id,
|
|
1100
|
+
epic_goals: resolveEpicGoals(task, epicLookup),
|
|
1101
|
+
current_subtask: subtask,
|
|
1102
|
+
progress: getProgress(task),
|
|
1103
|
+
files: task.files || [],
|
|
1104
|
+
last_result: lastContextResult(task),
|
|
1105
|
+
blocked: unmet.length > 0,
|
|
1106
|
+
unmet_dependencies: unmet.map((item) => item.id),
|
|
1107
|
+
blocks: computeBlocks(task.id, allTasks)
|
|
1108
|
+
};
|
|
1109
|
+
for (const key of Object.keys(payload)) {
|
|
1110
|
+
const value = payload[key];
|
|
1111
|
+
if (value === null || value === '') delete payload[key];
|
|
1112
|
+
else if (Array.isArray(value) && value.length === 0) delete payload[key];
|
|
1113
|
+
}
|
|
1114
|
+
return payload;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function isUnblockedWork(task, allTasks) {
|
|
1118
|
+
return unmetDependencies(task, allTasks).length === 0;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function isFailedGate(task) {
|
|
1122
|
+
const status = task.workflow && task.workflow.status;
|
|
1123
|
+
return (task.column === 'testing' || task.column === 'review')
|
|
1124
|
+
&& (status === 'fail' || status === 'blocked');
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function isManualGate(task) {
|
|
1128
|
+
return (task.column === 'testing' || task.column === 'review') && !isFailedGate(task);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function getContextPayload(options = {}) {
|
|
1132
|
+
const tasks = await allTasks();
|
|
1133
|
+
const epics = await listEpicEntities();
|
|
1134
|
+
const epicLookup = {};
|
|
1135
|
+
for (const epic of epics) {
|
|
1136
|
+
epicLookup[epic.id] = epic;
|
|
1137
|
+
}
|
|
1138
|
+
let liveTasks = filterTasksForList(tasks, epics, {});
|
|
1139
|
+
const epicFilter = options.epic_id || options.epic;
|
|
1140
|
+
if (epicFilter) {
|
|
1141
|
+
liveTasks = liveTasks.filter((task) => taskMatchesEpicFilter(task, epicFilter));
|
|
1142
|
+
}
|
|
1143
|
+
const active = liveTasks.find((task) => task.column === 'active' && isUnblockedWork(task, tasks));
|
|
1144
|
+
if (active) {
|
|
1145
|
+
return compactContextTask(active, tasks, epicLookup, 'continue');
|
|
1146
|
+
}
|
|
1147
|
+
const gateFix = liveTasks.find((task) => isFailedGate(task));
|
|
1148
|
+
if (gateFix) {
|
|
1149
|
+
return compactContextTask(gateFix, tasks, epicLookup, 'continue');
|
|
1150
|
+
}
|
|
1151
|
+
const manualGate = liveTasks.find((task) => isManualGate(task));
|
|
1152
|
+
if (manualGate) {
|
|
1153
|
+
return compactContextTask(manualGate, tasks, epicLookup, 'continue');
|
|
1154
|
+
}
|
|
1155
|
+
const ready = liveTasks
|
|
1156
|
+
.filter((task) => task.column === 'planned' && isUnblockedWork(task, tasks))
|
|
1157
|
+
.sort((a, b) => (a.task_number || 0) - (b.task_number || 0));
|
|
1158
|
+
if (ready.length > 0) {
|
|
1159
|
+
return compactContextTask(ready[0], tasks, epicLookup, 'start');
|
|
1160
|
+
}
|
|
1161
|
+
return { mode: 'idle', next_action: 'idle' };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
620
1164
|
function resolveEpicGoals(task, epicLookup) {
|
|
621
1165
|
if (!task.epic_id) return '';
|
|
622
1166
|
if (!epicLookup || typeof epicLookup !== 'object') return '';
|
|
@@ -629,17 +1173,14 @@ function pickFields(task, fieldNames) {
|
|
|
629
1173
|
const picked = {};
|
|
630
1174
|
|
|
631
1175
|
for (const field of fieldNames) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (field
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (field in task) {
|
|
641
|
-
picked[field] = task[field];
|
|
642
|
-
}
|
|
1176
|
+
let value;
|
|
1177
|
+
if (field === 'progress') value = getProgress(task);
|
|
1178
|
+
else if (field === 'current_subtask') value = currentSubtask(task);
|
|
1179
|
+
else if (field === 'epic_goals') value = task.epic_goals;
|
|
1180
|
+
else if (field in task) value = task[field];
|
|
1181
|
+
else continue;
|
|
1182
|
+
if (value == null || value === '' || (Array.isArray(value) && !value.length)) continue;
|
|
1183
|
+
picked[field] = value;
|
|
643
1184
|
}
|
|
644
1185
|
|
|
645
1186
|
return picked;
|
|
@@ -650,10 +1191,15 @@ function shapeTask(task, options = {}) {
|
|
|
650
1191
|
const fields = Array.isArray(options.fields) && options.fields.length > 0
|
|
651
1192
|
? options.fields
|
|
652
1193
|
: (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
|
|
1194
|
+
const allTasks = options.allTasks;
|
|
1195
|
+
const unmet = allTasks ? unmetDependencies(normalized, allTasks) : [];
|
|
653
1196
|
|
|
654
1197
|
const withGoals = {
|
|
655
1198
|
...normalized,
|
|
656
|
-
epic_goals: resolveEpicGoals(normalized, options.epicLookup)
|
|
1199
|
+
epic_goals: resolveEpicGoals(normalized, options.epicLookup),
|
|
1200
|
+
blocked: unmet.length > 0,
|
|
1201
|
+
unmet_dependencies: unmet.map((item) => item.id),
|
|
1202
|
+
blocks: allTasks ? computeBlocks(normalized.id, allTasks) : []
|
|
657
1203
|
};
|
|
658
1204
|
return pickFields(withGoals, fields);
|
|
659
1205
|
}
|
|
@@ -684,11 +1230,12 @@ function parseListSection(sectionText) {
|
|
|
684
1230
|
}
|
|
685
1231
|
|
|
686
1232
|
async function ensureBacklogDir() {
|
|
687
|
-
|
|
688
|
-
|
|
1233
|
+
// Always create the full known set so disabled gates can still hold legacy files until migrated.
|
|
1234
|
+
for (const col of KNOWN_COLS) {
|
|
1235
|
+
const colDir = path.join(backlogDir(), col);
|
|
689
1236
|
await fs.mkdir(colDir, { recursive: true });
|
|
690
1237
|
}
|
|
691
|
-
await fs.mkdir(
|
|
1238
|
+
await fs.mkdir(epicsDir(), { recursive: true });
|
|
692
1239
|
}
|
|
693
1240
|
|
|
694
1241
|
async function parseMarkdownTask(filePath, column) {
|
|
@@ -773,8 +1320,8 @@ async function parseEpic(filePath, column) {
|
|
|
773
1320
|
async function allEpics() {
|
|
774
1321
|
const epics = [];
|
|
775
1322
|
|
|
776
|
-
for (const col of
|
|
777
|
-
const colDir = path.join(
|
|
1323
|
+
for (const col of KNOWN_COLS) {
|
|
1324
|
+
const colDir = path.join(backlogDir(), col);
|
|
778
1325
|
try {
|
|
779
1326
|
const files = await fs.readdir(colDir);
|
|
780
1327
|
const taskFiles = files
|
|
@@ -795,9 +1342,8 @@ async function allEpics() {
|
|
|
795
1342
|
try {
|
|
796
1343
|
epics.push(await parseEpic(path.join(colDir, file), col));
|
|
797
1344
|
} catch (error) {
|
|
798
|
-
// File may vanish between readdir and read under concurrent delete.
|
|
799
1345
|
if (error.code === 'ENOENT') continue;
|
|
800
|
-
|
|
1346
|
+
throw error;
|
|
801
1347
|
}
|
|
802
1348
|
}
|
|
803
1349
|
} catch (error) {
|
|
@@ -813,14 +1359,14 @@ async function allTasks() {
|
|
|
813
1359
|
}
|
|
814
1360
|
|
|
815
1361
|
function epicFilePath(epicId) {
|
|
816
|
-
return path.join(
|
|
1362
|
+
return path.join(epicsDir(), `${epicId}.json`);
|
|
817
1363
|
}
|
|
818
1364
|
|
|
819
1365
|
async function nextEpicNumber() {
|
|
820
1366
|
await ensureBacklogDir();
|
|
821
1367
|
const ids = [];
|
|
822
1368
|
try {
|
|
823
|
-
const files = await fs.readdir(
|
|
1369
|
+
const files = await fs.readdir(epicsDir());
|
|
824
1370
|
for (const file of files) {
|
|
825
1371
|
if (!isTaskOrEpicDataFile(file)) continue;
|
|
826
1372
|
const match = file.match(/^E0*(\d+)\.json$/i);
|
|
@@ -870,14 +1416,15 @@ async function listEpicEntities() {
|
|
|
870
1416
|
await ensureBacklogDir();
|
|
871
1417
|
const epics = [];
|
|
872
1418
|
try {
|
|
873
|
-
const files = (await fs.readdir(
|
|
1419
|
+
const files = (await fs.readdir(epicsDir()))
|
|
874
1420
|
.filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.json'))
|
|
875
1421
|
.sort((left, right) => left.localeCompare(right));
|
|
876
1422
|
for (const file of files) {
|
|
877
1423
|
try {
|
|
878
|
-
epics.push(await parseJsonEpic(path.join(
|
|
1424
|
+
epics.push(await parseJsonEpic(path.join(epicsDir(), file)));
|
|
879
1425
|
} catch (error) {
|
|
880
|
-
|
|
1426
|
+
if (error.code === 'ENOENT') continue;
|
|
1427
|
+
throw error;
|
|
881
1428
|
}
|
|
882
1429
|
}
|
|
883
1430
|
} catch (error) {
|
|
@@ -1036,47 +1583,12 @@ async function updateEpicEntity(epicId, patch) {
|
|
|
1036
1583
|
const current = await getEpicEntity(epicId);
|
|
1037
1584
|
const next = { ...current };
|
|
1038
1585
|
|
|
1039
|
-
if (patch.title !== undefined)
|
|
1040
|
-
const title = normalizeString(patch.title);
|
|
1041
|
-
if (!title) {
|
|
1042
|
-
throw createKanbanError(
|
|
1043
|
-
'VALIDATION_ERROR',
|
|
1044
|
-
'title must be a non-empty string',
|
|
1045
|
-
'Send a non-empty title or omit the field',
|
|
1046
|
-
{ field: 'title' },
|
|
1047
|
-
false,
|
|
1048
|
-
400
|
|
1049
|
-
);
|
|
1050
|
-
}
|
|
1051
|
-
next.title = title;
|
|
1052
|
-
}
|
|
1586
|
+
if (patch.title !== undefined) next.title = requireNonEmptyTitle(patch.title);
|
|
1053
1587
|
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
1054
1588
|
if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
|
|
1055
|
-
if (patch.in_scope !== undefined)
|
|
1056
|
-
if (!Array.isArray(patch.in_scope)) {
|
|
1057
|
-
throw createKanbanError(
|
|
1058
|
-
'VALIDATION_ERROR',
|
|
1059
|
-
'in_scope must be an array of strings',
|
|
1060
|
-
'Send in_scope as an array',
|
|
1061
|
-
{ field: 'in_scope' },
|
|
1062
|
-
false,
|
|
1063
|
-
400
|
|
1064
|
-
);
|
|
1065
|
-
}
|
|
1066
|
-
next.in_scope = normalizeStringArray(patch.in_scope);
|
|
1067
|
-
}
|
|
1589
|
+
if (patch.in_scope !== undefined) next.in_scope = requireStringArray(patch.in_scope, 'in_scope');
|
|
1068
1590
|
if (patch.out_of_scope !== undefined) {
|
|
1069
|
-
|
|
1070
|
-
throw createKanbanError(
|
|
1071
|
-
'VALIDATION_ERROR',
|
|
1072
|
-
'out_of_scope must be an array of strings',
|
|
1073
|
-
'Send out_of_scope as an array',
|
|
1074
|
-
{ field: 'out_of_scope' },
|
|
1075
|
-
false,
|
|
1076
|
-
400
|
|
1077
|
-
);
|
|
1078
|
-
}
|
|
1079
|
-
next.out_of_scope = normalizeStringArray(patch.out_of_scope);
|
|
1591
|
+
next.out_of_scope = requireStringArray(patch.out_of_scope, 'out_of_scope');
|
|
1080
1592
|
}
|
|
1081
1593
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
1082
1594
|
if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
|
|
@@ -1123,7 +1635,7 @@ async function deleteTaskRecord(taskId) {
|
|
|
1123
1635
|
await fs.unlink(filePath).catch((error) => {
|
|
1124
1636
|
if (error.code !== 'ENOENT') throw error;
|
|
1125
1637
|
});
|
|
1126
|
-
await removeOtherTaskCopies(task.id, path.join(
|
|
1638
|
+
await removeOtherTaskCopies(task.id, path.join(backlogDir(), '__none__', `${task.id}.json`));
|
|
1127
1639
|
|
|
1128
1640
|
return {
|
|
1129
1641
|
ok: true,
|
|
@@ -1232,8 +1744,8 @@ function taskMatchesEpicFilter(task, epicFilter) {
|
|
|
1232
1744
|
}
|
|
1233
1745
|
|
|
1234
1746
|
async function findFile(epicId) {
|
|
1235
|
-
for (const col of
|
|
1236
|
-
const colDir = path.join(
|
|
1747
|
+
for (const col of KNOWN_COLS) {
|
|
1748
|
+
const colDir = path.join(backlogDir(), col);
|
|
1237
1749
|
try {
|
|
1238
1750
|
const files = await fs.readdir(colDir);
|
|
1239
1751
|
const candidates = files
|
|
@@ -1250,22 +1762,30 @@ async function findFile(epicId) {
|
|
|
1250
1762
|
return null;
|
|
1251
1763
|
}
|
|
1252
1764
|
|
|
1765
|
+
function taskNotFound(taskId) {
|
|
1766
|
+
return createKanbanError(
|
|
1767
|
+
'TASK_NOT_FOUND',
|
|
1768
|
+
`Task ${taskId} was not found`,
|
|
1769
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
1770
|
+
{ task_id: taskId },
|
|
1771
|
+
false,
|
|
1772
|
+
404
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1253
1776
|
async function getTask(taskId) {
|
|
1254
1777
|
const resolvedId = await resolveTaskId(taskId);
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
throw
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
);
|
|
1778
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1779
|
+
const filePath = await findFile(resolvedId);
|
|
1780
|
+
if (!filePath) throw taskNotFound(taskId);
|
|
1781
|
+
try {
|
|
1782
|
+
return await parseEpic(filePath, path.basename(path.dirname(filePath)));
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
// Relocate between readdir and read (workflow move).
|
|
1785
|
+
if (error.code !== 'ENOENT' || attempt === 2) throw error;
|
|
1786
|
+
}
|
|
1265
1787
|
}
|
|
1266
|
-
|
|
1267
|
-
const column = path.basename(path.dirname(filePath));
|
|
1268
|
-
return parseEpic(filePath, column);
|
|
1788
|
+
throw taskNotFound(taskId);
|
|
1269
1789
|
}
|
|
1270
1790
|
|
|
1271
1791
|
async function resolveTaskId(input) {
|
|
@@ -1277,8 +1797,8 @@ async function resolveTaskId(input) {
|
|
|
1277
1797
|
const num = parseInt(String(input), 10);
|
|
1278
1798
|
if (!Number.isFinite(num)) return null;
|
|
1279
1799
|
|
|
1280
|
-
for (const col of
|
|
1281
|
-
const colDir = path.join(
|
|
1800
|
+
for (const col of KNOWN_COLS) {
|
|
1801
|
+
const colDir = path.join(backlogDir(), col);
|
|
1282
1802
|
try {
|
|
1283
1803
|
const files = await fs.readdir(colDir);
|
|
1284
1804
|
const rawPattern = new RegExp(`^(?:[A-Z]+-)?${String(num)}(?:-|$)`);
|
|
@@ -1298,8 +1818,8 @@ async function resolveTaskId(input) {
|
|
|
1298
1818
|
|
|
1299
1819
|
async function removeOtherTaskCopies(taskId, keepPath) {
|
|
1300
1820
|
const keep = path.resolve(keepPath);
|
|
1301
|
-
for (const col of
|
|
1302
|
-
const colDir = path.join(
|
|
1821
|
+
for (const col of KNOWN_COLS) {
|
|
1822
|
+
const colDir = path.join(backlogDir(), col);
|
|
1303
1823
|
try {
|
|
1304
1824
|
const files = await fs.readdir(colDir);
|
|
1305
1825
|
for (const file of files) {
|
|
@@ -1321,7 +1841,7 @@ async function writeTask(task, previousFilePath = null, { exclusive = false } =
|
|
|
1321
1841
|
const normalized = normalizeTask(task);
|
|
1322
1842
|
await ensureBacklogDir();
|
|
1323
1843
|
|
|
1324
|
-
const nextFilePath = path.join(
|
|
1844
|
+
const nextFilePath = path.join(backlogDir(), normalized.column, `${normalized.id}.json`);
|
|
1325
1845
|
const payload = JSON.stringify(serializeTask(normalized), null, 2) + '\n';
|
|
1326
1846
|
await writeFileAtomic(nextFilePath, payload, { exclusive });
|
|
1327
1847
|
|
|
@@ -1341,8 +1861,8 @@ async function migrateAll(options = {}) {
|
|
|
1341
1861
|
const migrated = [];
|
|
1342
1862
|
const errors = [];
|
|
1343
1863
|
|
|
1344
|
-
for (const col of
|
|
1345
|
-
const colDir = path.join(
|
|
1864
|
+
for (const col of KNOWN_COLS) {
|
|
1865
|
+
const colDir = path.join(backlogDir(), col);
|
|
1346
1866
|
let files;
|
|
1347
1867
|
try {
|
|
1348
1868
|
files = await fs.readdir(colDir);
|
|
@@ -1387,8 +1907,8 @@ async function migrateAll(options = {}) {
|
|
|
1387
1907
|
async function nextTaskNumber() {
|
|
1388
1908
|
const ids = [];
|
|
1389
1909
|
|
|
1390
|
-
for (const col of
|
|
1391
|
-
const colDir = path.join(
|
|
1910
|
+
for (const col of KNOWN_COLS) {
|
|
1911
|
+
const colDir = path.join(backlogDir(), col);
|
|
1392
1912
|
try {
|
|
1393
1913
|
const files = await fs.readdir(colDir);
|
|
1394
1914
|
for (const file of files) {
|
|
@@ -1421,6 +1941,71 @@ function allowedColumnsFrom(fromColumn) {
|
|
|
1421
1941
|
return COLUMN_TRANSITIONS[fromColumn] ? COLUMN_TRANSITIONS[fromColumn].slice() : [];
|
|
1422
1942
|
}
|
|
1423
1943
|
|
|
1944
|
+
/** List tasks sitting in a known physical column dir (even if that column is disabled). */
|
|
1945
|
+
async function listTasksInKnownColumn(column) {
|
|
1946
|
+
if (!KNOWN_COLS.includes(column)) {
|
|
1947
|
+
throw createKanbanError(
|
|
1948
|
+
'INVALID_COLUMN',
|
|
1949
|
+
`Column ${column} is not valid`,
|
|
1950
|
+
`Use one of: ${KNOWN_COLS.join(', ')}`,
|
|
1951
|
+
{ column, valid_columns: KNOWN_COLS },
|
|
1952
|
+
false,
|
|
1953
|
+
400
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
const colDir = path.join(backlogDir(), column);
|
|
1957
|
+
const tasks = [];
|
|
1958
|
+
try {
|
|
1959
|
+
const files = await fs.readdir(colDir);
|
|
1960
|
+
for (const file of files) {
|
|
1961
|
+
if (!isTaskOrEpicDataFile(file)) continue;
|
|
1962
|
+
try {
|
|
1963
|
+
tasks.push(await parseEpic(path.join(colDir, file), column));
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
if (error.code === 'ENOENT') continue;
|
|
1966
|
+
throw error;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
if (error.code !== 'ENOENT') throw error;
|
|
1971
|
+
}
|
|
1972
|
+
return tasks;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Force-move a task to a column without transition checks (config migration only).
|
|
1977
|
+
* Still validates target is a known column and uses writeTask + board lock.
|
|
1978
|
+
*/
|
|
1979
|
+
async function relocateTask(taskId, targetColumn) {
|
|
1980
|
+
if (!KNOWN_COLS.includes(targetColumn)) {
|
|
1981
|
+
throw createKanbanError(
|
|
1982
|
+
'INVALID_COLUMN',
|
|
1983
|
+
`Column ${targetColumn} is not valid`,
|
|
1984
|
+
`Use one of: ${KNOWN_COLS.join(', ')}`,
|
|
1985
|
+
{ column: targetColumn, valid_columns: KNOWN_COLS },
|
|
1986
|
+
false,
|
|
1987
|
+
400
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
return withBoardLock(async () => {
|
|
1991
|
+
const resolvedId = await resolveTaskId(taskId);
|
|
1992
|
+
const previousFilePath = await findFile(resolvedId);
|
|
1993
|
+
if (!previousFilePath) {
|
|
1994
|
+
throw createKanbanError(
|
|
1995
|
+
'TASK_NOT_FOUND',
|
|
1996
|
+
`Task ${taskId} was not found`,
|
|
1997
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
1998
|
+
{ task_id: taskId },
|
|
1999
|
+
false,
|
|
2000
|
+
404
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
|
|
2004
|
+
if (current.column === targetColumn) return current;
|
|
2005
|
+
return writeTask({ ...current, column: targetColumn }, previousFilePath);
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1424
2009
|
function validateTransition(fromColumn, toColumn, taskId) {
|
|
1425
2010
|
if (fromColumn === toColumn) return;
|
|
1426
2011
|
validateColumn(toColumn);
|
|
@@ -1497,8 +2082,15 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
|
|
|
1497
2082
|
notes: extra.notes,
|
|
1498
2083
|
plan: extra.plan,
|
|
1499
2084
|
adr: extra.adr,
|
|
1500
|
-
evidence: extra.evidence
|
|
2085
|
+
evidence: extra.evidence,
|
|
2086
|
+
depends_on: extra.depends_on,
|
|
2087
|
+
files: extra.files
|
|
1501
2088
|
});
|
|
2089
|
+
const existing = await allTasks();
|
|
2090
|
+
assertNoDependencyCycle(task.id, normalizeDependsOn(extra.depends_on), existing);
|
|
2091
|
+
if (isWorkColumn(task.column)) {
|
|
2092
|
+
assertUnblockedForColumn(task, task.column, existing);
|
|
2093
|
+
}
|
|
1502
2094
|
try {
|
|
1503
2095
|
return await writeTask(task, null, { exclusive: true });
|
|
1504
2096
|
} catch (error) {
|
|
@@ -1516,174 +2108,189 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
|
|
|
1516
2108
|
});
|
|
1517
2109
|
}
|
|
1518
2110
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
throw createKanbanError(
|
|
1524
|
-
'TASK_NOT_FOUND',
|
|
1525
|
-
`Task ${taskId} was not found`,
|
|
1526
|
-
'Call kanban_read with operation=list to discover valid task ids',
|
|
1527
|
-
{ task_id: taskId },
|
|
1528
|
-
false,
|
|
1529
|
-
404
|
|
1530
|
-
);
|
|
2111
|
+
function applyColumnPatch(next, current, column) {
|
|
2112
|
+
validateColumn(column);
|
|
2113
|
+
if (column !== current.column) {
|
|
2114
|
+
validateTransition(current.column, column, current.id);
|
|
1531
2115
|
}
|
|
2116
|
+
next.column = column;
|
|
2117
|
+
}
|
|
1532
2118
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
if (patch.column !== undefined) {
|
|
1537
|
-
validateColumn(patch.column);
|
|
1538
|
-
if (patch.column !== current.column) {
|
|
1539
|
-
validateTransition(current.column, patch.column, current.id);
|
|
1540
|
-
}
|
|
1541
|
-
next.column = patch.column;
|
|
1542
|
-
}
|
|
1543
|
-
if (patch.title !== undefined) {
|
|
1544
|
-
const title = normalizeString(patch.title);
|
|
1545
|
-
if (!title) {
|
|
1546
|
-
throw createKanbanError(
|
|
1547
|
-
'VALIDATION_ERROR',
|
|
1548
|
-
'title must be a non-empty string',
|
|
1549
|
-
'Send a non-empty title or omit the field',
|
|
1550
|
-
{ field: 'title' },
|
|
1551
|
-
false,
|
|
1552
|
-
400
|
|
1553
|
-
);
|
|
1554
|
-
}
|
|
1555
|
-
next.title = title;
|
|
1556
|
-
}
|
|
1557
|
-
if (!patch._skipEpicResolve) {
|
|
1558
|
-
if (patch.epic_id !== undefined || patch.epic !== undefined || patch.epic_group !== undefined) {
|
|
1559
|
-
const ref = patch.epic_id !== undefined
|
|
1560
|
-
? patch.epic_id
|
|
1561
|
-
: (patch.epic !== undefined ? patch.epic : patch.epic_group);
|
|
1562
|
-
if (isBlankEpicRef(ref)) {
|
|
1563
|
-
next.epic_id = null;
|
|
1564
|
-
next.epic_group = '—';
|
|
1565
|
-
} else {
|
|
1566
|
-
const link = await resolveEpicRef(ref, {
|
|
1567
|
-
createIfMissing: Boolean(
|
|
1568
|
-
patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
|
|
1569
|
-
),
|
|
1570
|
-
skipLock: true
|
|
1571
|
-
});
|
|
1572
|
-
next.epic_id = link.epic_id;
|
|
1573
|
-
next.epic_group = link.epic_group;
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
} else {
|
|
2119
|
+
async function applyEpicPatch(next, patch) {
|
|
2120
|
+
if (patch._skipEpicResolve) {
|
|
1577
2121
|
if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
|
|
1578
2122
|
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
2123
|
+
return;
|
|
1579
2124
|
}
|
|
1580
|
-
if (patch.
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
2125
|
+
if (patch.epic_id === undefined && patch.epic === undefined && patch.epic_group === undefined) {
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
const ref = patch.epic_id !== undefined
|
|
2129
|
+
? patch.epic_id
|
|
2130
|
+
: (patch.epic !== undefined ? patch.epic : patch.epic_group);
|
|
2131
|
+
if (isBlankEpicRef(ref)) {
|
|
2132
|
+
next.epic_id = null;
|
|
2133
|
+
next.epic_group = '—';
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
const link = await resolveEpicRef(ref, {
|
|
2137
|
+
createIfMissing: Boolean(
|
|
2138
|
+
patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
|
|
2139
|
+
),
|
|
2140
|
+
skipLock: true
|
|
2141
|
+
});
|
|
2142
|
+
next.epic_id = link.epic_id;
|
|
2143
|
+
next.epic_group = link.epic_group;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function applyEvidencePatch(next, patch) {
|
|
2147
|
+
if (patch.appendEvidence !== undefined) {
|
|
2148
|
+
if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
|
|
1584
2149
|
throw createKanbanError(
|
|
1585
2150
|
'VALIDATION_ERROR',
|
|
1586
|
-
'
|
|
1587
|
-
'Send
|
|
1588
|
-
{ field: '
|
|
2151
|
+
'appendEvidence must be an evidence object',
|
|
2152
|
+
'Send a single evidence entry to append',
|
|
2153
|
+
{ field: 'appendEvidence' },
|
|
1589
2154
|
false,
|
|
1590
2155
|
400
|
|
1591
2156
|
);
|
|
1592
2157
|
}
|
|
1593
|
-
next.
|
|
2158
|
+
next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
|
|
2159
|
+
}
|
|
2160
|
+
if (patch.evidence !== undefined) {
|
|
2161
|
+
assertIsArray(
|
|
2162
|
+
patch.evidence,
|
|
2163
|
+
'evidence',
|
|
2164
|
+
'evidence must be an array',
|
|
2165
|
+
'Send evidence as an array of evidence objects'
|
|
2166
|
+
);
|
|
2167
|
+
next.evidence = patch.evidence;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function applyMarkSubtaskDone(next, taskId, rawIndex) {
|
|
2172
|
+
const index = rawIndex === undefined || rawIndex === null
|
|
2173
|
+
? next.subtasks.findIndex((subtask) => !subtask.done)
|
|
2174
|
+
: Number(rawIndex);
|
|
2175
|
+
if (!Number.isInteger(index) || index < 0 || index >= next.subtasks.length) {
|
|
2176
|
+
throw createKanbanError(
|
|
2177
|
+
'INVALID_SUBTASK_INDEX',
|
|
2178
|
+
'No valid plan step was provided',
|
|
2179
|
+
'Provide the zero-based index of an incomplete subtask',
|
|
2180
|
+
{ index, total_subtasks: next.subtasks.length, task_id: taskId },
|
|
2181
|
+
false,
|
|
2182
|
+
400
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
next.subtasks = next.subtasks.map((subtask, subtaskIndex) => ({
|
|
2186
|
+
...subtask,
|
|
2187
|
+
done: subtaskIndex === index ? true : subtask.done
|
|
2188
|
+
}));
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function applySubtaskProgressPatch(next, patch, taskId) {
|
|
2192
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'mark_subtask_done')) {
|
|
2193
|
+
applyMarkSubtaskDone(next, taskId, patch.mark_subtask_done);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function applyScalarTaskPatch(next, patch) {
|
|
2198
|
+
if (patch.depends_on !== undefined) {
|
|
2199
|
+
assertIsArray(
|
|
2200
|
+
patch.depends_on,
|
|
2201
|
+
'depends_on',
|
|
2202
|
+
'depends_on must be an array of task ids',
|
|
2203
|
+
'Send depends_on as an array like ["001"]'
|
|
2204
|
+
);
|
|
2205
|
+
next.depends_on = patch.depends_on;
|
|
2206
|
+
}
|
|
2207
|
+
if (patch.files !== undefined) {
|
|
2208
|
+
assertIsArray(
|
|
2209
|
+
patch.files,
|
|
2210
|
+
'files',
|
|
2211
|
+
'files must be an array of paths',
|
|
2212
|
+
'Send files as an array like ["src/foo.js"]'
|
|
2213
|
+
);
|
|
2214
|
+
next.files = patch.files;
|
|
1594
2215
|
}
|
|
2216
|
+
if (patch.title !== undefined) next.title = requireNonEmptyTitle(patch.title);
|
|
2217
|
+
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
2218
|
+
if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
|
|
2219
|
+
if (patch.in_scope !== undefined) next.in_scope = requireStringArray(patch.in_scope, 'in_scope');
|
|
1595
2220
|
if (patch.out_of_scope !== undefined) {
|
|
1596
|
-
|
|
1597
|
-
throw createKanbanError(
|
|
1598
|
-
'VALIDATION_ERROR',
|
|
1599
|
-
'out_of_scope must be an array of strings',
|
|
1600
|
-
'Send out_of_scope as an array',
|
|
1601
|
-
{ field: 'out_of_scope' },
|
|
1602
|
-
false,
|
|
1603
|
-
400
|
|
1604
|
-
);
|
|
1605
|
-
}
|
|
1606
|
-
next.out_of_scope = patch.out_of_scope;
|
|
2221
|
+
next.out_of_scope = requireStringArray(patch.out_of_scope, 'out_of_scope');
|
|
1607
2222
|
}
|
|
1608
2223
|
if (patch.acceptance_criteria !== undefined) {
|
|
1609
|
-
|
|
1610
|
-
throw createKanbanError(
|
|
1611
|
-
'VALIDATION_ERROR',
|
|
1612
|
-
'acceptance_criteria must be an array of strings',
|
|
1613
|
-
'Send acceptance_criteria as an array',
|
|
1614
|
-
{ field: 'acceptance_criteria' },
|
|
1615
|
-
false,
|
|
1616
|
-
400
|
|
1617
|
-
);
|
|
1618
|
-
}
|
|
1619
|
-
next.acceptance_criteria = patch.acceptance_criteria;
|
|
1620
|
-
}
|
|
1621
|
-
if (patch.test_cases !== undefined) {
|
|
1622
|
-
if (!Array.isArray(patch.test_cases)) {
|
|
1623
|
-
throw createKanbanError(
|
|
1624
|
-
'VALIDATION_ERROR',
|
|
1625
|
-
'test_cases must be an array of strings',
|
|
1626
|
-
'Send test_cases as an array',
|
|
1627
|
-
{ field: 'test_cases' },
|
|
1628
|
-
false,
|
|
1629
|
-
400
|
|
1630
|
-
);
|
|
1631
|
-
}
|
|
1632
|
-
next.test_cases = patch.test_cases;
|
|
2224
|
+
next.acceptance_criteria = requireStringArray(patch.acceptance_criteria, 'acceptance_criteria');
|
|
1633
2225
|
}
|
|
2226
|
+
if (patch.test_cases !== undefined) next.test_cases = requireStringArray(patch.test_cases, 'test_cases');
|
|
1634
2227
|
if (patch.subtasks !== undefined) {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
false,
|
|
1642
|
-
400
|
|
1643
|
-
);
|
|
1644
|
-
}
|
|
2228
|
+
assertIsArray(
|
|
2229
|
+
patch.subtasks,
|
|
2230
|
+
'subtasks',
|
|
2231
|
+
'subtasks must be an array',
|
|
2232
|
+
'Send subtasks as an array of objects'
|
|
2233
|
+
);
|
|
1645
2234
|
next.subtasks = patch.subtasks;
|
|
1646
2235
|
}
|
|
1647
|
-
if (patch.adr !== undefined)
|
|
1648
|
-
if (Array.isArray(patch.adr)) {
|
|
1649
|
-
next.adr = normalizeAdr(patch.adr);
|
|
1650
|
-
} else {
|
|
1651
|
-
next.adr = appendAdrEntry(next.adr, patch.adr);
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
2236
|
+
if (patch.adr !== undefined) next.adr = applyAdrPatch(next.adr, patch.adr);
|
|
1654
2237
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
1655
2238
|
if (patch.plan !== undefined) next.plan = patch.plan;
|
|
1656
2239
|
if (patch.workflow !== undefined) {
|
|
1657
2240
|
next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
|
|
1658
2241
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
);
|
|
1669
|
-
}
|
|
1670
|
-
next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
async function assertTaskPatchInvariants(current, next, patch) {
|
|
2245
|
+
const columnChanged = next.column !== current.column;
|
|
2246
|
+
const depsChanged = patch.depends_on !== undefined;
|
|
2247
|
+
const enteringDone = columnChanged && next.column === 'done';
|
|
2248
|
+
let others = null;
|
|
2249
|
+
if (depsChanged || (columnChanged && isWorkColumn(next.column)) || enteringDone) {
|
|
2250
|
+
others = await allTasks();
|
|
1671
2251
|
}
|
|
1672
|
-
if (
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
'Send evidence as an array of evidence objects',
|
|
1678
|
-
{ field: 'evidence' },
|
|
1679
|
-
false,
|
|
1680
|
-
400
|
|
1681
|
-
);
|
|
1682
|
-
}
|
|
1683
|
-
next.evidence = patch.evidence;
|
|
2252
|
+
if (depsChanged) {
|
|
2253
|
+
assertNoDependencyCycle(current.id, normalizeDependsOn(next.depends_on), others);
|
|
2254
|
+
}
|
|
2255
|
+
if (columnChanged && isWorkColumn(next.column)) {
|
|
2256
|
+
assertUnblockedForColumn(normalizeTask(next), next.column, others);
|
|
1684
2257
|
}
|
|
2258
|
+
if (columnChanged) {
|
|
2259
|
+
assertEvidenceForGate(current, next);
|
|
2260
|
+
}
|
|
2261
|
+
return { enteringDone, others };
|
|
2262
|
+
}
|
|
1685
2263
|
|
|
1686
|
-
|
|
2264
|
+
async function updateTaskRecord(taskId, patch) {
|
|
2265
|
+
const resolvedId = await resolveTaskId(taskId);
|
|
2266
|
+
const previousFilePath = await findFile(resolvedId);
|
|
2267
|
+
if (!previousFilePath) {
|
|
2268
|
+
throw createKanbanError(
|
|
2269
|
+
'TASK_NOT_FOUND',
|
|
2270
|
+
`Task ${taskId} was not found`,
|
|
2271
|
+
'Call kanban_read with operation=list to discover valid task ids',
|
|
2272
|
+
{ task_id: taskId },
|
|
2273
|
+
false,
|
|
2274
|
+
404
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
|
|
2279
|
+
const next = { ...current };
|
|
2280
|
+
|
|
2281
|
+
if (patch.column !== undefined) applyColumnPatch(next, current, patch.column);
|
|
2282
|
+
applyScalarTaskPatch(next, patch);
|
|
2283
|
+
applySubtaskProgressPatch(next, patch, current.id);
|
|
2284
|
+
await applyEpicPatch(next, patch);
|
|
2285
|
+
applyEvidencePatch(next, patch);
|
|
2286
|
+
|
|
2287
|
+
const { enteringDone, others } = await assertTaskPatchInvariants(current, next, patch);
|
|
2288
|
+
const written = await writeTask(next, previousFilePath);
|
|
2289
|
+
if (enteringDone) {
|
|
2290
|
+
const board = others.map((other) => (other.id === written.id ? written : other));
|
|
2291
|
+
written.unblocked_tasks = newlyUnblockedTasks(written.id, board);
|
|
2292
|
+
}
|
|
2293
|
+
return written;
|
|
1687
2294
|
}
|
|
1688
2295
|
|
|
1689
2296
|
function scheduleWorkflowEnqueue(previousColumn, updated) {
|
|
@@ -1723,7 +2330,12 @@ async function doMove(epicId, target) {
|
|
|
1723
2330
|
await updateTask(epicId, { column: target });
|
|
1724
2331
|
return true;
|
|
1725
2332
|
} catch (error) {
|
|
1726
|
-
if (
|
|
2333
|
+
if (
|
|
2334
|
+
error.code === 'TASK_NOT_FOUND'
|
|
2335
|
+
|| error.code === 'INVALID_COLUMN'
|
|
2336
|
+
|| error.code === 'INVALID_TRANSITION'
|
|
2337
|
+
|| error.code === 'EVIDENCE_REQUIRED'
|
|
2338
|
+
) {
|
|
1727
2339
|
return false;
|
|
1728
2340
|
}
|
|
1729
2341
|
throw error;
|
|
@@ -1808,10 +2420,18 @@ module.exports = {
|
|
|
1808
2420
|
missingRecommendedCreateFields,
|
|
1809
2421
|
missingRecommendedEpicCreateFields,
|
|
1810
2422
|
getProgress,
|
|
2423
|
+
getContextPayload,
|
|
2424
|
+
unmetDependencies,
|
|
2425
|
+
computeBlocks,
|
|
2426
|
+
newlyUnblockedTasks,
|
|
1811
2427
|
getEpicProgress,
|
|
1812
2428
|
deriveEpicStatus,
|
|
1813
2429
|
resolveTaskId,
|
|
2430
|
+
applyBoardLayout,
|
|
2431
|
+
listTasksInKnownColumn,
|
|
2432
|
+
relocateTask,
|
|
1814
2433
|
COLS,
|
|
2434
|
+
KNOWN_COLS,
|
|
1815
2435
|
STATUS_MAP,
|
|
1816
2436
|
WORKFLOW_STAGES,
|
|
1817
2437
|
COLUMN_TRANSITIONS,
|
|
@@ -1820,9 +2440,18 @@ module.exports = {
|
|
|
1820
2440
|
VIEW_FIELDS,
|
|
1821
2441
|
EPIC_VIEW_FIELDS,
|
|
1822
2442
|
normalizeEvidence,
|
|
2443
|
+
hasProofEvidence,
|
|
2444
|
+
assertEvidenceForGate,
|
|
1823
2445
|
normalizeWorkflow,
|
|
1824
2446
|
LIVE_EPIC_STATUSES,
|
|
1825
2447
|
RECOMMENDED_CREATE_FIELDS,
|
|
1826
2448
|
RECOMMENDED_EPIC_CREATE_FIELDS,
|
|
1827
|
-
EPICS_DIR
|
|
2449
|
+
get EPICS_DIR() {
|
|
2450
|
+
return epicsDir();
|
|
2451
|
+
}
|
|
1828
2452
|
};
|
|
2453
|
+
|
|
2454
|
+
// Keep live refs for applyBoardLayout consumers that read module.exports.COLS
|
|
2455
|
+
module.exports.COLS = COLS;
|
|
2456
|
+
module.exports.COLUMN_TRANSITIONS = COLUMN_TRANSITIONS;
|
|
2457
|
+
module.exports.WORKFLOW_STAGES = WORKFLOW_STAGES;
|