kanbango 3.6.2 → 5.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbango",
3
- "version": "3.6.2",
3
+ "version": "5.1.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
  });
@@ -100,44 +98,41 @@ async function create(payload = {}) {
100
98
  }
101
99
 
102
100
  async function advance(payload = {}) {
103
- const task = await kanban.getTask(payload.task_id);
104
- const index = payload.index !== undefined ? Number(payload.index)
105
- : task.subtasks.findIndex((subtask) => !subtask.done);
106
- if (!Number.isInteger(index) || index < 0 || index >= task.subtasks.length) {
107
- throw planError('INVALID_SUBTASK_INDEX', 'No valid plan step was provided',
108
- 'Provide the zero-based index of an incomplete subtask', { index, total_subtasks: task.subtasks.length });
109
- }
110
- const subtasks = task.subtasks.map((subtask, subtaskIndex) => ({
111
- ...subtask,
112
- done: subtaskIndex === index ? true : subtask.done
113
- }));
114
- const updated = await kanban.updateTask(task.id, { subtasks });
101
+ const hasIndex = payload.index !== undefined && payload.index !== null && payload.index !== '';
102
+ const patch = hasIndex
103
+ ? { mark_subtask_done: Number(payload.index) }
104
+ : { mark_subtask_done: null };
105
+ const updated = await kanban.updateTask(payload.task_id, patch);
115
106
  return result(updated, { current_step: updated.subtasks.findIndex((subtask) => !subtask.done) });
116
107
  }
117
108
 
118
109
  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
- }
110
+ const diff = payload.diff !== undefined ? String(payload.diff) : '';
111
+ const summary = payload.summary !== undefined ? String(payload.summary) : '';
112
+ const testCommand = payload.test_command !== undefined ? String(payload.test_command) : '';
113
+ if (!diff && !summary && !testCommand) {
114
+ throw planError('MISSING_REQUIRED_FIELD', 'diff, summary, or test_command is required',
115
+ 'Provide at least one of diff, summary, or test_command', { field: 'diff' });
125
116
  }
126
- if (!Number.isInteger(payload.exit_code)) {
117
+ if (payload.exit_code !== undefined && !Number.isInteger(payload.exit_code)) {
127
118
  throw planError('VALIDATION_ERROR', 'exit_code must be an integer',
128
- 'Use the process exit code from the test command', { field: 'exit_code' });
119
+ 'Use the process exit code from the test command, or omit exit_code', { field: 'exit_code' });
129
120
  }
130
- 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,
137
- created: new Date().toISOString()
121
+ const patch = {
122
+ appendEvidence: {
123
+ diff,
124
+ test_command: testCommand,
125
+ stdout: payload.stdout !== undefined ? String(payload.stdout) : '',
126
+ stderr: payload.stderr !== undefined ? String(payload.stderr) : '',
127
+ exit_code: Number.isInteger(payload.exit_code) ? payload.exit_code : null,
128
+ summary,
129
+ created: new Date().toISOString()
130
+ }
138
131
  };
139
- const updated = await kanban.updateTask(task.id, { evidence: [...task.evidence, entry] });
140
- return result(updated, { evidence: entry });
132
+ if (payload.files !== undefined) patch.files = payload.files;
133
+ const updated = await kanban.updateTask(payload.task_id, patch);
134
+ const evidenceList = updated.evidence || [];
135
+ return result(updated, { evidence: evidenceList[evidenceList.length - 1] });
141
136
  }
142
137
 
143
138
  async function done(payload = {}) {
@@ -147,11 +142,15 @@ async function done(payload = {}) {
147
142
  throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
148
143
  'Advance every plan step before marking the workflow done', { incomplete });
149
144
  }
145
+ const workflow = require('./workflow.js');
146
+ const targetColumn = await workflow.planDoneColumn();
150
147
  const updated = await kanban.updateTask(task.id, {
151
- column: 'testing',
148
+ column: targetColumn,
152
149
  plan: { ...(task.plan || {}), status: 'done' }
153
150
  });
154
- return result(updated, { status: 'done', column: updated.column });
151
+ const extra = { status: 'done', column: updated.column };
152
+ if (Array.isArray(updated.unblocked_tasks)) extra.unblocked_tasks = updated.unblocked_tasks;
153
+ return result(updated, extra);
155
154
  }
156
155
 
