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/kanban.js CHANGED
@@ -1,9 +1,21 @@
1
1
  const fs = require('fs').promises;
2
2
  const path = require('path');
3
3
 
4
- const BACKLOG = path.join(process.cwd(), 'backlog');
5
- const EPICS_DIR = path.join(BACKLOG, 'epics');
6
- const COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
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
- const WORKFLOW_STAGES = ['testing', 'review'];
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
- const COLUMN_TRANSITIONS = {
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', 'epic_group', 'created', 'progress'],
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
- 'description',
55
- 'specs',
56
- 'in_scope',
57
- 'out_of_scope',
58
- 'acceptance_criteria',
59
- 'test_cases',
92
+ 'current_subtask',
60
93
  'subtasks',
61
- 'adr',
62
- 'evidence',
63
- 'plan',
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(() => fn());
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
- column: COLS.includes(task.column) ? task.column : 'planned',
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
- picked[field] = epicPayload[field];
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 hiddenIds = nonLiveEpicIdSet(epics, tasks, options);
610
- if (hiddenIds.size === 0) return tasks;
611
- return tasks.filter((task) => !task.epic_id || !hiddenIds.has(task.epic_id));
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
- if (field === 'progress') {
633
- picked.progress = getProgress(task);
634
- continue;
635
- }
636
- if (field === 'epic_goals') {
637
- picked.epic_goals = task.epic_goals;
638
- continue;
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
- for (const col of COLS) {
688
- const colDir = path.join(BACKLOG, col);
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(EPICS_DIR, { recursive: true });
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 COLS) {
777
- const colDir = path.join(BACKLOG, col);
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
- console.error(` parse error ${file}: ${error.message}`);
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(EPICS_DIR, `${epicId}.json`);
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(EPICS_DIR);
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(EPICS_DIR))
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(EPICS_DIR, file)));
1424
+ epics.push(await parseJsonEpic(path.join(epicsDir(), file)));
879
1425
  } catch (error) {
880
- console.error(` parse error ${file}: ${error.message}`);
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
- if (!Array.isArray(patch.out_of_scope)) {
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(BACKLOG, '__none__', `${task.id}.json`));
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 COLS) {
1236
- const colDir = path.join(BACKLOG, col);
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
- const filePath = await findFile(resolvedId);
1256
- if (!filePath) {
1257
- throw createKanbanError(
1258
- 'TASK_NOT_FOUND',
1259
- `Task ${taskId} was not found`,
1260
- 'Call kanban_read with operation=list to discover valid task ids',
1261
- { task_id: taskId },
1262
- false,
1263
- 404
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 COLS) {
1281
- const colDir = path.join(BACKLOG, col);
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 COLS) {
1302
- const colDir = path.join(BACKLOG, col);
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(BACKLOG, normalized.column, `${normalized.id}.json`);
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 COLS) {
1345
- const colDir = path.join(BACKLOG, col);
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 COLS) {
1391
- const colDir = path.join(BACKLOG, col);
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
- async function updateTaskRecord(taskId, patch) {
1520
- const resolvedId = await resolveTaskId(taskId);
1521
- const previousFilePath = await findFile(resolvedId);
1522
- if (!previousFilePath) {
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
- const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1534
- const next = { ...current };
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.description !== undefined) next.description = normalizeString(patch.description);
1581
- if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
1582
- if (patch.in_scope !== undefined) {
1583
- if (!Array.isArray(patch.in_scope)) {
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
- 'in_scope must be an array of strings',
1587
- 'Send in_scope as an array',
1588
- { field: 'in_scope' },
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.in_scope = patch.in_scope;
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
- if (!Array.isArray(patch.out_of_scope)) {
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
- if (!Array.isArray(patch.acceptance_criteria)) {
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
- if (!Array.isArray(patch.subtasks)) {
1636
- throw createKanbanError(
1637
- 'VALIDATION_ERROR',
1638
- 'subtasks must be an array',
1639
- 'Send subtasks as an array of objects',
1640
- { field: 'subtasks' },
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
- if (patch.appendEvidence !== undefined) {
1660
- if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
1661
- throw createKanbanError(
1662
- 'VALIDATION_ERROR',
1663
- 'appendEvidence must be an evidence object',
1664
- 'Send a single evidence entry to append',
1665
- { field: 'appendEvidence' },
1666
- false,
1667
- 400
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 (patch.evidence !== undefined) {
1673
- if (!Array.isArray(patch.evidence)) {
1674
- throw createKanbanError(
1675
- 'VALIDATION_ERROR',
1676
- 'evidence must be an array',
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
- return writeTask(next, previousFilePath);
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 (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN' || error.code === 'INVALID_TRANSITION') {
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;