kanbango 3.8.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kanban.js CHANGED
@@ -1,8 +1,17 @@
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');
4
+ function backlogDir() {
5
+ return path.join(process.cwd(), 'backlog');
6
+ }
7
+
8
+ function epicsDir() {
9
+ return path.join(backlogDir(), 'epics');
10
+ }
11
+
12
+ const BOARD_LOCK_NAME = '.board.lock';
13
+ const BOARD_LOCK_TIMEOUT_MS = 2000;
14
+ const BOARD_LOCK_POLL_MS = 50;
6
15
  /** All physical column dirs that may exist on disk (including disabled gates). */
7
16
  const KNOWN_COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
8
17
  /** Active columns for validation / MCP / create — mutated by applyBoardLayout. */
@@ -52,7 +61,7 @@ function applyBoardLayout({ cols, transitions, workflowStages } = {}) {
52
61
  module.exports.WORKFLOW_STAGES = WORKFLOW_STAGES;
53
62
  }
54
63
  const VIEW_FIELDS = {
55
- summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress', 'blocked'],
64
+ summary: ['id', 'task_number', 'title', 'column', 'epic_id', 'progress', 'blocked'],
56
65
  planning: [
57
66
  'task_number',
58
67
  'title',
@@ -74,30 +83,17 @@ const VIEW_FIELDS = {
74
83
  'blocks'
75
84
  ],
76
85
  execution: [
86
+ 'id',
77
87
  'task_number',
78
88
  'title',
79
89
  'column',
80
90
  'epic_id',
81
- 'epic_group',
82
- 'epic_goals',
83
- 'created',
84
91
  'progress',
85
- 'description',
86
- 'specs',
87
- 'in_scope',
88
- 'out_of_scope',
89
- 'acceptance_criteria',
90
- 'test_cases',
92
+ 'current_subtask',
91
93
  'subtasks',
92
- 'adr',
93
- 'evidence',
94
- 'plan',
95
94
  'workflow',
96
- 'depends_on',
97
95
  'files',
98
- 'blocked',
99
- 'unmet_dependencies',
100
- 'blocks'
96
+ 'blocked'
101
97
  ],
102
98
  full: [
103
99
  'task_number',
@@ -189,10 +185,18 @@ function createKanbanError(code, message, hint, details = {}, retryable = false,
189
185
  }
190
186
 
191
187
  // Serialize board mutations so concurrent create/move/update cannot race on ids or paths.
188
+ // mutationTail = in-process; backlog/.board.lock = cross-process.
192
189
  let mutationTail = Promise.resolve();
193
190
 
194
191
  function withBoardLock(fn) {
195
- const run = mutationTail.then(() => 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
+ });
196
200
  mutationTail = run.then(() => undefined, () => undefined);
197
201
  return run;
198
202
  }
@@ -225,6 +229,119 @@ async function writeFileAtomic(filePath, payload, { exclusive = false } = {}) {
225
229
  }
226
230
  }
227
231
 
232
+ function boardLockPath() {
233
+ return path.join(backlogDir(), BOARD_LOCK_NAME);
234
+ }
235
+
236
+ function newBoardLockOwner() {
237
+ return {
238
+ pid: process.pid,
239
+ token: `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`,
240
+ started: new Date().toISOString()
241
+ };
242
+ }
243
+
244
+ function sleep(ms) {
245
+ return new Promise((resolve) => setTimeout(resolve, ms));
246
+ }
247
+
248
+ function isPidAlive(pid) {
249
+ try {
250
+ process.kill(pid, 0);
251
+ return true;
252
+ } catch (error) {
253
+ if (error.code === 'ESRCH') return false;
254
+ return true;
255
+ }
256
+ }
257
+
258
+ function boardLockedError(details = {}) {
259
+ return createKanbanError(
260
+ 'BOARD_LOCKED',
261
+ 'Board is locked by another process',
262
+ 'Retry the mutation after the other process finishes writing',
263
+ details,
264
+ true,
265
+ 409
266
+ );
267
+ }
268
+
269
+ async function readBoardLockOwner() {
270
+ let raw;
271
+ try {
272
+ raw = await fs.readFile(boardLockPath(), 'utf-8');
273
+ } catch (error) {
274
+ if (error.code === 'ENOENT') return undefined;
275
+ return null;
276
+ }
277
+ try {
278
+ const data = JSON.parse(raw);
279
+ const pid = Number(data && data.pid);
280
+ const token = data && typeof data.token === 'string' ? data.token : '';
281
+ if (!Number.isInteger(pid) || pid <= 0 || !token) return null;
282
+ return { pid, token, started: data.started };
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ async function tryPublishBoardLock(owner) {
289
+ await ensureBacklogDir();
290
+ try {
291
+ await writeFileAtomic(boardLockPath(), JSON.stringify(owner) + '\n', { exclusive: true });
292
+ return true;
293
+ } catch (error) {
294
+ if (error.code === 'EEXIST') return false;
295
+ throw error;
296
+ }
297
+ }
298
+
299
+ async function stealDeadBoardLock(expectedToken) {
300
+ const current = await readBoardLockOwner();
301
+ if (!current || current.token !== expectedToken) return false;
302
+ if (isPidAlive(current.pid)) return false;
303
+ const again = await readBoardLockOwner();
304
+ if (!again || again.token !== expectedToken) return false;
305
+ if (isPidAlive(again.pid)) return false;
306
+ try {
307
+ await fs.unlink(boardLockPath());
308
+ } catch (error) {
309
+ if (error.code !== 'ENOENT') throw error;
310
+ }
311
+ return true;
312
+ }
313
+
314
+ async function acquireBoardLock() {
315
+ const owner = newBoardLockOwner();
316
+ const deadline = Date.now() + BOARD_LOCK_TIMEOUT_MS;
317
+ while (Date.now() <= deadline) {
318
+ if (await tryPublishBoardLock(owner)) return owner;
319
+ await sleep(BOARD_LOCK_POLL_MS);
320
+ }
321
+ const observed = await readBoardLockOwner();
322
+ if (observed && observed.token && !isPidAlive(observed.pid)) {
323
+ await stealDeadBoardLock(observed.token);
324
+ if (await tryPublishBoardLock(owner)) return owner;
325
+ } else if (observed === undefined) {
326
+ if (await tryPublishBoardLock(owner)) return owner;
327
+ }
328
+ throw boardLockedError({
329
+ timeout_ms: BOARD_LOCK_TIMEOUT_MS,
330
+ holder_pid: observed && observed.pid
331
+ });
332
+ }
333
+
334
+ async function releaseBoardLock(owner) {
335
+ if (!owner || !owner.token) return;
336
+ const current = await readBoardLockOwner();
337
+ if (!current || current.token !== owner.token) return;
338
+ try {
339
+ await fs.unlink(boardLockPath());
340
+ } catch (error) {
341
+ if (error.code !== 'ENOENT') throw error;
342
+ }
343
+ }
344
+
228
345
  function todayIso() {
229
346
  return new Date().toISOString().split('T')[0];
230
347
  }
@@ -249,6 +366,48 @@ function normalizeStringArray(value) {
249
366
  .filter(Boolean);
250
367
  }
251
368
 
369
+ function assertIsArray(value, field, message, hint) {
370
+ if (Array.isArray(value)) return;
371
+ throw createKanbanError(
372
+ 'VALIDATION_ERROR',
373
+ message,
374
+ hint,
375
+ { field },
376
+ false,
377
+ 400
378
+ );
379
+ }
380
+
381
+ function requireStringArray(value, field) {
382
+ assertIsArray(
383
+ value,
384
+ field,
385
+ `${field} must be an array of strings`,
386
+ `Send ${field} as an array`
387
+ );
388
+ return normalizeStringArray(value);
389
+ }
390
+
391
+ function requireNonEmptyTitle(value) {
392
+ const title = normalizeString(value);
393
+ if (!title) {
394
+ throw createKanbanError(
395
+ 'VALIDATION_ERROR',
396
+ 'title must be a non-empty string',
397
+ 'Send a non-empty title or omit the field',
398
+ { field: 'title' },
399
+ false,
400
+ 400
401
+ );
402
+ }
403
+ return title;
404
+ }
405
+
406
+ function applyAdrPatch(existing, adr) {
407
+ if (Array.isArray(adr)) return normalizeAdr(adr);
408
+ return appendAdrEntry(existing, adr);
409
+ }
410
+
252
411
  function normalizeTaskIdRef(value) {
253
412
  const raw = normalizeString(value);
254
413
  if (!raw) return '';
@@ -390,6 +549,68 @@ function firstEnabledGateColumn() {
390
549
  return 'done';
391
550
  }
392
551
 
552
+ function evidenceHasProof(entry) {
553
+ if (!entry || typeof entry !== 'object') return false;
554
+ return Boolean(
555
+ normalizeString(entry.diff)
556
+ || normalizeString(entry.summary)
557
+ || normalizeString(entry.test_command)
558
+ );
559
+ }
560
+
561
+ function hasProofEvidence(evidence) {
562
+ return normalizeEvidence(evidence).some(evidenceHasProof);
563
+ }
564
+
565
+ function requiresEvidenceForColumn(toColumn) {
566
+ if (toColumn === 'testing') return COLS.includes('testing');
567
+ if (toColumn === 'review') return COLS.includes('review');
568
+ if (toColumn === 'done') return COLS.includes('testing') || COLS.includes('review');
569
+ return false;
570
+ }
571
+
572
+ function assertEvidenceForGate(fromTask, toTask) {
573
+ const fromColumn = fromTask && fromTask.column;
574
+ const toColumn = toTask && toTask.column;
575
+ if (!toColumn || fromColumn === toColumn) return;
576
+ if (!requiresEvidenceForColumn(toColumn)) return;
577
+
578
+ const status = toTask.workflow && toTask.workflow.status;
579
+ const fromGate = fromColumn === 'testing' || fromColumn === 'review';
580
+ if (fromGate && (status === 'fail' || status === 'blocked')) {
581
+ throw createKanbanError(
582
+ 'EVIDENCE_REQUIRED',
583
+ `Cannot move task ${toTask.id} from ${fromColumn} to ${toColumn} while workflow is ${status}`,
584
+ 'Move back to active, add new evidence (diff|summary|test_command), then re-enter the gate',
585
+ {
586
+ task_id: toTask.id,
587
+ from: fromColumn,
588
+ to: toColumn,
589
+ workflow_status: status
590
+ },
591
+ false,
592
+ 400
593
+ );
594
+ }
595
+
596
+ if (!hasProofEvidence(toTask.evidence)) {
597
+ throw createKanbanError(
598
+ 'EVIDENCE_REQUIRED',
599
+ `Cannot move task ${toTask.id} from ${fromColumn} to ${toColumn} without evidence`,
600
+ 'Add evidence with at least one of diff, summary, or test_command before entering the gate',
601
+ {
602
+ task_id: toTask.id,
603
+ from: fromColumn,
604
+ to: toColumn,
605
+ missing: ['evidence'],
606
+ need: 'diff|summary|test_command'
607
+ },
608
+ false,
609
+ 400
610
+ );
611
+ }
612
+ }
613
+
393
614
  function nextMoveAction() {
394
615
  const gate = firstEnabledGateColumn();
395
616
  if (gate === 'testing') return 'move_testing';
@@ -408,8 +629,7 @@ function resolveNextAction(task, mode) {
408
629
  return 'fix_gate';
409
630
  }
410
631
  if (currentSubtask(task)) return 'advance';
411
- const evidence = Array.isArray(task.evidence) ? task.evidence : [];
412
- if (evidence.length === 0) return 'evidence';
632
+ if (!hasProofEvidence(task.evidence)) return 'evidence';
413
633
  if (task.column === 'active') return nextMoveAction();
414
634
  if (task.column === 'testing' && COLS.includes('review')) return 'move_review';
415
635
  if (task.column === 'testing' || task.column === 'review') return 'move_done';
@@ -720,9 +940,11 @@ function getEpicProgress(tasks) {
720
940
  function pickEpicFields(epicPayload, fieldNames) {
721
941
  const picked = {};
722
942
  for (const field of fieldNames) {
723
- if (field in epicPayload) {
724
- picked[field] = epicPayload[field];
725
- }
943
+ if (!(field in epicPayload)) continue;
944
+ const value = epicPayload[field];
945
+ if (value === null || value === '') continue;
946
+ if (Array.isArray(value) && value.length === 0) continue;
947
+ picked[field] = value;
726
948
  }
727
949
  return picked;
728
950
  }
@@ -809,7 +1031,7 @@ function nonLiveEpicIdSet(epics, tasks, options = {}) {
809
1031
 
810
1032
  function filterTasksForList(tasks, epics, options = {}) {
811
1033
  // Explicit epic filter (show that epic's tasks) is applied by caller after this.
812
- // Default agent list: hide tasks under done/archived epics.
1034
+ // Default agent list: hide tasks under done/archived epics and column=done.
813
1035
  if (options.include_archived && options.include_done) return tasks;
814
1036
  // GUI path: hide only archived-epic tasks (done epics still show done cards)
815
1037
  if (options.live_only === false) {
@@ -819,9 +1041,19 @@ function filterTasksForList(tasks, epics, options = {}) {
819
1041
  return tasks.filter((task) => !task.epic_id || !archivedIds.has(task.epic_id));
820
1042
  }
821
1043
 
822
- const hiddenIds = nonLiveEpicIdSet(epics, tasks, options);
823
- if (hiddenIds.size === 0) return tasks;
824
- 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;
825
1057
  }
826
1058
 
827
1059
  function getProgress(task) {
@@ -837,10 +1069,28 @@ function currentSubtask(task) {
837
1069
  return { id: open.id, text: open.text };
838
1070
  }
839
1071
 
1072
+ function evidenceProofText(entry) {
1073
+ return normalizeString(entry && entry.summary)
1074
+ || normalizeString(entry && entry.test_command)
1075
+ || normalizeString(entry && entry.diff);
1076
+ }
1077
+
1078
+ function lastContextResult(task) {
1079
+ const evidence = Array.isArray(task && task.evidence) ? task.evidence : [];
1080
+ for (let i = evidence.length - 1; i >= 0; i--) {
1081
+ const proof = evidenceProofText(evidence[i]);
1082
+ if (proof) return proof;
1083
+ }
1084
+ const notes = normalizeString(task && task.notes);
1085
+ if (notes) return notes;
1086
+ const adr = Array.isArray(task && task.adr) ? task.adr : [];
1087
+ return normalizeString(adr[adr.length - 1] && adr[adr.length - 1].decision);
1088
+ }
1089
+
840
1090
  function compactContextTask(task, allTasks, epicLookup, mode) {
841
1091
  const unmet = unmetDependencies(task, allTasks);
842
1092
  const subtask = currentSubtask(task);
843
- return {
1093
+ const payload = {
844
1094
  mode,
845
1095
  next_action: resolveNextAction(task, mode),
846
1096
  task_id: task.id,
@@ -851,10 +1101,31 @@ function compactContextTask(task, allTasks, epicLookup, mode) {
851
1101
  current_subtask: subtask,
852
1102
  progress: getProgress(task),
853
1103
  files: task.files || [],
1104
+ last_result: lastContextResult(task),
854
1105
  blocked: unmet.length > 0,
855
1106
  unmet_dependencies: unmet.map((item) => item.id),
856
1107
  blocks: computeBlocks(task.id, allTasks)
857
1108
  };
1109
+ for (const key of Object.keys(payload)) {
1110
+ const value = payload[key];
1111
+ if (value === null || value === '') delete payload[key];
1112
+ else if (Array.isArray(value) && value.length === 0) delete payload[key];
1113
+ }
1114
+ return payload;
1115
+ }
1116
+
1117
+ function isUnblockedWork(task, allTasks) {
1118
+ return unmetDependencies(task, allTasks).length === 0;
1119
+ }
1120
+
1121
+ function isFailedGate(task) {
1122
+ const status = task.workflow && task.workflow.status;
1123
+ return (task.column === 'testing' || task.column === 'review')
1124
+ && (status === 'fail' || status === 'blocked');
1125
+ }
1126
+
1127
+ function isManualGate(task) {
1128
+ return (task.column === 'testing' || task.column === 'review') && !isFailedGate(task);
858
1129
  }
859
1130
 
860
1131
  async function getContextPayload(options = {}) {
@@ -869,20 +1140,20 @@ async function getContextPayload(options = {}) {
869
1140
  if (epicFilter) {
870
1141
  liveTasks = liveTasks.filter((task) => taskMatchesEpicFilter(task, epicFilter));
871
1142
  }
872
- const active = liveTasks.find((task) => task.column === 'active');
1143
+ const active = liveTasks.find((task) => task.column === 'active' && isUnblockedWork(task, tasks));
873
1144
  if (active) {
874
1145
  return compactContextTask(active, tasks, epicLookup, 'continue');
875
1146
  }
876
- const gateFix = liveTasks.find((task) => {
877
- const status = task.workflow && task.workflow.status;
878
- return (task.column === 'testing' || task.column === 'review')
879
- && (status === 'fail' || status === 'blocked');
880
- });
1147
+ const gateFix = liveTasks.find((task) => isFailedGate(task));
881
1148
  if (gateFix) {
882
1149
  return compactContextTask(gateFix, tasks, epicLookup, 'continue');
883
1150
  }
1151
+ const manualGate = liveTasks.find((task) => isManualGate(task));
1152
+ if (manualGate) {
1153
+ return compactContextTask(manualGate, tasks, epicLookup, 'continue');
1154
+ }
884
1155
  const ready = liveTasks
885
- .filter((task) => task.column === 'planned' && unmetDependencies(task, tasks).length === 0)
1156
+ .filter((task) => task.column === 'planned' && isUnblockedWork(task, tasks))
886
1157
  .sort((a, b) => (a.task_number || 0) - (b.task_number || 0));
887
1158
  if (ready.length > 0) {
888
1159
  return compactContextTask(ready[0], tasks, epicLookup, 'start');
@@ -902,17 +1173,14 @@ function pickFields(task, fieldNames) {
902
1173
  const picked = {};
903
1174
 
904
1175
  for (const field of fieldNames) {
905
- if (field === 'progress') {
906
- picked.progress = getProgress(task);
907
- continue;
908
- }
909
- if (field === 'epic_goals') {
910
- picked.epic_goals = task.epic_goals;
911
- continue;
912
- }
913
- if (field in task) {
914
- picked[field] = task[field];
915
- }
1176
+ let value;
1177
+ if (field === 'progress') value = getProgress(task);
1178
+ else if (field === 'current_subtask') value = currentSubtask(task);
1179
+ else if (field === 'epic_goals') value = task.epic_goals;
1180
+ else if (field in task) value = task[field];
1181
+ else continue;
1182
+ if (value == null || value === '' || (Array.isArray(value) && !value.length)) continue;
1183
+ picked[field] = value;
916
1184
  }
917
1185
 
918
1186
  return picked;
@@ -964,10 +1232,10 @@ function parseListSection(sectionText) {
964
1232
  async function ensureBacklogDir() {
965
1233
  // Always create the full known set so disabled gates can still hold legacy files until migrated.
966
1234
  for (const col of KNOWN_COLS) {
967
- const colDir = path.join(BACKLOG, col);
1235
+ const colDir = path.join(backlogDir(), col);
968
1236
  await fs.mkdir(colDir, { recursive: true });
969
1237
  }
970
- await fs.mkdir(EPICS_DIR, { recursive: true });
1238
+ await fs.mkdir(epicsDir(), { recursive: true });
971
1239
  }
972
1240
 
973
1241
  async function parseMarkdownTask(filePath, column) {
@@ -1053,7 +1321,7 @@ async function allEpics() {
1053
1321
  const epics = [];
1054
1322
 
1055
1323
  for (const col of KNOWN_COLS) {
1056
- const colDir = path.join(BACKLOG, col);
1324
+ const colDir = path.join(backlogDir(), col);
1057
1325
  try {
1058
1326
  const files = await fs.readdir(colDir);
1059
1327
  const taskFiles = files
@@ -1074,9 +1342,8 @@ async function allEpics() {
1074
1342
  try {
1075
1343
  epics.push(await parseEpic(path.join(colDir, file), col));
1076
1344
  } catch (error) {
1077
- // File may vanish between readdir and read under concurrent delete.
1078
1345
  if (error.code === 'ENOENT') continue;
1079
- console.error(` parse error ${file}: ${error.message}`);
1346
+ throw error;
1080
1347
  }
1081
1348
  }
1082
1349
  } catch (error) {
@@ -1092,14 +1359,14 @@ async function allTasks() {
1092
1359
  }
1093
1360
 
1094
1361
  function epicFilePath(epicId) {
1095
- return path.join(EPICS_DIR, `${epicId}.json`);
1362
+ return path.join(epicsDir(), `${epicId}.json`);
1096
1363
  }
1097
1364
 
1098
1365
  async function nextEpicNumber() {
1099
1366
  await ensureBacklogDir();
1100
1367
  const ids = [];
1101
1368
  try {
1102
- const files = await fs.readdir(EPICS_DIR);
1369
+ const files = await fs.readdir(epicsDir());
1103
1370
  for (const file of files) {
1104
1371
  if (!isTaskOrEpicDataFile(file)) continue;
1105
1372
  const match = file.match(/^E0*(\d+)\.json$/i);
@@ -1149,14 +1416,15 @@ async function listEpicEntities() {
1149
1416
  await ensureBacklogDir();
1150
1417
  const epics = [];
1151
1418
  try {
1152
- const files = (await fs.readdir(EPICS_DIR))
1419
+ const files = (await fs.readdir(epicsDir()))
1153
1420
  .filter((file) => isTaskOrEpicDataFile(file) && file.endsWith('.json'))
1154
1421
  .sort((left, right) => left.localeCompare(right));
1155
1422
  for (const file of files) {
1156
1423
  try {
1157
- epics.push(await parseJsonEpic(path.join(EPICS_DIR, file)));
1424
+ epics.push(await parseJsonEpic(path.join(epicsDir(), file)));
1158
1425
  } catch (error) {
1159
- console.error(` parse error ${file}: ${error.message}`);
1426
+ if (error.code === 'ENOENT') continue;
1427
+ throw error;
1160
1428
  }
1161
1429
  }
1162
1430
  } catch (error) {
@@ -1315,47 +1583,12 @@ async function updateEpicEntity(epicId, patch) {
1315
1583
  const current = await getEpicEntity(epicId);
1316
1584
  const next = { ...current };
1317
1585
 
1318
- if (patch.title !== undefined) {
1319
- const title = normalizeString(patch.title);
1320
- if (!title) {
1321
- throw createKanbanError(
1322
- 'VALIDATION_ERROR',
1323
- 'title must be a non-empty string',
1324
- 'Send a non-empty title or omit the field',
1325
- { field: 'title' },
1326
- false,
1327
- 400
1328
- );
1329
- }
1330
- next.title = title;
1331
- }
1586
+ if (patch.title !== undefined) next.title = requireNonEmptyTitle(patch.title);
1332
1587
  if (patch.description !== undefined) next.description = normalizeString(patch.description);
1333
1588
  if (patch.goals !== undefined) next.goals = normalizeString(patch.goals);
1334
- if (patch.in_scope !== undefined) {
1335
- if (!Array.isArray(patch.in_scope)) {
1336
- throw createKanbanError(
1337
- 'VALIDATION_ERROR',
1338
- 'in_scope must be an array of strings',
1339
- 'Send in_scope as an array',
1340
- { field: 'in_scope' },
1341
- false,
1342
- 400
1343
- );
1344
- }
1345
- next.in_scope = normalizeStringArray(patch.in_scope);
1346
- }
1589
+ if (patch.in_scope !== undefined) next.in_scope = requireStringArray(patch.in_scope, 'in_scope');
1347
1590
  if (patch.out_of_scope !== undefined) {
1348
- if (!Array.isArray(patch.out_of_scope)) {
1349
- throw createKanbanError(
1350
- 'VALIDATION_ERROR',
1351
- 'out_of_scope must be an array of strings',
1352
- 'Send out_of_scope as an array',
1353
- { field: 'out_of_scope' },
1354
- false,
1355
- 400
1356
- );
1357
- }
1358
- next.out_of_scope = normalizeStringArray(patch.out_of_scope);
1591
+ next.out_of_scope = requireStringArray(patch.out_of_scope, 'out_of_scope');
1359
1592
  }
1360
1593
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1361
1594
  if (patch.archived !== undefined) next.archived = Boolean(patch.archived);
@@ -1402,7 +1635,7 @@ async function deleteTaskRecord(taskId) {
1402
1635
  await fs.unlink(filePath).catch((error) => {
1403
1636
  if (error.code !== 'ENOENT') throw error;
1404
1637
  });
1405
- await removeOtherTaskCopies(task.id, path.join(BACKLOG, '__none__', `${task.id}.json`));
1638
+ await removeOtherTaskCopies(task.id, path.join(backlogDir(), '__none__', `${task.id}.json`));
1406
1639
 
1407
1640
  return {
1408
1641
  ok: true,
@@ -1512,7 +1745,7 @@ function taskMatchesEpicFilter(task, epicFilter) {
1512
1745
 
1513
1746
  async function findFile(epicId) {
1514
1747
  for (const col of KNOWN_COLS) {
1515
- const colDir = path.join(BACKLOG, col);
1748
+ const colDir = path.join(backlogDir(), col);
1516
1749
  try {
1517
1750
  const files = await fs.readdir(colDir);
1518
1751
  const candidates = files
@@ -1529,22 +1762,30 @@ async function findFile(epicId) {
1529
1762
  return null;
1530
1763
  }
1531
1764
 
1765
+ function taskNotFound(taskId) {
1766
+ return createKanbanError(
1767
+ 'TASK_NOT_FOUND',
1768
+ `Task ${taskId} was not found`,
1769
+ 'Call kanban_read with operation=list to discover valid task ids',
1770
+ { task_id: taskId },
1771
+ false,
1772
+ 404
1773
+ );
1774
+ }
1775
+
1532
1776
  async function getTask(taskId) {
1533
1777
  const resolvedId = await resolveTaskId(taskId);
1534
- const filePath = await findFile(resolvedId);
1535
- if (!filePath) {
1536
- throw createKanbanError(
1537
- 'TASK_NOT_FOUND',
1538
- `Task ${taskId} was not found`,
1539
- 'Call kanban_read with operation=list to discover valid task ids',
1540
- { task_id: taskId },
1541
- false,
1542
- 404
1543
- );
1778
+ for (let attempt = 0; attempt < 3; attempt++) {
1779
+ const filePath = await findFile(resolvedId);
1780
+ if (!filePath) throw taskNotFound(taskId);
1781
+ try {
1782
+ return await parseEpic(filePath, path.basename(path.dirname(filePath)));
1783
+ } catch (error) {
1784
+ // Relocate between readdir and read (workflow move).
1785
+ if (error.code !== 'ENOENT' || attempt === 2) throw error;
1786
+ }
1544
1787
  }
1545
-
1546
- const column = path.basename(path.dirname(filePath));
1547
- return parseEpic(filePath, column);
1788
+ throw taskNotFound(taskId);
1548
1789
  }
1549
1790
 
1550
1791
  async function resolveTaskId(input) {
@@ -1557,7 +1798,7 @@ async function resolveTaskId(input) {
1557
1798
  if (!Number.isFinite(num)) return null;
1558
1799
 
1559
1800
  for (const col of KNOWN_COLS) {
1560
- const colDir = path.join(BACKLOG, col);
1801
+ const colDir = path.join(backlogDir(), col);
1561
1802
  try {
1562
1803
  const files = await fs.readdir(colDir);
1563
1804
  const rawPattern = new RegExp(`^(?:[A-Z]+-)?${String(num)}(?:-|$)`);
@@ -1578,7 +1819,7 @@ async function resolveTaskId(input) {
1578
1819
  async function removeOtherTaskCopies(taskId, keepPath) {
1579
1820
  const keep = path.resolve(keepPath);
1580
1821
  for (const col of KNOWN_COLS) {
1581
- const colDir = path.join(BACKLOG, col);
1822
+ const colDir = path.join(backlogDir(), col);
1582
1823
  try {
1583
1824
  const files = await fs.readdir(colDir);
1584
1825
  for (const file of files) {
@@ -1600,7 +1841,7 @@ async function writeTask(task, previousFilePath = null, { exclusive = false } =
1600
1841
  const normalized = normalizeTask(task);
1601
1842
  await ensureBacklogDir();
1602
1843
 
1603
- const nextFilePath = path.join(BACKLOG, normalized.column, `${normalized.id}.json`);
1844
+ const nextFilePath = path.join(backlogDir(), normalized.column, `${normalized.id}.json`);
1604
1845
  const payload = JSON.stringify(serializeTask(normalized), null, 2) + '\n';
1605
1846
  await writeFileAtomic(nextFilePath, payload, { exclusive });
1606
1847
 
@@ -1621,7 +1862,7 @@ async function migrateAll(options = {}) {
1621
1862
  const errors = [];
1622
1863
 
1623
1864
  for (const col of KNOWN_COLS) {
1624
- const colDir = path.join(BACKLOG, col);
1865
+ const colDir = path.join(backlogDir(), col);
1625
1866
  let files;
1626
1867
  try {
1627
1868
  files = await fs.readdir(colDir);
@@ -1667,7 +1908,7 @@ async function nextTaskNumber() {
1667
1908
  const ids = [];
1668
1909
 
1669
1910
  for (const col of KNOWN_COLS) {
1670
- const colDir = path.join(BACKLOG, col);
1911
+ const colDir = path.join(backlogDir(), col);
1671
1912
  try {
1672
1913
  const files = await fs.readdir(colDir);
1673
1914
  for (const file of files) {
@@ -1712,7 +1953,7 @@ async function listTasksInKnownColumn(column) {
1712
1953
  400
1713
1954
  );
1714
1955
  }
1715
- const colDir = path.join(BACKLOG, column);
1956
+ const colDir = path.join(backlogDir(), column);
1716
1957
  const tasks = [];
1717
1958
  try {
1718
1959
  const files = await fs.readdir(colDir);
@@ -1867,199 +2108,140 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1867
2108
  });
1868
2109
  }
1869
2110
 
1870
- async function updateTaskRecord(taskId, patch) {
1871
- const resolvedId = await resolveTaskId(taskId);
1872
- const previousFilePath = await findFile(resolvedId);
1873
- if (!previousFilePath) {
1874
- throw createKanbanError(
1875
- 'TASK_NOT_FOUND',
1876
- `Task ${taskId} was not found`,
1877
- 'Call kanban_read with operation=list to discover valid task ids',
1878
- { task_id: taskId },
1879
- false,
1880
- 404
1881
- );
2111
+ function applyColumnPatch(next, current, column) {
2112
+ validateColumn(column);
2113
+ if (column !== current.column) {
2114
+ validateTransition(current.column, column, current.id);
1882
2115
  }
2116
+ next.column = column;
2117
+ }
1883
2118
 
1884
- const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1885
- const next = { ...current };
1886
-
1887
- if (patch.column !== undefined) {
1888
- validateColumn(patch.column);
1889
- if (patch.column !== current.column) {
1890
- validateTransition(current.column, patch.column, current.id);
1891
- }
1892
- next.column = patch.column;
2119
+ async function applyEpicPatch(next, patch) {
2120
+ if (patch._skipEpicResolve) {
2121
+ if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
2122
+ if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
2123
+ return;
1893
2124
  }
1894
- if (patch.depends_on !== undefined) {
1895
- if (!Array.isArray(patch.depends_on)) {
2125
+ if (patch.epic_id === undefined && patch.epic === undefined && patch.epic_group === undefined) {
2126
+ return;
2127
+ }
2128
+ const ref = patch.epic_id !== undefined
2129
+ ? patch.epic_id
2130
+ : (patch.epic !== undefined ? patch.epic : patch.epic_group);
2131
+ if (isBlankEpicRef(ref)) {
2132
+ next.epic_id = null;
2133
+ next.epic_group = '—';
2134
+ return;
2135
+ }
2136
+ const link = await resolveEpicRef(ref, {
2137
+ createIfMissing: Boolean(
2138
+ patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
2139
+ ),
2140
+ skipLock: true
2141
+ });
2142
+ next.epic_id = link.epic_id;
2143
+ next.epic_group = link.epic_group;
2144
+ }
2145
+
2146
+ function applyEvidencePatch(next, patch) {
2147
+ if (patch.appendEvidence !== undefined) {
2148
+ if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
1896
2149
  throw createKanbanError(
1897
2150
  'VALIDATION_ERROR',
1898
- 'depends_on must be an array of task ids',
1899
- 'Send depends_on as an array like ["001"]',
1900
- { field: 'depends_on' },
2151
+ 'appendEvidence must be an evidence object',
2152
+ 'Send a single evidence entry to append',
2153
+ { field: 'appendEvidence' },
1901
2154
  false,
1902
2155
  400
1903
2156
  );
1904
2157
  }
2158
+ next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
2159
+ }
2160
+ if (patch.evidence !== undefined) {
2161
+ assertIsArray(
2162
+ patch.evidence,
2163
+ 'evidence',
2164
+ 'evidence must be an array',
2165
+ 'Send evidence as an array of evidence objects'
2166
+ );
2167
+ next.evidence = patch.evidence;
2168
+ }
2169
+ }
2170
+
2171
+ function applyMarkSubtaskDone(next, taskId, rawIndex) {
2172
+ const index = rawIndex === undefined || rawIndex === null
2173
+ ? next.subtasks.findIndex((subtask) => !subtask.done)
2174
+ : Number(rawIndex);
2175
+ if (!Number.isInteger(index) || index < 0 || index >= next.subtasks.length) {
2176
+ throw createKanbanError(
2177
+ 'INVALID_SUBTASK_INDEX',
2178
+ 'No valid plan step was provided',
2179
+ 'Provide the zero-based index of an incomplete subtask',
2180
+ { index, total_subtasks: next.subtasks.length, task_id: taskId },
2181
+ false,
2182
+ 400
2183
+ );
2184
+ }
2185
+ next.subtasks = next.subtasks.map((subtask, subtaskIndex) => ({
2186
+ ...subtask,
2187
+ done: subtaskIndex === index ? true : subtask.done
2188
+ }));
2189
+ }
2190
+
2191
+ function applySubtaskProgressPatch(next, patch, taskId) {
2192
+ if (Object.prototype.hasOwnProperty.call(patch, 'mark_subtask_done')) {
2193
+ applyMarkSubtaskDone(next, taskId, patch.mark_subtask_done);
2194
+ }
2195
+ }
2196
+
2197
+ function applyScalarTaskPatch(next, patch) {
2198
+ if (patch.depends_on !== undefined) {
2199
+ assertIsArray(
2200
+ patch.depends_on,
2201
+ 'depends_on',
2202
+ 'depends_on must be an array of task ids',
2203
+ 'Send depends_on as an array like ["001"]'
2204
+ );
1905
2205
  next.depends_on = patch.depends_on;
1906
2206
  }
1907
2207
  if (patch.files !== undefined) {
1908
- 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
- }
2208
+ assertIsArray(
2209
+ patch.files,
2210
+ 'files',
2211
+ 'files must be an array of paths',
2212
+ 'Send files as an array like ["src/foo.js"]'
2213
+ );
1918
2214
  next.files = patch.files;
1919
2215
  }
1920
- if (patch.title !== undefined) {
1921
- const title = normalizeString(patch.title);
1922
- if (!title) {
1923
- throw createKanbanError(
1924
- 'VALIDATION_ERROR',
1925
- 'title must be a non-empty string',
1926
- 'Send a non-empty title or omit the field',
1927
- { field: 'title' },
1928
- false,
1929
- 400
1930
- );
1931
- }
1932
- next.title = title;
1933
- }
1934
- if (!patch._skipEpicResolve) {
1935
- if (patch.epic_id !== undefined || patch.epic !== undefined || patch.epic_group !== undefined) {
1936
- const ref = patch.epic_id !== undefined
1937
- ? patch.epic_id
1938
- : (patch.epic !== undefined ? patch.epic : patch.epic_group);
1939
- if (isBlankEpicRef(ref)) {
1940
- next.epic_id = null;
1941
- next.epic_group = '—';
1942
- } else {
1943
- const link = await resolveEpicRef(ref, {
1944
- createIfMissing: Boolean(
1945
- patch.epic_group !== undefined && patch.epic_id === undefined && patch.epic === undefined
1946
- ),
1947
- skipLock: true
1948
- });
1949
- next.epic_id = link.epic_id;
1950
- next.epic_group = link.epic_group;
1951
- }
1952
- }
1953
- } else {
1954
- if (patch.epic_id !== undefined) next.epic_id = normalizeEpicId(patch.epic_id);
1955
- if (patch.epic_group !== undefined) next.epic_group = normalizeString(patch.epic_group, '—') || '—';
1956
- }
2216
+ if (patch.title !== undefined) next.title = requireNonEmptyTitle(patch.title);
1957
2217
  if (patch.description !== undefined) next.description = normalizeString(patch.description);
1958
2218
  if (patch.specs !== undefined) next.specs = normalizeString(patch.specs);
1959
- if (patch.in_scope !== undefined) {
1960
- if (!Array.isArray(patch.in_scope)) {
1961
- throw createKanbanError(
1962
- 'VALIDATION_ERROR',
1963
- 'in_scope must be an array of strings',
1964
- 'Send in_scope as an array',
1965
- { field: 'in_scope' },
1966
- false,
1967
- 400
1968
- );
1969
- }
1970
- next.in_scope = patch.in_scope;
1971
- }
2219
+ if (patch.in_scope !== undefined) next.in_scope = requireStringArray(patch.in_scope, 'in_scope');
1972
2220
  if (patch.out_of_scope !== undefined) {
1973
- if (!Array.isArray(patch.out_of_scope)) {
1974
- throw createKanbanError(
1975
- 'VALIDATION_ERROR',
1976
- 'out_of_scope must be an array of strings',
1977
- 'Send out_of_scope as an array',
1978
- { field: 'out_of_scope' },
1979
- false,
1980
- 400
1981
- );
1982
- }
1983
- next.out_of_scope = patch.out_of_scope;
2221
+ next.out_of_scope = requireStringArray(patch.out_of_scope, 'out_of_scope');
1984
2222
  }
1985
2223
  if (patch.acceptance_criteria !== undefined) {
1986
- if (!Array.isArray(patch.acceptance_criteria)) {
1987
- throw createKanbanError(
1988
- 'VALIDATION_ERROR',
1989
- 'acceptance_criteria must be an array of strings',
1990
- 'Send acceptance_criteria as an array',
1991
- { field: 'acceptance_criteria' },
1992
- false,
1993
- 400
1994
- );
1995
- }
1996
- next.acceptance_criteria = patch.acceptance_criteria;
1997
- }
1998
- if (patch.test_cases !== undefined) {
1999
- if (!Array.isArray(patch.test_cases)) {
2000
- throw createKanbanError(
2001
- 'VALIDATION_ERROR',
2002
- 'test_cases must be an array of strings',
2003
- 'Send test_cases as an array',
2004
- { field: 'test_cases' },
2005
- false,
2006
- 400
2007
- );
2008
- }
2009
- next.test_cases = patch.test_cases;
2224
+ next.acceptance_criteria = requireStringArray(patch.acceptance_criteria, 'acceptance_criteria');
2010
2225
  }
2226
+ if (patch.test_cases !== undefined) next.test_cases = requireStringArray(patch.test_cases, 'test_cases');
2011
2227
  if (patch.subtasks !== undefined) {
2012
- if (!Array.isArray(patch.subtasks)) {
2013
- throw createKanbanError(
2014
- 'VALIDATION_ERROR',
2015
- 'subtasks must be an array',
2016
- 'Send subtasks as an array of objects',
2017
- { field: 'subtasks' },
2018
- false,
2019
- 400
2020
- );
2021
- }
2228
+ assertIsArray(
2229
+ patch.subtasks,
2230
+ 'subtasks',
2231
+ 'subtasks must be an array',
2232
+ 'Send subtasks as an array of objects'
2233
+ );
2022
2234
  next.subtasks = patch.subtasks;
2023
2235
  }
2024
- if (patch.adr !== undefined) {
2025
- if (Array.isArray(patch.adr)) {
2026
- next.adr = normalizeAdr(patch.adr);
2027
- } else {
2028
- next.adr = appendAdrEntry(next.adr, patch.adr);
2029
- }
2030
- }
2236
+ if (patch.adr !== undefined) next.adr = applyAdrPatch(next.adr, patch.adr);
2031
2237
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
2032
2238
  if (patch.plan !== undefined) next.plan = patch.plan;
2033
2239
  if (patch.workflow !== undefined) {
2034
2240
  next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
2035
2241
  }
2036
- 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
- }
2049
- if (patch.evidence !== undefined) {
2050
- if (!Array.isArray(patch.evidence)) {
2051
- throw createKanbanError(
2052
- 'VALIDATION_ERROR',
2053
- 'evidence must be an array',
2054
- 'Send evidence as an array of evidence objects',
2055
- { field: 'evidence' },
2056
- false,
2057
- 400
2058
- );
2059
- }
2060
- next.evidence = patch.evidence;
2061
- }
2242
+ }
2062
2243
 
2244
+ async function assertTaskPatchInvariants(current, next, patch) {
2063
2245
  const columnChanged = next.column !== current.column;
2064
2246
  const depsChanged = patch.depends_on !== undefined;
2065
2247
  const enteringDone = columnChanged && next.column === 'done';
@@ -2073,7 +2255,36 @@ async function updateTaskRecord(taskId, patch) {
2073
2255
  if (columnChanged && isWorkColumn(next.column)) {
2074
2256
  assertUnblockedForColumn(normalizeTask(next), next.column, others);
2075
2257
  }
2258
+ if (columnChanged) {
2259
+ assertEvidenceForGate(current, next);
2260
+ }
2261
+ return { enteringDone, others };
2262
+ }
2263
+
2264
+ async function updateTaskRecord(taskId, patch) {
2265
+ const resolvedId = await resolveTaskId(taskId);
2266
+ const previousFilePath = await findFile(resolvedId);
2267
+ if (!previousFilePath) {
2268
+ throw createKanbanError(
2269
+ 'TASK_NOT_FOUND',
2270
+ `Task ${taskId} was not found`,
2271
+ 'Call kanban_read with operation=list to discover valid task ids',
2272
+ { task_id: taskId },
2273
+ false,
2274
+ 404
2275
+ );
2276
+ }
2076
2277
 
2278
+ const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
2279
+ const next = { ...current };
2280
+
2281
+ if (patch.column !== undefined) applyColumnPatch(next, current, patch.column);
2282
+ applyScalarTaskPatch(next, patch);
2283
+ applySubtaskProgressPatch(next, patch, current.id);
2284
+ await applyEpicPatch(next, patch);
2285
+ applyEvidencePatch(next, patch);
2286
+
2287
+ const { enteringDone, others } = await assertTaskPatchInvariants(current, next, patch);
2077
2288
  const written = await writeTask(next, previousFilePath);
2078
2289
  if (enteringDone) {
2079
2290
  const board = others.map((other) => (other.id === written.id ? written : other));
@@ -2119,7 +2330,12 @@ async function doMove(epicId, target) {
2119
2330
  await updateTask(epicId, { column: target });
2120
2331
  return true;
2121
2332
  } catch (error) {
2122
- if (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
+ ) {
2123
2339
  return false;
2124
2340
  }
2125
2341
  throw error;
@@ -2224,11 +2440,15 @@ module.exports = {
2224
2440
  VIEW_FIELDS,
2225
2441
  EPIC_VIEW_FIELDS,
2226
2442
  normalizeEvidence,
2443
+ hasProofEvidence,
2444
+ assertEvidenceForGate,
2227
2445
  normalizeWorkflow,
2228
2446
  LIVE_EPIC_STATUSES,
2229
2447
  RECOMMENDED_CREATE_FIELDS,
2230
2448
  RECOMMENDED_EPIC_CREATE_FIELDS,
2231
- EPICS_DIR
2449
+ get EPICS_DIR() {
2450
+ return epicsDir();
2451
+ }
2232
2452
  };
2233
2453
 
2234
2454
  // Keep live refs for applyBoardLayout consumers that read module.exports.COLS