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/mcp-server.js CHANGED
@@ -11,13 +11,25 @@ const plan = require('./plan.js');
11
11
  const guiRegistry = require('./gui-registry.js');
12
12
  const playbook = require('./agent-playbook.js');
13
13
 
14
- const COLS = kanban.COLS;
15
14
  const READ_VIEWS = Object.keys(kanban.VIEW_FIELDS);
16
15
  const GUI_READY_TIMEOUT_MS = 8000;
17
16
  const GUI_READY_POLL_MS = 50;
18
17
  let guiProcess = null;
19
18
  let guiPort = null;
20
19
 
20
+ function activeCols() {
21
+ return kanban.COLS.slice();
22
+ }
23
+
24
+ function transitionHint() {
25
+ const map = kanban.COLUMN_TRANSITIONS || {};
26
+ const parts = Object.keys(map).map((from) => {
27
+ const to = (map[from] || []).join('|');
28
+ return `${from}→${to || '(none)'}`;
29
+ });
30
+ return parts.join('; ') || 'see backlog/kanbango.json columns';
31
+ }
32
+
21
33
  function normalizePort(value) {
22
34
  return guiRegistry.normalizeGuiPort(value);
23
35
  }
@@ -151,12 +163,23 @@ async function buildEpicLookup() {
151
163
  }
152
164
 
