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/.ai/lessons.jsonl +5 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +1 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +38 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +34 -3
- package/agent-playbook.js +30 -13
- package/bin/kanban.js +62 -7
- package/config-wizard.js +441 -0
- package/index.html +67 -36
- package/kanban.js +429 -20
- package/mcp-server.js +87 -22
- package/package.json +1 -1
- package/plan.js +31 -27
- package/tests/run.js +5 -0
- package/workflow.js +364 -18
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|