kanbango 3.6.2 → 3.8.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
@@ -3,7 +3,10 @@ const path = require('path');
3
3
 
4
4
  const BACKLOG = path.join(process.cwd(), 'backlog');
5
5
  const EPICS_DIR = path.join(BACKLOG, 'epics');
6
- const COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
6
+ /** All physical column dirs that may exist on disk (including disabled gates). */
7
+ const KNOWN_COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
8
+ /** Active columns for validation / MCP / create — mutated by applyBoardLayout. */
9
+ let COLS = KNOWN_COLS.slice();
7
10
  const STATUS_MAP = {
8
11
  active: 'in_progress',
9
12
  planned: 'planned',
@@ -12,11 +15,12 @@ const STATUS_MAP = {
12
15
  review: 'review',
13
16
  done: 'done'
14
17
  };
15
- const WORKFLOW_STAGES = ['testing', 'review'];
18
+ /** Gate stages that may spawn agents — subset of active COLS. */
19
+ let WORKFLOW_STAGES = ['testing', 'review'];
16
20
  const WORKFLOW_STATUSES = ['idle', 'running', 'pass', 'fail', 'blocked'];
17
21
  const EVIDENCE_VERDICTS = ['pass', 'fail', 'blocked', ''];
18
- // Agent/human move contract. Same column is always a no-op.
19
- const COLUMN_TRANSITIONS = {
22
+ // Agent/human move contract. Same column is always a no-op. Mutated by applyBoardLayout.
23
+ let COLUMN_TRANSITIONS = {
20
24
  icebox: ['planned'],
21
25
  planned: ['active', 'icebox', 'testing'],
22
26
  active: ['planned', 'testing', 'icebox'],
@@ -24,8 +28,31 @@ const COLUMN_TRANSITIONS = {
24
28
  review: ['active', 'done'],
25
29
  done: ['active']
26
30
  };
31
+
32
+ /**
33
+ * Apply project board layout from backlog/kanbango.json (via workflow.ensureBoardConfig).
34
+ * Does not create/delete dirs; only changes which columns are legal and which transitions apply.
35
+ */
36
+ function applyBoardLayout({ cols, transitions, workflowStages } = {}) {
37
+ if (Array.isArray(cols) && cols.length > 0) {
38
+ COLS = cols.slice();
39
+ }
40
+ if (transitions && typeof transitions === 'object' && !Array.isArray(transitions)) {
41
+ COLUMN_TRANSITIONS = {};
42
+ for (const [from, targets] of Object.entries(transitions)) {
43
+ COLUMN_TRANSITIONS[from] = Array.isArray(targets) ? targets.slice() : [];
44
+ }
45
+ }
46
+ if (Array.isArray(workflowStages)) {
47
+ WORKFLOW_STAGES = workflowStages.slice();
48
+ }
49
+ // Keep module.exports live references in sync for consumers that cached the export object.
50
+ module.exports.COLS = COLS;
51
+ module.exports.COLUMN_TRANSITIONS = COLUMN_TRANSITIONS;
52
+ module.exports.WORKFLOW_STAGES = WORKFLOW_STAGES;
53
+ }
27
54
  const VIEW_FIELDS = {
28
- summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
55
+ summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress', 'blocked'],
29
56
  planning: [
30
57
  'task_number',
31
58
  'title',
@@ -40,7 +67,11 @@ const VIEW_FIELDS = {
40
67
  'in_scope',
41
68
  'out_of_scope',
42
69
  'acceptance_criteria',
43
- 'test_cases'
70
+ 'test_cases',
71
+ 'depends_on',
72
+ 'blocked',
73
+ 'unmet_dependencies',
74
+ 'blocks'
44
75
  ],
45
76
  execution: [
46
77
  'task_number',
@@ -61,7 +92,12 @@ const VIEW_FIELDS = {
61
92
  'adr',
62
93
  'evidence',
63
94
  'plan',
64
- 'workflow'
95
+ 'workflow',
96
+ 'depends_on',
97
+ 'files',
98
+ 'blocked',
99
+ 'unmet_dependencies',
100
+ 'blocks'
65
101
  ],
66
102
  full: [
67
103
  'task_number',
@@ -83,7 +119,12 @@ const VIEW_FIELDS = {
83
119
  'notes',
84
120
  'evidence',
85
121
  'plan',
86
- 'workflow'
122
+ 'workflow',
123
+ 'depends_on',
124
+ 'files',
125
+ 'blocked',
126
+ 'unmet_dependencies',
127
+ 'blocks'
87
128
  ]
88
129
  };
89
130
 
@@ -208,6 +249,173 @@ function normalizeStringArray(value) {
208
249
  .filter(Boolean);
209
250
  }
210
251
 
252
+ function normalizeTaskIdRef(value) {
253
+ const raw = normalizeString(value);
254
+ if (!raw) return '';
255
+ if (/^\d+$/.test(raw)) return raw.padStart(3, '0');
256
+ return raw;
257
+ }
258
+
259
+ function uniqueNormalizedStrings(value, mapItem) {
260
+ if (!Array.isArray(value)) return [];
261
+ const seen = new Set();
262
+ const out = [];
263
+ for (const item of value) {
264
+ const next = mapItem(item);
265
+ if (!next || seen.has(next)) continue;
266
+ seen.add(next);
267
+ out.push(next);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function normalizeDependsOn(value) {
273
+ return uniqueNormalizedStrings(value, normalizeTaskIdRef);
274
+ }
275
+
276
+ function normalizeFiles(value) {
277
+ return uniqueNormalizedStrings(value, normalizeString);
278
+ }
279
+
280
+ function isWorkColumn(column) {
281
+ return column === 'active' || WORKFLOW_STAGES.includes(column);
282
+ }
283
+
284
+ function unmetDependencies(task, allTasks) {
285
+ const deps = Array.isArray(task && task.depends_on) ? task.depends_on : [];
286
+ if (deps.length === 0) return [];
287
+ const byId = new Map();
288
+ for (const other of allTasks || []) {
289
+ byId.set(other.id, other);
290
+ }
291
+ const unmet = [];
292
+ for (const depId of deps) {
293
+ const dep = byId.get(depId);
294
+ if (!dep || dep.column !== 'done') {
295
+ unmet.push({
296
+ id: depId,
297
+ column: dep ? dep.column : null,
298
+ missing: !dep
299
+ });
300
+ }
301
+ }
302
+ return unmet;
303
+ }
304
+
305
+ function assertUnblockedForColumn(task, targetColumn, allTasks) {
306
+ if (!isWorkColumn(targetColumn)) return;
307
+ const unmet = unmetDependencies(task, allTasks);
308
+ if (unmet.length === 0) return;
309
+ const labels = unmet.map((item) => (
310
+ item.missing ? `${item.id} (missing)` : `${item.id} (${item.column})`
311
+ ));
312
+ throw createKanbanError(
313
+ 'TASK_BLOCKED',
314
+ `Task ${task.id} is blocked by incomplete tasks: ${labels.join(', ')}`,
315
+ 'Finish dependency tasks (move them to done) or remove them from depends_on',
316
+ {
317
+ task_id: task.id,
318
+ unmet_dependencies: unmet.map((item) => item.id),
319
+ blocked_by: unmet
320
+ },
321
+ false,
322
+ 400
323
+ );
324
+ }
325
+
326
+ function computeBlocks(taskId, allTasks) {
327
+ if (!taskId) return [];
328
+ return (allTasks || [])
329
+ .filter((other) => other.id !== taskId && (other.depends_on || []).includes(taskId))
330
+ .map((other) => other.id)
331
+ .sort();
332
+ }
333
+
334
+ function findDependencyCycle(taskId, dependsOn, allTasks) {
335
+ const graph = new Map();
336
+ for (const other of allTasks || []) {
337
+ graph.set(other.id, (other.depends_on || []).slice());
338
+ }
339
+ graph.set(taskId, Array.isArray(dependsOn) ? dependsOn.slice() : []);
340
+
341
+ const visiting = new Set();
342
+ const visited = new Set();
343
+ const stack = [];
344
+
345
+ function dfs(id) {
346
+ if (visiting.has(id)) {
347
+ const start = stack.indexOf(id);
348
+ return stack.slice(start).concat(id);
349
+ }
350
+ if (visited.has(id)) return null;
351
+ visiting.add(id);
352
+ stack.push(id);
353
+ for (const depId of graph.get(id) || []) {
354
+ const cycle = dfs(depId);
355
+ if (cycle) return cycle;
356
+ }
357
+ stack.pop();
358
+ visiting.delete(id);
359
+ visited.add(id);
360
+ return null;
361
+ }
362
+
363
+ return dfs(taskId);
364
+ }
365
+
366
+ function assertNoDependencyCycle(taskId, dependsOn, allTasks) {
367
+ const cycle = findDependencyCycle(taskId, dependsOn, allTasks);
368
+ if (!cycle) return;
369
+ throw createKanbanError(
370
+ 'CIRCULAR_DEPENDENCY',
371
+ `Circular dependency: ${cycle.join(' → ')}`,
372
+ 'Remove one of the depends_on links that closes the loop',
373
+ { cycle },
374
+ false,
375
+ 400
376
+ );
377
+ }
378
+
379
+ function newlyUnblockedTasks(doneTaskId, allTasks) {
380
+ return (allTasks || [])
381
+ .filter((other) => other.id !== doneTaskId && (other.depends_on || []).includes(doneTaskId))
382
+ .filter((other) => unmetDependencies(other, allTasks).length === 0)
383
+ .map((other) => other.id)
384
+ .sort();
385
+ }
386
+
387
+ function firstEnabledGateColumn() {
388
+ if (COLS.includes('testing')) return 'testing';
389
+ if (COLS.includes('review')) return 'review';
390
+ return 'done';
391
+ }
392
+
393
+ function nextMoveAction() {
394
+ const gate = firstEnabledGateColumn();
395
+ if (gate === 'testing') return 'move_testing';
396
+ if (gate === 'review') return 'move_review';
397
+ return 'move_done';
398
+ }
399
+
400
+ function resolveNextAction(task, mode) {
401
+ if (mode === 'start') return 'start';
402
+ if (mode === 'idle') return 'idle';
403
+ const status = task.workflow && task.workflow.status;
404
+ if (
405
+ (task.column === 'testing' || task.column === 'review')
406
+ && (status === 'fail' || status === 'blocked')
407
+ ) {
408
+ return 'fix_gate';
409
+ }
410
+ if (currentSubtask(task)) return 'advance';
411
+ const evidence = Array.isArray(task.evidence) ? task.evidence : [];
412
+ if (evidence.length === 0) return 'evidence';
413
+ if (task.column === 'active') return nextMoveAction();
414
+ if (task.column === 'testing' && COLS.includes('review')) return 'move_review';
415
+ if (task.column === 'testing' || task.column === 'review') return 'move_done';
416
+ return 'implement';
417
+ }
418
+
211
419
  function isPresentCreateField(field, value) {
212
420
  if (
213
421
  field === 'description'
@@ -385,7 +593,8 @@ function normalizeTask(task) {
385
593
  const normalized = {
386
594
  id,
387
595
  title: stripTitlePrefix(task.title || id),
388
- column: COLS.includes(task.column) ? task.column : 'planned',
596
+ // Accept any known physical column so disabled-gate cards stay readable until migrated.
597
+ column: KNOWN_COLS.includes(task.column) ? task.column : 'planned',
389
598
  epic_id: epicId,
390
599
  epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
391
600
  created: normalizeString(task.created) || todayIso(),
@@ -401,6 +610,8 @@ function normalizeTask(task) {
401
610
  plan: normalizePlan(task.plan),
402
611
  evidence: normalizeEvidence(task.evidence),
403
612
  workflow: normalizeWorkflow(task.workflow),
613
+ depends_on: normalizeDependsOn(task.depends_on).filter((depId) => depId !== id),
614
+ files: normalizeFiles(task.files),
404
615
  task_number: extractTaskNumber(id)
405
616
  };
406
617
 
@@ -428,6 +639,8 @@ function serializeTask(task) {
428
639
  plan: normalized.plan,
429
640
  evidence: normalized.evidence,
430
641
  workflow: normalized.workflow,
642
+ depends_on: normalized.depends_on,
643
+ files: normalized.files,
431
644
  task_number: normalized.task_number
432
645
  };
433
646
  }
@@ -539,7 +752,7 @@ function shapeEpic(epic, tasks = [], options = {}) {
539
752
  ...normalized,
540
753
  status: deriveEpicStatus(childTasks, normalized),
541
754
  progress: getEpicProgress(childTasks),
542
- tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' })),
755
+ tasks: childTasks.map((task) => shapeTask(task, { view: 'summary', allTasks: tasks })),
543
756
  adrs: collectEpicAdrs(childTasks)
544
757
  };
545
758
  const fields = Array.isArray(options.fields) && options.fields.length > 0
@@ -617,6 +830,66 @@ function getProgress(task) {
617
830
  return { done, total };
618
831
  }
619
832
 
833
+ function currentSubtask(task) {
834
+ const subtasks = Array.isArray(task && task.subtasks) ? task.subtasks : [];
835
+ const open = subtasks.find((subtask) => !subtask.done);
836
+ if (!open) return null;
837
+ return { id: open.id, text: open.text };
838
+ }
839
+
840
+ function compactContextTask(task, allTasks, epicLookup, mode) {
841
+ const unmet = unmetDependencies(task, allTasks);
842
+ const subtask = currentSubtask(task);
843
+ return {
844
+ mode,
845
+ next_action: resolveNextAction(task, mode),
846
+ task_id: task.id,
847
+ title: task.title,
848
+ column: task.column,
849
+ epic_id: task.epic_id,
850
+ epic_goals: resolveEpicGoals(task, epicLookup),
851
+ current_subtask: subtask,
852
+ progress: getProgress(task),
853
+ files: task.files || [],
854
+ blocked: unmet.length > 0,
855
+ unmet_dependencies: unmet.map((item) => item.id),
856
+ blocks: computeBlocks(task.id, allTasks)
857
+ };
858
+ }
859
+
860
+ async function getContextPayload(options = {}) {
861
+ const tasks = await allTasks();
862
+ const epics = await listEpicEntities();
863
+ const epicLookup = {};
864
+ for (const epic of epics) {
865
+ epicLookup[epic.id] = epic;
866
+ }
867
+ let liveTasks = filterTasksForList(tasks, epics, {});
868
+ const epicFilter = options.epic_id || options.epic;
869
+ if (epicFilter) {
870
+ liveTasks = liveTasks.filter((task) => taskMatchesEpicFilter(task, epicFilter));
871
+ }
872
+ const active = liveTasks.find((task) => task.column === 'active');
873
+ if (active) {
874
+ return compactContextTask(active, tasks, epicLookup, 'continue');
875
+ }
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
+ });
881
+ if (gateFix) {
882
+ return compactContextTask(gateFix, tasks, epicLookup, 'continue');
883
+ }
884
+ const ready = liveTasks
885
+ .filter((task) => task.column === 'planned' && unmetDependencies(task, tasks).length === 0)
886
+ .sort((a, b) => (a.task_number || 0) - (b.task_number || 0));
887
+ if (ready.length > 0) {
888
+ return compactContextTask(ready[0], tasks, epicLookup, 'start');
889
+ }
890
+ return { mode: 'idle', next_action: 'idle' };
891
+ }
892
+
620
893
  function resolveEpicGoals(task, epicLookup) {
621
894
  if (!task.epic_id) return '';
622
895
  if (!epicLookup || typeof epicLookup !== 'object') return '';
@@ -650,10 +923,15 @@ function shapeTask(task, options = {}) {
650
923
  const fields = Array.isArray(options.fields) && options.fields.length > 0
651
924
  ? options.fields
652
925
  : (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
926
+ const allTasks = options.allTasks;
927
+ const unmet = allTasks ? unmetDependencies(normalized, allTasks) : [];
653
928
 
654
929
  const withGoals = {
655
930
  ...normalized,
656
- epic_goals: resolveEpicGoals(normalized, options.epicLookup)
931
+ epic_goals: resolveEpicGoals(normalized, options.epicLookup),
932
+ blocked: unmet.length > 0,
933
+ unmet_dependencies: unmet.map((item) => item.id),
934
+ blocks: allTasks ? computeBlocks(normalized.id, allTasks) : []
657
935
  };
658
936
  return pickFields(withGoals, fields);
659
937
  }
@@ -684,7 +962,8 @@ function parseListSection(sectionText) {
684
962
  }
685
963
 
686
964
  async function ensureBacklogDir() {
687
- for (const col of COLS) {
965
+ // Always create the full known set so disabled gates can still hold legacy files until migrated.
966
+ for (const col of KNOWN_COLS) {
688
967
  const colDir = path.join(BACKLOG, col);
689
968
  await fs.mkdir(colDir, { recursive: true });
690
969
  }
@@ -773,7 +1052,7 @@ async function parseEpic(filePath, column) {
773
1052
  async function allEpics() {
774
1053
  const epics = [];
775
1054
 
776
- for (const col of COLS) {
1055
+ for (const col of KNOWN_COLS) {
777
1056
  const colDir = path.join(BACKLOG, col);
778
1057
  try {
779
1058
  const files = await fs.readdir(colDir);
@@ -1232,7 +1511,7 @@ function taskMatchesEpicFilter(task, epicFilter) {
1232
1511
  }
1233
1512
 
1234
1513
  async function findFile(epicId) {
1235
- for (const col of COLS) {
1514
+ for (const col of KNOWN_COLS) {
1236
1515
  const colDir = path.join(BACKLOG, col);
1237
1516
  try {
1238
1517
  const files = await fs.readdir(colDir);
@@ -1277,7 +1556,7 @@ async function resolveTaskId(input) {
1277
1556
  const num = parseInt(String(input), 10);
1278
1557
  if (!Number.isFinite(num)) return null;
1279
1558
 
1280
- for (const col of COLS) {
1559
+ for (const col of KNOWN_COLS) {
1281
1560
  const colDir = path.join(BACKLOG, col);
1282
1561
  try {
1283
1562
  const files = await fs.readdir(colDir);
@@ -1298,7 +1577,7 @@ async function resolveTaskId(input) {
1298
1577
 
1299
1578
  async function removeOtherTaskCopies(taskId, keepPath) {
1300
1579
  const keep = path.resolve(keepPath);
1301
- for (const col of COLS) {
1580
+ for (const col of KNOWN_COLS) {
1302
1581
  const colDir = path.join(BACKLOG, col);
1303
1582
  try {
1304
1583
  const files = await fs.readdir(colDir);
@@ -1341,7 +1620,7 @@ async function migrateAll(options = {}) {
1341
1620
  const migrated = [];
1342
1621
  const errors = [];
1343
1622
 
1344
- for (const col of COLS) {
1623
+ for (const col of KNOWN_COLS) {
1345
1624
  const colDir = path.join(BACKLOG, col);
1346
1625
  let files;
1347
1626
  try {
@@ -1387,7 +1666,7 @@ async function migrateAll(options = {}) {
1387
1666
  async function nextTaskNumber() {
1388
1667
  const ids = [];
1389
1668
 
1390
- for (const col of COLS) {
1669
+ for (const col of KNOWN_COLS) {
1391
1670
  const colDir = path.join(BACKLOG, col);
1392
1671
  try {
1393
1672
  const files = await fs.readdir(colDir);
@@ -1421,6 +1700,71 @@ function allowedColumnsFrom(fromColumn) {
1421
1700
  return COLUMN_TRANSITIONS[fromColumn] ? COLUMN_TRANSITIONS[fromColumn].slice() : [];
1422
1701
  }
1423
1702
 
1703
+ /** List tasks sitting in a known physical column dir (even if that column is disabled). */
1704
+ async function listTasksInKnownColumn(column) {
1705
+ if (!KNOWN_COLS.includes(column)) {
1706
+ throw createKanbanError(
1707
+ 'INVALID_COLUMN',
1708
+ `Column ${column} is not valid`,
1709
+ `Use one of: ${KNOWN_COLS.join(', ')}`,
1710
+ { column, valid_columns: KNOWN_COLS },
1711
+ false,
1712
+ 400
1713
+ );
1714
+ }
1715
+ const colDir = path.join(BACKLOG, column);
1716
+ const tasks = [];
1717
+ try {
1718
+ const files = await fs.readdir(colDir);
1719
+ for (const file of files) {
1720
+ if (!isTaskOrEpicDataFile(file)) continue;
1721
+ try {
1722
+ tasks.push(await parseEpic(path.join(colDir, file), column));
1723
+ } catch (error) {
1724
+ if (error.code === 'ENOENT') continue;
1725
+ throw error;
1726
+ }
1727
+ }
1728
+ } catch (error) {
1729
+ if (error.code !== 'ENOENT') throw error;
1730
+ }
1731
+ return tasks;
1732
+ }
1733
+
1734
+ /**
1735
+ * Force-move a task to a column without transition checks (config migration only).
1736
+ * Still validates target is a known column and uses writeTask + board lock.
1737
+ */
1738
+ async function relocateTask(taskId, targetColumn) {
1739
+ if (!KNOWN_COLS.includes(targetColumn)) {
1740
+ throw createKanbanError(
1741
+ 'INVALID_COLUMN',
1742
+ `Column ${targetColumn} is not valid`,
1743
+ `Use one of: ${KNOWN_COLS.join(', ')}`,
1744
+ { column: targetColumn, valid_columns: KNOWN_COLS },
1745
+ false,
1746
+ 400
1747
+ );
1748
+ }
1749
+ return withBoardLock(async () => {
1750
+ const resolvedId = await resolveTaskId(taskId);
1751
+ const previousFilePath = await findFile(resolvedId);
1752
+ if (!previousFilePath) {
1753
+ throw createKanbanError(
1754
+ 'TASK_NOT_FOUND',
1755
+ `Task ${taskId} was not found`,
1756
+ 'Call kanban_read with operation=list to discover valid task ids',
1757
+ { task_id: taskId },
1758
+ false,
1759
+ 404
1760
+ );
1761
+ }
1762
+ const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1763
+ if (current.column === targetColumn) return current;
1764
+ return writeTask({ ...current, column: targetColumn }, previousFilePath);
1765
+ });
1766
+ }
1767
+
1424
1768
  function validateTransition(fromColumn, toColumn, taskId) {
1425
1769
  if (fromColumn === toColumn) return;
1426
1770
  validateColumn(toColumn);
@@ -1497,8 +1841,15 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1497
1841
  notes: extra.notes,
1498
1842
  plan: extra.plan,
1499
1843
  adr: extra.adr,
1500
- evidence: extra.evidence
1844
+ evidence: extra.evidence,
1845
+ depends_on: extra.depends_on,
1846
+ files: extra.files
1501
1847
  });
1848
+ const existing = await allTasks();
1849
+ assertNoDependencyCycle(task.id, normalizeDependsOn(extra.depends_on), existing);
1850
+ if (isWorkColumn(task.column)) {
1851
+ assertUnblockedForColumn(task, task.column, existing);
1852
+ }
1502
1853
  try {
1503
1854
  return await writeTask(task, null, { exclusive: true });
1504
1855
  } catch (error) {
@@ -1540,6 +1891,32 @@ async function updateTaskRecord(taskId, patch) {
1540
1891
  }
1541
1892
  next.column = patch.column;
1542
1893
  }
1894
+ if (patch.depends_on !== undefined) {
1895
+ if (!Array.isArray(patch.depends_on)) {
1896
+ throw createKanbanError(
1897
+ 'VALIDATION_ERROR',
1898
+ 'depends_on must be an array of task ids',
1899
+ 'Send depends_on as an array like ["001"]',
1900
+ { field: 'depends_on' },
1901
+ false,
1902
+ 400
1903
+ );
1904
+ }
1905
+ next.depends_on = patch.depends_on;
1906
+ }
1907
+ if (patch.files !== undefined) {
1908
+ if (!Array.isArray(patch.files)) {
1909
+ throw createKanbanError(
1910
+ 'VALIDATION_ERROR',
1911
+ 'files must be an array of paths',
1912
+ 'Send files as an array like ["src/foo.js"]',
1913
+ { field: 'files' },
1914
+ false,
1915
+ 400
1916
+ );
1917
+ }
1918
+ next.files = patch.files;
1919
+ }
1543
1920
  if (patch.title !== undefined) {
1544
1921
  const title = normalizeString(patch.title);
1545
1922
  if (!title) {
@@ -1683,7 +2060,26 @@ async function updateTaskRecord(taskId, patch) {
1683
2060
  next.evidence = patch.evidence;
1684
2061
  }
1685
2062
 
1686
- return writeTask(next, previousFilePath);
2063
+ const columnChanged = next.column !== current.column;
2064
+ const depsChanged = patch.depends_on !== undefined;
2065
+ const enteringDone = columnChanged && next.column === 'done';
2066
+ let others = null;
2067
+ if (depsChanged || (columnChanged && isWorkColumn(next.column)) || enteringDone) {
2068
+ others = await allTasks();
2069
+ }
2070
+ if (depsChanged) {
2071
+ assertNoDependencyCycle(current.id, normalizeDependsOn(next.depends_on), others);
2072
+ }
2073
+ if (columnChanged && isWorkColumn(next.column)) {
2074
+ assertUnblockedForColumn(normalizeTask(next), next.column, others);
2075
+ }
2076
+
2077
+ const written = await writeTask(next, previousFilePath);
2078
+ if (enteringDone) {
2079
+ const board = others.map((other) => (other.id === written.id ? written : other));
2080
+ written.unblocked_tasks = newlyUnblockedTasks(written.id, board);
2081
+ }
2082
+ return written;
1687
2083
  }
1688
2084
 
1689
2085
  function scheduleWorkflowEnqueue(previousColumn, updated) {
@@ -1808,10 +2204,18 @@ module.exports = {
1808
2204
  missingRecommendedCreateFields,
1809
2205
  missingRecommendedEpicCreateFields,
1810
2206
  getProgress,
2207
+ getContextPayload,
2208
+ unmetDependencies,
2209
+ computeBlocks,
2210
+ newlyUnblockedTasks,
1811
2211
  getEpicProgress,
1812
2212
  deriveEpicStatus,
1813
2213
  resolveTaskId,
2214
+ applyBoardLayout,
2215
+ listTasksInKnownColumn,
2216
+ relocateTask,
1814
2217
  COLS,
2218
+ KNOWN_COLS,
1815
2219
  STATUS_MAP,
1816
2220
  WORKFLOW_STAGES,
1817
2221
  COLUMN_TRANSITIONS,
@@ -1826,3 +2230,8 @@ module.exports = {
1826
2230
  RECOMMENDED_EPIC_CREATE_FIELDS,
1827
2231
  EPICS_DIR
1828
2232
  };
2233
+
2234
+ // Keep live refs for applyBoardLayout consumers that read module.exports.COLS
2235
+ module.exports.COLS = COLS;
2236
+ module.exports.COLUMN_TRANSITIONS = COLUMN_TRANSITIONS;
2237
+ module.exports.WORKFLOW_STAGES = WORKFLOW_STAGES;