153
165
  async function formatTaskResult(task, returnShape) {
154
- if (returnShape === 'none') return { ok: true };
166
+ if (returnShape === 'none') {
167
+ if (Array.isArray(task.unblocked_tasks) && task.unblocked_tasks.length > 0) {
168
+ return { ok: true, unblocked_tasks: task.unblocked_tasks };
169
+ }
170
+ return { ok: true };
171
+ }
155
172
  const epicLookup = await buildEpicLookup();
156
- return kanban.shapeTask(task, {
173
+ const allTasks = await kanban.allTasks();
174
+ const shaped = kanban.shapeTask(task, {
157
175
  view: returnShape === 'full' ? 'full' : 'summary',
158
- epicLookup
176
+ epicLookup,
177
+ allTasks
159
178
  });
179
+ if (Array.isArray(task.unblocked_tasks)) {
180
+ shaped.unblocked_tasks = task.unblocked_tasks;
181
+ }
182
+ return shaped;
160
183
  }
161
184
 
162
185
  function guiIdentity(extra = {}) {
@@ -317,6 +340,8 @@ const server = new Server(
317
340
  );
318
341
 
319
342
  server.setRequestHandler(ListToolsRequestSchema, async () => {
343
+ await require('./workflow.js').ensureBoardConfig();
344
+ const COLS = activeCols();
320
345
  return {
321
346
  tools: [
322
347
  {
@@ -327,8 +352,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
327
352
  properties: {
328
353
  operation: {
329
354
  type: 'string',
330
- enum: ['list', 'show', 'list_epics', 'show_epic', 'help'],
331
- description: 'list/show=tasks; list_epics/show_epic=initiative containers; help=token playbook',
355
+ enum: ['list', 'show', 'list_epics', 'show_epic', 'context', 'help'],
356
+ description: 'list/show=tasks; context=compact next action; list_epics/show_epic=initiative containers; help=token playbook',
332
357
  default: 'list'
333
358
  },
334
359
  task_id: {
@@ -452,6 +477,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
452
477
  description: 'Verification scenarios',
453
478
  items: { type: 'string' }
454
479
  },
480
+ depends_on: {
481
+ type: 'array',
482
+ description: 'Task ids that must be done before this task can enter active/gates',
483
+ items: { type: 'string' }
484
+ },
485
+ files: {
486
+ type: 'array',
487
+ description: 'Touched file paths for this task (create/update/plan_evidence)',
488
+ items: { type: 'string' }
489
+ },
455
490
  subtasks: {
456
491
  type: 'array',
457
492
  description: 'Full subtask list replace on update (send complete array, not a single toggle)',
@@ -502,7 +537,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
502
537
  column: {
503
538
  type: 'string',
504
539
  enum: COLS,
505
- description: 'Target column for move (not col)'
540
+ description:
541
+ 'Target column for move (not col). Must be a legal transition from the current column. '
542
+ + `Allowed (from project config): ${transitionHint()}. `
543
+ + 'Illegal → INVALID_TRANSITION + allowed_columns.'
506
544
  },
507
545
  patch: {
508
546
  type: 'object',
@@ -520,31 +558,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
520
558
  steps: {
521
559
  type: 'array',
522
560
  items: { type: 'string' },
523
- description: 'plan_create: implementation steps between red/green test steps'
561
+ description: 'create/plan_create: checklist texts subtasks (create uses when subtasks omitted; plan_create uses as-is, no forced TDD steps)'
524
562
  },
525
563
  project_root: {
526
564
  type: 'string',
527
- description: 'plan_create: root for test-runner detect (default cwd)'
565
+ description: 'plan_create: root for optional test-runner detect (default cwd); missing runner → null, not error'
528
566
  },
529
567
  diff: {
530
568
  type: 'string',
531
- description: 'plan_evidence: short diff or summary (not whole repo)'
569
+ description: 'plan_evidence: short diff or note (one of diff/summary/test_command required)'
570
+ },
571
+ summary: {
572
+ type: 'string',
573
+ description: 'plan_evidence: short implementation summary (alternative to diff)'
532
574
  },
533
575
  test_command: {
534
576
  type: 'string',
535
- description: 'plan_evidence: exact command run'
577
+ description: 'plan_evidence: optional exact command run'
536
578
  },
537
579
  stdout: {
538
580
  type: 'string',
539
- description: 'plan_evidence: test stdout (truncate to last ~2KB if huge)'
581
+ description: 'plan_evidence: optional test stdout (truncate to last ~2KB if huge)'
540
582
  },
541
583
  stderr: {
542
584
  type: 'string',
543
- description: 'plan_evidence: test stderr (truncate if huge)'
585
+ description: 'plan_evidence: optional test stderr (truncate if huge)'
544
586
  },
545
587
  exit_code: {
546
588
  type: 'integer',
547
- description: 'plan_evidence: process exit code'
589
+ description: 'plan_evidence: optional process exit code'
548
590
  }
549
591
  },
550
592
  required: ['action'],
@@ -590,12 +632,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
590
632
  break;
591
633
  }
592
634
 
635
+ await require('./workflow.js').ensureBoardConfig();
636
+
637
+ if (operation === 'context') {
638
+ result = await kanban.getContextPayload({
639
+ epic: args.epic,
640
+ epic_id: args.epic_id
641
+ });
642
+ break;
643
+ }
644
+
593
645
  const readOptions = normalizeReadOptions(args, 'summary');
594
646
 
595
647
  if (operation === 'list') {
596
648
  await kanban.migrateEpicGroups();
597
649
  const epics = await kanban.listEpicEntities();
598
- let tasks = await kanban.allTasks();
650
+ const allBoardTasks = await kanban.allTasks();
651
+ let tasks = allBoardTasks;
599
652
  if (args.col) {
600
653
  tasks = tasks.filter((task) => task.column === args.col);
601
654
  }
@@ -610,8 +663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
610
663
  });
611
664
  }
612
665
 
613
- // summary omits epic_goals; no lookup needed for list tokens
614
- result = tasks.map((task) => kanban.shapeTask(task, readOptions));
666
+ result = tasks.map((task) => kanban.shapeTask(task, { ...readOptions, allTasks: allBoardTasks }));
615
667
  } else if (operation === 'show') {
616
668
  if (!args.task_id) {
617
669
  throw invalidRequest(
@@ -623,9 +675,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
623
675
 
624
676
  await kanban.migrateEpicGroups();
625
677
  const epicLookup = await buildEpicLookup();
678
+ const allTasks = await kanban.allTasks();
626
679
  result = kanban.shapeTask(await kanban.getTask(args.task_id), {
627
680
  ...readOptions,
628
- epicLookup
681
+ epicLookup,
682
+ allTasks
629
683
  });
630
684
  } else if (operation === 'list_epics') {
631
685
  await kanban.migrateEpicGroups();
@@ -663,7 +717,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
663
717
  } else {
664
718
  throw invalidRequest(
665
719
  `Unknown operation: ${operation}`,
666
- 'Use one of: list, show, list_epics, show_epic, help',
720
+ 'Use one of: list, show, list_epics, show_epic, context, help',
667
721
  { operation }
668
722
  );
669
723
  }
@@ -673,9 +727,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
673
727
  case 'kanban_manage': {
674
728
  const action = args.action;
675
729
  const returnShape = normalizeReturnShape(args.return);
730
+ await require('./workflow.js').ensureBoardConfig();
676
731
 
677
732
  switch (action) {
678
733
  case 'create': {
734
+ let subtasks = args.subtasks;
735
+ if (subtasks === undefined && Array.isArray(args.steps)) {
736
+ subtasks = args.steps.filter(Boolean).map(String).map((text, index) => ({
737
+ id: `st-${index + 1}`,
738
+ text,
739
+ done: false,
740
+ description: ''
741
+ }));
742
+ }
679
743
  const createPayload = {
680
744
  description: args.description,
681
745
  specs: args.specs,
@@ -683,8 +747,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
683
747
  out_of_scope: args.out_of_scope,
684
748
  acceptance_criteria: args.acceptance_criteria,
685
749
  test_cases: args.test_cases,
686
- subtasks: args.subtasks,
687
- notes: args.notes
750
+ subtasks,
751
+ notes: args.notes,
752
+ depends_on: args.depends_on,
753
+ files: args.files
688
754
  };
689
755
  const epicRef = args.epic_id || args.epic || '—';
690
756
  const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
@@ -839,6 +905,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
839
905
  if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
840
906
  if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
841
907
  if (args.notes !== undefined) patch.notes = args.notes;
908
+ if (args.depends_on !== undefined) patch.depends_on = args.depends_on;
909
+ if (args.files !== undefined) patch.files = args.files;
842
910
  if (args.adr !== undefined) patch.adr = args.adr;
843
911
  if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
844
912
  else if (args.epic !== undefined) patch.epic = args.epic;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.5.0",
3
+ "version": "3.8.0",
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
@@ -55,17 +55,13 @@ async function detectTestRunner(projectRoot = process.cwd()) {
55
55
  }
56
56
  }
57
57
 
58
- throw planError('NO_TEST_RUNNER', 'No supported test runner was found',
59
- 'Add a supported project manifest or set OPENCODE_TEST_COMMAND', { project_root: projectRoot });
58
+ return null;
60
59
  }
61
60
 
62
61
  function planSubtasks(implementationSteps) {
63
- const steps = [
64
- 'Write tests',
65
- 'Run tests and confirm red',
66
- ...implementationSteps,
67
- 'Run tests and confirm green'
68
- ];
62
+ const steps = Array.isArray(implementationSteps)
63
+ ? implementationSteps.filter(Boolean).map(String)
64
+ : [];
69
65
  return steps.map((text, index) => ({ id: `st-${index + 1}`, text, done: false, description: '' }));
70
66
  }
71
67
 
@@ -93,6 +89,8 @@ async function create(payload = {}) {
93
89
  test_cases: payload.test_cases,
94
90
  subtasks: planSubtasks(implementationSteps),
95
91
  notes: payload.notes,
92
+ depends_on: payload.depends_on,
93
+ files: payload.files,
96
94
  plan: { runner, status: 'active' },
97
95
  evidence: []
98
96
  });
@@ -116,28 +114,30 @@ async function advance(payload = {}) {
116
114
  }
117
115
 
118
116
  async function evidence(payload = {}) {
119
- const required = ['diff', 'test_command', 'stdout', 'stderr', 'exit_code'];
120
- for (const field of required) {
121
- if (payload[field] === undefined) {
122
- throw planError('MISSING_REQUIRED_FIELD', `${field} is required`,
123
- 'Provide diff, test_command, stdout, stderr, and exit_code', { field });
124
- }
117
+ const diff = payload.diff !== undefined ? String(payload.diff) : '';
118
+ const summary = payload.summary !== undefined ? String(payload.summary) : '';
119
+ const testCommand = payload.test_command !== undefined ? String(payload.test_command) : '';
120
+ if (!diff && !summary && !testCommand) {
121
+ throw planError('MISSING_REQUIRED_FIELD', 'diff, summary, or test_command is required',
122
+ 'Provide at least one of diff, summary, or test_command', { field: 'diff' });
125
123
  }
126
- if (!Number.isInteger(payload.exit_code)) {
124
+ if (payload.exit_code !== undefined && !Number.isInteger(payload.exit_code)) {
127
125
  throw planError('VALIDATION_ERROR', 'exit_code must be an integer',
128
- 'Use the process exit code from the test command', { field: 'exit_code' });
126
+ 'Use the process exit code from the test command, or omit exit_code', { field: 'exit_code' });
129
127
  }
130
128
  const task = await kanban.getTask(payload.task_id);
131
- const entry = {
132
- diff: String(payload.diff),
133
- test_command: String(payload.test_command),
134
- stdout: String(payload.stdout),
135
- stderr: String(payload.stderr),
136
- exit_code: payload.exit_code,
129
+ const patch = { evidence: [...task.evidence, {
130
+ diff,
131
+ test_command: testCommand,
132
+ stdout: payload.stdout !== undefined ? String(payload.stdout) : '',
133
+ stderr: payload.stderr !== undefined ? String(payload.stderr) : '',
134
+ exit_code: Number.isInteger(payload.exit_code) ? payload.exit_code : null,
135
+ summary,
137
136
  created: new Date().toISOString()
138
- };
139
- const updated = await kanban.updateTask(task.id, { evidence: [...task.evidence, entry] });
140
- return result(updated, { evidence: entry });
137
+ }] };
138
+ if (payload.files !== undefined) patch.files = payload.files;
139
+ const updated = await kanban.updateTask(task.id, patch);
140
+ return result(updated, { evidence: patch.evidence[patch.evidence.length - 1] });
141
141
  }
142
142
 
143
143
  async function done(payload = {}) {
@@ -147,8 +147,15 @@ 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 workflow = require('./workflow.js');
151
+ const targetColumn = await workflow.planDoneColumn();
152
+ const updated = await kanban.updateTask(task.id, {
153
+ column: targetColumn,
154
+ plan: { ...(task.plan || {}), status: 'done' }
155
+ });
156
+ const extra = { status: 'done', column: updated.column };
157
+ if (Array.isArray(updated.unblocked_tasks)) extra.unblocked_tasks = updated.unblocked_tasks;
158
+ return result(updated, extra);
152
159
  }
153
160
 
154
161
  async function status(taskId) {
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fake opencode runner for workflow-gates tests.
4
+ * Env:
5
+ * FAKE_OPENCODE_MODE = pass|fail|blocked|gate_pass|gate_fail|gate_missing|hang|exit1
6
+ * FAKE_OPENCODE_REVIEW_MODE = gate_pass|gate_fail|gate_missing (optional; overrides when --agent is review)
7
+ * FAKE_OPENCODE_ARGV_FILE = path to write JSON argv
8
+ * FAKE_OPENCODE_HANG_MS = hang duration (default 60000)
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const args = process.argv.slice(2);
14
+ const agentIdx = args.indexOf('--agent');
15
+ const agent = agentIdx >= 0 ? args[agentIdx + 1] : '';
16
+
17
+ let mode = String(process.env.FAKE_OPENCODE_MODE || 'pass').trim();
18
+ const reviewMode = String(process.env.FAKE_OPENCODE_REVIEW_MODE || '').trim();
19
+ // Review agents: prefer review mode, else map testing modes to gate_* defaults.
20
+ if (agent === 'temida' || agent === 'review' || /temida|review/i.test(agent)) {
21
+ if (reviewMode) {
22
+ mode = reviewMode;
23
+ } else if (mode === 'pass') {
24
+ mode = 'gate_pass';
25
+ } else if (mode === 'fail') {
26
+ mode = 'gate_fail';
27
+ } else if (mode === 'blocked' || mode === 'gate_missing') {
28
+ mode = mode === 'blocked' ? 'gate_missing' : mode;
29
+ }
30
+ }
31
+
32
+ const argvFile = process.env.FAKE_OPENCODE_ARGV_FILE;
33
+ if (argvFile) {
34
+ try {
35
+ fs.mkdirSync(path.dirname(argvFile), { recursive: true });
36
+ fs.writeFileSync(argvFile, JSON.stringify(args, null, 2) + '\n', 'utf-8');
37
+ } catch (err) {
38
+ process.stderr.write(`argv write failed: ${err.message}\n`);
39
+ }
40
+ }
41
+
42
+ if (mode === 'hang') {
43
+ const ms = Number(process.env.FAKE_OPENCODE_HANG_MS || 60000);
44
+ setTimeout(() => process.exit(0), ms);
45
+ return;
46
+ }
47
+
48
+ const outputs = {
49
+ pass: { out: 'Tests green\nPASS\n', code: 0 },
50
+ fail: { out: 'Tests red\nFAIL\n', code: 1 },
51
+ blocked: { out: 'Missing env\nBLOCKED\n', code: 0 },
52
+ gate_pass: {
53
+ out: 'Nagroda: solidny diff.\nKara kosmetyczna: naming.\nWedka: ok.\nGATE: PASS\n',
54
+ code: 0
55
+ },
56
+ gate_fail: {
57
+ out: 'Kara: brak testow produkcji.\nWedka: dodaj testy.\nGATE: FAIL\n',
58
+ code: 0
59
+ },
60
+ gate_missing: {
61
+ out: 'Wyrok bez markera bramki.\nTylko narracja.\n',
62
+ code: 0
63
+ },
64
+ exit1: { out: 'crashed without verdict\n', code: 1 }
65
+ };
66
+
67
+ const picked = outputs[mode] || outputs.pass;
68
+ process.stdout.write(picked.out);
69
+ process.exit(picked.code);
package/tests/index.js ADDED
@@ -0,0 +1,19 @@
1
+ const assert = require('assert');
2
+ const path = require('path');
3
+
4
+ function run() {
5
+ const pkg = require(path.join(__dirname, '..', 'index.js'));
6
+ assert.ok(pkg.kanban, 'index exports kanban');
7
+ assert.ok(pkg.plan, 'index exports plan');
8
+ assert.ok(pkg.workflow, 'index exports workflow');
9
+ assert.ok(pkg.guiRegistry, 'index exports guiRegistry');
10
+ assert.ok(pkg.playbook, 'index exports playbook');
11
+ assert.ok(pkg.kanban.COLS.includes('testing'));
12
+ assert.ok(pkg.kanban.COLS.includes('review'));
13
+ assert.strictEqual(typeof pkg.workflow.loadConfig, 'function');
14
+ assert.strictEqual(typeof pkg.workflow.maybeEnqueueOnColumnEnter, 'function');
15
+ assert.strictEqual(typeof pkg.plan.done, 'function');
16
+ console.log('✓ index.test.js passed');
17
+ }
18
+
19
+ run();
@@ -0,0 +1,118 @@
1
+ const assert = require('assert');
2
+ const crypto = require('crypto');
3
+ const fs = require('fs').promises;
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const PKG = path.join(__dirname, '..');
9
+ const CLI = path.join(PKG, 'bin', 'kanban.js');
10
+ const SRC_QA = path.join(PKG, 'agents', 'qa-tester.md');
11
+
12
+ function runCli(cwd, args) {
13
+ return spawnSync(process.execPath, [CLI, ...args], {
14
+ cwd,
15
+ encoding: 'utf-8'
16
+ });
17
+ }
18
+
19
+ function sha256(content) {
20
+ return crypto.createHash('sha256').update(content).digest('hex');
21
+ }
22
+
23
+ async function run() {
24
+ // init creates testing/review dirs + README
25
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-bin-'));
26
+ const init = runCli(root, ['init']);
27
+ assert.strictEqual(init.status, 0, init.stderr);
28
+ for (const col of ['testing', 'review', 'active', 'planned', 'icebox', 'done']) {
29
+ const st = await fs.stat(path.join(root, 'backlog', col));
30
+ assert.ok(st.isDirectory(), col);
31
+ }
32
+ const readme = await fs.readFile(path.join(root, 'backlog', 'README.md'), 'utf-8');
33
+ assert.ok(readme.includes('testing/'));
34
+ assert.ok(readme.includes('review/'));
35
+
36
+ // mcp-init --opencode copies agents + writes manifest
37
+ const ocRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-bin-oc-'));
38
+ const oc = runCli(ocRoot, ['mcp-init', '--opencode']);
39
+ assert.strictEqual(oc.status, 0, oc.stderr);
40
+ const agentDir = path.join(ocRoot, '.opencode', 'agent');
41
+ const agents = await fs.readdir(agentDir);
42
+ assert.ok(agents.includes('qa-tester.md'));
43
+ assert.ok(agents.includes('temida.md'));
44
+ assert.ok(agents.includes('.kanbango-agents.json'));
45
+ const temida = await fs.readFile(path.join(agentDir, 'temida.md'), 'utf-8');
46
+ assert.ok(temida.includes('GATE: PASS'));
47
+
48
+ const srcQa = await fs.readFile(SRC_QA, 'utf-8');
49
+ const srcHash = sha256(srcQa);
50
+ const manifest1 = JSON.parse(await fs.readFile(path.join(agentDir, '.kanbango-agents.json'), 'utf-8'));
51
+ assert.strictEqual(manifest1.agents['qa-tester.md'], srcHash);
52
+
53
+ // second run, identical source → unchanged
54
+ const againSame = runCli(ocRoot, ['mcp-init', '--opencode']);
55
+ assert.strictEqual(againSame.status, 0);
56
+ assert.ok(againSame.stdout.includes('Bez zmian') || againSame.stdout.includes('unchanged')
57
+ || againSame.stdout.includes('Pominięto') === false);
58
+ assert.ok(againSame.stdout.includes('Bez zmian .opencode/agent/qa-tester.md'));
59
+ assert.strictEqual(await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'), srcQa);
60
+
61
+ // local edit while manifest matches previous package hash → conflict, keep local
62
+ await fs.writeFile(path.join(agentDir, 'qa-tester.md'), 'KEEP_LOCAL_EDIT', 'utf-8');
63
+ const conflictRun = runCli(ocRoot, ['mcp-init', '--opencode']);
64
+ assert.strictEqual(conflictRun.status, 0);
65
+ assert.ok(
66
+ conflictRun.stdout.includes('Konflikt') || conflictRun.stdout.includes('Pominięto'),
67
+ `expected conflict/skip message, got: ${conflictRun.stdout}`
68
+ );
69
+ assert.strictEqual(
70
+ await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'),
71
+ 'KEEP_LOCAL_EDIT'
72
+ );
73
+
74
+ // clean older package version: dest matches recorded hash, source "new" → auto update
75
+ // Simulate by writing dest = old body, manifest hash = old hash, then force-path via
76
+ // restoring package content is fixed; instead write dest to old body with matching manifest.
77
+ const oldBody = 'OLD_PACKAGE_VERSION\n';
78
+ const oldHash = sha256(oldBody);
79
+ await fs.writeFile(path.join(agentDir, 'qa-tester.md'), oldBody, 'utf-8');
80
+ const man = JSON.parse(await fs.readFile(path.join(agentDir, '.kanbango-agents.json'), 'utf-8'));
81
+ man.agents['qa-tester.md'] = oldHash;
82
+ await fs.writeFile(
83
+ path.join(agentDir, '.kanbango-agents.json'),
84
+ JSON.stringify(man, null, 2) + '\n',
85
+ 'utf-8'
86
+ );
87
+ const autoUpdate = runCli(ocRoot, ['mcp-init', '--opencode']);
88
+ assert.strictEqual(autoUpdate.status, 0, autoUpdate.stderr);
89
+ assert.ok(
90
+ autoUpdate.stdout.includes('Zaktualizowano .opencode/agent/qa-tester.md'),
91
+ autoUpdate.stdout
92
+ );
93
+ assert.strictEqual(await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'), srcQa);
94
+ const manAfter = JSON.parse(await fs.readFile(path.join(agentDir, '.kanbango-agents.json'), 'utf-8'));
95
+ assert.strictEqual(manAfter.agents['qa-tester.md'], srcHash);
96
+
97
+ // --force overwrites local edit
98
+ await fs.writeFile(path.join(agentDir, 'qa-tester.md'), 'FORCE_ME', 'utf-8');
99
+ const forceRun = runCli(ocRoot, ['mcp-init', '--opencode', '--force']);
100
+ assert.strictEqual(forceRun.status, 0);
101
+ assert.strictEqual(await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'), srcQa);
102
+
103
+ // --claude does not create .opencode/agent
104
+ const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-bin-cl-'));
105
+ const cl = runCli(claudeRoot, ['mcp-init', '--claude']);
106
+ assert.strictEqual(cl.status, 0, cl.stderr);
107
+ await assert.rejects(
108
+ () => fs.access(path.join(claudeRoot, '.opencode', 'agent')),
109
+ (err) => err.code === 'ENOENT'
110
+ );
111
+
112
+ console.log('✓ bin-kanban.test.js passed');
113
+ }
114
+
115
+ run().catch((err) => {
116
+ console.error(err);
117
+ process.exit(1);
118
+ });
@@ -0,0 +1,104 @@
1
+ const assert = require('assert');
2
+ const fs = require('fs').promises;
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ async function run() {
7
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-kanban-'));
8
+ process.chdir(tempRoot);
9
+ const kanban = require(path.join(__dirname, '..', 'kanban.js'));
10
+
11
+ assert.ok(kanban.COLS.includes('testing'));
12
+ assert.ok(kanban.COLS.includes('review'));
13
+ assert.strictEqual(kanban.STATUS_MAP.testing, 'testing');
14
+ assert.strictEqual(kanban.STATUS_MAP.review, 'review');
15
+
16
+ await kanban.ensureBacklogDir();
17
+ for (const col of ['testing', 'review']) {
18
+ const st = await fs.stat(path.join(tempRoot, 'backlog', col));
19
+ assert.ok(st.isDirectory());
20
+ }
21
+
22
+ const ev = kanban.normalizeEvidence([{
23
+ diff: 'old',
24
+ test_command: 't',
25
+ stdout: '',
26
+ stderr: '',
27
+ exit_code: 0,
28
+ stage: 'testing',
29
+ agent: 'qa-tester',
30
+ verdict: 'pass',
31
+ summary: 'ok'
32
+ }]);
33
+ assert.strictEqual(ev[0].stage, 'testing');
34
+ assert.strictEqual(ev[0].verdict, 'pass');
35
+
36
+ const wf = kanban.normalizeWorkflow({
37
+ stage: 'review',
38
+ status: 'running',
39
+ agent: 'temida',
40
+ run_id: 'run-x'
41
+ });
42
+ assert.strictEqual(wf.stage, 'review');
43
+ assert.strictEqual(wf.status, 'running');
44
+
45
+ const task = await kanban.doCreate('Kanban unit', 'active', '—', {});
46
+ const moved = await kanban.updateTask(task.id, { column: 'testing' });
47
+ assert.strictEqual(moved.column, 'testing');
48
+
49
+ await assert.rejects(
50
+ () => kanban.updateTask(task.id, { column: 'done' }),
51
+ (err) => {
52
+ assert.strictEqual(err.code, 'INVALID_TRANSITION');
53
+ assert.deepStrictEqual(err.details.allowed_columns, ['active', 'review']);
54
+ assert.strictEqual(err.details.from, 'testing');
55
+ assert.strictEqual(err.details.to, 'done');
56
+ assert.ok(err.hint.includes('active, review'));
57
+ return true;
58
+ }
59
+ );
60
+ assert.deepStrictEqual(kanban.allowedColumnsFrom('active'), ['planned', 'testing', 'icebox']);
61
+ assert.deepStrictEqual(kanban.allowedColumnsFrom('review'), ['active', 'done']);
62
+ kanban.validateTransition('active', 'testing', '001');
63
+ kanban.validateTransition('testing', 'testing', '001');
64
+
65
+ const appended = await kanban.updateTask(task.id, {
66
+ appendEvidence: {
67
+ diff: '',
68
+ test_command: 'fake',
69
+ stdout: 'PASS',
70
+ stderr: '',
71
+ exit_code: 0,
72
+ stage: 'testing',
73
+ agent: 'qa-tester',
74
+ verdict: 'pass',
75
+ summary: 'PASS'
76
+ },
77
+ workflow: {
78
+ stage: 'testing',
79
+ status: 'pass',
80
+ agent: 'qa-tester',
81
+ run_id: 'run-1'
82
+ }
83
+ });
84
+ assert.ok(appended.evidence.some((e) => e.verdict === 'pass'));
85
+ assert.strictEqual(appended.workflow.status, 'pass');
86
+
87
+ assert.strictEqual(
88
+ kanban.deriveEpicStatus([{ column: 'testing' }], {}),
89
+ 'active'
90
+ );
91
+ const progress = kanban.getEpicProgress([
92
+ { column: 'testing' },
93
+ { column: 'review' }
94
+ ]);
95
+ assert.strictEqual(progress.tasks_testing, 1);
96
+ assert.strictEqual(progress.tasks_review, 1);
97
+
98
+ console.log('✓ kanban.test.js passed');
99
+ }
100
+
101
+ run().catch((err) => {
102
+ console.error(err);
103
+ process.exit(1);
104
+ });