kanbango 3.5.0 → 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,15 +3,56 @@ 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', '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',
10
13
  icebox: 'icebox',
14
+ testing: 'testing',
15
+ review: 'review',
11
16
  done: 'done'
12
17
  };
18
+ /** Gate stages that may spawn agents — subset of active COLS. */
19
+ let WORKFLOW_STAGES = ['testing', 'review'];
20
+ const WORKFLOW_STATUSES = ['idle', 'running', 'pass', 'fail', 'blocked'];
21
+ const EVIDENCE_VERDICTS = ['pass', 'fail', 'blocked', ''];
22
+ // Agent/human move contract. Same column is always a no-op. Mutated by applyBoardLayout.
23
+ let COLUMN_TRANSITIONS = {
24
+ icebox: ['planned'],
25
+ planned: ['active', 'icebox', 'testing'],
26
+ active: ['planned', 'testing', 'icebox'],
27
+ testing: ['active', 'review'],
28
+ review: ['active', 'done'],
29
+ done: ['active']
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
+ }
13
54
  const VIEW_FIELDS = {
14
- summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
55
+ summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress', 'blocked'],
15
56
  planning: [
16
57
  'task_number',
17
58
  'title',
@@ -26,7 +67,11 @@ const VIEW_FIELDS = {
26
67
  'in_scope',
27
68
  'out_of_scope',
28
69
  'acceptance_criteria',
29
- 'test_cases'
70
+ 'test_cases',
71
+ 'depends_on',
72
+ 'blocked',
73
+ 'unmet_dependencies',
74
+ 'blocks'
30
75
  ],
31
76
  execution: [
32
77
  'task_number',
@@ -44,7 +89,15 @@ const VIEW_FIELDS = {
44
89
  'acceptance_criteria',
45
90
  'test_cases',
46
91
  'subtasks',
47
- 'adr'
92
+ 'adr',
93
+ 'evidence',
94
+ 'plan',
95
+ 'workflow',
96
+ 'depends_on',
97
+ 'files',
98
+ 'blocked',
99
+ 'unmet_dependencies',
100
+ 'blocks'
48
101
  ],
49
102
  full: [
50
103
  'task_number',
@@ -63,7 +116,15 @@ const VIEW_FIELDS = {
63
116
  'test_cases',
64
117
  'subtasks',
65
118
  'adr',
66
- 'notes'
119
+ 'notes',
120
+ 'evidence',
121
+ 'plan',
122
+ 'workflow',
123
+ 'depends_on',
124
+ 'files',
125
+ 'blocked',
126
+ 'unmet_dependencies',
127
+ 'blocks'
67
128
  ]
68
129
  };
69
130
 
@@ -188,6 +249,173 @@ function normalizeStringArray(value) {
188
249
  .filter(Boolean);
189
250
  }
190
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
+
191
419
  function isPresentCreateField(field, value) {
192
420
  if (
193
421
  field === 'description'
@@ -261,14 +489,39 @@ function normalizeSubtasks(value) {
261
489
 
262
490
  function normalizeEvidence(value) {
263
491
  if (!Array.isArray(value)) return [];
264
- return value.map((item) => ({
265
- diff: normalizeString(item && item.diff),
266
- test_command: normalizeString(item && item.test_command),
267
- stdout: normalizeString(item && item.stdout),
268
- stderr: normalizeString(item && item.stderr),
269
- exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
270
- created: normalizeString(item && item.created) || todayIso()
271
- }));
492
+ return value.map((item) => {
493
+ const stage = normalizeString(item && item.stage);
494
+ const verdict = normalizeString(item && item.verdict);
495
+ return {
496
+ diff: normalizeString(item && item.diff),
497
+ test_command: normalizeString(item && item.test_command),
498
+ stdout: normalizeString(item && item.stdout),
499
+ stderr: normalizeString(item && item.stderr),
500
+ exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
501
+ created: normalizeString(item && item.created) || todayIso(),
502
+ stage: WORKFLOW_STAGES.includes(stage) || stage === 'plan' ? stage : '',
503
+ agent: normalizeString(item && item.agent),
504
+ verdict: EVIDENCE_VERDICTS.includes(verdict) ? verdict : '',
505
+ summary: normalizeString(item && item.summary)
506
+ };
507
+ });
508
+ }
509
+
510
+ function normalizeWorkflow(value) {
511
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
512
+ const stage = normalizeString(value.stage);
513
+ const status = normalizeString(value.status);
514
+ const agent = normalizeString(value.agent);
515
+ const runId = normalizeString(value.run_id);
516
+ if (!stage && !status && !agent && !runId) return null;
517
+ return {
518
+ stage: WORKFLOW_STAGES.includes(stage) ? stage : null,
519
+ status: WORKFLOW_STATUSES.includes(status) ? status : 'idle',
520
+ agent,
521
+ run_id: runId,
522
+ started_at: normalizeString(value.started_at) || undefined,
523
+ finished_at: normalizeString(value.finished_at) || undefined
524
+ };
272
525
  }
273
526
 
274
527
  function normalizeAdr(value) {
@@ -340,7 +593,8 @@ function normalizeTask(task) {
340
593
  const normalized = {
341
594
  id,
342
595
  title: stripTitlePrefix(task.title || id),
343
- 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',
344
598
  epic_id: epicId,
345
599
  epic_group: epicId ? (epicGroup === '—' ? epicId : epicGroup) : (epicGroup === '—' ? '—' : epicGroup),
346
600
  created: normalizeString(task.created) || todayIso(),
@@ -355,6 +609,9 @@ function normalizeTask(task) {
355
609
  notes: normalizeString(task.notes),
356
610
  plan: normalizePlan(task.plan),
357
611
  evidence: normalizeEvidence(task.evidence),
612
+ workflow: normalizeWorkflow(task.workflow),
613
+ depends_on: normalizeDependsOn(task.depends_on).filter((depId) => depId !== id),
614
+ files: normalizeFiles(task.files),
358
615
  task_number: extractTaskNumber(id)
359
616
  };
360
617
 
@@ -381,6 +638,9 @@ function serializeTask(task) {
381
638
  notes: normalized.notes,
382
639
  plan: normalized.plan,
383
640
  evidence: normalized.evidence,
641
+ workflow: normalized.workflow,
642
+ depends_on: normalized.depends_on,
643
+ files: normalized.files,
384
644
  task_number: normalized.task_number
385
645
  };
386
646
  }
@@ -418,7 +678,9 @@ function serializeEpic(epic) {
418
678
  function deriveEpicStatus(tasks, epic) {
419
679
  if (epic && epic.archived) return 'archived';
420
680
  if (!tasks || tasks.length === 0) return 'empty';
421
- if (tasks.some((task) => task.column === 'active')) return 'active';
681
+ if (tasks.some((task) => WORKFLOW_STAGES.includes(task.column) || task.column === 'active')) {
682
+ return 'active';
683
+ }
422
684
  if (tasks.every((task) => task.column === 'done')) return 'done';
423
685
  return 'planned';
424
686
  }
@@ -436,13 +698,21 @@ function getEpicProgress(tasks) {
436
698
  tasks_done: 0,
437
699
  tasks_active: 0,
438
700
  tasks_planned: 0,
439
- tasks_icebox: 0
701
+ tasks_icebox: 0,
702
+ tasks_testing: 0,
703
+ tasks_review: 0
704
+ };
705
+ const keyByCol = {
706
+ done: 'tasks_done',
707
+ active: 'tasks_active',
708
+ planned: 'tasks_planned',
709
+ icebox: 'tasks_icebox',
710
+ testing: 'tasks_testing',
711
+ review: 'tasks_review'
440
712
  };
441
713
  for (const task of tasks) {
442
- if (task.column === 'done') progress.tasks_done += 1;
443
- else if (task.column === 'active') progress.tasks_active += 1;
444
- else if (task.column === 'planned') progress.tasks_planned += 1;
445
- else if (task.column === 'icebox') progress.tasks_icebox += 1;
714
+ const key = keyByCol[task.column];
715
+ if (key) progress[key] += 1;
446
716
  }
447
717
  return progress;
448
718
  }
@@ -482,7 +752,7 @@ function shapeEpic(epic, tasks = [], options = {}) {
482
752
  ...normalized,
483
753
  status: deriveEpicStatus(childTasks, normalized),
484
754
  progress: getEpicProgress(childTasks),
485
- tasks: childTasks.map((task) => shapeTask(task, { view: 'summary' })),
755
+ tasks: childTasks.map((task) => shapeTask(task, { view: 'summary', allTasks: tasks })),
486
756
  adrs: collectEpicAdrs(childTasks)
487
757
  };
488
758
  const fields = Array.isArray(options.fields) && options.fields.length > 0
@@ -560,6 +830,66 @@ function getProgress(task) {
560
830
  return { done, total };
561
831
  }
562
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
+
563
893
  function resolveEpicGoals(task, epicLookup) {
564
894
  if (!task.epic_id) return '';
565
895
  if (!epicLookup || typeof epicLookup !== 'object') return '';
@@ -593,10 +923,15 @@ function shapeTask(task, options = {}) {
593
923
  const fields = Array.isArray(options.fields) && options.fields.length > 0
594
924
  ? options.fields
595
925
  : (VIEW_FIELDS[options.view || 'full'] || VIEW_FIELDS.full);
926
+ const allTasks = options.allTasks;
927
+ const unmet = allTasks ? unmetDependencies(normalized, allTasks) : [];
596
928
 
597
929
  const withGoals = {
598
930
  ...normalized,
599
- 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) : []
600
935
  };
601
936
  return pickFields(withGoals, fields);
602
937
  }
@@ -627,7 +962,8 @@ function parseListSection(sectionText) {
627
962
  }
628
963
 
629
964
  async function ensureBacklogDir() {
630
- 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) {
631
967
  const colDir = path.join(BACKLOG, col);
632
968
  await fs.mkdir(colDir, { recursive: true });
633
969
  }
@@ -716,7 +1052,7 @@ async function parseEpic(filePath, column) {
716
1052
  async function allEpics() {
717
1053
  const epics = [];
718
1054
 
719
- for (const col of COLS) {
1055
+ for (const col of KNOWN_COLS) {
720
1056
  const colDir = path.join(BACKLOG, col);
721
1057
  try {
722
1058
  const files = await fs.readdir(colDir);
@@ -1175,7 +1511,7 @@ function taskMatchesEpicFilter(task, epicFilter) {
1175
1511
  }
1176
1512
 
1177
1513
  async function findFile(epicId) {
1178
- for (const col of COLS) {
1514
+ for (const col of KNOWN_COLS) {
1179
1515
  const colDir = path.join(BACKLOG, col);
1180
1516
  try {
1181
1517
  const files = await fs.readdir(colDir);
@@ -1220,7 +1556,7 @@ async function resolveTaskId(input) {
1220
1556
  const num = parseInt(String(input), 10);
1221
1557
  if (!Number.isFinite(num)) return null;
1222
1558
 
1223
- for (const col of COLS) {
1559
+ for (const col of KNOWN_COLS) {
1224
1560
  const colDir = path.join(BACKLOG, col);
1225
1561
  try {
1226
1562
  const files = await fs.readdir(colDir);
@@ -1241,7 +1577,7 @@ async function resolveTaskId(input) {
1241
1577
 
1242
1578
  async function removeOtherTaskCopies(taskId, keepPath) {
1243
1579
  const keep = path.resolve(keepPath);
1244
- for (const col of COLS) {
1580
+ for (const col of KNOWN_COLS) {
1245
1581
  const colDir = path.join(BACKLOG, col);
1246
1582
  try {
1247
1583
  const files = await fs.readdir(colDir);
@@ -1284,7 +1620,7 @@ async function migrateAll(options = {}) {
1284
1620
  const migrated = [];
1285
1621
  const errors = [];
1286
1622
 
1287
- for (const col of COLS) {
1623
+ for (const col of KNOWN_COLS) {
1288
1624
  const colDir = path.join(BACKLOG, col);
1289
1625
  let files;
1290
1626
  try {
@@ -1330,7 +1666,7 @@ async function migrateAll(options = {}) {
1330
1666
  async function nextTaskNumber() {
1331
1667
  const ids = [];
1332
1668
 
1333
- for (const col of COLS) {
1669
+ for (const col of KNOWN_COLS) {
1334
1670
  const colDir = path.join(BACKLOG, col);
1335
1671
  try {
1336
1672
  const files = await fs.readdir(colDir);
@@ -1360,6 +1696,97 @@ function validateColumn(column, fieldName = 'column') {
1360
1696
  }
1361
1697
  }
1362
1698
 
1699
+ function allowedColumnsFrom(fromColumn) {
1700
+ return COLUMN_TRANSITIONS[fromColumn] ? COLUMN_TRANSITIONS[fromColumn].slice() : [];
1701
+ }
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
+
1768
+ function validateTransition(fromColumn, toColumn, taskId) {
1769
+ if (fromColumn === toColumn) return;
1770
+ validateColumn(toColumn);
1771
+ const allowed = allowedColumnsFrom(fromColumn);
1772
+ if (allowed.includes(toColumn)) return;
1773
+ throw createKanbanError(
1774
+ 'INVALID_TRANSITION',
1775
+ taskId
1776
+ ? `Cannot move task ${taskId} from ${fromColumn} to ${toColumn}`
1777
+ : `Cannot move from ${fromColumn} to ${toColumn}`,
1778
+ `From ${fromColumn} you can move only to: ${allowed.join(', ') || '(none)'}`,
1779
+ {
1780
+ task_id: taskId || undefined,
1781
+ from: fromColumn,
1782
+ to: toColumn,
1783
+ allowed_columns: allowed
1784
+ },
1785
+ false,
1786
+ 400
1787
+ );
1788
+ }
1789
+
1363
1790
  function validatePatch(patch) {
1364
1791
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1365
1792
  throw createKanbanError(
@@ -1413,8 +1840,16 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1413
1840
  subtasks: extra.subtasks,
1414
1841
  notes: extra.notes,
1415
1842
  plan: extra.plan,
1416
- evidence: extra.evidence
1843
+ adr: extra.adr,
1844
+ evidence: extra.evidence,
1845
+ depends_on: extra.depends_on,
1846
+ files: extra.files
1417
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
+ }
1418
1853
  try {
1419
1854
  return await writeTask(task, null, { exclusive: true });
1420
1855
  } catch (error) {
@@ -1451,8 +1886,37 @@ async function updateTaskRecord(taskId, patch) {
1451
1886
 
1452
1887
  if (patch.column !== undefined) {
1453
1888
  validateColumn(patch.column);
1889
+ if (patch.column !== current.column) {
1890
+ validateTransition(current.column, patch.column, current.id);
1891
+ }
1454
1892
  next.column = patch.column;
1455
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
+ }
1456
1920
  if (patch.title !== undefined) {
1457
1921
  const title = normalizeString(patch.title);
1458
1922
  if (!title) {
@@ -1566,6 +2030,22 @@ async function updateTaskRecord(taskId, patch) {
1566
2030
  }
1567
2031
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1568
2032
  if (patch.plan !== undefined) next.plan = patch.plan;
2033
+ if (patch.workflow !== undefined) {
2034
+ next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
2035
+ }
2036
+ if (patch.appendEvidence !== undefined) {
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
+ }
1569
2049
  if (patch.evidence !== undefined) {
1570
2050
  if (!Array.isArray(patch.evidence)) {
1571
2051
  throw createKanbanError(
@@ -1580,12 +2060,58 @@ async function updateTaskRecord(taskId, patch) {
1580
2060
  next.evidence = patch.evidence;
1581
2061
  }
1582
2062
 
1583
- 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;
2083
+ }
2084
+
2085
+ function scheduleWorkflowEnqueue(previousColumn, updated) {
2086
+ if (!updated || !WORKFLOW_STAGES.includes(updated.column)) return;
2087
+ if (previousColumn === updated.column) return;
2088
+ // Lazy require avoids circular load: workflow.js requires kanban.js.
2089
+ setImmediate(() => {
2090
+ try {
2091
+ const workflow = require('./workflow.js');
2092
+ Promise.resolve(workflow.maybeEnqueueOnColumnEnter(updated, previousColumn)).catch((err) => {
2093
+ console.error('workflow enqueue failed:', err && err.message ? err.message : err);
2094
+ });
2095
+ } catch (err) {
2096
+ console.error('workflow load failed:', err && err.message ? err.message : err);
2097
+ }
2098
+ });
1584
2099
  }
1585
2100
 
1586
2101
  async function updateTask(taskId, patch) {
1587
2102
  validatePatch(patch);
1588
- return withBoardLock(() => updateTaskRecord(taskId, patch));
2103
+ let previousColumn = null;
2104
+ const updated = await withBoardLock(async () => {
2105
+ const resolvedId = await resolveTaskId(taskId);
2106
+ const previousFilePath = await findFile(resolvedId);
2107
+ if (previousFilePath) {
2108
+ const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
2109
+ previousColumn = current.column;
2110
+ }
2111
+ return updateTaskRecord(taskId, patch);
2112
+ });
2113
+ scheduleWorkflowEnqueue(previousColumn, updated);
2114
+ return updated;
1589
2115
  }
1590
2116
 
1591
2117
  async function doMove(epicId, target) {
@@ -1593,7 +2119,7 @@ async function doMove(epicId, target) {
1593
2119
  await updateTask(epicId, { column: target });
1594
2120
  return true;
1595
2121
  } catch (error) {
1596
- if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN') {
2122
+ if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN' || error.code === 'INVALID_TRANSITION') {
1597
2123
  return false;
1598
2124
  }
1599
2125
  throw error;
@@ -1678,15 +2204,34 @@ module.exports = {
1678
2204
  missingRecommendedCreateFields,
1679
2205
  missingRecommendedEpicCreateFields,
1680
2206
  getProgress,
2207
+ getContextPayload,
2208
+ unmetDependencies,
2209
+ computeBlocks,
2210
+ newlyUnblockedTasks,
1681
2211
  getEpicProgress,
1682
2212
  deriveEpicStatus,
1683
2213
  resolveTaskId,
2214
+ applyBoardLayout,
2215
+ listTasksInKnownColumn,
2216
+ relocateTask,
1684
2217
  COLS,
2218
+ KNOWN_COLS,
1685
2219
  STATUS_MAP,
2220
+ WORKFLOW_STAGES,
2221
+ COLUMN_TRANSITIONS,
2222
+ allowedColumnsFrom,
2223
+ validateTransition,
1686
2224
  VIEW_FIELDS,
1687
2225
  EPIC_VIEW_FIELDS,
2226
+ normalizeEvidence,
2227
+ normalizeWorkflow,
1688
2228
  LIVE_EPIC_STATUSES,
1689
2229
  RECOMMENDED_CREATE_FIELDS,
1690
2230
  RECOMMENDED_EPIC_CREATE_FIELDS,
1691
2231
  EPICS_DIR
1692
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;