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/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)',
@@ -504,8 +539,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
504
539
  enum: COLS,
505
540
  description:
506
541
  '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.'
542
+ + `Allowed (from project config): ${transitionHint()}. `
543
+ + 'Illegal → INVALID_TRANSITION + allowed_columns.'
509
544
  },
510
545
  patch: {
511
546
  type: 'object',
@@ -523,31 +558,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
523
558
  steps: {
524
559
  type: 'array',
525
560
  items: { type: 'string' },
526
- 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)'
527
562
  },
528
563
  project_root: {
529
564
  type: 'string',
530
- 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'
531
566
  },
532
567
  diff: {
533
568
  type: 'string',
534
- 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)'
535
574
  },
536
575
  test_command: {
537
576
  type: 'string',
538
- description: 'plan_evidence: exact command run'
577
+ description: 'plan_evidence: optional exact command run'
539
578
  },
540
579
  stdout: {
541
580
  type: 'string',
542
- description: 'plan_evidence: test stdout (truncate to last ~2KB if huge)'
581
+ description: 'plan_evidence: optional test stdout (truncate to last ~2KB if huge)'
543
582
  },
544
583
  stderr: {
545
584
  type: 'string',
546
- description: 'plan_evidence: test stderr (truncate if huge)'
585
+ description: 'plan_evidence: optional test stderr (truncate if huge)'
547
586
  },
548
587
  exit_code: {
549
588
  type: 'integer',
550
- description: 'plan_evidence: process exit code'
589
+ description: 'plan_evidence: optional process exit code'
551
590
  }
552
591
  },
553
592
  required: ['action'],
@@ -593,12 +632,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
593
632
  break;
594
633
  }
595
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
+
596
645
  const readOptions = normalizeReadOptions(args, 'summary');
597
646
 
598
647
  if (operation === 'list') {
599
648
  await kanban.migrateEpicGroups();
600
649
  const epics = await kanban.listEpicEntities();
601
- let tasks = await kanban.allTasks();
650
+ const allBoardTasks = await kanban.allTasks();
651
+ let tasks = allBoardTasks;
602
652
  if (args.col) {
603
653
  tasks = tasks.filter((task) => task.column === args.col);
604
654
  }
@@ -613,8 +663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
613
663
  });
614
664
  }
615
665
 
616
- // summary omits epic_goals; no lookup needed for list tokens
617
- result = tasks.map((task) => kanban.shapeTask(task, readOptions));
666
+ result = tasks.map((task) => kanban.shapeTask(task, { ...readOptions, allTasks: allBoardTasks }));
618
667
  } else if (operation === 'show') {
619
668
  if (!args.task_id) {
620
669
  throw invalidRequest(
@@ -626,9 +675,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
626
675
 
627
676
  await kanban.migrateEpicGroups();
628
677
  const epicLookup = await buildEpicLookup();
678
+ const allTasks = await kanban.allTasks();
629
679
  result = kanban.shapeTask(await kanban.getTask(args.task_id), {
630
680
  ...readOptions,
631
- epicLookup
681
+ epicLookup,
682
+ allTasks
632
683
  });
633
684
  } else if (operation === 'list_epics') {
634
685
  await kanban.migrateEpicGroups();
@@ -666,7 +717,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
666
717
  } else {
667
718
  throw invalidRequest(
668
719
  `Unknown operation: ${operation}`,
669
- 'Use one of: list, show, list_epics, show_epic, help',
720
+ 'Use one of: list, show, list_epics, show_epic, context, help',
670
721
  { operation }
671
722
  );
672
723
  }
@@ -676,9 +727,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
676
727
  case 'kanban_manage': {
677
728
  const action = args.action;
678
729
  const returnShape = normalizeReturnShape(args.return);
730
+ await require('./workflow.js').ensureBoardConfig();
679
731
 
680
732
  switch (action) {
681
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
+ }
682
743
  const createPayload = {
683
744
  description: args.description,
684
745
  specs: args.specs,
@@ -686,8 +747,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
686
747
  out_of_scope: args.out_of_scope,
687
748
  acceptance_criteria: args.acceptance_criteria,
688
749
  test_cases: args.test_cases,
689
- subtasks: args.subtasks,
690
- notes: args.notes
750
+ subtasks,
751
+ notes: args.notes,
752
+ depends_on: args.depends_on,
753
+ files: args.files
691
754
  };
692
755
  const epicRef = args.epic_id || args.epic || '—';
693
756
  const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
@@ -842,6 +905,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
842
905
  if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
843
906
  if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
844
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;
845
910
  if (args.adr !== undefined) patch.adr = args.adr;
846
911
  if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
847
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.6.2",
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,11 +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 workflow = require('./workflow.js');
151
+ const targetColumn = await workflow.planDoneColumn();
150
152
  const updated = await kanban.updateTask(task.id, {
151
- column: 'testing',
153
+ column: targetColumn,
152
154
  plan: { ...(task.plan || {}), status: 'done' }
153
155
  });
154
- return result(updated, { status: 'done', column: updated.column });
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);
155
159
  }
156
160
 
157
161
  async function status(taskId) {
package/tests/run.js CHANGED
@@ -5,6 +5,8 @@ function runNode(scriptPath, args, label) {
5
5
  const fullPath = path.join(process.cwd(), scriptPath);
6
6
  const result = spawnSync(process.execPath, [fullPath, ...(args || [])], {
7
7
  stdio: 'inherit',
8
+ // Agent shells often export KANBANGO_AUTO_GUI=1; MCP child tests would hang.
9
+ env: { ...process.env, KANBANGO_AUTO_GUI: '0' }
8
10
  });
9
11
  if (result.status !== 0) {
10
12
  console.error(`✗ ${label} failed`);
@@ -23,10 +25,13 @@ runNode(path.join('tests', 'fenced-text.test.js'), [], 'Fenced text test');
23
25
  runNode(path.join('tests', 'kanban-mermaid-vendor.test.js'), [], 'Kanban mermaid vendor route test');
24
26
  runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
25
27
  runNode(path.join('tests', 'workflow-gates.test.js'), [], 'Workflow gates test');
28
+ runNode(path.join('tests', 'board-config.test.js'), [], 'Board config columns/agents test');
29
+ runNode(path.join('tests', 'config-wizard.test.js'), [], 'Config wizard TUI test');
26
30
  runNode(path.join('tests', 'kanban.js'), [], 'Kanban core test');
27
31
  runNode(path.join('tests', 'index.js'), [], 'Package index export test');
28
32
  runNode(path.join('tests', 'bin-kanban.test.js'), [], 'CLI bin/kanban test');
29
33
  runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
34
+ runNode(path.join('tests', 'agent-context.test.js'), [], 'Agent context / depends_on / files test');
30
35
  runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
31
36
  runNode(path.join('tests', 'kanban-epic-goals-adr.test.js'), [], 'Epic goals + ADR test');
32
37
  runNode(path.join('tests', 'delete-archive.test.js'), [], 'Delete/archive test');