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/AGENTS.md +25 -18
- package/API.md +58 -170
- package/CHANGELOG.md +76 -0
- package/LLM_AGENTS.md +21 -502
- package/README.md +45 -12
- package/agent-playbook.js +27 -49
- package/bin/kanban.js +395 -300
- package/config-wizard.js +441 -0
- package/index.html +124 -36
- package/kanban.js +892 -263
- package/mcp-server.js +319 -196
- package/package.json +1 -1
- package/plan.js +39 -40
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +368 -21
- package/.ai/lessons.jsonl +0 -9
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -25
- 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 -33
package/workflow.js
CHANGED
|
@@ -7,11 +7,25 @@ const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
|
7
7
|
const CONFIG_PATH = path.join(BACKLOG, 'kanbango.json');
|
|
8
8
|
const JOBS_DIR = path.join(BACKLOG, '.workflow');
|
|
9
9
|
|
|
10
|
+
const GATE_COLUMNS = ['testing', 'review'];
|
|
11
|
+
const DEFAULT_LABELS = {
|
|
12
|
+
icebox: 'Icebox',
|
|
13
|
+
planned: 'Planned',
|
|
14
|
+
active: 'NOW',
|
|
15
|
+
testing: 'Testing',
|
|
16
|
+
review: 'Review',
|
|
17
|
+
done: 'Done'
|
|
18
|
+
};
|
|
19
|
+
|
|
10
20
|
/** PIDs spawned by this process — only these may be SIGTERM'd on timeout. */
|
|
11
21
|
const ownedPids = new Set();
|
|
12
22
|
/** In-flight run_ids to prevent double-spawn within this process. */
|
|
13
23
|
const runningLocal = new Set();
|
|
14
24
|
|
|
25
|
+
let cachedConfig = null;
|
|
26
|
+
let cacheCwd = null;
|
|
27
|
+
let ensureTail = Promise.resolve();
|
|
28
|
+
|
|
15
29
|
function workflowError(code, message, hint, details = {}) {
|
|
16
30
|
return kanban.createKanbanError(code, message, hint, details, false, 400);
|
|
17
31
|
}
|
|
@@ -20,6 +34,85 @@ function isNonEmptyString(value) {
|
|
|
20
34
|
return typeof value === 'string' && value.trim().length > 0;
|
|
21
35
|
}
|
|
22
36
|
|
|
37
|
+
function parseAgentField(value, fieldName, defaultName) {
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
return { enabled: true, name: defaultName };
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
if (!isNonEmptyString(value)) {
|
|
43
|
+
throw workflowError(
|
|
44
|
+
'CONFIG_INVALID',
|
|
45
|
+
`workflow.${fieldName} must be a non-empty string when set`,
|
|
46
|
+
`Fix workflow.${fieldName} in backlog/kanbango.json`,
|
|
47
|
+
{ field: `workflow.${fieldName}` }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return { enabled: true, name: value.trim() };
|
|
51
|
+
}
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
53
|
+
throw workflowError(
|
|
54
|
+
'CONFIG_INVALID',
|
|
55
|
+
`workflow.${fieldName} must be a string or { enabled, name }`,
|
|
56
|
+
`Use a name string or object with optional enabled/name`,
|
|
57
|
+
{ field: `workflow.${fieldName}` }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
61
|
+
throw workflowError(
|
|
62
|
+
'CONFIG_INVALID',
|
|
63
|
+
`workflow.${fieldName}.enabled must be a boolean`,
|
|
64
|
+
'Use true or false',
|
|
65
|
+
{ field: `workflow.${fieldName}.enabled` }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (value.name !== undefined && !isNonEmptyString(value.name)) {
|
|
69
|
+
throw workflowError(
|
|
70
|
+
'CONFIG_INVALID',
|
|
71
|
+
`workflow.${fieldName}.name must be a non-empty string when set`,
|
|
72
|
+
`Fix workflow.${fieldName}.name in backlog/kanbango.json`,
|
|
73
|
+
{ field: `workflow.${fieldName}.name` }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
enabled: value.enabled === undefined ? true : value.enabled,
|
|
78
|
+
name: isNonEmptyString(value.name) ? value.name.trim() : defaultName
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseGateColumn(value, columnId) {
|
|
83
|
+
if (value === undefined) {
|
|
84
|
+
return { enabled: true, label: DEFAULT_LABELS[columnId] };
|
|
85
|
+
}
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
87
|
+
throw workflowError(
|
|
88
|
+
'CONFIG_INVALID',
|
|
89
|
+
`columns.${columnId} must be an object`,
|
|
90
|
+
`Use { "enabled": true, "label": "${DEFAULT_LABELS[columnId]}" }`,
|
|
91
|
+
{ field: `columns.${columnId}` }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
95
|
+
throw workflowError(
|
|
96
|
+
'CONFIG_INVALID',
|
|
97
|
+
`columns.${columnId}.enabled must be a boolean`,
|
|
98
|
+
'Use true or false',
|
|
99
|
+
{ field: `columns.${columnId}.enabled` }
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (value.label !== undefined && !isNonEmptyString(value.label)) {
|
|
103
|
+
throw workflowError(
|
|
104
|
+
'CONFIG_INVALID',
|
|
105
|
+
`columns.${columnId}.label must be a non-empty string when set`,
|
|
106
|
+
`Fix columns.${columnId}.label in backlog/kanbango.json`,
|
|
107
|
+
{ field: `columns.${columnId}.label` }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
enabled: value.enabled === undefined ? true : value.enabled,
|
|
112
|
+
label: isNonEmptyString(value.label) ? value.label.trim() : DEFAULT_LABELS[columnId]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
23
116
|
function assertWorkflowShape(workflow) {
|
|
24
117
|
if (!workflow || typeof workflow !== 'object' || Array.isArray(workflow)) {
|
|
25
118
|
throw workflowError(
|
|
@@ -37,14 +130,23 @@ function assertWorkflowShape(workflow) {
|
|
|
37
130
|
{ field: 'workflow.enabled' }
|
|
38
131
|
);
|
|
39
132
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
133
|
+
if (workflow.command !== undefined && !isNonEmptyString(workflow.command)) {
|
|
134
|
+
throw workflowError(
|
|
135
|
+
'CONFIG_INVALID',
|
|
136
|
+
'workflow.command must be a non-empty string when set',
|
|
137
|
+
'Fix workflow.command in backlog/kanbango.json',
|
|
138
|
+
{ field: 'workflow.command' }
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (workflow.e2e_agent !== undefined && !isNonEmptyString(workflow.e2e_agent)
|
|
142
|
+
&& !(workflow.e2e_agent && typeof workflow.e2e_agent === 'object')) {
|
|
143
|
+
// Keep legacy string validation; object form not used for spawn yet.
|
|
144
|
+
if (typeof workflow.e2e_agent === 'string' && !isNonEmptyString(workflow.e2e_agent)) {
|
|
43
145
|
throw workflowError(
|
|
44
146
|
'CONFIG_INVALID',
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{ field:
|
|
147
|
+
'workflow.e2e_agent must be a non-empty string when set',
|
|
148
|
+
'Fix workflow.e2e_agent in backlog/kanbango.json',
|
|
149
|
+
{ field: 'workflow.e2e_agent' }
|
|
48
150
|
);
|
|
49
151
|
}
|
|
50
152
|
}
|
|
@@ -58,6 +160,130 @@ function assertWorkflowShape(workflow) {
|
|
|
58
160
|
);
|
|
59
161
|
}
|
|
60
162
|
}
|
|
163
|
+
// Validate agent fields early (parseAgentField throws CONFIG_INVALID).
|
|
164
|
+
parseAgentField(workflow.testing_agent, 'testing_agent', 'qa-tester');
|
|
165
|
+
parseAgentField(workflow.review_agent, 'review_agent', 'temida');
|
|
166
|
+
if (workflow.e2e_agent !== undefined && typeof workflow.e2e_agent === 'string') {
|
|
167
|
+
parseAgentField(workflow.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertColumnsShape(columns) {
|
|
172
|
+
if (columns === undefined) return;
|
|
173
|
+
if (!columns || typeof columns !== 'object' || Array.isArray(columns)) {
|
|
174
|
+
throw workflowError(
|
|
175
|
+
'CONFIG_INVALID',
|
|
176
|
+
'columns config must be an object',
|
|
177
|
+
'Use { "testing": { "enabled": true, "label": "Testing" }, "review": { ... } }',
|
|
178
|
+
{ field: 'columns' }
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
for (const key of Object.keys(columns)) {
|
|
182
|
+
if (!GATE_COLUMNS.includes(key)) {
|
|
183
|
+
throw workflowError(
|
|
184
|
+
'CONFIG_INVALID',
|
|
185
|
+
`columns.${key} is not configurable`,
|
|
186
|
+
'Only testing and review may be set under columns',
|
|
187
|
+
{ field: `columns.${key}`, allowed: GATE_COLUMNS }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
parseGateColumn(columns.testing, 'testing');
|
|
192
|
+
parseGateColumn(columns.review, 'review');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildTransitions(testingOn, reviewOn) {
|
|
196
|
+
const afterActive = testingOn ? 'testing' : (reviewOn ? 'review' : 'done');
|
|
197
|
+
const afterTesting = reviewOn ? 'review' : 'done';
|
|
198
|
+
|
|
199
|
+
const transitions = {
|
|
200
|
+
icebox: ['planned'],
|
|
201
|
+
planned: ['active', 'icebox', afterActive],
|
|
202
|
+
active: ['planned', afterActive, 'icebox'],
|
|
203
|
+
done: ['active']
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Dedupe while preserving order
|
|
207
|
+
for (const key of Object.keys(transitions)) {
|
|
208
|
+
transitions[key] = [...new Set(transitions[key])];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (testingOn) {
|
|
212
|
+
transitions.testing = ['active', afterTesting];
|
|
213
|
+
}
|
|
214
|
+
if (reviewOn) {
|
|
215
|
+
transitions.review = ['active', 'done'];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return transitions;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildActiveCols(testingOn, reviewOn) {
|
|
222
|
+
const cols = ['active', 'planned', 'icebox'];
|
|
223
|
+
if (testingOn) cols.push('testing');
|
|
224
|
+
if (reviewOn) cols.push('review');
|
|
225
|
+
cols.push('done');
|
|
226
|
+
return cols;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function planDoneTarget(testingOn, reviewOn) {
|
|
230
|
+
if (testingOn) return 'testing';
|
|
231
|
+
if (reviewOn) return 'review';
|
|
232
|
+
return 'done';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function defaultConfig() {
|
|
236
|
+
const testingOn = false;
|
|
237
|
+
const reviewOn = false;
|
|
238
|
+
return {
|
|
239
|
+
missing: true,
|
|
240
|
+
enabled: false,
|
|
241
|
+
command: 'opencode',
|
|
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
|
+
timeout_ms: 600000,
|
|
246
|
+
columns: {
|
|
247
|
+
icebox: { enabled: true, label: DEFAULT_LABELS.icebox },
|
|
248
|
+
planned: { enabled: true, label: DEFAULT_LABELS.planned },
|
|
249
|
+
active: { enabled: true, label: DEFAULT_LABELS.active },
|
|
250
|
+
testing: { enabled: false, label: DEFAULT_LABELS.testing },
|
|
251
|
+
review: { enabled: false, label: DEFAULT_LABELS.review },
|
|
252
|
+
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
253
|
+
},
|
|
254
|
+
active_cols: buildActiveCols(testingOn, reviewOn),
|
|
255
|
+
workflow_stages: [],
|
|
256
|
+
transitions: buildTransitions(testingOn, reviewOn),
|
|
257
|
+
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
258
|
+
move_order: ['icebox', 'planned', 'active', 'done'],
|
|
259
|
+
board_columns: [
|
|
260
|
+
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
261
|
+
{ id: 'planned', label: DEFAULT_LABELS.planned },
|
|
262
|
+
{ id: 'done', label: DEFAULT_LABELS.done }
|
|
263
|
+
]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function shapePublicConfig(config) {
|
|
268
|
+
return {
|
|
269
|
+
columns: config.columns,
|
|
270
|
+
active_cols: config.active_cols.slice(),
|
|
271
|
+
workflow_stages: config.workflow_stages.slice(),
|
|
272
|
+
transitions: Object.fromEntries(
|
|
273
|
+
Object.entries(config.transitions).map(([k, v]) => [k, v.slice()])
|
|
274
|
+
),
|
|
275
|
+
plan_done_column: config.plan_done_column,
|
|
276
|
+
move_order: config.move_order.slice(),
|
|
277
|
+
board_columns: config.board_columns.map((c) => ({ id: c.id, label: c.label })),
|
|
278
|
+
workflow: {
|
|
279
|
+
enabled: config.enabled,
|
|
280
|
+
command: config.command,
|
|
281
|
+
testing_agent: { ...config.testing_agent },
|
|
282
|
+
e2e_agent: { ...config.e2e_agent },
|
|
283
|
+
review_agent: { ...config.review_agent },
|
|
284
|
+
timeout_ms: config.timeout_ms
|
|
285
|
+
}
|
|
286
|
+
};
|
|
61
287
|
}
|
|
62
288
|
|
|
63
289
|
async function loadConfig() {
|
|
@@ -66,7 +292,7 @@ async function loadConfig() {
|
|
|
66
292
|
raw = await fs.readFile(CONFIG_PATH, 'utf-8');
|
|
67
293
|
} catch (error) {
|
|
68
294
|
if (error.code === 'ENOENT') {
|
|
69
|
-
return
|
|
295
|
+
return defaultConfig();
|
|
70
296
|
}
|
|
71
297
|
throw error;
|
|
72
298
|
}
|
|
@@ -87,26 +313,121 @@ async function loadConfig() {
|
|
|
87
313
|
throw workflowError(
|
|
88
314
|
'CONFIG_INVALID',
|
|
89
315
|
'kanbango.json root must be an object',
|
|
90
|
-
'Use { "workflow": { ... } }',
|
|
316
|
+
'Use { "workflow": { ... }, "columns": { ... } }',
|
|
91
317
|
{ field: 'root' }
|
|
92
318
|
);
|
|
93
319
|
}
|
|
94
320
|
|
|
95
|
-
|
|
96
|
-
|
|
321
|
+
assertColumnsShape(parsed.columns);
|
|
322
|
+
|
|
323
|
+
// workflow optional when only columns are configured
|
|
324
|
+
let w = parsed.workflow;
|
|
325
|
+
if (w === undefined) {
|
|
326
|
+
w = { enabled: false };
|
|
327
|
+
}
|
|
328
|
+
assertWorkflowShape(w);
|
|
329
|
+
|
|
330
|
+
const testingCol = parseGateColumn(parsed.columns && parsed.columns.testing, 'testing');
|
|
331
|
+
const reviewCol = parseGateColumn(parsed.columns && parsed.columns.review, 'review');
|
|
332
|
+
const testingOn = testingCol.enabled;
|
|
333
|
+
const reviewOn = reviewCol.enabled;
|
|
334
|
+
|
|
335
|
+
const testingAgent = parseAgentField(w.testing_agent, 'testing_agent', 'qa-tester');
|
|
336
|
+
const reviewAgent = parseAgentField(w.review_agent, 'review_agent', 'temida');
|
|
337
|
+
let e2eAgent = { enabled: true, name: 'qa-e2e-tester' };
|
|
338
|
+
if (w.e2e_agent !== undefined) {
|
|
339
|
+
if (typeof w.e2e_agent === 'string') {
|
|
340
|
+
e2eAgent = parseAgentField(w.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
341
|
+
} else {
|
|
342
|
+
e2eAgent = parseAgentField(w.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
97
346
|
const envRunner = String(process.env.KANBANGO_WORKFLOW_RUNNER || '').trim();
|
|
347
|
+
const activeCols = buildActiveCols(testingOn, reviewOn);
|
|
348
|
+
const workflowStages = GATE_COLUMNS.filter((col) => (col === 'testing' ? testingOn : reviewOn));
|
|
349
|
+
const moveOrder = ['icebox', 'planned', 'active']
|
|
350
|
+
.concat(testingOn ? ['testing'] : [])
|
|
351
|
+
.concat(reviewOn ? ['review'] : [])
|
|
352
|
+
.concat(['done']);
|
|
353
|
+
|
|
354
|
+
const boardColumns = [
|
|
355
|
+
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
356
|
+
{ id: 'planned', label: DEFAULT_LABELS.planned }
|
|
357
|
+
];
|
|
358
|
+
if (testingOn) boardColumns.push({ id: 'testing', label: testingCol.label });
|
|
359
|
+
if (reviewOn) boardColumns.push({ id: 'review', label: reviewCol.label });
|
|
360
|
+
boardColumns.push({ id: 'done', label: DEFAULT_LABELS.done });
|
|
98
361
|
|
|
99
362
|
return {
|
|
100
|
-
enabled: w.enabled,
|
|
101
363
|
missing: false,
|
|
364
|
+
enabled: w.enabled,
|
|
102
365
|
command: envRunner || (isNonEmptyString(w.command) ? w.command.trim() : 'opencode'),
|
|
103
|
-
testing_agent:
|
|
104
|
-
e2e_agent:
|
|
105
|
-
review_agent:
|
|
106
|
-
timeout_ms: Number.isInteger(w.timeout_ms) && w.timeout_ms > 0 ? w.timeout_ms : 600000
|
|
366
|
+
testing_agent: testingAgent,
|
|
367
|
+
e2e_agent: e2eAgent,
|
|
368
|
+
review_agent: reviewAgent,
|
|
369
|
+
timeout_ms: Number.isInteger(w.timeout_ms) && w.timeout_ms > 0 ? w.timeout_ms : 600000,
|
|
370
|
+
columns: {
|
|
371
|
+
icebox: { enabled: true, label: DEFAULT_LABELS.icebox },
|
|
372
|
+
planned: { enabled: true, label: DEFAULT_LABELS.planned },
|
|
373
|
+
active: { enabled: true, label: DEFAULT_LABELS.active },
|
|
374
|
+
testing: testingCol,
|
|
375
|
+
review: reviewCol,
|
|
376
|
+
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
377
|
+
},
|
|
378
|
+
active_cols: activeCols,
|
|
379
|
+
workflow_stages: workflowStages,
|
|
380
|
+
transitions: buildTransitions(testingOn, reviewOn),
|
|
381
|
+
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
382
|
+
move_order: moveOrder,
|
|
383
|
+
board_columns: boardColumns
|
|
107
384
|
};
|
|
108
385
|
}
|
|
109
386
|
|
|
387
|
+
async function migrateDisabledGateCards(config) {
|
|
388
|
+
const migrated = [];
|
|
389
|
+
for (const col of GATE_COLUMNS) {
|
|
390
|
+
if (config.columns[col].enabled) continue;
|
|
391
|
+
const tasks = await kanban.listTasksInKnownColumn(col);
|
|
392
|
+
for (const task of tasks) {
|
|
393
|
+
await kanban.relocateTask(task.id, 'planned');
|
|
394
|
+
migrated.push({ id: task.id, from: col, to: 'planned' });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return migrated;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Load board config, apply layout to kanban, migrate cards out of disabled gates.
|
|
402
|
+
* Always re-reads backlog/kanbango.json (file is small) so enable/disable changes apply.
|
|
403
|
+
* Migration is idempotent: second call finds no cards in disabled columns.
|
|
404
|
+
*/
|
|
405
|
+
async function ensureBoardConfig({ force = false } = {}) {
|
|
406
|
+
const run = ensureTail.then(async () => {
|
|
407
|
+
const cwd = process.cwd();
|
|
408
|
+
// force kept for API/tests; always reload from disk so config edits are live.
|
|
409
|
+
void force;
|
|
410
|
+
const config = await loadConfig();
|
|
411
|
+
kanban.applyBoardLayout({
|
|
412
|
+
cols: config.active_cols,
|
|
413
|
+
transitions: config.transitions,
|
|
414
|
+
workflowStages: config.workflow_stages
|
|
415
|
+
});
|
|
416
|
+
await migrateDisabledGateCards(config);
|
|
417
|
+
|
|
418
|
+
cachedConfig = config;
|
|
419
|
+
cacheCwd = cwd;
|
|
420
|
+
return config;
|
|
421
|
+
});
|
|
422
|
+
ensureTail = run.then(() => undefined, () => undefined);
|
|
423
|
+
return run;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function clearConfigCache() {
|
|
427
|
+
cachedConfig = null;
|
|
428
|
+
cacheCwd = null;
|
|
429
|
+
}
|
|
430
|
+
|
|
110
431
|
function newRunId() {
|
|
111
432
|
return `run-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
112
433
|
}
|
|
@@ -139,9 +460,12 @@ function truncate(text, max = 2000) {
|
|
|
139
460
|
|
|
140
461
|
function parseTestingVerdict(stdout, stderr, exitCode) {
|
|
141
462
|
const text = `${stdout}\n${stderr}`;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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';
|
|
145
469
|
if (exitCode !== 0) return 'fail';
|
|
146
470
|
return 'fail';
|
|
147
471
|
}
|
|
@@ -265,9 +589,11 @@ function outcomeForResult(stage, result) {
|
|
|
265
589
|
if (stage === 'testing') {
|
|
266
590
|
const verdict = parseTestingVerdict(result.stdout, result.stderr, result.exitCode);
|
|
267
591
|
if (verdict === 'pass') {
|
|
592
|
+
const config = cachedConfig || defaultConfig();
|
|
593
|
+
const nextColumn = config.columns.review.enabled ? 'review' : 'done';
|
|
268
594
|
return {
|
|
269
595
|
verdict: 'pass',
|
|
270
|
-
nextColumn
|
|
596
|
+
nextColumn,
|
|
271
597
|
workflowStatus: 'pass',
|
|
272
598
|
summary: truncate(result.stdout || 'PASS', 500)
|
|
273
599
|
};
|
|
@@ -356,7 +682,8 @@ async function finishRun(taskId, stage, agent, runId, result) {
|
|
|
356
682
|
}
|
|
357
683
|
|
|
358
684
|
async function startRun(task, stage, config) {
|
|
359
|
-
const
|
|
685
|
+
const agentField = agentForStage(config, stage);
|
|
686
|
+
const agent = agentField.name;
|
|
360
687
|
const runId = newRunId();
|
|
361
688
|
const startedAt = new Date().toISOString();
|
|
362
689
|
|
|
@@ -411,11 +738,20 @@ async function maybeEnqueueOnColumnEnter(task, previousColumn) {
|
|
|
411
738
|
return { enqueued: false, reason: 'same_column' };
|
|
412
739
|
}
|
|
413
740
|
|
|
414
|
-
const config = await
|
|
741
|
+
const config = await ensureBoardConfig();
|
|
415
742
|
if (config.missing || !config.enabled) {
|
|
416
743
|
return { enqueued: false, reason: config.missing ? 'no_config' : 'disabled' };
|
|
417
744
|
}
|
|
418
745
|
|
|
746
|
+
if (!config.columns[task.column] || !config.columns[task.column].enabled) {
|
|
747
|
+
return { enqueued: false, reason: 'column_disabled' };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const agentField = agentForStage(config, task.column);
|
|
751
|
+
if (!agentField.enabled) {
|
|
752
|
+
return { enqueued: false, reason: 'agent_disabled' };
|
|
753
|
+
}
|
|
754
|
+
|
|
419
755
|
// Dedup: already running this stage
|
|
420
756
|
const wf = task.workflow;
|
|
421
757
|
if (wf && wf.status === 'running' && wf.stage === task.column) {
|
|
@@ -449,12 +785,23 @@ async function waitForIdle(taskId, timeoutMs = 15000) {
|
|
|
449
785
|
throw new Error(`waitForIdle timeout for task ${taskId}`);
|
|
450
786
|
}
|
|
451
787
|
|
|
788
|
+
async function planDoneColumn() {
|
|
789
|
+
const config = await ensureBoardConfig();
|
|
790
|
+
return config.plan_done_column;
|
|
791
|
+
}
|
|
792
|
+
|
|
452
793
|
module.exports = {
|
|
453
794
|
loadConfig,
|
|
795
|
+
ensureBoardConfig,
|
|
796
|
+
clearConfigCache,
|
|
797
|
+
shapePublicConfig,
|
|
454
798
|
maybeEnqueueOnColumnEnter,
|
|
455
799
|
parseTestingVerdict,
|
|
456
800
|
parseReviewGate,
|
|
457
801
|
waitForIdle,
|
|
802
|
+
planDoneColumn,
|
|
803
|
+
buildTransitions,
|
|
804
|
+
DEFAULT_LABELS,
|
|
458
805
|
JOBS_DIR,
|
|
459
806
|
CONFIG_PATH
|
|
460
807
|
};
|
package/.ai/lessons.jsonl
DELETED
|
@@ -1,9 +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}
|
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-08-25T07:24:47.875Z","sessionID":"ses_fcd476c1effeADbTA3UAQxjKMA","added":["260825-d68"],"bumped":[]}
|
package/.ait-quality.yml
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# kanbango quality policy
|
|
2
|
-
# CLI/GUI entrypoints use console.log as user-facing output (not debug leftovers).
|
|
3
|
-
version: 1
|
|
4
|
-
include: []
|
|
5
|
-
exclude:
|
|
6
|
-
- node_modules/**
|
|
7
|
-
- backlog/**
|
|
8
|
-
- examples/**
|
|
9
|
-
languages:
|
|
10
|
-
- javascript
|
|
11
|
-
- markdown
|
|
12
|
-
rules:
|
|
13
|
-
enabled: true
|
|
14
|
-
debug-leftover: off
|
|
15
|
-
swallowed-exception: error
|
|
16
|
-
hidden-fallback: error
|
|
17
|
-
unsafe-pattern: error
|
|
18
|
-
placeholder-todo: warning
|
|
19
|
-
dead-code: warning
|
|
20
|
-
cognitive-complexity: warning
|
|
21
|
-
function-length: warning
|
|
22
|
-
nesting-depth: warning
|
|
23
|
-
duplicate-blocks: warning
|
|
24
|
-
gate:
|
|
25
|
-
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
|
-
});
|
package/kan2.md
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# Plan kan2: Kanbango Plan Workflow
|
|
2
|
-
|
|
3
|
-
## Cel
|
|
4
|
-
|
|
5
|
-
Rozszerzyć Kanbango o niezależny workflow planów, który może być używany przez
|
|
6
|
-
OpenCode, inne agenty i CLI. Kanbango pozostaje osobnym repozytorium.
|
|
7
|
-
|
|
8
|
-
## Zasada architektoniczna
|
|
9
|
-
|
|
10
|
-
Kanbango nie zna API OpenCode ani hooków pluginu. Udostępnia stabilne operacje
|
|
11
|
-
na planie i evidence. Plugin OpenCode jest tylko klientem oraz strażnikiem sesji.
|
|
12
|
-
|
|
13
|
-
## Funkcjonalność
|
|
14
|
-
|
|
15
|
-
Dodać bibliotekę domenową oraz odpowiednie operacje CLI/MCP:
|
|
16
|
-
|
|
17
|
-
- utworzenie taska z zaakceptowanym planem,
|
|
18
|
-
- zapis kroków planu jako subtasks,
|
|
19
|
-
- oznaczenie bieżącego kroku jako zakończonego,
|
|
20
|
-
- zapis evidence: diff, komenda testowa, stdout, stderr i exit code,
|
|
21
|
-
- zakończenie workflow i przeniesienie taska do `done`,
|
|
22
|
-
- odczyt pełnego statusu planu w stabilnym formacie JSON.
|
|
23
|
-
|
|
24
|
-
## Domyślne kroki
|
|
25
|
-
|
|
26
|
-
Każdy zaakceptowany plan dostaje kolejno:
|
|
27
|
-
|
|
28
|
-
1. `Write tests`
|
|
29
|
-
2. `Run tests and confirm red`
|
|
30
|
-
3. Kroki implementacyjne dostarczone przez advisora
|
|
31
|
-
4. `Run tests and confirm green`
|
|
32
|
-
|
|
33
|
-
## Test runner
|
|
34
|
-
|
|
35
|
-
Autodetekcja musi działać per projekt, bez założenia Node:
|
|
36
|
-
|
|
37
|
-
- Rust: `cargo test` przy `Cargo.toml`,
|
|
38
|
-
- Go: `go test ./...` przy `go.mod`,
|
|
39
|
-
- Python: `python -m pytest` przy `pyproject.toml` lub `pytest.ini`,
|
|
40
|
-
- JavaScript/TypeScript: `npm test`, `pnpm test`, `yarn test` albo `bun test`
|
|
41
|
-
zgodnie z lockfilem i skryptem `test` w `package.json`,
|
|
42
|
-
- jawny override przez `OPENCODE_TEST_COMMAND`.
|
|
43
|
-
|
|
44
|
-
Autodetekcja ma zwracać także powód wyboru komendy i czytelny błąd, gdy nie
|
|
45
|
-
znaleziono testów.
|
|
46
|
-
|
|
47
|
-
## Interfejs
|
|
48
|
-
|
|
49
|
-
Preferowany jest wspólny moduł JS oraz JSON CLI/MCP, zamiast parsowania tekstu:
|
|
50
|
-
|
|
51
|
-
- `plan create --json <payload>`,
|
|
52
|
-
- `plan advance --json <payload>`,
|
|
53
|
-
- `plan evidence --json <payload>`,
|
|
54
|
-
- `plan done --json <payload>`,
|
|
55
|
-
- odpowiadające operacje w `kanban_manage` lub osobnym narzędziu MCP.
|
|
56
|
-
|
|
57
|
-
CLI musi zwracać stabilny JSON z `ok`, `task_id`, `subtasks` i `error`.
|
|
58
|
-
|
|
59
|
-
## Testy TDD
|
|
60
|
-
|
|
61
|
-
- autodetekcja runnera dla Rust, Go, Python i Node,
|
|
62
|
-
- utworzenie planu z czterema grupami kroków,
|
|
63
|
-
- aktualizacja kroku bez kasowania pozostałych subtasks,
|
|
64
|
-
- zapis evidence bez silent fail,
|
|
65
|
-
- zakończenie planu i przejście do `done`,
|
|
66
|
-
- błędna komenda testowa i brak testów,
|
|
67
|
-
- wywołanie przez CLI i MCP z tym samym rezultatem.
|
|
68
|
-
|
|
69
|
-
## Kolejność implementacji
|
|
70
|
-
|
|
71
|
-
1. Model planu i runner detection.
|
|
72
|
-
2. Operacje biblioteki Kanbango.
|
|
73
|
-
3. CLI i JSON output.
|
|
74
|
-
4. MCP adapter.
|
|
75
|
-
5. Integracja pluginu OpenCode z tym kontraktem.
|
|
76
|
-
6. Test end-to-end w przykładowym projekcie.
|