157
156
  async function status(taskId) {
@@ -0,0 +1,117 @@
1
+ const path = require('path');
2
+
3
+ const LIST_COLUMNS = ['active', 'planned', 'icebox'];
4
+
5
+ const kanbanPalette = {
6
+ commandName: 'kanban.open',
7
+ title: 'Kanban',
8
+ desc: 'Browse board and paste a start prompt',
9
+ category: 'Kanban',
10
+ slashName: 'kanban',
11
+ shortcut: 'ctrl+alt+k'
12
+ };
13
+
14
+ function defaultKanban() {
15
+ return require(path.join(__dirname, '..', 'kanban.js'));
16
+ }
17
+
18
+ function formatEpicLabel(epic) {
19
+ if (!epic || !epic.id) return '—';
20
+ const title = typeof epic.title === 'string' ? epic.title.trim() : '';
21
+ return title ? `${epic.id}/${title}` : String(epic.id);
22
+ }
23
+
24
+ function formatStartPrompt(task, epic) {
25
+ const id = task && task.id != null ? String(task.id) : '';
26
+ const title = task && task.title != null ? String(task.title) : '';
27
+ return `Zacznij implementować zadanie #${id}: ${title} z epiku ${formatEpicLabel(epic)}.`;
28
+ }
29
+
30
+ function epicLookup(epics) {
31
+ const byId = new Map();
32
+ for (const epic of epics || []) {
33
+ if (epic && epic.id) byId.set(epic.id, epic);
34
+ }
35
+ return byId;
36
+ }
37
+
38
+ function shapeRow(task, epicsById, allTasks, unmetDependencies) {
39
+ const unmet = unmetDependencies(task, allTasks) || [];
40
+ const epic = task.epic_id ? epicsById.get(task.epic_id) : null;
41
+ return {
42
+ id: task.id,
43
+ title: task.title || '',
44
+ column: task.column,
45
+ epic_id: task.epic_id || '',
46
+ epicTitle: epic && epic.title ? epic.title : '',
47
+ epicLabel: formatEpicLabel(epic || (task.epic_id ? { id: task.epic_id } : null)),
48
+ blocked: unmet.length > 0,
49
+ unmet_dependencies: unmet,
50
+ description: task.description || '',
51
+ acceptance_criteria: Array.isArray(task.acceptance_criteria) ? task.acceptance_criteria : [],
52
+ depends_on: Array.isArray(task.depends_on) ? task.depends_on : []
53
+ };
54
+ }
55
+
56
+ function toBoardRows(tasks, epics, helpers = {}) {
57
+ const filterTasksForList = helpers.filterTasksForList || ((list) => list);
58
+ const unmetDependencies = helpers.unmetDependencies || (() => []);
59
+ const filtered = filterTasksForList(tasks || [], epics || [], {});
60
+ const epicsById = epicLookup(epics);
61
+ const byColumn = new Map(LIST_COLUMNS.map((column) => [column, []]));
62
+ for (const task of filtered) {
63
+ if (!task || !byColumn.has(task.column)) continue;
64
+ byColumn.get(task.column).push(shapeRow(task, epicsById, tasks, unmetDependencies));
65
+ }
66
+ return LIST_COLUMNS.flatMap((column) => byColumn.get(column));
67
+ }
68
+
69
+ async function loadBoard(options = {}) {
70
+ const kanban = options.kanban || defaultKanban();
71
+ const cwd = options.cwd;
72
+ const previous = process.cwd();
73
+ let changed = false;
74
+ try {
75
+ if (cwd && cwd !== previous) {
76
+ process.chdir(cwd);
77
+ changed = true;
78
+ }
79
+ const tasks = await kanban.allTasks();
80
+ const epics = await kanban.listEpicEntities();
81
+ return {
82
+ rows: toBoardRows(tasks, epics, {
83
+ filterTasksForList: kanban.filterTasksForList,
84
+ unmetDependencies: kanban.unmetDependencies
85
+ }),
86
+ tasks,
87
+ epics
88
+ };
89
+ } finally {
90
+ if (changed) process.chdir(previous);
91
+ }
92
+ }
93
+
94
+ function moveSelection(index, delta, length) {
95
+ if (!length || length <= 0) return 0;
96
+ const next = Number(index || 0) + Number(delta || 0);
97
+ if (next < 0) return 0;
98
+ if (next >= length) return length - 1;
99
+ return next;
100
+ }
101
+
102
+ function selectedRow(rows, index) {
103
+ if (!Array.isArray(rows) || rows.length === 0) return null;
104
+ if (index < 0 || index >= rows.length) return null;
105
+ return rows[index];
106
+ }
107
+
108
+ module.exports = {
109
+ LIST_COLUMNS,
110
+ kanbanPalette,
111
+ formatEpicLabel,
112
+ formatStartPrompt,
113
+ toBoardRows,
114
+ loadBoard,
115
+ moveSelection,
116
+ selectedRow
117
+ };
@@ -0,0 +1,314 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createRequire } from 'node:module';
3
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiThemeCurrent } from '@opencode-ai/plugin/tui';
4
+ import { createSignal, For, Show, type Accessor } from 'solid-js';
5
+
6
+ type BoardRow = {
7
+ id: string;
8
+ title: string;
9
+ column: string;
10
+ epic_id: string;
11
+ epicTitle: string;
12
+ epicLabel: string;
13
+ blocked: boolean;
14
+ unmet_dependencies: Array<{ id: string }>;
15
+ description: string;
16
+ acceptance_criteria: string[];
17
+ depends_on: string[];
18
+ };
19
+
20
+ const requireController = createRequire(import.meta.url);
21
+ const controller = requireController('./tui-kanban-controller.js') as {
22
+ kanbanPalette: {
23
+ commandName: string;
24
+ title: string;
25
+ desc: string;
26
+ category: string;
27
+ slashName: string;
28
+ shortcut: string;
29
+ };
30
+ formatStartPrompt: (
31
+ task: { id: string; title: string },
32
+ epic: { id?: string; title?: string } | null
33
+ ) => string;
34
+ loadBoard: (options?: { cwd?: string }) => Promise<{ rows: BoardRow[] }>;
35
+ moveSelection: (index: number, delta: number, length: number) => number;
36
+ selectedRow: (rows: BoardRow[], index: number) => BoardRow | null;
37
+ };
38
+
39
+ const id = 'kanbango.tui';
40
+ type TuiApi = TuiPluginApi;
41
+ type Theme = TuiThemeCurrent;
42
+
43
+ type View =
44
+ | { status: 'loading' }
45
+ | { status: 'error'; message: string }
46
+ | { status: 'ready'; rows: BoardRow[]; selected: number };
47
+
48
+ type DialogCtrl = {
49
+ view: Accessor<View>;
50
+ reload: () => Promise<void>;
51
+ move: (delta: number) => void;
52
+ confirm: () => Promise<void>;
53
+ };
54
+
55
+ let dialogOpen = false;
56
+ let dialogCtrl: DialogCtrl | null = null;
57
+
58
+ function closeKanbanDialog(api: TuiApi) {
59
+ dialogOpen = false;
60
+ dialogCtrl = null;
61
+ api.ui.dialog.clear();
62
+ }
63
+
64
+ function createDialogController(api: TuiApi): DialogCtrl {
65
+ const [view, setView] = createSignal<View>({ status: 'loading' });
66
+
67
+ const reload = async () => {
68
+ setView({ status: 'loading' });
69
+ try {
70
+ const board = await controller.loadBoard({ cwd: api.state.path.directory });
71
+ setView({
72
+ status: 'ready',
73
+ rows: board.rows,
74
+ selected: controller.moveSelection(0, 0, board.rows.length)
75
+ });
76
+ } catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ setView({ status: 'error', message });
79
+ }
80
+ };
81
+
82
+ const move = (delta: number) => {
83
+ setView((current) => {
84
+ if (current.status !== 'ready') return current;
85
+ return {
86
+ ...current,
87
+ selected: controller.moveSelection(current.selected, delta, current.rows.length)
88
+ };
89
+ });
90
+ };
91
+
92
+ const confirm = async () => {
93
+ const current = view();
94
+ if (current.status !== 'ready') return;
95
+ const row = controller.selectedRow(current.rows, current.selected);
96
+ if (!row) return;
97
+ const epic = row.epic_id ? { id: row.epic_id, title: row.epicTitle } : null;
98
+ const text = controller.formatStartPrompt(row, epic);
99
+ await api.client.tui.appendPrompt({ text });
100
+ closeKanbanDialog(api);
101
+ };
102
+
103
+ return { view, reload, move, confirm };
104
+ }
105
+
106
+ export function openKanbanDialog(api: TuiApi) {
107
+ const ctrl = createDialogController(api);
108
+ dialogCtrl = ctrl;
109
+ dialogOpen = true;
110
+ api.ui.dialog.setSize('large');
111
+ api.ui.dialog.replace(
112
+ () => <KanbanDialog api={api} ctrl={ctrl} />,
113
+ () => {
114
+ dialogOpen = false;
115
+ if (dialogCtrl === ctrl) dialogCtrl = null;
116
+ }
117
+ );
118
+ void ctrl.reload();
119
+ }
120
+
121
+ function registerPalette(api: TuiApi) {
122
+ api.keymap.registerLayer({
123
+ commands: [
124
+ {
125
+ name: controller.kanbanPalette.commandName,
126
+ title: controller.kanbanPalette.title,
127
+ desc: controller.kanbanPalette.desc,
128
+ category: controller.kanbanPalette.category,
129
+ namespace: 'palette',
130
+ slashName: controller.kanbanPalette.slashName,
131
+ run() {
132
+ openKanbanDialog(api);
133
+ }
134
+ }
135
+ ]
136
+ });
137
+ }
138
+
139
+ function registerShortcut(api: TuiApi) {
140
+ api.keymap.registerLayer({
141
+ enabled: () => !dialogOpen,
142
+ bindings: [
143
+ {
144
+ key: controller.kanbanPalette.shortcut,
145
+ cmd: () => openKanbanDialog(api),
146
+ desc: controller.kanbanPalette.title
147
+ }
148
+ ]
149
+ });
150
+ }
151
+
152
+ function registerDialogKeys(api: TuiApi) {
153
+ api.keymap.registerLayer({
154
+ enabled: () => dialogOpen && api.ui.dialog.open,
155
+ bindings: [
156
+ { key: 'up', cmd: () => dialogCtrl?.move(-1), desc: 'Previous task' },
157
+ { key: 'down', cmd: () => dialogCtrl?.move(1), desc: 'Next task' },
158
+ { key: 'k', cmd: () => dialogCtrl?.move(-1), desc: 'Previous task' },
159
+ { key: 'j', cmd: () => dialogCtrl?.move(1), desc: 'Next task' },
160
+ { key: 'return', cmd: () => void dialogCtrl?.confirm(), desc: 'Paste start prompt' },
161
+ { key: 'escape', cmd: () => closeKanbanDialog(api), desc: 'Close' }
162
+ ]
163
+ });
164
+ }
165
+
166
+ const tui: TuiPlugin = async (api) => {
167
+ registerPalette(api);
168
+ registerShortcut(api);
169
+ registerDialogKeys(api);
170
+ };
171
+
172
+ function KanbanDialog(props: { api: TuiApi; ctrl: DialogCtrl }) {
173
+ const theme = () => props.api.theme.current as Theme;
174
+ const state = () => props.ctrl.view();
175
+ const selected = () => {
176
+ const current = state();
177
+ if (current.status !== 'ready') return null;
178
+ return controller.selectedRow(current.rows, current.selected);
179
+ };
180
+
181
+ return (
182
+ <box flexDirection="column" width="100%" height="100%" padding={1} gap={1}>
183
+ <text>
184
+ <span style={{ fg: theme().primary, bold: true }}>Kanban</span>
185
+ <span style={{ fg: theme().textMuted }}> · Enter paste start · Esc close</span>
186
+ </text>
187
+ <Show when={state().status === 'loading'}>
188
+ <text fg={theme().textMuted}>Loading board…</text>
189
+ </Show>
190
+ <Show when={state().status === 'error'}>
191
+ <text fg={theme().error}>{(state() as { message: string }).message}</text>
192
+ </Show>
193
+ <Show when={state().status === 'ready'}>
194
+ <box flexDirection="row" width="100%" height="100%" gap={1} flexGrow={1}>
195
+ <TaskList
196
+ rows={(state() as Extract<View, { status: 'ready' }>).rows}
197
+ selected={(state() as Extract<View, { status: 'ready' }>).selected}
198
+ theme={theme()}
199
+ />
200
+ <DetailPanel row={selected()} theme={theme()} />
201
+ </box>
202
+ </Show>
203
+ </box>
204
+ );
205
+ }
206
+
207
+ function groupedRows(rows: BoardRow[]) {
208
+ const groups: Array<{ column: string; items: Array<{ row: BoardRow; index: number }> }> = [];
209
+ for (let index = 0; index < rows.length; index++) {
210
+ const row = rows[index];
211
+ const last = groups[groups.length - 1];
212
+ if (!last || last.column !== row.column) {
213
+ groups.push({ column: row.column, items: [{ row, index }] });
214
+ } else {
215
+ last.items.push({ row, index });
216
+ }
217
+ }
218
+ return groups;
219
+ }
220
+
221
+ function TaskList(props: { rows: BoardRow[]; selected: number; theme: Theme }) {
222
+ const groups = () => groupedRows(props.rows);
223
+ return (
224
+ <box
225
+ flexDirection="column"
226
+ flexGrow={1}
227
+ width="50%"
228
+ height="100%"
229
+ border={true}
230
+ borderColor={props.theme.border}
231
+ padding={1}
232
+ title=" tasks "
233
+ titleAlignment="left"
234
+ >
235
+ <Show when={props.rows.length > 0} fallback={<text fg={props.theme.textMuted}>No open tasks</text>}>
236
+ <For each={groups()}>
237
+ {(group) => (
238
+ <box flexDirection="column" gap={0}>
239
+ <text>
240
+ <span style={{ fg: props.theme.accent ?? props.theme.primary, bold: true }}>
241
+ {group.column}
242
+ </span>
243
+ </text>
244
+ <For each={group.items}>
245
+ {(item) => (
246
+ <TaskRow row={item.row} selected={item.index === props.selected} theme={props.theme} />
247
+ )}
248
+ </For>
249
+ </box>
250
+ )}
251
+ </For>
252
+ </Show>
253
+ </box>
254
+ );
255
+ }
256
+
257
+ function TaskRow(props: { row: BoardRow; selected: boolean; theme: Theme }) {
258
+ const mark = props.row.blocked ? '!' : ' ';
259
+ return (
260
+ <text>
261
+ <span style={{ fg: props.theme.primary }}>{props.selected ? '❯ ' : ' '}</span>
262
+ <span style={{ fg: props.row.blocked ? props.theme.error : props.theme.textMuted }}>{mark}</span>
263
+ <span style={{ fg: props.selected ? props.theme.primary : props.theme.textMuted }}>
264
+ {` ${props.row.id} `}
265
+ </span>
266
+ <span style={{ fg: props.selected ? props.theme.text : props.theme.textMuted }}>
267
+ {props.row.title}
268
+ </span>
269
+ <span style={{ fg: props.theme.textMuted }}>{` ${props.row.epicLabel}`}</span>
270
+ </text>
271
+ );
272
+ }
273
+
274
+ function DetailPanel(props: { row: BoardRow | null; theme: Theme }) {
275
+ const row = () => props.row;
276
+ const ac = () => row()?.acceptance_criteria ?? [];
277
+ const unmet = () => (row()?.unmet_dependencies ?? []).map((item) => item.id).join(', ');
278
+ return (
279
+ <box
280
+ flexDirection="column"
281
+ flexGrow={1}
282
+ width="50%"
283
+ height="100%"
284
+ border={true}
285
+ borderColor={props.theme.borderActive ?? props.theme.primary}
286
+ padding={1}
287
+ title=" detail "
288
+ titleAlignment="left"
289
+ gap={0}
290
+ >
291
+ <Show when={row()} fallback={<text fg={props.theme.textMuted}>Select a task</text>}>
292
+ <text>
293
+ <span style={{ fg: props.theme.primary, bold: true }}>{row()!.id}</span>
294
+ <span style={{ fg: props.theme.text }}>{` ${row()!.title}`}</span>
295
+ </text>
296
+ <text fg={props.theme.textMuted}>{`epic ${row()!.epicLabel}`}</text>
297
+ <Show when={row()!.blocked}>
298
+ <text fg={props.theme.error}>{`blocked ${unmet() || 'yes'}`}</text>
299
+ </Show>
300
+ <text fg={props.theme.textMuted}>
301
+ {`depends_on ${row()!.depends_on.length ? row()!.depends_on.join(', ') : '—'}`}
302
+ </text>
303
+ <text fg={props.theme.text}>{row()!.description || '(no description)'}</text>
304
+ <text fg={props.theme.textMuted}>acceptance criteria</text>
305
+ <Show when={ac().length > 0} fallback={<text fg={props.theme.textMuted}>—</text>}>
306
+ <For each={ac()}>{(line) => <text fg={props.theme.text}>{`- ${line}`}</text>}</For>
307
+ </Show>
308
+ </Show>
309
+ </box>
310
+ );
311
+ }
312
+
313
+ const plugin: TuiPluginModule & { id: string } = { id, tui };
314
+ export default plugin;