kanbango 2.1.0 → 2.4.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 +7 -7
- package/API.md +20 -12
- package/CHANGELOG.md +32 -0
- package/LLM_AGENTS.md +141 -175
- package/README.md +31 -14
- package/bin/kanban.js +101 -26
- package/index.html +162 -85
- package/index.js +2 -0
- package/kan2.md +76 -0
- package/kanban.js +263 -34
- package/mcp-server.js +389 -185
- package/package.json +1 -1
- package/plan.js +159 -0
- package/planv2.md +17 -7
- package/tests/run.js +1 -0
package/package.json
CHANGED
package/plan.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const kanban = require('./kanban.js');
|
|
4
|
+
|
|
5
|
+
function planError(code, message, hint, details = {}) {
|
|
6
|
+
return kanban.createKanbanError(code, message, hint, details, false, 400);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function exists(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(filePath);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function detectTestRunner(projectRoot = process.cwd()) {
|
|
19
|
+
const override = String(process.env.OPENCODE_TEST_COMMAND || '').trim();
|
|
20
|
+
if (override) return { command: override, reason: 'OPENCODE_TEST_COMMAND override' };
|
|
21
|
+
|
|
22
|
+
if (await exists(path.join(projectRoot, 'Cargo.toml'))) {
|
|
23
|
+
return { command: 'cargo test', reason: 'Cargo.toml detected' };
|
|
24
|
+
}
|
|
25
|
+
if (await exists(path.join(projectRoot, 'go.mod'))) {
|
|
26
|
+
return { command: 'go test ./...', reason: 'go.mod detected' };
|
|
27
|
+
}
|
|
28
|
+
if (await exists(path.join(projectRoot, 'pyproject.toml'))
|
|
29
|
+
|| await exists(path.join(projectRoot, 'pytest.ini'))) {
|
|
30
|
+
return { command: 'python -m pytest', reason: 'Python test configuration detected' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const packagePath = path.join(projectRoot, 'package.json');
|
|
34
|
+
if (await exists(packagePath)) {
|
|
35
|
+
let pkg;
|
|
36
|
+
try {
|
|
37
|
+
pkg = JSON.parse(await fs.readFile(packagePath, 'utf-8'));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw planError('TEST_RUNNER_ERROR', 'package.json could not be parsed',
|
|
40
|
+
'Fix package.json before creating a plan', { reason: error.message });
|
|
41
|
+
}
|
|
42
|
+
if (pkg.scripts && pkg.scripts.test) {
|
|
43
|
+
const lockfiles = [
|
|
44
|
+
['pnpm-lock.yaml', 'pnpm test'],
|
|
45
|
+
['yarn.lock', 'yarn test'],
|
|
46
|
+
['bun.lockb', 'bun test'],
|
|
47
|
+
['bun.lock', 'bun test']
|
|
48
|
+
];
|
|
49
|
+
for (const [lockfile, command] of lockfiles) {
|
|
50
|
+
if (await exists(path.join(projectRoot, lockfile))) {
|
|
51
|
+
return { command, reason: `${lockfile} and package.json test script detected` };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { command: 'npm test', reason: 'package.json test script detected' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
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 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function planSubtasks(implementationSteps) {
|
|
63
|
+
const steps = [
|
|
64
|
+
'Write tests',
|
|
65
|
+
'Run tests and confirm red',
|
|
66
|
+
...implementationSteps,
|
|
67
|
+
'Run tests and confirm green'
|
|
68
|
+
];
|
|
69
|
+
return steps.map((text, index) => ({ id: `st-${index + 1}`, text, done: false, description: '' }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function result(task, extra = {}) {
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
task_id: task.id,
|
|
76
|
+
subtasks: task.subtasks,
|
|
77
|
+
...extra
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function create(payload = {}) {
|
|
82
|
+
if (!payload.title || typeof payload.title !== 'string') {
|
|
83
|
+
throw planError('MISSING_REQUIRED_FIELD', 'title is required', 'Provide the accepted plan title', { field: 'title' });
|
|
84
|
+
}
|
|
85
|
+
const implementationSteps = Array.isArray(payload.steps) ? payload.steps.filter(Boolean).map(String) : [];
|
|
86
|
+
const runner = await detectTestRunner(payload.project_root || process.cwd());
|
|
87
|
+
const task = await kanban.doCreate(payload.title, payload.column || 'planned', payload.epic || '—', {
|
|
88
|
+
description: payload.description,
|
|
89
|
+
specs: payload.specs,
|
|
90
|
+
in_scope: payload.in_scope,
|
|
91
|
+
out_of_scope: payload.out_of_scope,
|
|
92
|
+
acceptance_criteria: payload.acceptance_criteria,
|
|
93
|
+
test_cases: payload.test_cases,
|
|
94
|
+
subtasks: planSubtasks(implementationSteps),
|
|
95
|
+
notes: payload.notes,
|
|
96
|
+
plan: { runner, status: 'active' },
|
|
97
|
+
evidence: []
|
|
98
|
+
});
|
|
99
|
+
return result(task, { runner });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
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 });
|
|
115
|
+
return result(updated, { current_step: updated.subtasks.findIndex((subtask) => !subtask.done) });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
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
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!Number.isInteger(payload.exit_code)) {
|
|
127
|
+
throw planError('VALIDATION_ERROR', 'exit_code must be an integer',
|
|
128
|
+
'Use the process exit code from the test command', { field: 'exit_code' });
|
|
129
|
+
}
|
|
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()
|
|
138
|
+
};
|
|
139
|
+
const updated = await kanban.updateTask(task.id, { evidence: [...task.evidence, entry] });
|
|
140
|
+
return result(updated, { evidence: entry });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function done(payload = {}) {
|
|
144
|
+
const task = await kanban.getTask(payload.task_id);
|
|
145
|
+
const incomplete = task.subtasks.filter((subtask) => !subtask.done);
|
|
146
|
+
if (incomplete.length > 0) {
|
|
147
|
+
throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
|
|
148
|
+
'Advance every plan step before marking the workflow done', { incomplete });
|
|
149
|
+
}
|
|
150
|
+
const updated = await kanban.updateTask(task.id, { column: 'done', plan: { ...(task.plan || {}), status: 'done' } });
|
|
151
|
+
return result(updated, { status: 'done' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function status(taskId) {
|
|
155
|
+
const task = await kanban.getTask(taskId);
|
|
156
|
+
return result(task, { status: task.plan && task.plan.status || 'active', evidence: task.evidence });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { detectTestRunner, create, advance, evidence, done, status };
|
package/planv2.md
CHANGED
|
@@ -41,13 +41,21 @@ Prefer:
|
|
|
41
41
|
|
|
42
42
|
```json
|
|
43
43
|
{
|
|
44
|
-
"id": "
|
|
44
|
+
"id": "014",
|
|
45
45
|
"title": "Google Calendar Integration",
|
|
46
46
|
"column": "active",
|
|
47
47
|
"epic_group": "Phase 1",
|
|
48
48
|
"created": "2026-07-07",
|
|
49
49
|
"description": "High-level context and implementation plan in markdown.",
|
|
50
50
|
"specs": "Technical constraints, APIs, data model, edge cases.",
|
|
51
|
+
"in_scope": [
|
|
52
|
+
"OAuth connect/disconnect",
|
|
53
|
+
"Two-way event sync"
|
|
54
|
+
],
|
|
55
|
+
"out_of_scope": [
|
|
56
|
+
"Microsoft Calendar",
|
|
57
|
+
"Mobile push notifications"
|
|
58
|
+
],
|
|
51
59
|
"acceptance_criteria": [
|
|
52
60
|
"User can connect Google account",
|
|
53
61
|
"Events sync in both directions",
|
|
@@ -69,6 +77,8 @@ Prefer:
|
|
|
69
77
|
|
|
70
78
|
- `description`: the main context, why the work exists, and the implementation plan
|
|
71
79
|
- `specs`: concrete technical facts, constraints, APIs, data shape, edge cases
|
|
80
|
+
- `in_scope`: what this task includes (boundaries)
|
|
81
|
+
- `out_of_scope`: explicit non-goals / exclusions
|
|
72
82
|
- `acceptance_criteria`: what must be true for the task to count as done
|
|
73
83
|
- `subtasks[].description`: local execution detail for a subtask
|
|
74
84
|
- `notes`: optional freeform leftovers, references, or observations
|
|
@@ -100,7 +110,7 @@ Add support for response shaping.
|
|
|
100
110
|
```json
|
|
101
111
|
{
|
|
102
112
|
"operation": "show",
|
|
103
|
-
"task_id": "
|
|
113
|
+
"task_id": "014",
|
|
104
114
|
"view": "planning"
|
|
105
115
|
}
|
|
106
116
|
```
|
|
@@ -108,7 +118,7 @@ Add support for response shaping.
|
|
|
108
118
|
Suggested views:
|
|
109
119
|
|
|
110
120
|
- `summary`: id, title, column, epic, created, progress counts
|
|
111
|
-
- `planning`: summary + description + specs + acceptance_criteria
|
|
121
|
+
- `planning`: summary + description + specs + in_scope + out_of_scope + acceptance_criteria
|
|
112
122
|
- `execution`: planning + subtasks
|
|
113
123
|
- `full`: everything including notes and metadata
|
|
114
124
|
|
|
@@ -117,7 +127,7 @@ Suggested views:
|
|
|
117
127
|
```json
|
|
118
128
|
{
|
|
119
129
|
"operation": "show",
|
|
120
|
-
"task_id": "
|
|
130
|
+
"task_id": "014",
|
|
121
131
|
"fields": ["title", "description", "acceptance_criteria"]
|
|
122
132
|
}
|
|
123
133
|
```
|
|
@@ -171,7 +181,7 @@ Use a patch-like payload and let callers control the returned payload size.
|
|
|
171
181
|
```json
|
|
172
182
|
{
|
|
173
183
|
"operation": "update",
|
|
174
|
-
"task_id": "
|
|
184
|
+
"task_id": "014",
|
|
175
185
|
"patch": {
|
|
176
186
|
"description": "Updated implementation plan...",
|
|
177
187
|
"acceptance_criteria": [
|
|
@@ -243,10 +253,10 @@ Recommended shape:
|
|
|
243
253
|
{
|
|
244
254
|
"error": {
|
|
245
255
|
"code": "TASK_NOT_FOUND",
|
|
246
|
-
"message": "Task
|
|
256
|
+
"message": "Task 014 was not found",
|
|
247
257
|
"hint": "Call kanban_read with operation=list to discover valid task ids",
|
|
248
258
|
"details": {
|
|
249
|
-
"task_id": "
|
|
259
|
+
"task_id": "014"
|
|
250
260
|
},
|
|
251
261
|
"retryable": false
|
|
252
262
|
}
|
package/tests/run.js
CHANGED
|
@@ -17,3 +17,4 @@ runNode(path.join('bin', 'kanban.js'), ['list', '--json'], 'CLI list');
|
|
|
17
17
|
runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
|
|
18
18
|
runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
|
|
19
19
|
runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
|
|
20
|
+
runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
|