kanbango 3.8.0 → 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 +23 -18
- package/API.md +58 -170
- package/CHANGELOG.md +39 -1
- package/LLM_AGENTS.md +21 -502
- package/README.md +18 -16
- package/agent-playbook.js +18 -57
- package/bin/kanban.js +344 -304
- package/config-wizard.js +4 -4
- package/index.html +59 -2
- package/kanban.js +503 -283
- package/mcp-server.js +259 -201
- package/package.json +1 -1
- package/plan.js +19 -24
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +15 -14
- package/.ai/lessons.jsonl +0 -14
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -26
- 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 -38
package/kanban.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
const fs = require('fs').promises;
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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;
|
|
6
15
|
/** All physical column dirs that may exist on disk (including disabled gates). */
|
|
7
16
|
const KNOWN_COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
|
|
8
17
|
/** Active columns for validation / MCP / create — mutated by applyBoardLayout. */
|
|
@@ -52,7 +61,7 @@ function applyBoardLayout({ cols, transitions, workflowStages } = {}) {
|
|
|
52
61
|
module.exports.WORKFLOW_STAGES = WORKFLOW_STAGES;
|
|
53
62
|
}
|
|
54
63
|
const VIEW_FIELDS = {
|
|
55
|
-
summary: ['task_number', 'title', 'column', 'epic_id', '
|
|
64
|
+
summary: ['id', 'task_number', 'title', 'column', 'epic_id', 'progress', 'blocked'],
|
|
56
65
|
planning: [
|
|
57
66
|
'task_number',
|
|
58
67
|
'title',
|
|
@@ -74,30 +83,17 @@ const VIEW_FIELDS = {
|
|
|
74
83
|
'blocks'
|
|
75
84
|
],
|
|
76
85
|
execution: [
|
|
86
|
+
'id',
|
|
77
87
|
'task_number',
|
|
78
88
|
'title',
|
|
79
89
|
'column',
|
|
80
90
|
'epic_id',
|
|
81
|
-
'epic_group',
|
|
82
|
-
'epic_goals',
|
|
83
|
-
'created',
|
|
84
91
|
'progress',
|
|
85
|
-
'
|
|
86
|
-
'specs',
|
|
87
|
-
'in_scope',
|
|
88
|
-
'out_of_scope',
|
|
89
|
-
'acceptance_criteria',
|
|
90
|
-
'test_cases',
|
|
92
|
+
'current_subtask',
|
|
91
93
|
'subtasks',
|
|
92
|
-
'adr',
|
|
93
|
-
'evidence',
|
|
94
|
-
'plan',
|
|
95
94
|
'workflow',
|
|
96
|
-
'depends_on',
|
|
97
95
|
'files',
|
|
98
|
-
'blocked'
|
|
99
|
-
'unmet_dependencies',
|
|
100
|
-
'blocks'
|
|
96
|
+
'blocked'
|
|
101
97
|
],
|
|
102
98
|
full: [
|
|
103
99
|
'task_number',
|
|
@@ -189,10 +185,18 @@ function createKanbanError(code, message, hint, details = {}, retryable = false,
|
|
|
189
185
|
}
|
|
190
186
|
|
|
191
187
|
// Serialize board mutations so concurrent create/move/update cannot race on ids or paths.
|
|
188
|
+
// mutationTail = in-process; backlog/.board.lock = cross-process.
|
|
192
189
|
let mutationTail = Promise.resolve();
|
|
193
190
|
|
|
194
191
|
function withBoardLock(fn) {
|
|
195
|
-
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
|
+
});
|
|
196
200
|
mutationTail = run.then(() => undefined, () => undefined);
|
|
197
201
|
return run;
|
|
198
202
|
}
|
|
@@ -225,6 +229,119 @@ async function writeFileAtomic(filePath, payload, { exclusive = false } = {}) {
|
|
|
225
229
|
}
|
|
226
230
|
}
|
|
227
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
|
+
|
|
228
345
|
function todayIso() {
|
|
229
346
|
return new Date().toISOString().split('T')[0];
|
|
230
347
|
}
|
|
@@ -249,6 +366,48 @@ function normalizeStringArray(value) {
|
|
|
249
366
|
.filter(Boolean);
|
|
250
367
|
}
|
|
251
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
|
+
|
|
252
411
|
function normalizeTaskIdRef(value) {
|
|
253
412
|
const raw = normalizeString(value);
|
|
254
413
|
if (!raw) return '';
|
|
@@ -390,6 +549,68 @@ function firstEnabledGateColumn() {
|
|
|
390
549
|
return 'done';
|
|
391
550
|
}
|
|
392
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
|
+
|
|
393
614
|
function nextMoveAction() {
|
|
394
615
|
const gate = firstEnabledGateColumn();
|
|
395
616
|
if (gate === 'testing') return 'move_testing';
|
|
@@ -408,8 +629,7 @@ function resolveNextAction(task, mode) {
|
|
|
408
629
|
return 'fix_gate';
|
|
409
630
|
}
|
|
410
631
|
if (currentSubtask(task)) return 'advance';
|
|
411
|
-
|
|
412
|
-
if (evidence.length === 0) return 'evidence';
|
|
632
|
+
if (!hasProofEvidence(task.evidence)) return 'evidence';
|
|
413
633
|
if (task.column === 'active') return nextMoveAction();
|
|
414
634
|
if (task.column === 'testing' && COLS.includes('review')) return 'move_review';
|
|
415
635
|
if (task.column === 'testing' || task.column === 'review') return 'move_done';
|
|
@@ -720,9 +940,11 @@ function getEpicProgress(tasks) {
|
|
|
720
940
|
function pickEpicFields(epicPayload, fieldNames) {
|
|
721
941
|
const picked = {};
|
|
722
942
|
for (const field of fieldNames) {
|
|
723
|
-
if (field in epicPayload)
|
|
724
|
-
|
|
725
|
-
|
|
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;
|
|
726
948
|
}
|
|
727
949
|
return picked;
|
|
728
950
|
}
|
|
@@ -809,7 +1031,7 @@ function nonLiveEpicIdSet(epics, tasks, options = {}) {
|
|
|
809
1031
|
|
|
810
1032
|
function filterTasksForList(tasks, epics, options = {}) {
|
|
811
1033
|
// Explicit epic filter (show that epic's tasks) is applied by caller after this.
|
|
812
|
-
// Default agent list: hide tasks under done/archived epics.
|
|
1034
|
+
// Default agent list: hide tasks under done/archived epics and column=done.
|
|
813
1035
|
if (options.include_archived && options.include_done) return tasks;
|
|
814
1036
|
// GUI path: hide only archived-epic tasks (done epics still show done cards)
|
|
815
1037
|
if (options.live_only === false) {
|
|
@@ -819,9 +1041,19 @@ function filterTasksForList(tasks, epics, options = {}) {
|
|
|
819
1041
|
return tasks.filter((task) => !task.epic_id || !archivedIds.has(task.epic_id));
|
|
820
1042
|
}
|
|
821
1043
|
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
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;
|
|
825
1057
|
}
|
|
826
1058
|
|
|
827
1059
|
function getProgress(task) {
|
|
@@ -837,10 +1069,28 @@ function currentSubtask(task) {
|
|
|
837
1069
|
return { id: open.id, text: open.text };
|
|
838
1070
|
}
|
|
839
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
|
+
|
|
840
1090
|
function compactContextTask(task, allTasks, epicLookup, mode) {
|
|
841
1091
|
const unmet = unmetDependencies(task, allTasks);
|
|
842
1092
|
const subtask = currentSubtask(task);
|
|
843
|
-
|
|
1093
|
+
const payload = {
|
|
844
1094
|
mode,
|
|
845
1095
|
next_action: resolveNextAction(task, mode),
|
|
846
1096
|
task_id: task.id,
|
|
@@ -851,10 +1101,31 @@ function compactContextTask(task, allTasks, epicLookup, mode) {
|
|
|
851
1101
|
current_subtask: subtask,
|
|
852
1102
|
progress: getProgress(task),
|
|
853
1103
|
files: task.files || [],
|
|
1104
|
+
last_result: lastContextResult(task),
|
|
854
1105
|
blocked: unmet.length > 0,
|
|
855
1106
|
unmet_dependencies: unmet.map((item) => item.id),
|
|
856
1107
|
blocks: computeBlocks(task.id, allTasks)
|
|
857
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);
|
|
858
1129
|
}
|
|
859
1130
|
|
|
860
1131
|
async function getContextPayload(options = {}) {
|
|
@@ -869,20 +1140,20 @@ async function getContextPayload(options = {}) {
|
|
|
869
1140
|
if (epicFilter) {
|
|
870
1141
|
liveTasks = liveTasks.filter((task) => taskMatchesEpicFilter(task, epicFilter));
|
|
871
1142
|
}
|
|
872
|
-
const active = liveTasks.find((task) => task.column === 'active');
|
|
1143
|
+
const active = liveTasks.find((task) => task.column === 'active' && isUnblockedWork(task, tasks));
|
|
873
1144
|
if (active) {
|
|
874
1145
|
return compactContextTask(active, tasks, epicLookup, 'continue');
|
|
875
1146
|
}
|
|
876
|
-
const gateFix = liveTasks.find((task) =>
|
|
877
|
-
const status = task.workflow && task.workflow.status;
|
|
878
|
-
return (task.column === 'testing' || task.column === 'review')
|
|
879
|
-
&& (status === 'fail' || status === 'blocked');
|
|
880
|
-
});
|
|
1147
|
+
const gateFix = liveTasks.find((task) => isFailedGate(task));
|
|
881
1148
|
if (gateFix) {
|
|
882
1149
|
return compactContextTask(gateFix, tasks, epicLookup, 'continue');
|
|
883
1150
|
}
|
|
1151
|
+
const manualGate = liveTasks.find((task) => isManualGate(task));
|
|
1152
|
+
if (manualGate) {
|
|
1153
|
+
return compactContextTask(manualGate, tasks, epicLookup, 'continue');
|
|
1154
|
+
}
|
|
884
1155
|
const ready = liveTasks
|
|
885
|
-
.filter((task) => task.column === 'planned' &&
|
|
1156
|
+
.filter((task) => task.column === 'planned' && isUnblockedWork(task, tasks))
|
|
886
1157
|
.sort((a, b) => (a.task_number || 0) - (b.task_number || 0));
|
|
887
1158
|
if (ready.length > 0) {
|
|
888
1159
|
return compactContextTask(ready[0], tasks, epicLookup, 'start');
|
|
@@ -902,17 +1173,14 @@ function pickFields(task, fieldNames) {
|
|
|
902
1173
|
const picked = {};
|
|
903
1174
|
|
|
904
1175
|
for (const field of fieldNames) {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
if (field
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
if (field in task) {
|
|
914
|
-
picked[field] = task[field];
|
|
915
|
-
}
|
|
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;
|
|
916
1184
|
}
|
|
917
1185
|
|
|
918
1186
|
return picked;
|
|
@@ -964,10 +1232,10 @@ function parseListSection(sectionText) {
|
|
|
964
1232
|
async function ensureBacklogDir() {
|
|
965
1233
|
// Always create the full known set so disabled gates can still hold legacy files until migrated.
|
|
966
1234
|
for (const col of KNOWN_COLS) {
|
|
967
|
-
const colDir = path.join(
|
|
1235
|
+
const colDir = path.join(backlogDir(), col);
|
|
968
1236
|
await fs.mkdir(colDir, { recursive: true });
|
|
969
1237
|
}
|
|
970
|
-
await fs.mkdir(
|
|
1238
|
+
await fs.mkdir(epicsDir(), { recursive: true });
|
|
971
1239
|
}
|
|
972
1240
|
|
|
973
1241
|
async function parseMarkdownTask(filePath, column) {
|
|
@@ -1053,7 +1321,7 @@ async function allEpics() {
|
|
|
1053
1321
|
const epics = [];
|
|
1054
1322
|
|
|
1055
1323
|
for (const col of KNOWN_COLS) {
|
|
1056
|
-
const colDir = path.join(
|
|
1324
|
+
const colDir = path.join(backlogDir(), col);
|
|
1057
1325
|
try {
|
|
1058
1326
|
const files = await fs.readdir(colDir);
|
|
1059
1327
|
const taskFiles = files
|
|
@@ -1074,9 +1342,8 @@ async function allEpics() {
|
|
|
1074
1342
|
try {
|
|
1075
1343
|
epics.push(await parseEpic(path.join(colDir, file), col));
|
|
1076
1344
|
} catch (error) {
|
|
1077
|
-
// File may vanish between readdir and read under concurrent delete.
|
|
1078
1345
|
if (error.code === 'ENOENT') continue;
|
|
1079
|
-
|
|
1346
|
+
throw error;
|
|
1080
1347
|
}
|
|
1081
1348
|
}
|
|
1082
1349
|
} catch (error) {
|
|
@@ -1092,14 +1359,14 @@ async function allTasks() {
|
|
|
1092
1359
|
}
|
|
1093
1360
|
|
|
1094
1361
|
function epicFilePath(epicId) {
|
|
1095
|
-
return path.join(
|
|
1362
|
+
return path.join(epicsDir(), `${epicId}.json`);
|
|
1096
1363
|
}
|
|
1097
1364
|
|
|
1098
1365
|
async function nextEpicNumber() {
|
|
1099
1366
|
await ensureBacklogDir();
|
|
1100
1367
|
const ids = [];
|
|
1101
1368
|
try {
|
|
1102
|
-
const files = await fs.readdir(
|
|
1369
|
+
const files = await fs.readdir(epicsDir());
|
|
1103
1370
|
for (const file of files) {
|
|
1104
1371
|
if (!isTaskOrEpicDataFile(file)) continue;
|
|
1105
1372
|
const match = file.match(/^E0*(\d+)\.json$/i);
|
|
@@ -1149,14 +1416,15 @@ async function listEpicEntities() {
|
|
|
1149
1416
|
await ensureBacklogDir();
|
|
1150
1417
|
const epics = [];
|
|
1151
1418
|
try {
|
|
1152
|
-
const files = (await fs.readdir(
|
|
1419
|
+
const files = (await fs.readdir(epicsDir()))
|
|
1153
1420
|
.filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.json'))
|
|
1154
1421
|
.sort((left, right) => left.localeCompare(right));
|
|
1155
1422
|
for (const file of files) {
|
|
1156
1423
|
try {
|
|
1157
|
-
epics.push(await parseJsonEpic(path.join(
|
|
1424
|
+
epics.push(await parseJsonEpic(path.join(epicsDir(), file)));
|
|
1158
1425
|
} catch (error) {
|
|
1159
|
-
|
|
1426
|
+
if (error.code === 'ENOENT') continue;
|
|
1427
|
+
throw error;
|
|
1160
1428
|
}
|
|
1161
1429
|
}
|
|
1162
1430
|
} catch (error) {
|
|
@@ -1315,47 +1583,12 @@ async function updateEpicEntity(epicId, patch) {
|
|
|
1315
1583
|
const current = await getEpicEntity(epicId);
|
|
1316
1584
|
const next = { ...current };
|
|
1317
1585
|
|
|
1318
|
-
if (patch.title !== undefined)
|
|
1319
|
-
const title = normalizeString(patch.title);
|
|
1320
|
-
if (!title) {
|
|
1321
|
-
throw createKanbanError(
|
|
1322
|
-
'VALIDATION_ERROR',
|
|
1323
|
-
'title must be a non-empty string',
|
|
1324
|
-
'Send a non-empty title or omit the field',
|
|
1325
|
-
{ field: 'title' },
|
|
1326
|
-
false,
|
|
1327
|
-
400
|
|
1328
|
-
);
|
|
1329
|
-
}
|
|
1330
|
-
next.title = title;
|
|
1331
|
-
}
|
|
1586
|
+
if (patch.title !== undefined) next.title = requireNonEmptyTitle(patch.title);
|
|
1332
1587
|
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
1333
1588
|
if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
|
|
1334
|
-
if (patch.in_scope !== undefined)
|
|
1335
|
-
if (!Array.isArray(patch.in_scope)) {
|
|
1336
|
-
throw createKanbanError(
|
|
1337
|
-
'VALIDATION_ERROR',
|
|
1338
|
-
'in_scope must be an array of strings',
|
|
1339
|
-
'Send in_scope as an array',
|
|
1340
|
-
{ field: 'in_scope' },
|
|
1341
|
-
false,
|
|
1342
|
-
400
|
|
1343
|
-
);
|
|
1344
|
-
}
|
|
1345
|
-
next.in_scope = normalizeStringArray(patch.in_scope);
|
|
1346
|
-
}
|
|
1589
|
+
if (patch.in_scope !== undefined) next.in_scope = requireStringArray(patch.in_scope, 'in_scope');
|
|
1347
1590
|
if (patch.out_of_scope !== undefined) {
|
|
1348
|
-
|
|
1349
|
-
throw createKanbanError(
|
|
1350
|
-
'VALIDATION_ERROR',
|
|
1351
|
-
'out_of_scope must be an array of strings',
|
|
1352
|
-
'Send out_of_scope as an array',
|
|
1353
|
-
{ field: 'out_of_scope' },
|
|
1354
|
-
false,
|
|
1355
|
-
400
|
|
1356
|
-
);
|
|
1357
|
-
}
|
|
1358
|
-
next.out_of_scope = normalizeStringArray(patch.out_of_scope);
|
|
1591
|
+
next.out_of_scope = requireStringArray(patch.out_of_scope, 'out_of_scope');
|
|
1359
1592
|
}
|
|
1360
1593
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
1361
1594
|
if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
|
|
@@ -1402,7 +1635,7 @@ async function deleteTaskRecord(taskId) {
|
|
|
1402
1635
|
await fs.unlink(filePath).catch((error) => {
|
|
1403
1636
|
if (error.code !== 'ENOENT') throw error;
|
|
1404
1637
|
});
|
|
1405
|
-
await removeOtherTaskCopies(task.id, path.join(
|
|
1638
|
+
await removeOtherTaskCopies(task.id, path.join(backlogDir(), '__none__', `${task.id}.json`));
|
|
1406
1639
|
|
|
1407
1640
|
return {
|
|
1408
1641
|
ok: true,
|
|
@@ -1512,7 +1745,7 @@ function taskMatchesEpicFilter(task, epicFilter) {
|
|
|
1512
1745
|
|
|
1513
1746
|
async function findFile(epicId) {
|
|
1514
1747
|
for (const col of KNOWN_COLS) {
|
|
1515
|
-
const colDir = path.join(
|
|
1748
|
+
const colDir = path.join(backlogDir(), col);
|
|
1516
1749
|
try {
|
|
1517
1750
|
const files = await fs.readdir(colDir);
|
|
1518
1751
|
const candidates = files
|
|
@@ -1529,22 +1762,30 @@ async function findFile(epicId) {
|
|
|
1529
1762
|
return null;
|
|
1530
1763
|
}
|
|
1531
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
|
+
|
|
1532
1776
|
async function getTask(taskId) {
|
|
1533
1777
|
const resolvedId = await resolveTaskId(taskId);
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
throw
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
);
|
|
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
|
+
}
|
|
1544
1787
|
}
|
|
1545
|
-
|
|
1546
|
-
const column = path.basename(path.dirname(filePath));
|
|
1547
|
-
return parseEpic(filePath, column);
|
|
1788
|
+
throw taskNotFound(taskId);
|
|
1548
1789
|
}
|
|
1549
1790
|
|
|
1550
1791
|
async function resolveTaskId(input) {
|
|
@@ -1557,7 +1798,7 @@ async function resolveTaskId(input) {
|
|
|
1557
1798
|
if (!Number.isFinite(num)) return null;
|
|
1558
1799
|
|
|
1559
1800
|
for (const col of KNOWN_COLS) {
|
|
1560
|
-
const colDir = path.join(
|
|
1801
|
+
const colDir = path.join(backlogDir(), col);
|
|
1561
1802
|
try {
|
|
1562
1803
|
const files = await fs.readdir(colDir);
|
|
1563
1804
|
const rawPattern = new RegExp(`^(?:[A-Z]+-)?${String(num)}(?:-|$)`);
|
|
@@ -1578,7 +1819,7 @@ async function resolveTaskId(input) {
|
|
|
1578
1819
|
async function removeOtherTaskCopies(taskId, keepPath) {
|
|
1579
1820
|
const keep = path.resolve(keepPath);
|
|
1580
1821
|
for (const col of KNOWN_COLS) {
|
|
1581
|
-
const colDir = path.join(
|
|
1822
|
+
const colDir = path.join(backlogDir(), col);
|
|
1582
1823
|
try {
|
|
1583
1824
|
const files = await fs.readdir(colDir);
|
|
1584
1825
|
for (const file of files) {
|
|
@@ -1600,7 +1841,7 @@ async function writeTask(task, previousFilePath = null, { exclusive = false } =
|
|
|
1600
1841
|
const normalized = normalizeTask(task);
|
|
1601
1842
|
await ensureBacklogDir();
|
|
1602
1843
|
|
|
1603
|
-
const nextFilePath = path.join(
|
|
1844
|
+
const nextFilePath = path.join(backlogDir(), normalized.column, `${normalized.id}.json`);
|
|
1604
1845
|
const payload = JSON.stringify(serializeTask(normalized), null, 2) + '\n';
|
|
1605
1846
|
await writeFileAtomic(nextFilePath, payload, { exclusive });
|
|
1606
1847
|
|
|
@@ -1621,7 +1862,7 @@ async function migrateAll(options = {}) {
|
|
|
1621
1862
|
const errors = [];
|
|
1622
1863
|
|
|
1623
1864
|
for (const col of KNOWN_COLS) {
|
|
1624
|
-
const colDir = path.join(
|
|
1865
|
+
const colDir = path.join(backlogDir(), col);
|
|
1625
1866
|
let files;
|
|
1626
1867
|
try {
|
|
1627
1868
|
files = await fs.readdir(colDir);
|
|
@@ -1667,7 +1908,7 @@ async function nextTaskNumber() {
|
|
|
1667
1908
|
const ids = [];
|
|
1668
1909
|
|
|
1669
1910
|
for (const col of KNOWN_COLS) {
|
|
1670
|
-
const colDir = path.join(
|
|
1911
|
+
const colDir = path.join(backlogDir(), col);
|
|
1671
1912
|
try {
|
|
1672
1913
|
const files = await fs.readdir(colDir);
|
|
1673
1914
|
for (const file of files) {
|
|
@@ -1712,7 +1953,7 @@ async function listTasksInKnownColumn(column) {
|
|
|
1712
1953
|
400
|
|
1713
1954
|
);
|
|
1714
1955
|
}
|
|
1715
|
-
const colDir = path.join(
|
|
1956
|
+
const colDir = path.join(backlogDir(), column);
|
|
1716
1957
|
const tasks = [];
|
|
1717
1958
|
try {
|
|
1718
1959
|
const files = await fs.readdir(colDir);
|
|
@@ -1867,199 +2108,140 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
|
|
|
1867
2108
|
});
|
|
1868
2109
|
}
|
|
1869
2110
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
throw createKanbanError(
|
|
1875
|
-
'TASK_NOT_FOUND',
|
|
1876
|
-
`Task ${taskId} was not found`,
|
|
1877
|
-
'Call kanban_read with operation=list to discover valid task ids',
|
|
1878
|
-
{ task_id: taskId },
|
|
1879
|
-
false,
|
|
1880
|
-
404
|
|
1881
|
-
);
|
|
2111
|
+
function applyColumnPatch(next, current, column) {
|
|
2112
|
+
validateColumn(column);
|
|
2113
|
+
if (column !== current.column) {
|
|
2114
|
+
validateTransition(current.column, column, current.id);
|
|
1882
2115
|
}
|
|
2116
|
+
next.column = column;
|
|
2117
|
+
}
|
|
1883
2118
|
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
if (patch.column !== current.column) {
|
|
1890
|
-
validateTransition(current.column, patch.column, current.id);
|
|
1891
|
-
}
|
|
1892
|
-
next.column = patch.column;
|
|
2119
|
+
async function applyEpicPatch(next, patch) {
|
|
2120
|
+
if (patch._skipEpicResolve) {
|
|
2121
|
+
if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
|
|
2122
|
+
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
2123
|
+
return;
|
|
1893
2124
|
}
|
|
1894
|
-
if (patch.
|
|
1895
|
-
|
|
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)) {
|
|
1896
2149
|
throw createKanbanError(
|
|
1897
2150
|
'VALIDATION_ERROR',
|
|
1898
|
-
'
|
|
1899
|
-
'Send
|
|
1900
|
-
{ field: '
|
|
2151
|
+
'appendEvidence must be an evidence object',
|
|
2152
|
+
'Send a single evidence entry to append',
|
|
2153
|
+
{ field: 'appendEvidence' },
|
|
1901
2154
|
false,
|
|
1902
2155
|
400
|
|
1903
2156
|
);
|
|
1904
2157
|
}
|
|
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
|
+
);
|
|
1905
2205
|
next.depends_on = patch.depends_on;
|
|
1906
2206
|
}
|
|
1907
2207
|
if (patch.files !== undefined) {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
false,
|
|
1915
|
-
400
|
|
1916
|
-
);
|
|
1917
|
-
}
|
|
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
|
+
);
|
|
1918
2214
|
next.files = patch.files;
|
|
1919
2215
|
}
|
|
1920
|
-
if (patch.title !== undefined)
|
|
1921
|
-
const title = normalizeString(patch.title);
|
|
1922
|
-
if (!title) {
|
|
1923
|
-
throw createKanbanError(
|
|
1924
|
-
'VALIDATION_ERROR',
|
|
1925
|
-
'title must be a non-empty string',
|
|
1926
|
-
'Send a non-empty title or omit the field',
|
|
1927
|
-
{ field: 'title' },
|
|
1928
|
-
false,
|
|
1929
|
-
400
|
|
1930
|
-
);
|
|
1931
|
-
}
|
|
1932
|
-
next.title = title;
|
|
1933
|
-
}
|
|
1934
|
-
if (!patch._skipEpicResolve) {
|
|
1935
|
-
if (patch.epic_id !== undefined || patch.epic !== undefined || patch.epic_group !== undefined) {
|
|
1936
|
-
const ref = patch.epic_id !== undefined
|
|
1937
|
-
? patch.epic_id
|
|
1938
|
-
: (patch.epic !== undefined ? patch.epic : patch.epic_group);
|
|
1939
|
-
if (isBlankEpicRef(ref)) {
|
|
1940
|
-
next.epic_id = null;
|
|
1941
|
-
next.epic_group = '—';
|
|
1942
|
-
} else {
|
|
1943
|
-
const link = await resolveEpicRef(ref, {
|
|
1944
|
-
createIfMissing: Boolean(
|
|
1945
|
-
patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
|
|
1946
|
-
),
|
|
1947
|
-
skipLock: true
|
|
1948
|
-
});
|
|
1949
|
-
next.epic_id = link.epic_id;
|
|
1950
|
-
next.epic_group = link.epic_group;
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
} else {
|
|
1954
|
-
if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
|
|
1955
|
-
if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
|
|
1956
|
-
}
|
|
2216
|
+
if (patch.title !== undefined) next.title = requireNonEmptyTitle(patch.title);
|
|
1957
2217
|
if (patch.description !== undefined) next.description = normalizeString(patch.description);
|
|
1958
2218
|
if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
|
|
1959
|
-
if (patch.in_scope !== undefined)
|
|
1960
|
-
if (!Array.isArray(patch.in_scope)) {
|
|
1961
|
-
throw createKanbanError(
|
|
1962
|
-
'VALIDATION_ERROR',
|
|
1963
|
-
'in_scope must be an array of strings',
|
|
1964
|
-
'Send in_scope as an array',
|
|
1965
|
-
{ field: 'in_scope' },
|
|
1966
|
-
false,
|
|
1967
|
-
400
|
|
1968
|
-
);
|
|
1969
|
-
}
|
|
1970
|
-
next.in_scope = patch.in_scope;
|
|
1971
|
-
}
|
|
2219
|
+
if (patch.in_scope !== undefined) next.in_scope = requireStringArray(patch.in_scope, 'in_scope');
|
|
1972
2220
|
if (patch.out_of_scope !== undefined) {
|
|
1973
|
-
|
|
1974
|
-
throw createKanbanError(
|
|
1975
|
-
'VALIDATION_ERROR',
|
|
1976
|
-
'out_of_scope must be an array of strings',
|
|
1977
|
-
'Send out_of_scope as an array',
|
|
1978
|
-
{ field: 'out_of_scope' },
|
|
1979
|
-
false,
|
|
1980
|
-
400
|
|
1981
|
-
);
|
|
1982
|
-
}
|
|
1983
|
-
next.out_of_scope = patch.out_of_scope;
|
|
2221
|
+
next.out_of_scope = requireStringArray(patch.out_of_scope, 'out_of_scope');
|
|
1984
2222
|
}
|
|
1985
2223
|
if (patch.acceptance_criteria !== undefined) {
|
|
1986
|
-
|
|
1987
|
-
throw createKanbanError(
|
|
1988
|
-
'VALIDATION_ERROR',
|
|
1989
|
-
'acceptance_criteria must be an array of strings',
|
|
1990
|
-
'Send acceptance_criteria as an array',
|
|
1991
|
-
{ field: 'acceptance_criteria' },
|
|
1992
|
-
false,
|
|
1993
|
-
400
|
|
1994
|
-
);
|
|
1995
|
-
}
|
|
1996
|
-
next.acceptance_criteria = patch.acceptance_criteria;
|
|
1997
|
-
}
|
|
1998
|
-
if (patch.test_cases !== undefined) {
|
|
1999
|
-
if (!Array.isArray(patch.test_cases)) {
|
|
2000
|
-
throw createKanbanError(
|
|
2001
|
-
'VALIDATION_ERROR',
|
|
2002
|
-
'test_cases must be an array of strings',
|
|
2003
|
-
'Send test_cases as an array',
|
|
2004
|
-
{ field: 'test_cases' },
|
|
2005
|
-
false,
|
|
2006
|
-
400
|
|
2007
|
-
);
|
|
2008
|
-
}
|
|
2009
|
-
next.test_cases = patch.test_cases;
|
|
2224
|
+
next.acceptance_criteria = requireStringArray(patch.acceptance_criteria, 'acceptance_criteria');
|
|
2010
2225
|
}
|
|
2226
|
+
if (patch.test_cases !== undefined) next.test_cases = requireStringArray(patch.test_cases, 'test_cases');
|
|
2011
2227
|
if (patch.subtasks !== undefined) {
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
false,
|
|
2019
|
-
400
|
|
2020
|
-
);
|
|
2021
|
-
}
|
|
2228
|
+
assertIsArray(
|
|
2229
|
+
patch.subtasks,
|
|
2230
|
+
'subtasks',
|
|
2231
|
+
'subtasks must be an array',
|
|
2232
|
+
'Send subtasks as an array of objects'
|
|
2233
|
+
);
|
|
2022
2234
|
next.subtasks = patch.subtasks;
|
|
2023
2235
|
}
|
|
2024
|
-
if (patch.adr !== undefined)
|
|
2025
|
-
if (Array.isArray(patch.adr)) {
|
|
2026
|
-
next.adr = normalizeAdr(patch.adr);
|
|
2027
|
-
} else {
|
|
2028
|
-
next.adr = appendAdrEntry(next.adr, patch.adr);
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2236
|
+
if (patch.adr !== undefined) next.adr = applyAdrPatch(next.adr, patch.adr);
|
|
2031
2237
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
2032
2238
|
if (patch.plan !== undefined) next.plan = patch.plan;
|
|
2033
2239
|
if (patch.workflow !== undefined) {
|
|
2034
2240
|
next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
|
|
2035
2241
|
}
|
|
2036
|
-
|
|
2037
|
-
if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
|
|
2038
|
-
throw createKanbanError(
|
|
2039
|
-
'VALIDATION_ERROR',
|
|
2040
|
-
'appendEvidence must be an evidence object',
|
|
2041
|
-
'Send a single evidence entry to append',
|
|
2042
|
-
{ field: 'appendEvidence' },
|
|
2043
|
-
false,
|
|
2044
|
-
400
|
|
2045
|
-
);
|
|
2046
|
-
}
|
|
2047
|
-
next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
|
|
2048
|
-
}
|
|
2049
|
-
if (patch.evidence !== undefined) {
|
|
2050
|
-
if (!Array.isArray(patch.evidence)) {
|
|
2051
|
-
throw createKanbanError(
|
|
2052
|
-
'VALIDATION_ERROR',
|
|
2053
|
-
'evidence must be an array',
|
|
2054
|
-
'Send evidence as an array of evidence objects',
|
|
2055
|
-
{ field: 'evidence' },
|
|
2056
|
-
false,
|
|
2057
|
-
400
|
|
2058
|
-
);
|
|
2059
|
-
}
|
|
2060
|
-
next.evidence = patch.evidence;
|
|
2061
|
-
}
|
|
2242
|
+
}
|
|
2062
2243
|
|
|
2244
|
+
async function assertTaskPatchInvariants(current, next, patch) {
|
|
2063
2245
|
const columnChanged = next.column !== current.column;
|
|
2064
2246
|
const depsChanged = patch.depends_on !== undefined;
|
|
2065
2247
|
const enteringDone = columnChanged && next.column === 'done';
|
|
@@ -2073,7 +2255,36 @@ async function updateTaskRecord(taskId, patch) {
|
|
|
2073
2255
|
if (columnChanged && isWorkColumn(next.column)) {
|
|
2074
2256
|
assertUnblockedForColumn(normalizeTask(next), next.column, others);
|
|
2075
2257
|
}
|
|
2258
|
+
if (columnChanged) {
|
|
2259
|
+
assertEvidenceForGate(current, next);
|
|
2260
|
+
}
|
|
2261
|
+
return { enteringDone, others };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
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
|
+
}
|
|
2076
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);
|
|
2077
2288
|
const written = await writeTask(next, previousFilePath);
|
|
2078
2289
|
if (enteringDone) {
|
|
2079
2290
|
const board = others.map((other) => (other.id === written.id ? written : other));
|
|
@@ -2119,7 +2330,12 @@ async function doMove(epicId, target) {
|
|
|
2119
2330
|
await updateTask(epicId, { column: target });
|
|
2120
2331
|
return true;
|
|
2121
2332
|
} catch (error) {
|
|
2122
|
-
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
|
+
) {
|
|
2123
2339
|
return false;
|
|
2124
2340
|
}
|
|
2125
2341
|
throw error;
|
|
@@ -2224,11 +2440,15 @@ module.exports = {
|
|
|
2224
2440
|
VIEW_FIELDS,
|
|
2225
2441
|
EPIC_VIEW_FIELDS,
|
|
2226
2442
|
normalizeEvidence,
|
|
2443
|
+
hasProofEvidence,
|
|
2444
|
+
assertEvidenceForGate,
|
|
2227
2445
|
normalizeWorkflow,
|
|
2228
2446
|
LIVE_EPIC_STATUSES,
|
|
2229
2447
|
RECOMMENDED_CREATE_FIELDS,
|
|
2230
2448
|
RECOMMENDED_EPIC_CREATE_FIELDS,
|
|
2231
|
-
EPICS_DIR
|
|
2449
|
+
get EPICS_DIR() {
|
|
2450
|
+
return epicsDir();
|
|
2451
|
+
}
|
|
2232
2452
|
};
|
|
2233
2453
|
|
|
2234
2454
|
// Keep live refs for applyBoardLayout consumers that read module.exports.COLS
|