kanbango 3.8.0 → 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/AGENTS.md +23 -18
- package/API.md +58 -170
- package/CHANGELOG.md +39 -1
- package/LLM_AGENTS.md +21 -502
- package/README.md +18 -16
- package/agent-playbook.js +18 -57
- package/bin/kanban.js +344 -304
- package/config-wizard.js +4 -4
- package/index.html +59 -2
- package/kanban.js +503 -283
- package/mcp-server.js +259 -201
- package/package.json +1 -1
- package/plan.js +19 -24
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +15 -14
- package/.ai/lessons.jsonl +0 -14
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -26
- package/bin/kanban-cmd.js +0 -40
- package/kan2.md +0 -76
- package/kanbango.md +0 -48
- package/planv2.md +0 -317
- package/tests/fixtures/fake-opencode.js +0 -69
- package/tests/index.js +0 -19
- package/tests/kanban-cli.js +0 -118
- package/tests/kanban.js +0 -104
- package/tests/run.js +0 -38
package/plan.js
CHANGED
|
@@ -98,18 +98,11 @@ async function create(payload = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
async function advance(payload = {}) {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
'Provide the zero-based index of an incomplete subtask', { index, total_subtasks: task.subtasks.length });
|
|
107
|
-
}
|
|
108
|
-
const subtasks = task.subtasks.map((subtask, subtaskIndex) => ({
|
|
109
|
-
...subtask,
|
|
110
|
-
done: subtaskIndex === index ? true : subtask.done
|
|
111
|
-
}));
|
|
112
|
-
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);
|
|
113
106
|
return result(updated, { current_step: updated.subtasks.findIndex((subtask) => !subtask.done) });
|
|
114
107
|
}
|
|
115
108
|
|
|
@@ -125,19 +118,21 @@ async function evidence(payload = {}) {
|
|
|
125
118
|
throw planError('VALIDATION_ERROR', 'exit_code must be an integer',
|
|
126
119
|
'Use the process exit code from the test command, or omit exit_code', { field: 'exit_code' });
|
|
127
120
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
131
|
+
};
|
|
138
132
|
if (payload.files !== undefined) patch.files = payload.files;
|
|
139
|
-
const updated = await kanban.updateTask(
|
|
140
|
-
|
|
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 = {}) {
|
|
@@ -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;
|
package/workflow.js
CHANGED
|
@@ -233,34 +233,32 @@ function planDoneTarget(testingOn, reviewOn) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
function defaultConfig() {
|
|
236
|
-
const testingOn =
|
|
237
|
-
const reviewOn =
|
|
236
|
+
const testingOn = false;
|
|
237
|
+
const reviewOn = false;
|
|
238
238
|
return {
|
|
239
239
|
missing: true,
|
|
240
240
|
enabled: false,
|
|
241
241
|
command: 'opencode',
|
|
242
|
-
testing_agent: { enabled:
|
|
243
|
-
e2e_agent: { enabled:
|
|
244
|
-
review_agent: { enabled:
|
|
242
|
+
testing_agent: { enabled: false, name: 'qa-tester' },
|
|
243
|
+
e2e_agent: { enabled: false, name: 'qa-e2e-tester' },
|
|
244
|
+
review_agent: { enabled: false, name: 'temida' },
|
|
245
245
|
timeout_ms: 600000,
|
|
246
246
|
columns: {
|
|
247
247
|
icebox: { enabled: true, label: DEFAULT_LABELS.icebox },
|
|
248
248
|
planned: { enabled: true, label: DEFAULT_LABELS.planned },
|
|
249
249
|
active: { enabled: true, label: DEFAULT_LABELS.active },
|
|
250
|
-
testing: { enabled:
|
|
251
|
-
review: { enabled:
|
|
250
|
+
testing: { enabled: false, label: DEFAULT_LABELS.testing },
|
|
251
|
+
review: { enabled: false, label: DEFAULT_LABELS.review },
|
|
252
252
|
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
253
253
|
},
|
|
254
254
|
active_cols: buildActiveCols(testingOn, reviewOn),
|
|
255
|
-
workflow_stages:
|
|
255
|
+
workflow_stages: [],
|
|
256
256
|
transitions: buildTransitions(testingOn, reviewOn),
|
|
257
257
|
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
258
|
-
move_order: ['icebox', 'planned', 'active', '
|
|
258
|
+
move_order: ['icebox', 'planned', 'active', 'done'],
|
|
259
259
|
board_columns: [
|
|
260
260
|
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
261
261
|
{ id: 'planned', label: DEFAULT_LABELS.planned },
|
|
262
|
-
{ id: 'testing', label: DEFAULT_LABELS.testing },
|
|
263
|
-
{ id: 'review', label: DEFAULT_LABELS.review },
|
|
264
262
|
{ id: 'done', label: DEFAULT_LABELS.done }
|
|
265
263
|
]
|
|
266
264
|
};
|
|
@@ -462,9 +460,12 @@ function truncate(text, max = 2000) {
|
|
|
462
460
|
|
|
463
461
|
function parseTestingVerdict(stdout, stderr, exitCode) {
|
|
464
462
|
const text = `${stdout}\n${stderr}`;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
463
|
+
// Last standalone PASS|FAIL|BLOCKED wins — prompt text itself contains all three.
|
|
464
|
+
const matches = text.match(/(?:^|[^A-Za-z])(PASS|FAIL|BLOCKED)(?:[^A-Za-z]|$)/gi) || [];
|
|
465
|
+
const last = matches.length ? matches[matches.length - 1].replace(/[^A-Za-z]/g, '').toLowerCase() : '';
|
|
466
|
+
if (last === 'pass') return 'pass';
|
|
467
|
+
if (last === 'blocked') return 'blocked';
|
|
468
|
+
if (last === 'fail') return 'fail';
|
|
468
469
|
if (exitCode !== 0) return 'fail';
|
|
469
470
|
return 'fail';
|
|
470
471
|
}
|
package/.ai/lessons.jsonl
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{"id":"260729-3h4","ts":"2026-07-29","scope":"proj","tags":["tests","mcp","cli","review"],"rule":"DO Add automated tests for every new public CLI/MCP workflow before shipping","when":"api-review","sev":2,"hits":1}
|
|
2
|
-
{"id":"260729-1ur","ts":"2026-07-29","scope":"proj","tags":["javascript","objects"],"rule":"DON'T Use empty object fallbacks in spreads like `...(obj || {})` since spreading falsy is safe","when":"object spread","sev":2,"hits":1}
|
|
3
|
-
{"id":"260729-5eo","ts":"2026-07-29","scope":"proj","tags":["processes","mcp","ownership"],"rule":"DO Track process ownership before stopping or cleaning up discovered services","when":"gui-process management","sev":2,"hits":1}
|
|
4
|
-
{"id":"260729-1ch","ts":"2026-07-29","scope":"proj","tags":["gui","process","mcp"],"rule":"DO GUI/MCP stop must only SIGTERM processes spawned by the current process; treat port-file PIDs as external_running","when":"gui-process management","sev":2,"hits":1}
|
|
5
|
-
{"id":"260731-o6g","ts":"2026-07-31","scope":"proj","tags":["kanban","epics","mcp"],"rule":"DO Default list/list_epics hide done+archived; hard-delete cascades epic children; archive is reversible flag","when":"kanban cleanup context","sev":2,"hits":1}
|
|
6
|
-
{"id":"260803-b27","ts":"2026-08-03","scope":"proj","tags":["tests","race","fs"],"rule":"DO Serialize board file mutations with an in-process lock and atomic write (temp+rename/link); race tests should assert…","when":"kanban concurrent IO","sev":2,"hits":1}
|
|
7
|
-
{"id":"260813-9t0","ts":"2026-08-13","scope":"proj","tags":["mcp","errors","dx"],"rule":"DO MCP missing-field errors: put Valid list + Example JSON + sent keys in message so the agent can retry without guessi…","when":"MCP tool validation / agent-facing erro…","sev":2,"hits":1}
|
|
8
|
-
{"id":"260819-ztl","ts":"2026-08-19","scope":"proj","tags":["config","kanban","validation"],"rule":"DO Field-policy config: skip only ENOENT; invalid JSON/shape must throw CONFIG_INVALID, never silently fall back to def…","when":"config load / required fields","sev":2,"hits":1}
|
|
9
|
-
{"id":"260825-d68","ts":"2026-08-25","scope":"proj","tags":["http","adr"],"rule":"DO HTTP POST/PATCH must forward adr and evidence the same way MCP update does — doCreate already persisted evidence, on…","when":"http adr evidence","sev":2,"hits":1}
|
|
10
|
-
{"id":"260826-bvz","ts":"2026-08-26","scope":"proj","tags":["gui","testing","config"],"rule":"DO Test dynamic GUI columns through runtime config, not static HTML IDs.","when":"config-driven GUI","sev":2,"hits":1}
|
|
11
|
-
{"id":"260827-mmv","ts":"2026-08-27","scope":"proj","tags":["plan","mcp","qa"],"rule":"DO When create maps steps→subtasks, assert subtasks win if both sent; evidence accepts any of diff|summary|test_command…","when":"plan_create / MCP create steps","sev":2,"hits":1}
|
|
12
|
-
{"id":"260827-m2q","ts":"2026-08-27","scope":"proj","tags":["plan","mcp","evidence"],"rule":"DO When create maps steps→subtasks, assert subtasks win if both sent; evidence accepts any of diff|summary|test_command","when":"plan_create / MCP create steps","sev":2,"hits":1}
|
|
13
|
-
{"id":"260916-9im","ts":"2026-09-16","scope":"proj","tags":["kanban","mcp","context","depends-on"],"rule":"DO Agent wake: one kanban_read operation=context instead of list+show; store depends_on and files on the task so gates …","when":"kanban MCP agent context","sev":2,"hits":1}
|
|
14
|
-
{"id":"260916-guc","ts":"2026-09-16","scope":"proj","tags":["kanban","mcp","depends-on","dag"],"rule":"DO On depends_on writes detect cycles immediately (CIRCULAR_DEPENDENCY with details.cycle); expose blocked/blocks on li…","when":"kanban DAG depends_on","sev":2,"hits":1}
|
package/.ai/retro/close.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"goal":"push do npm","done":["session had mutations"],"next":[],"block":[],"lessons":[],"files":["/Users/mkorbas/projects/personal/markdown-kanban/mcp-server.js","/Users/mkorbas/projects/personal/markdown-kanban/kanban.js","/Users/mkorbas/projects/personal/markdown-kanban/tests/mcp-server.test.js","/Users/mkorbas/projects/personal/markdown-kanban/plan.js","/Users/mkorbas/projects/personal/markdown-kanban/CHANGELOG.md","/Users/mkorbas/projects/personal/markdown-kanban/bin/kanban.js","/Users/mkorbas/projects/personal/markdown-kanban/tests/update-tasks.test.js"],"verify":""}
|
package/.ai/retro/last-run.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"ts":"2026-09-16T10:23:54.497Z","sessionID":"ses_f5682fb36ffeOu9sf2lXfIUCGB","added":["260916-guc"],"bumped":[]}
|
package/.ait-quality.yml
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# kanbango quality policy
|
|
2
|
-
# CLI/GUI entrypoints use console.log as user-facing output (not debug leftovers).
|
|
3
|
-
# This file is local policy only — kanbango does not depend on or invoke ait-quality.
|
|
4
|
-
version: 1
|
|
5
|
-
include: []
|
|
6
|
-
exclude:
|
|
7
|
-
- node_modules/**
|
|
8
|
-
- backlog/**
|
|
9
|
-
- examples/**
|
|
10
|
-
languages:
|
|
11
|
-
- javascript
|
|
12
|
-
- markdown
|
|
13
|
-
rules:
|
|
14
|
-
enabled: true
|
|
15
|
-
debug-leftover: off
|
|
16
|
-
swallowed-exception: error
|
|
17
|
-
hidden-fallback: error
|
|
18
|
-
unsafe-pattern: error
|
|
19
|
-
placeholder-todo: warning
|
|
20
|
-
dead-code: warning
|
|
21
|
-
cognitive-complexity: warning
|
|
22
|
-
function-length: warning
|
|
23
|
-
nesting-depth: warning
|
|
24
|
-
duplicate-blocks: warning
|
|
25
|
-
gate:
|
|
26
|
-
fail_on: error
|
package/bin/kanban-cmd.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
const { spawn } = require('child_process');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
|
|
6
|
-
// Find Python executable
|
|
7
|
-
function findPython() {
|
|
8
|
-
const pyCommands = os.platform() === 'win32' ? ['python', 'py'] : ['python3', 'python'];
|
|
9
|
-
|
|
10
|
-
for (const cmd of pyCommands) {
|
|
11
|
-
try {
|
|
12
|
-
const result = require('child_process').spawnSync(cmd, ['--version'], { stdio: 'ignore' });
|
|
13
|
-
if (result.status === 0) {
|
|
14
|
-
return cmd;
|
|
15
|
-
}
|
|
16
|
-
} catch {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
throw new Error('Python not found. Please install Python 3.7+');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Main execution
|
|
24
|
-
const python = findPython();
|
|
25
|
-
const scriptPath = path.join(__dirname, '..', 'kanban-cmd.py');
|
|
26
|
-
const args = process.argv.slice(2);
|
|
27
|
-
|
|
28
|
-
const child = spawn(python, [scriptPath, ...args], {
|
|
29
|
-
stdio: 'inherit',
|
|
30
|
-
env: { ...process.env }
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
child.on('exit', (code) => {
|
|
34
|
-
process.exit(code || 0);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
child.on('error', (err) => {
|
|
38
|
-
console.error('Error running kanban-cmd.py:', err.message);
|
|
39
|
-
process.exit(1);
|
|
40
|
-
});
|