kanbango 3.5.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/lessons.jsonl +6 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +1 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +77 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +34 -3
- package/agent-playbook.js +31 -7
- package/agents/qa-e2e-tester.md +315 -0
- package/agents/qa-tester.md +182 -0
- package/agents/temida.md +81 -0
- package/bin/kanban.js +200 -7
- package/config-wizard.js +441 -0
- package/index.html +115 -23
- package/index.js +2 -0
- package/kanban.js +578 -33
- package/mcp-server.js +89 -21
- package/package.json +1 -1
- package/plan.js +34 -27
- package/tests/fixtures/fake-opencode.js +69 -0
- package/tests/index.js +19 -0
- package/tests/kanban-cli.js +118 -0
- package/tests/kanban.js +104 -0
- package/tests/run.js +9 -0
- package/workflow.js +806 -0
package/workflow.js
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const kanban = require('./kanban.js');
|
|
5
|
+
|
|
6
|
+
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
7
|
+
const CONFIG_PATH = path.join(BACKLOG, 'kanbango.json');
|
|
8
|
+
const JOBS_DIR = path.join(BACKLOG, '.workflow');
|
|
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
|
+
|
|
20
|
+
/** PIDs spawned by this process — only these may be SIGTERM'd on timeout. */
|
|
21
|
+
const ownedPids = new Set();
|
|
22
|
+
/** In-flight run_ids to prevent double-spawn within this process. */
|
|
23
|
+
const runningLocal = new Set();
|
|
24
|
+
|
|
25
|
+
let cachedConfig = null;
|
|
26
|
+
let cacheCwd = null;
|
|
27
|
+
let ensureTail = Promise.resolve();
|
|
28
|
+
|
|
29
|
+
function workflowError(code, message, hint, details = {}) {
|
|
30
|
+
return kanban.createKanbanError(code, message, hint, details, false, 400);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isNonEmptyString(value) {
|
|
34
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
35
|
+
}
|
|
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
|
+
|
|
116
|
+
function assertWorkflowShape(workflow) {
|
|
117
|
+
if (!workflow || typeof workflow !== 'object' || Array.isArray(workflow)) {
|
|
118
|
+
throw workflowError(
|
|
119
|
+
'CONFIG_INVALID',
|
|
120
|
+
'workflow config must be an object',
|
|
121
|
+
'Set backlog/kanbango.json with a workflow object',
|
|
122
|
+
{ field: 'workflow' }
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (typeof workflow.enabled !== 'boolean') {
|
|
126
|
+
throw workflowError(
|
|
127
|
+
'CONFIG_INVALID',
|
|
128
|
+
'workflow.enabled must be a boolean',
|
|
129
|
+
'Use "enabled": true or false',
|
|
130
|
+
{ field: 'workflow.enabled' }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
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)) {
|
|
145
|
+
throw workflowError(
|
|
146
|
+
'CONFIG_INVALID',
|
|
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' }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (workflow.timeout_ms !== undefined) {
|
|
154
|
+
if (!Number.isInteger(workflow.timeout_ms) || workflow.timeout_ms <= 0) {
|
|
155
|
+
throw workflowError(
|
|
156
|
+
'CONFIG_INVALID',
|
|
157
|
+
'workflow.timeout_ms must be a positive integer',
|
|
158
|
+
'Use milliseconds, e.g. 600000',
|
|
159
|
+
{ field: 'workflow.timeout_ms' }
|
|
160
|
+
);
|
|
161
|
+
}
|
|
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 = true;
|
|
237
|
+
const reviewOn = true;
|
|
238
|
+
return {
|
|
239
|
+
missing: true,
|
|
240
|
+
enabled: false,
|
|
241
|
+
command: 'opencode',
|
|
242
|
+
testing_agent: { enabled: true, name: 'qa-tester' },
|
|
243
|
+
e2e_agent: { enabled: true, name: 'qa-e2e-tester' },
|
|
244
|
+
review_agent: { enabled: true, 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: true, label: DEFAULT_LABELS.testing },
|
|
251
|
+
review: { enabled: true, label: DEFAULT_LABELS.review },
|
|
252
|
+
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
253
|
+
},
|
|
254
|
+
active_cols: buildActiveCols(testingOn, reviewOn),
|
|
255
|
+
workflow_stages: GATE_COLUMNS.slice(),
|
|
256
|
+
transitions: buildTransitions(testingOn, reviewOn),
|
|
257
|
+
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
258
|
+
move_order: ['icebox', 'planned', 'active', 'testing', 'review', 'done'],
|
|
259
|
+
board_columns: [
|
|
260
|
+
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
261
|
+
{ id: 'planned', label: DEFAULT_LABELS.planned },
|
|
262
|
+
{ id: 'testing', label: DEFAULT_LABELS.testing },
|
|
263
|
+
{ id: 'review', label: DEFAULT_LABELS.review },
|
|
264
|
+
{ id: 'done', label: DEFAULT_LABELS.done }
|
|
265
|
+
]
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function shapePublicConfig(config) {
|
|
270
|
+
return {
|
|
271
|
+
columns: config.columns,
|
|
272
|
+
active_cols: config.active_cols.slice(),
|
|
273
|
+
workflow_stages: config.workflow_stages.slice(),
|
|
274
|
+
transitions: Object.fromEntries(
|
|
275
|
+
Object.entries(config.transitions).map(([k, v]) => [k, v.slice()])
|
|
276
|
+
),
|
|
277
|
+
plan_done_column: config.plan_done_column,
|
|
278
|
+
move_order: config.move_order.slice(),
|
|
279
|
+
board_columns: config.board_columns.map((c) => ({ id: c.id, label: c.label })),
|
|
280
|
+
workflow: {
|
|
281
|
+
enabled: config.enabled,
|
|
282
|
+
command: config.command,
|
|
283
|
+
testing_agent: { ...config.testing_agent },
|
|
284
|
+
e2e_agent: { ...config.e2e_agent },
|
|
285
|
+
review_agent: { ...config.review_agent },
|
|
286
|
+
timeout_ms: config.timeout_ms
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function loadConfig() {
|
|
292
|
+
let raw;
|
|
293
|
+
try {
|
|
294
|
+
raw = await fs.readFile(CONFIG_PATH, 'utf-8');
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (error.code === 'ENOENT') {
|
|
297
|
+
return defaultConfig();
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let parsed;
|
|
303
|
+
try {
|
|
304
|
+
parsed = JSON.parse(raw);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
throw workflowError(
|
|
307
|
+
'CONFIG_INVALID',
|
|
308
|
+
'backlog/kanbango.json is not valid JSON',
|
|
309
|
+
'Fix JSON syntax or remove the file to disable workflow',
|
|
310
|
+
{ reason: error.message }
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
315
|
+
throw workflowError(
|
|
316
|
+
'CONFIG_INVALID',
|
|
317
|
+
'kanbango.json root must be an object',
|
|
318
|
+
'Use { "workflow": { ... }, "columns": { ... } }',
|
|
319
|
+
{ field: 'root' }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
assertColumnsShape(parsed.columns);
|
|
324
|
+
|
|
325
|
+
// workflow optional when only columns are configured
|
|
326
|
+
let w = parsed.workflow;
|
|
327
|
+
if (w === undefined) {
|
|
328
|
+
w = { enabled: false };
|
|
329
|
+
}
|
|
330
|
+
assertWorkflowShape(w);
|
|
331
|
+
|
|
332
|
+
const testingCol = parseGateColumn(parsed.columns && parsed.columns.testing, 'testing');
|
|
333
|
+
const reviewCol = parseGateColumn(parsed.columns && parsed.columns.review, 'review');
|
|
334
|
+
const testingOn = testingCol.enabled;
|
|
335
|
+
const reviewOn = reviewCol.enabled;
|
|
336
|
+
|
|
337
|
+
const testingAgent = parseAgentField(w.testing_agent, 'testing_agent', 'qa-tester');
|
|
338
|
+
const reviewAgent = parseAgentField(w.review_agent, 'review_agent', 'temida');
|
|
339
|
+
let e2eAgent = { enabled: true, name: 'qa-e2e-tester' };
|
|
340
|
+
if (w.e2e_agent !== undefined) {
|
|
341
|
+
if (typeof w.e2e_agent === 'string') {
|
|
342
|
+
e2eAgent = parseAgentField(w.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
343
|
+
} else {
|
|
344
|
+
e2eAgent = parseAgentField(w.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const envRunner = String(process.env.KANBANGO_WORKFLOW_RUNNER || '').trim();
|
|
349
|
+
const activeCols = buildActiveCols(testingOn, reviewOn);
|
|
350
|
+
const workflowStages = GATE_COLUMNS.filter((col) => (col === 'testing' ? testingOn : reviewOn));
|
|
351
|
+
const moveOrder = ['icebox', 'planned', 'active']
|
|
352
|
+
.concat(testingOn ? ['testing'] : [])
|
|
353
|
+
.concat(reviewOn ? ['review'] : [])
|
|
354
|
+
.concat(['done']);
|
|
355
|
+
|
|
356
|
+
const boardColumns = [
|
|
357
|
+
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
358
|
+
{ id: 'planned', label: DEFAULT_LABELS.planned }
|
|
359
|
+
];
|
|
360
|
+
if (testingOn) boardColumns.push({ id: 'testing', label: testingCol.label });
|
|
361
|
+
if (reviewOn) boardColumns.push({ id: 'review', label: reviewCol.label });
|
|
362
|
+
boardColumns.push({ id: 'done', label: DEFAULT_LABELS.done });
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
missing: false,
|
|
366
|
+
enabled: w.enabled,
|
|
367
|
+
command: envRunner || (isNonEmptyString(w.command) ? w.command.trim() : 'opencode'),
|
|
368
|
+
testing_agent: testingAgent,
|
|
369
|
+
e2e_agent: e2eAgent,
|
|
370
|
+
review_agent: reviewAgent,
|
|
371
|
+
timeout_ms: Number.isInteger(w.timeout_ms) && w.timeout_ms > 0 ? w.timeout_ms : 600000,
|
|
372
|
+
columns: {
|
|
373
|
+
icebox: { enabled: true, label: DEFAULT_LABELS.icebox },
|
|
374
|
+
planned: { enabled: true, label: DEFAULT_LABELS.planned },
|
|
375
|
+
active: { enabled: true, label: DEFAULT_LABELS.active },
|
|
376
|
+
testing: testingCol,
|
|
377
|
+
review: reviewCol,
|
|
378
|
+
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
379
|
+
},
|
|
380
|
+
active_cols: activeCols,
|
|
381
|
+
workflow_stages: workflowStages,
|
|
382
|
+
transitions: buildTransitions(testingOn, reviewOn),
|
|
383
|
+
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
384
|
+
move_order: moveOrder,
|
|
385
|
+
board_columns: boardColumns
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function migrateDisabledGateCards(config) {
|
|
390
|
+
const migrated = [];
|
|
391
|
+
for (const col of GATE_COLUMNS) {
|
|
392
|
+
if (config.columns[col].enabled) continue;
|
|
393
|
+
const tasks = await kanban.listTasksInKnownColumn(col);
|
|
394
|
+
for (const task of tasks) {
|
|
395
|
+
await kanban.relocateTask(task.id, 'planned');
|
|
396
|
+
migrated.push({ id: task.id, from: col, to: 'planned' });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return migrated;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Load board config, apply layout to kanban, migrate cards out of disabled gates.
|
|
404
|
+
* Always re-reads backlog/kanbango.json (file is small) so enable/disable changes apply.
|
|
405
|
+
* Migration is idempotent: second call finds no cards in disabled columns.
|
|
406
|
+
*/
|
|
407
|
+
async function ensureBoardConfig({ force = false } = {}) {
|
|
408
|
+
const run = ensureTail.then(async () => {
|
|
409
|
+
const cwd = process.cwd();
|
|
410
|
+
// force kept for API/tests; always reload from disk so config edits are live.
|
|
411
|
+
void force;
|
|
412
|
+
const config = await loadConfig();
|
|
413
|
+
kanban.applyBoardLayout({
|
|
414
|
+
cols: config.active_cols,
|
|
415
|
+
transitions: config.transitions,
|
|
416
|
+
workflowStages: config.workflow_stages
|
|
417
|
+
});
|
|
418
|
+
await migrateDisabledGateCards(config);
|
|
419
|
+
|
|
420
|
+
cachedConfig = config;
|
|
421
|
+
cacheCwd = cwd;
|
|
422
|
+
return config;
|
|
423
|
+
});
|
|
424
|
+
ensureTail = run.then(() => undefined, () => undefined);
|
|
425
|
+
return run;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function clearConfigCache() {
|
|
429
|
+
cachedConfig = null;
|
|
430
|
+
cacheCwd = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function newRunId() {
|
|
434
|
+
return `run-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function agentForStage(config, stage) {
|
|
438
|
+
if (stage === 'review') return config.review_agent;
|
|
439
|
+
return config.testing_agent;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function buildPrompt(taskId, stage, agent) {
|
|
443
|
+
const lines = [
|
|
444
|
+
`Kanbango workflow stage=${stage} agent=${agent} task_id=${taskId}.`,
|
|
445
|
+
`Read the kanbango task ${taskId} (kanban_read show view=execution).`,
|
|
446
|
+
'Perform your agent role.'
|
|
447
|
+
];
|
|
448
|
+
if (stage === 'testing') {
|
|
449
|
+
lines.push('Write/run tests only. End with a clear verdict line: PASS or FAIL or BLOCKED.');
|
|
450
|
+
}
|
|
451
|
+
if (stage === 'review') {
|
|
452
|
+
lines.push('After your verdict, the LAST line of the response MUST be exactly GATE: PASS or GATE: FAIL.');
|
|
453
|
+
}
|
|
454
|
+
return lines.join(' ');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function truncate(text, max = 2000) {
|
|
458
|
+
const s = String(text || '');
|
|
459
|
+
if (s.length <= max) return s;
|
|
460
|
+
return s.slice(s.length - max);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function parseTestingVerdict(stdout, stderr, exitCode) {
|
|
464
|
+
const text = `${stdout}\n${stderr}`;
|
|
465
|
+
if (/\bBLOCKED\b/i.test(text)) return 'blocked';
|
|
466
|
+
if (/\bPASS\b/i.test(text) && !/\bFAIL\b/i.test(text)) return 'pass';
|
|
467
|
+
if (/\bFAIL\b/i.test(text)) return 'fail';
|
|
468
|
+
if (exitCode !== 0) return 'fail';
|
|
469
|
+
return 'fail';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function parseReviewGate(stdout, stderr) {
|
|
473
|
+
const lines = `${stdout}\n${stderr}`
|
|
474
|
+
.split('\n')
|
|
475
|
+
.map((line) => line.trim())
|
|
476
|
+
.filter(Boolean);
|
|
477
|
+
const last = lines[lines.length - 1] || '';
|
|
478
|
+
if (last === 'GATE: PASS') return 'pass';
|
|
479
|
+
if (last === 'GATE: FAIL') return 'fail';
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function writeJob(job) {
|
|
484
|
+
await fs.mkdir(JOBS_DIR, { recursive: true });
|
|
485
|
+
const filePath = path.join(JOBS_DIR, `${job.run_id}.json`);
|
|
486
|
+
await fs.writeFile(filePath, JSON.stringify(job, null, 2) + '\n', 'utf-8');
|
|
487
|
+
return filePath;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function updateJob(runId, patch) {
|
|
491
|
+
const filePath = path.join(JOBS_DIR, `${runId}.json`);
|
|
492
|
+
let job = {};
|
|
493
|
+
try {
|
|
494
|
+
job = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
495
|
+
} catch {
|
|
496
|
+
job = { run_id: runId };
|
|
497
|
+
}
|
|
498
|
+
const next = { ...job, ...patch };
|
|
499
|
+
await fs.writeFile(filePath, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
500
|
+
return next;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function spawnRunner(config, prompt, agent) {
|
|
504
|
+
return new Promise((resolve) => {
|
|
505
|
+
const args = ['run', prompt, '--agent', agent, '--format', 'json', '--dir', process.cwd()];
|
|
506
|
+
let child;
|
|
507
|
+
try {
|
|
508
|
+
child = spawn(config.command, args, {
|
|
509
|
+
cwd: process.cwd(),
|
|
510
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
511
|
+
windowsHide: true,
|
|
512
|
+
env: process.env
|
|
513
|
+
});
|
|
514
|
+
} catch (error) {
|
|
515
|
+
resolve({
|
|
516
|
+
stdout: '',
|
|
517
|
+
stderr: error.message || String(error),
|
|
518
|
+
exitCode: 1,
|
|
519
|
+
timedOut: false,
|
|
520
|
+
pid: null,
|
|
521
|
+
argv: [config.command, ...args]
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const pid = child.pid;
|
|
527
|
+
if (pid) ownedPids.add(pid);
|
|
528
|
+
|
|
529
|
+
let stdout = '';
|
|
530
|
+
let stderr = '';
|
|
531
|
+
let settled = false;
|
|
532
|
+
let timedOut = false;
|
|
533
|
+
|
|
534
|
+
const timer = setTimeout(() => {
|
|
535
|
+
timedOut = true;
|
|
536
|
+
if (pid && ownedPids.has(pid)) {
|
|
537
|
+
try {
|
|
538
|
+
child.kill('SIGTERM');
|
|
539
|
+
} catch (killErr) {
|
|
540
|
+
// process may have already exited
|
|
541
|
+
if (killErr && killErr.code !== 'ESRCH') {
|
|
542
|
+
stderr += `\nkill failed: ${killErr.message || killErr}`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}, config.timeout_ms);
|
|
547
|
+
|
|
548
|
+
child.stdout.on('data', (chunk) => {
|
|
549
|
+
stdout += chunk.toString();
|
|
550
|
+
});
|
|
551
|
+
child.stderr.on('data', (chunk) => {
|
|
552
|
+
stderr += chunk.toString();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const finish = (exitCode) => {
|
|
556
|
+
if (settled) return;
|
|
557
|
+
settled = true;
|
|
558
|
+
clearTimeout(timer);
|
|
559
|
+
if (pid) ownedPids.delete(pid);
|
|
560
|
+
resolve({
|
|
561
|
+
stdout,
|
|
562
|
+
stderr,
|
|
563
|
+
exitCode: exitCode == null ? 1 : exitCode,
|
|
564
|
+
timedOut,
|
|
565
|
+
pid,
|
|
566
|
+
argv: [config.command, ...args]
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
child.on('error', (error) => {
|
|
571
|
+
stderr += error.message || String(error);
|
|
572
|
+
finish(1);
|
|
573
|
+
});
|
|
574
|
+
child.on('close', (code) => finish(code));
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function outcomeForResult(stage, result) {
|
|
579
|
+
if (result.timedOut) {
|
|
580
|
+
return {
|
|
581
|
+
verdict: 'blocked',
|
|
582
|
+
nextColumn: null,
|
|
583
|
+
workflowStatus: 'blocked',
|
|
584
|
+
summary: `timeout after runner; card stays in ${stage}`
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (stage === 'testing') {
|
|
589
|
+
const verdict = parseTestingVerdict(result.stdout, result.stderr, result.exitCode);
|
|
590
|
+
if (verdict === 'pass') {
|
|
591
|
+
const config = cachedConfig || defaultConfig();
|
|
592
|
+
const nextColumn = config.columns.review.enabled ? 'review' : 'done';
|
|
593
|
+
return {
|
|
594
|
+
verdict: 'pass',
|
|
595
|
+
nextColumn,
|
|
596
|
+
workflowStatus: 'pass',
|
|
597
|
+
summary: truncate(result.stdout || 'PASS', 500)
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
if (verdict === 'blocked') {
|
|
601
|
+
return {
|
|
602
|
+
verdict: 'blocked',
|
|
603
|
+
nextColumn: 'active',
|
|
604
|
+
workflowStatus: 'blocked',
|
|
605
|
+
summary: truncate(result.stdout || result.stderr || 'BLOCKED', 500)
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
verdict: 'fail',
|
|
610
|
+
nextColumn: 'active',
|
|
611
|
+
workflowStatus: 'fail',
|
|
612
|
+
summary: truncate(result.stdout || result.stderr || 'FAIL', 500)
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// review
|
|
617
|
+
const gate = parseReviewGate(result.stdout, result.stderr);
|
|
618
|
+
if (gate === 'pass') {
|
|
619
|
+
return {
|
|
620
|
+
verdict: 'pass',
|
|
621
|
+
nextColumn: 'done',
|
|
622
|
+
workflowStatus: 'pass',
|
|
623
|
+
summary: 'GATE: PASS'
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (gate === 'fail') {
|
|
627
|
+
return {
|
|
628
|
+
verdict: 'fail',
|
|
629
|
+
nextColumn: 'active',
|
|
630
|
+
workflowStatus: 'fail',
|
|
631
|
+
summary: 'GATE: FAIL'
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
verdict: 'blocked',
|
|
636
|
+
nextColumn: null,
|
|
637
|
+
workflowStatus: 'blocked',
|
|
638
|
+
summary: truncate(result.stdout || result.stderr || 'missing GATE marker', 500)
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function finishRun(taskId, stage, agent, runId, result) {
|
|
643
|
+
const outcome = outcomeForResult(stage, result);
|
|
644
|
+
const finishedAt = new Date().toISOString();
|
|
645
|
+
const evidence = {
|
|
646
|
+
diff: '',
|
|
647
|
+
test_command: (result.argv || []).join(' '),
|
|
648
|
+
stdout: truncate(result.stdout),
|
|
649
|
+
stderr: truncate(result.stderr),
|
|
650
|
+
exit_code: result.exitCode,
|
|
651
|
+
created: finishedAt,
|
|
652
|
+
stage,
|
|
653
|
+
agent,
|
|
654
|
+
verdict: outcome.verdict,
|
|
655
|
+
summary: outcome.summary
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const patch = {
|
|
659
|
+
appendEvidence: evidence,
|
|
660
|
+
workflow: {
|
|
661
|
+
stage,
|
|
662
|
+
status: outcome.workflowStatus,
|
|
663
|
+
agent,
|
|
664
|
+
run_id: runId,
|
|
665
|
+
finished_at: finishedAt
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
if (outcome.nextColumn) {
|
|
669
|
+
patch.column = outcome.nextColumn;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
await kanban.updateTask(taskId, patch);
|
|
673
|
+
await updateJob(runId, {
|
|
674
|
+
status: outcome.workflowStatus,
|
|
675
|
+
finished_at: finishedAt,
|
|
676
|
+
verdict: outcome.verdict,
|
|
677
|
+
exit_code: result.exitCode,
|
|
678
|
+
timed_out: Boolean(result.timedOut)
|
|
679
|
+
});
|
|
680
|
+
runningLocal.delete(runId);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function startRun(task, stage, config) {
|
|
684
|
+
const agentField = agentForStage(config, stage);
|
|
685
|
+
const agent = agentField.name;
|
|
686
|
+
const runId = newRunId();
|
|
687
|
+
const startedAt = new Date().toISOString();
|
|
688
|
+
|
|
689
|
+
runningLocal.add(runId);
|
|
690
|
+
|
|
691
|
+
await kanban.updateTask(task.id, {
|
|
692
|
+
workflow: {
|
|
693
|
+
stage,
|
|
694
|
+
status: 'running',
|
|
695
|
+
agent,
|
|
696
|
+
run_id: runId,
|
|
697
|
+
started_at: startedAt
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await writeJob({
|
|
702
|
+
run_id: runId,
|
|
703
|
+
task_id: task.id,
|
|
704
|
+
stage,
|
|
705
|
+
agent,
|
|
706
|
+
started_at: startedAt,
|
|
707
|
+
status: 'running'
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// Spawn outside any board lock (caller already released lock).
|
|
711
|
+
setImmediate(async () => {
|
|
712
|
+
const prompt = buildPrompt(task.id, stage, agent);
|
|
713
|
+
const result = await spawnRunner(config, prompt, agent);
|
|
714
|
+
if (result.pid) {
|
|
715
|
+
await updateJob(runId, { pid: result.pid });
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
await finishRun(task.id, stage, agent, runId, result);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error('workflow finish failed:', error && error.message ? error.message : error);
|
|
721
|
+
runningLocal.delete(runId);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
return { run_id: runId, agent, stage, status: 'running' };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Called after a successful column change into testing|review.
|
|
730
|
+
* Must not run inside withBoardLock.
|
|
731
|
+
*/
|
|
732
|
+
async function maybeEnqueueOnColumnEnter(task, previousColumn) {
|
|
733
|
+
if (!task || !kanban.WORKFLOW_STAGES.includes(task.column)) {
|
|
734
|
+
return { enqueued: false, reason: 'not_gate_column' };
|
|
735
|
+
}
|
|
736
|
+
if (previousColumn === task.column) {
|
|
737
|
+
return { enqueued: false, reason: 'same_column' };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const config = await ensureBoardConfig();
|
|
741
|
+
if (config.missing || !config.enabled) {
|
|
742
|
+
return { enqueued: false, reason: config.missing ? 'no_config' : 'disabled' };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!config.columns[task.column] || !config.columns[task.column].enabled) {
|
|
746
|
+
return { enqueued: false, reason: 'column_disabled' };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const agentField = agentForStage(config, task.column);
|
|
750
|
+
if (!agentField.enabled) {
|
|
751
|
+
return { enqueued: false, reason: 'agent_disabled' };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Dedup: already running this stage
|
|
755
|
+
const wf = task.workflow;
|
|
756
|
+
if (wf && wf.status === 'running' && wf.stage === task.column) {
|
|
757
|
+
return { enqueued: false, reason: 'already_running' };
|
|
758
|
+
}
|
|
759
|
+
if (wf && wf.run_id && runningLocal.has(wf.run_id) && wf.stage === task.column) {
|
|
760
|
+
return { enqueued: false, reason: 'already_running_local' };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const fresh = await kanban.getTask(task.id);
|
|
764
|
+
if (fresh.workflow && fresh.workflow.status === 'running' && fresh.workflow.stage === task.column) {
|
|
765
|
+
return { enqueued: false, reason: 'already_running' };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const started = await startRun(fresh, task.column, config);
|
|
769
|
+
return { enqueued: true, ...started };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Test helper: wait until a run reaches a terminal workflow status. */
|
|
773
|
+
async function waitForIdle(taskId, timeoutMs = 15000) {
|
|
774
|
+
const deadline = Date.now() + timeoutMs;
|
|
775
|
+
let sawRunning = false;
|
|
776
|
+
while (Date.now() < deadline) {
|
|
777
|
+
const task = await kanban.getTask(taskId);
|
|
778
|
+
const status = task.workflow && task.workflow.status;
|
|
779
|
+
if (status === 'running') sawRunning = true;
|
|
780
|
+
if (status === 'pass' || status === 'fail' || status === 'blocked') return task;
|
|
781
|
+
if (sawRunning && status !== 'running') return task;
|
|
782
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
783
|
+
}
|
|
784
|
+
throw new Error(`waitForIdle timeout for task ${taskId}`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function planDoneColumn() {
|
|
788
|
+
const config = await ensureBoardConfig();
|
|
789
|
+
return config.plan_done_column;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
module.exports = {
|
|
793
|
+
loadConfig,
|
|
794
|
+
ensureBoardConfig,
|
|
795
|
+
clearConfigCache,
|
|
796
|
+
shapePublicConfig,
|
|
797
|
+
maybeEnqueueOnColumnEnter,
|
|
798
|
+
parseTestingVerdict,
|
|
799
|
+
parseReviewGate,
|
|
800
|
+
waitForIdle,
|
|
801
|
+
planDoneColumn,
|
|
802
|
+
buildTransitions,
|
|
803
|
+
DEFAULT_LABELS,
|
|
804
|
+
JOBS_DIR,
|
|
805
|
+
CONFIG_PATH
|
|
806
|
+
};
|