kanbango 3.5.0 → 3.6.2

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/bin/kanban.js CHANGED
@@ -6,8 +6,10 @@ const guiRegistry = require('../gui-registry.js');
6
6
  const http = require('http');
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const crypto = require('crypto');
9
10
 
10
11
  const BACKLOG = path.join(process.cwd(), 'backlog');
12
+ const AGENT_MANIFEST = '.kanbango-agents.json';
11
13
 
12
14
  function shortId(taskId) {
13
15
  const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
@@ -99,6 +101,8 @@ async function cliInit() {
99
101
  + '- `active/` — w trakcie (max 1-2)\n'
100
102
  + '- `planned/` — zaplanowane\n'
101
103
  + '- `icebox/` — zamrozone / nice-to-have\n'
104
+ + '- `testing/` — bramka QA (async agent)\n'
105
+ + '- `review/` — bramka review / Temida (async agent)\n'
102
106
  + '- `done/` — ukonczone\n'
103
107
  + '- `epics/` — first-class epic containers (context for initiatives)\n',
104
108
  'utf-8'
@@ -108,6 +112,129 @@ async function cliInit() {
108
112
  console.log(`✓ Backlog w: ${BACKLOG}`);
109
113
  }
110
114
 
115
+ function sha256Hex(content) {
116
+ return crypto.createHash('sha256').update(content).digest('hex');
117
+ }
118
+
119
+ async function readAgentManifest(destDir) {
120
+ const manifestPath = path.join(destDir, AGENT_MANIFEST);
121
+ try {
122
+ const raw = await fs.promises.readFile(manifestPath, 'utf-8');
123
+ const parsed = JSON.parse(raw);
124
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
125
+ const agents = parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents)
126
+ ? parsed.agents
127
+ : parsed;
128
+ const out = {};
129
+ for (const [name, value] of Object.entries(agents)) {
130
+ if (typeof value === 'string' && value) out[name] = value;
131
+ else if (value && typeof value.hash === 'string' && value.hash) out[name] = value.hash;
132
+ }
133
+ return out;
134
+ } catch (error) {
135
+ if (error.code === 'ENOENT') return {};
136
+ if (error instanceof SyntaxError) return {};
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ async function writeAgentManifest(destDir, hashes) {
142
+ const manifestPath = path.join(destDir, AGENT_MANIFEST);
143
+ const payload = {
144
+ version: 1,
145
+ updated: new Date().toISOString(),
146
+ agents: hashes
147
+ };
148
+ await fs.promises.writeFile(manifestPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
149
+ }
150
+
151
+ async function copyOpenCodeAgents(cwd, force) {
152
+ const srcDir = path.join(__dirname, '..', 'agents');
153
+ const destDir = path.join(cwd, '.opencode', 'agent');
154
+ let names;
155
+ try {
156
+ names = (await fs.promises.readdir(srcDir)).filter((f) => f.endsWith('.md'));
157
+ } catch (error) {
158
+ if (error.code === 'ENOENT') return { written: 0, updated: 0, unchanged: 0, skipped: 0, conflict: 0 };
159
+ throw error;
160
+ }
161
+ await fs.promises.mkdir(destDir, { recursive: true });
162
+ const prevHashes = await readAgentManifest(destDir);
163
+ const nextHashes = { ...prevHashes };
164
+ let written = 0;
165
+ let updated = 0;
166
+ let unchanged = 0;
167
+ let skipped = 0;
168
+ let conflict = 0;
169
+
170
+ for (const name of names) {
171
+ const srcPath = path.join(srcDir, name);
172
+ const destPath = path.join(destDir, name);
173
+ const srcContent = await fs.promises.readFile(srcPath, 'utf-8');
174
+ const srcHash = sha256Hex(srcContent);
175
+ const destExists = fs.existsSync(destPath);
176
+
177
+ if (!destExists) {
178
+ await fs.promises.writeFile(destPath, srcContent, 'utf-8');
179
+ nextHashes[name] = srcHash;
180
+ written += 1;
181
+ console.log(`✓ Skopiowano .opencode/agent/${name}`);
182
+ continue;
183
+ }
184
+
185
+ const destContent = await fs.promises.readFile(destPath, 'utf-8');
186
+ const destHash = sha256Hex(destContent);
187
+ const recorded = prevHashes[name] || null;
188
+
189
+ if (force) {
190
+ if (destHash === srcHash) {
191
+ nextHashes[name] = srcHash;
192
+ unchanged += 1;
193
+ console.log(`• Bez zmian .opencode/agent/${name}`);
194
+ } else {
195
+ await fs.promises.writeFile(destPath, srcContent, 'utf-8');
196
+ nextHashes[name] = srcHash;
197
+ updated += 1;
198
+ console.log(`✓ Zaktualizowano .opencode/agent/${name} (--force)`);
199
+ }
200
+ continue;
201
+ }
202
+
203
+ // Local copy already matches package source.
204
+ if (destHash === srcHash) {
205
+ nextHashes[name] = srcHash;
206
+ unchanged += 1;
207
+ console.log(`• Bez zmian .opencode/agent/${name}`);
208
+ continue;
209
+ }
210
+
211
+ // Clean packaged copy from an older version → safe auto-update.
212
+ if (recorded && destHash === recorded && recorded !== srcHash) {
213
+ await fs.promises.writeFile(destPath, srcContent, 'utf-8');
214
+ nextHashes[name] = srcHash;
215
+ updated += 1;
216
+ console.log(`✓ Zaktualizowano .opencode/agent/${name} (nowa wersja w paczce)`);
217
+ continue;
218
+ }
219
+
220
+ // Local edit (or legacy copy without matching manifest) → do not overwrite.
221
+ conflict += 1;
222
+ skipped += 1;
223
+ if (recorded && destHash !== recorded) {
224
+ console.log(
225
+ `• Konflikt .opencode/agent/${name} (lokalna edycja; użyj --force aby nadpisać)`
226
+ );
227
+ } else {
228
+ console.log(
229
+ `• Pominięto .opencode/agent/${name} (lokalna kopia różni się od paczki; użyj --force)`
230
+ );
231
+ }
232
+ }
233
+
234
+ await writeAgentManifest(destDir, nextHashes);
235
+ return { written, updated, unchanged, skipped, conflict };
236
+ }
237
+
111
238
  async function cliMcpInit(options) {
112
239
  const useNpx = options.useNpx;
113
240
  const force = options.force;
@@ -140,6 +267,11 @@ async function cliMcpInit(options) {
140
267
  }
141
268
  }
142
269
 
270
+ // OpenCode agents ship with the package; Claude path does not get them.
271
+ if (!onlyClaude) {
272
+ await copyOpenCodeAgents(cwd, force);
273
+ }
274
+
143
275
  if (!force) {
144
276
  console.log(' (użyj --force, aby nadpisać istniejące pliki)');
145
277
  }
@@ -453,7 +585,9 @@ async function serveWeb(port) {
453
585
  acceptance_criteria: body.acceptance_criteria,
454
586
  test_cases: body.test_cases,
455
587
  subtasks: body.subtasks,
456
- notes: body.notes
588
+ notes: body.notes,
589
+ adr: body.adr,
590
+ evidence: body.evidence
457
591
  }
458
592
  );
459
593
  sendJson(res, 201, task);
@@ -474,7 +608,9 @@ async function serveWeb(port) {
474
608
  acceptance_criteria: body.acceptance_criteria,
475
609
  test_cases: body.test_cases,
476
610
  subtasks: body.subtasks,
477
- notes: body.notes
611
+ notes: body.notes,
612
+ adr: body.adr,
613
+ evidence: body.evidence
478
614
  }
479
615
  );
480
616
  sendJson(res, 201, task);
@@ -568,6 +704,8 @@ async function serveWeb(port) {
568
704
  if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
569
705
  if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
570
706
  if (body.notes !== undefined) patch.notes = body.notes;
707
+ if (body.adr !== undefined) patch.adr = body.adr;
708
+ if (body.evidence !== undefined) patch.evidence = body.evidence;
571
709
  if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
572
710
  else if (body.epic !== undefined) patch.epic = body.epic;
573
711
  else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
package/index.html CHANGED
@@ -11,6 +11,8 @@
11
11
  --active: #e85d04;
12
12
  --planned: #2563eb;
13
13
  --icebox: #64748b;
14
+ --testing: #ca8a04;
15
+ --review: #7c3aed;
14
16
  --done: #16a34a;
15
17
  --bg: #e8eaef;
16
18
  --card: #ffffff;
@@ -285,6 +287,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
285
287
  }
286
288
  .col-head.icebox { color: var(--icebox); }
287
289
  .col-head.planned { color: var(--planned); }
290
+ .col-head.testing { color: var(--testing); }
291
+ .col-head.review { color: var(--review); }
288
292
  .col-head.done { color: var(--done); }
289
293
  .ch-count {
290
294
  background: rgba(0,0,0,0.07);
@@ -327,6 +331,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
327
331
  box-shadow: 0 2px 6px rgba(232,93,4,0.10);
328
332
  }
329
333
  .card.col-active.selected { box-shadow: 0 0 0 2px var(--active); }
334
+ .card.col-testing { border-left-color: var(--testing); }
335
+ .card.col-review { border-left-color: var(--review); }
330
336
  .card.col-done { border-left-color: var(--done); opacity: 0.7; }
331
337
  .card.col-icebox {
332
338
  background: #edf0f7;
@@ -353,8 +359,24 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
353
359
  .prog-fill { height: 100%; border-radius: 2px; }
354
360
  .prog-fill.planned { background: var(--planned); }
355
361
  .prog-fill.active { background: var(--active); }
362
+ .prog-fill.testing { background: var(--testing); }
363
+ .prog-fill.review { background: var(--review); }
356
364
  .prog-fill.done { background: var(--done); }
357
365
  .prog-fill.icebox { background: var(--icebox); }
366
+ .wf-badge {
367
+ font-size: 9px;
368
+ font-weight: 700;
369
+ text-transform: uppercase;
370
+ letter-spacing: 0.4px;
371
+ padding: 1px 5px;
372
+ border-radius: 4px;
373
+ margin-left: auto;
374
+ }
375
+ .wf-badge.running { background: #fef3c7; color: #92400e; }
376
+ .wf-badge.pass { background: #dcfce7; color: #166534; }
377
+ .wf-badge.fail { background: #fee2e2; color: #991b1b; }
378
+ .wf-badge.blocked { background: #e2e8f0; color: #475569; }
379
+ .wf-badge.idle { background: #f1f5f9; color: #64748b; }
358
380
  .prog-txt { font-size: 10px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
359
381
 
360
382
  .card-actions {
@@ -783,6 +805,14 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
783
805
  <div class="col-head planned">Planned <span class="ch-count" id="cnt-planned">0</span></div>
784
806
  <div class="col-body" id="col-planned"></div>
785
807
  </div>
808
+ <div class="col" data-col="testing">
809
+ <div class="col-head testing">Testing <span class="ch-count" id="cnt-testing">0</span></div>
810
+ <div class="col-body" id="col-testing"></div>
811
+ </div>
812
+ <div class="col" data-col="review">
813
+ <div class="col-head review">Review <span class="ch-count" id="cnt-review">0</span></div>
814
+ <div class="col-body" id="col-review"></div>
815
+ </div>
786
816
  <div class="col" data-col="done">
787
817
  <div class="col-head done">Done <span class="ch-count" id="cnt-done">0</span></div>
788
818
  <div class="col-body" id="col-done"></div>
@@ -804,10 +834,12 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
804
834
  const BOARD_COLS = [
805
835
  { id: "icebox", label: "Icebox" },
806
836
  { id: "planned", label: "Planned" },
837
+ { id: "testing", label: "Testing" },
838
+ { id: "review", label: "Review" },
807
839
  { id: "done", label: "Done" }
808
840
  ];
809
- // Move order: icebox ↔ planned ↔ active(NOW) ↔ done
810
- const MOVE_ORDER = ["icebox", "planned", "active", "done"];
841
+ // Move order: icebox ↔ planned ↔ active(NOW) ↔ testing ↔ review ↔ done
842
+ const MOVE_ORDER = ["icebox", "planned", "active", "testing", "review", "done"];
811
843
 
812
844
  let allTasks = [];
813
845
  let allEpicEntities = [];
@@ -1002,11 +1034,23 @@ function currentEpicRef() {
1002
1034
  return "";
1003
1035
  }
1004
1036
 
1037
+ const COLUMN_TRANSITIONS = {
1038
+ icebox: ["planned"],
1039
+ planned: ["active", "icebox", "testing"],
1040
+ active: ["planned", "testing", "icebox"],
1041
+ testing: ["active", "review"],
1042
+ review: ["active", "done"],
1043
+ done: ["active"]
1044
+ };
1045
+
1005
1046
  function getNeighborCols(colId) {
1047
+ const allowed = COLUMN_TRANSITIONS[colId] || [];
1006
1048
  const idx = MOVE_ORDER.indexOf(colId);
1049
+ const behind = idx > 0 ? MOVE_ORDER.slice(0, idx).reverse() : [];
1050
+ const ahead = idx >= 0 ? MOVE_ORDER.slice(idx + 1) : [];
1007
1051
  return {
1008
- prev: idx > 0 ? MOVE_ORDER[idx - 1] : null,
1009
- next: idx >= 0 && idx < MOVE_ORDER.length - 1 ? MOVE_ORDER[idx + 1] : null
1052
+ prev: behind.find((col) => allowed.includes(col)) || null,
1053
+ next: ahead.find((col) => allowed.includes(col)) || null
1010
1054
  };
1011
1055
  }
1012
1056
 
@@ -1035,6 +1079,12 @@ function renderCard(task, colId) {
1035
1079
  const idBadge = el("span", "card-id");
1036
1080
  idBadge.textContent = shortId;
1037
1081
  top.appendChild(idBadge);
1082
+ const wfStatus = task.workflow && task.workflow.status;
1083
+ if (wfStatus && (colId === "testing" || colId === "review" || wfStatus === "running")) {
1084
+ const badge = el("span", `wf-badge ${wfStatus}`);
1085
+ badge.textContent = wfStatus;
1086
+ top.appendChild(badge);
1087
+ }
1038
1088
  card.appendChild(top);
1039
1089
 
1040
1090
  const titleEl = el("div", "card-title");
@@ -1267,8 +1317,19 @@ function renderTaskPaperView(paper, task) {
1267
1317
  cmd.textContent = ev.test_command || "(no command)";
1268
1318
  card.appendChild(cmd);
1269
1319
  const meta = el("div", "ev-meta");
1270
- meta.textContent = `exit ${ev.exit_code == null ? "?" : ev.exit_code}${ev.created ? " · " + ev.created : ""}`;
1320
+ const bits = [];
1321
+ if (ev.stage) bits.push(ev.stage);
1322
+ if (ev.agent) bits.push(ev.agent);
1323
+ if (ev.verdict) bits.push(ev.verdict);
1324
+ bits.push(`exit ${ev.exit_code == null ? "?" : ev.exit_code}`);
1325
+ if (ev.created) bits.push(ev.created);
1326
+ meta.textContent = bits.join(" · ");
1271
1327
  card.appendChild(meta);
1328
+ if (ev.summary) {
1329
+ const sum = el("div", "ev-diff");
1330
+ sum.textContent = ev.summary;
1331
+ card.appendChild(sum);
1332
+ }
1272
1333
  if (ev.diff) {
1273
1334
  const diff = el("div", "ev-diff");
1274
1335
  diff.textContent = ev.diff;
package/index.js CHANGED
@@ -10,12 +10,14 @@
10
10
 
11
11
  const kanban = require('./kanban.js');
12
12
  const plan = require('./plan.js');
13
+ const workflow = require('./workflow.js');
13
14
  const guiRegistry = require('./gui-registry.js');
14
15
  const playbook = require('./agent-playbook.js');
15
16
 
16
17
  module.exports = {
17
18
  kanban,
18
19
  plan,
20
+ workflow,
19
21
  guiRegistry,
20
22
  playbook,
21
23
  };
package/kanban.js CHANGED
@@ -3,13 +3,27 @@ const path = require('path');
3
3
 
4
4
  const BACKLOG = path.join(process.cwd(), 'backlog');
5
5
  const EPICS_DIR = path.join(BACKLOG, 'epics');
6
- const COLS = ['active', 'planned', 'icebox', 'done'];
6
+ const COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
7
7
  const STATUS_MAP = {
8
8
  active: 'in_progress',
9
9
  planned: 'planned',
10
10
  icebox: 'icebox',
11
+ testing: 'testing',
12
+ review: 'review',
11
13
  done: 'done'
12
14
  };
15
+ const WORKFLOW_STAGES = ['testing', 'review'];
16
+ const WORKFLOW_STATUSES = ['idle', 'running', 'pass', 'fail', 'blocked'];
17
+ const EVIDENCE_VERDICTS = ['pass', 'fail', 'blocked', ''];
18
+ // Agent/human move contract. Same column is always a no-op.
19
+ const COLUMN_TRANSITIONS = {
20
+ icebox: ['planned'],
21
+ planned: ['active', 'icebox', 'testing'],
22
+ active: ['planned', 'testing', 'icebox'],
23
+ testing: ['active', 'review'],
24
+ review: ['active', 'done'],
25
+ done: ['active']
26
+ };
13
27
  const VIEW_FIELDS = {
14
28
  summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
15
29
  planning: [
@@ -44,7 +58,10 @@ const VIEW_FIELDS = {
44
58
  'acceptance_criteria',
45
59
  'test_cases',
46
60
  'subtasks',
47
- 'adr'
61
+ 'adr',
62
+ 'evidence',
63
+ 'plan',
64
+ 'workflow'
48
65
  ],
49
66
  full: [
50
67
  'task_number',
@@ -63,7 +80,10 @@ const VIEW_FIELDS = {
63
80
  'test_cases',
64
81
  'subtasks',
65
82
  'adr',
66
- 'notes'
83
+ 'notes',
84
+ 'evidence',
85
+ 'plan',
86
+ 'workflow'
67
87
  ]
68
88
  };
69
89
 
@@ -261,14 +281,39 @@ function normalizeSubtasks(value) {
261
281
 
262
282
  function normalizeEvidence(value) {
263
283
  if (!Array.isArray(value)) return [];
264
- return value.map((item) => ({
265
- diff: normalizeString(item && item.diff),
266
- test_command: normalizeString(item && item.test_command),
267
- stdout: normalizeString(item && item.stdout),
268
- stderr: normalizeString(item && item.stderr),
269
- exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
270
- created: normalizeString(item && item.created) || todayIso()
271
- }));
284
+ return value.map((item) => {
285
+ const stage = normalizeString(item && item.stage);
286
+ const verdict = normalizeString(item && item.verdict);
287
+ return {
288
+ diff: normalizeString(item && item.diff),
289
+ test_command: normalizeString(item && item.test_command),
290
+ stdout: normalizeString(item && item.stdout),
291
+ stderr: normalizeString(item && item.stderr),
292
+ exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
293
+ created: normalizeString(item && item.created) || todayIso(),
294
+ stage: WORKFLOW_STAGES.includes(stage) || stage === 'plan' ? stage : '',
295
+ agent: normalizeString(item && item.agent),
296
+ verdict: EVIDENCE_VERDICTS.includes(verdict) ? verdict : '',
297
+ summary: normalizeString(item && item.summary)
298
+ };
299
+ });
300
+ }
301
+
302
+ function normalizeWorkflow(value) {
303
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
304
+ const stage = normalizeString(value.stage);
305
+ const status = normalizeString(value.status);
306
+ const agent = normalizeString(value.agent);
307
+ const runId = normalizeString(value.run_id);
308
+ if (!stage && !status && !agent && !runId) return null;
309
+ return {
310
+ stage: WORKFLOW_STAGES.includes(stage) ? stage : null,
311
+ status: WORKFLOW_STATUSES.includes(status) ? status : 'idle',
312
+ agent,
313
+ run_id: runId,
314
+ started_at: normalizeString(value.started_at) || undefined,
315
+ finished_at: normalizeString(value.finished_at) || undefined
316
+ };
272
317
  }
273
318
 
274
319
  function normalizeAdr(value) {
@@ -355,6 +400,7 @@ function normalizeTask(task) {
355
400
  notes: normalizeString(task.notes),
356
401
  plan: normalizePlan(task.plan),
357
402
  evidence: normalizeEvidence(task.evidence),
403
+ workflow: normalizeWorkflow(task.workflow),
358
404
  task_number: extractTaskNumber(id)
359
405
  };
360
406
 
@@ -381,6 +427,7 @@ function serializeTask(task) {
381
427
  notes: normalized.notes,
382
428
  plan: normalized.plan,
383
429
  evidence: normalized.evidence,
430
+ workflow: normalized.workflow,
384
431
  task_number: normalized.task_number
385
432
  };
386
433
  }
@@ -418,7 +465,9 @@ function serializeEpic(epic) {
418
465
  function deriveEpicStatus(tasks, epic) {
419
466
  if (epic && epic.archived) return 'archived';
420
467
  if (!tasks || tasks.length === 0) return 'empty';
421
- if (tasks.some((task) => task.column === 'active')) return 'active';
468
+ if (tasks.some((task) => WORKFLOW_STAGES.includes(task.column) || task.column === 'active')) {
469
+ return 'active';
470
+ }
422
471
  if (tasks.every((task) => task.column === 'done')) return 'done';
423
472
  return 'planned';
424
473
  }
@@ -436,13 +485,21 @@ function getEpicProgress(tasks) {
436
485
  tasks_done: 0,
437
486
  tasks_active: 0,
438
487
  tasks_planned: 0,
439
- tasks_icebox: 0
488
+ tasks_icebox: 0,
489
+ tasks_testing: 0,
490
+ tasks_review: 0
491
+ };
492
+ const keyByCol = {
493
+ done: 'tasks_done',
494
+ active: 'tasks_active',
495
+ planned: 'tasks_planned',
496
+ icebox: 'tasks_icebox',
497
+ testing: 'tasks_testing',
498
+ review: 'tasks_review'
440
499
  };
441
500
  for (const task of tasks) {
442
- if (task.column === 'done') progress.tasks_done += 1;
443
- else if (task.column === 'active') progress.tasks_active += 1;
444
- else if (task.column === 'planned') progress.tasks_planned += 1;
445
- else if (task.column === 'icebox') progress.tasks_icebox += 1;
501
+ const key = keyByCol[task.column];
502
+ if (key) progress[key] += 1;
446
503
  }
447
504
  return progress;
448
505
  }
@@ -1360,6 +1417,32 @@ function validateColumn(column, fieldName = 'column') {
1360
1417
  }
1361
1418
  }
1362
1419
 
1420
+ function allowedColumnsFrom(fromColumn) {
1421
+ return COLUMN_TRANSITIONS[fromColumn] ? COLUMN_TRANSITIONS[fromColumn].slice() : [];
1422
+ }
1423
+
1424
+ function validateTransition(fromColumn, toColumn, taskId) {
1425
+ if (fromColumn === toColumn) return;
1426
+ validateColumn(toColumn);
1427
+ const allowed = allowedColumnsFrom(fromColumn);
1428
+ if (allowed.includes(toColumn)) return;
1429
+ throw createKanbanError(
1430
+ 'INVALID_TRANSITION',
1431
+ taskId
1432
+ ? `Cannot move task ${taskId} from ${fromColumn} to ${toColumn}`
1433
+ : `Cannot move from ${fromColumn} to ${toColumn}`,
1434
+ `From ${fromColumn} you can move only to: ${allowed.join(', ') || '(none)'}`,
1435
+ {
1436
+ task_id: taskId || undefined,
1437
+ from: fromColumn,
1438
+ to: toColumn,
1439
+ allowed_columns: allowed
1440
+ },
1441
+ false,
1442
+ 400
1443
+ );
1444
+ }
1445
+
1363
1446
  function validatePatch(patch) {
1364
1447
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1365
1448
  throw createKanbanError(
@@ -1413,6 +1496,7 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
1413
1496
  subtasks: extra.subtasks,
1414
1497
  notes: extra.notes,
1415
1498
  plan: extra.plan,
1499
+ adr: extra.adr,
1416
1500
  evidence: extra.evidence
1417
1501
  });
1418
1502
  try {
@@ -1451,6 +1535,9 @@ async function updateTaskRecord(taskId, patch) {
1451
1535
 
1452
1536
  if (patch.column !== undefined) {
1453
1537
  validateColumn(patch.column);
1538
+ if (patch.column !== current.column) {
1539
+ validateTransition(current.column, patch.column, current.id);
1540
+ }
1454
1541
  next.column = patch.column;
1455
1542
  }
1456
1543
  if (patch.title !== undefined) {
@@ -1566,6 +1653,22 @@ async function updateTaskRecord(taskId, patch) {
1566
1653
  }
1567
1654
  if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
1568
1655
  if (patch.plan !== undefined) next.plan = patch.plan;
1656
+ if (patch.workflow !== undefined) {
1657
+ next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
1658
+ }
1659
+ if (patch.appendEvidence !== undefined) {
1660
+ if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
1661
+ throw createKanbanError(
1662
+ 'VALIDATION_ERROR',
1663
+ 'appendEvidence must be an evidence object',
1664
+ 'Send a single evidence entry to append',
1665
+ { field: 'appendEvidence' },
1666
+ false,
1667
+ 400
1668
+ );
1669
+ }
1670
+ next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
1671
+ }
1569
1672
  if (patch.evidence !== undefined) {
1570
1673
  if (!Array.isArray(patch.evidence)) {
1571
1674
  throw createKanbanError(
@@ -1583,9 +1686,36 @@ async function updateTaskRecord(taskId, patch) {
1583
1686
  return writeTask(next, previousFilePath);
1584
1687
  }
1585
1688
 
1689
+ function scheduleWorkflowEnqueue(previousColumn, updated) {
1690
+ if (!updated || !WORKFLOW_STAGES.includes(updated.column)) return;
1691
+ if (previousColumn === updated.column) return;
1692
+ // Lazy require avoids circular load: workflow.js requires kanban.js.
1693
+ setImmediate(() => {
1694
+ try {
1695
+ const workflow = require('./workflow.js');
1696
+ Promise.resolve(workflow.maybeEnqueueOnColumnEnter(updated, previousColumn)).catch((err) => {
1697
+ console.error('workflow enqueue failed:', err && err.message ? err.message : err);
1698
+ });
1699
+ } catch (err) {
1700
+ console.error('workflow load failed:', err && err.message ? err.message : err);
1701
+ }
1702
+ });
1703
+ }
1704
+
1586
1705
  async function updateTask(taskId, patch) {
1587
1706
  validatePatch(patch);
1588
- return withBoardLock(() => updateTaskRecord(taskId, patch));
1707
+ let previousColumn = null;
1708
+ const updated = await withBoardLock(async () => {
1709
+ const resolvedId = await resolveTaskId(taskId);
1710
+ const previousFilePath = await findFile(resolvedId);
1711
+ if (previousFilePath) {
1712
+ const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
1713
+ previousColumn = current.column;
1714
+ }
1715
+ return updateTaskRecord(taskId, patch);
1716
+ });
1717
+ scheduleWorkflowEnqueue(previousColumn, updated);
1718
+ return updated;
1589
1719
  }
1590
1720
 
1591
1721
  async function doMove(epicId, target) {
@@ -1593,7 +1723,7 @@ async function doMove(epicId, target) {
1593
1723
  await updateTask(epicId, { column: target });
1594
1724
  return true;
1595
1725
  } catch (error) {
1596
- if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN') {
1726
+ if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN' || error.code === 'INVALID_TRANSITION') {
1597
1727
  return false;
1598
1728
  }
1599
1729
  throw error;
@@ -1683,8 +1813,14 @@ module.exports = {
1683
1813
  resolveTaskId,
1684
1814
  COLS,
1685
1815
  STATUS_MAP,
1816
+ WORKFLOW_STAGES,
1817
+ COLUMN_TRANSITIONS,
1818
+ allowedColumnsFrom,
1819
+ validateTransition,
1686
1820
  VIEW_FIELDS,
1687
1821
  EPIC_VIEW_FIELDS,
1822
+ normalizeEvidence,
1823
+ normalizeWorkflow,
1688
1824
  LIVE_EPIC_STATUSES,
1689
1825
  RECOMMENDED_CREATE_FIELDS,
1690
1826
  RECOMMENDED_EPIC_CREATE_FIELDS,
package/mcp-server.js CHANGED
@@ -502,7 +502,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
502
502
  column: {
503
503
  type: 'string',
504
504
  enum: COLS,
505
- description: 'Target column for move (not col)'
505
+ description:
506
+ 'Target column for move (not col). Must be a legal transition from the current column. '
507
+ + 'icebox→planned; planned→active|icebox|testing; active→planned|testing|icebox; '
508
+ + 'testing→active|review; review→active|done; done→active. Illegal → INVALID_TRANSITION + allowed_columns.'
506
509
  },
507
510
  patch: {
508
511
  type: 'object',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.5.0",
3
+ "version": "3.6.2",
4
4
  "description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
5
5
  "main": "index.js",
6
6
  "bin": {
package/plan.js CHANGED
@@ -147,8 +147,11 @@ async function done(payload = {}) {
147
147
  throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
148
148
  'Advance every plan step before marking the workflow done', { incomplete });
149
149
  }
150
- const updated = await kanban.updateTask(task.id, { column: 'done', plan: { ...task.plan, status: 'done' } });
151
- return result(updated, { status: 'done' });
150
+ const updated = await kanban.updateTask(task.id, {
151
+ column: 'testing',
152
+ plan: { ...(task.plan || {}), status: 'done' }
153
+ });
154
+ return result(updated, { status: 'done', column: updated.column });
152
155
  }
153
156
 
154
157
  async function status(taskId) {