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/.ai/lessons.jsonl +6 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +1 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +77 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +34 -3
- package/agent-playbook.js +31 -7
- package/agents/qa-e2e-tester.md +315 -0
- package/agents/qa-tester.md +182 -0
- package/agents/temida.md +81 -0
- package/bin/kanban.js +200 -7
- package/config-wizard.js +441 -0
- package/index.html +115 -23
- package/index.js +2 -0
- package/kanban.js +578 -33
- package/mcp-server.js +89 -21
- package/package.json +1 -1
- package/plan.js +34 -27
- package/tests/fixtures/fake-opencode.js +69 -0
- package/tests/index.js +19 -0
- package/tests/kanban-cli.js +118 -0
- package/tests/kanban.js +104 -0
- package/tests/run.js +9 -0
- package/workflow.js +806 -0
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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'))
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|