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/config-wizard.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive checkbox TUI for backlog/kanbango.json.
|
|
3
|
+
* Zero deps. Injectable stdin/stdout for tests (fake TTY + setRawMode).
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs').promises;
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const CONFIG_REL = path.join('backlog', 'kanbango.json');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_ANSWERS = {
|
|
11
|
+
testing: true,
|
|
12
|
+
testingAgent: true,
|
|
13
|
+
review: true,
|
|
14
|
+
reviewAgent: true
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ITEMS = [
|
|
18
|
+
{ key: 'testing', label: 'Testing column (QA gate)' },
|
|
19
|
+
{ key: 'testingAgent', label: 'Auto QA agent (qa-tester)', dependsOn: 'testing' },
|
|
20
|
+
{ key: 'review', label: 'Review column (Temida gate)' },
|
|
21
|
+
{ key: 'reviewAgent', label: 'Auto review agent (temida)', dependsOn: 'review' }
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const NON_TTY_HINT =
|
|
25
|
+
'Run kanban config in a terminal to enable auto QA/review gates (backlog/kanbango.json).';
|
|
26
|
+
|
|
27
|
+
const IGNORABLE_STREAM_CODES = new Set(['ERR_INVALID_STATE', 'EIO', 'ERR_STREAM_DESTROYED']);
|
|
28
|
+
|
|
29
|
+
function cloneAnswers(source = DEFAULT_ANSWERS) {
|
|
30
|
+
return {
|
|
31
|
+
testing: Boolean(source.testing),
|
|
32
|
+
testingAgent: Boolean(source.testingAgent),
|
|
33
|
+
review: Boolean(source.review),
|
|
34
|
+
reviewAgent: Boolean(source.reviewAgent)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function agentEnabled(field) {
|
|
39
|
+
if (field === undefined) return true;
|
|
40
|
+
if (typeof field === 'string') return true;
|
|
41
|
+
if (!field || typeof field !== 'object') return false;
|
|
42
|
+
return field.enabled !== false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Map wizard answers → kanbango.json body consumed by workflow.js.
|
|
47
|
+
* Agent flags forced off when their column is off.
|
|
48
|
+
* workflow.enabled = true when any agent is on.
|
|
49
|
+
*/
|
|
50
|
+
function answersToConfig(answers) {
|
|
51
|
+
const a = cloneAnswers(answers);
|
|
52
|
+
const testing = a.testing;
|
|
53
|
+
const review = a.review;
|
|
54
|
+
const testingAgent = testing && a.testingAgent;
|
|
55
|
+
const reviewAgent = review && a.reviewAgent;
|
|
56
|
+
return {
|
|
57
|
+
columns: {
|
|
58
|
+
testing: { enabled: testing, label: 'Testing' },
|
|
59
|
+
review: { enabled: review, label: 'Review' }
|
|
60
|
+
},
|
|
61
|
+
workflow: {
|
|
62
|
+
enabled: testingAgent || reviewAgent,
|
|
63
|
+
testing_agent: { enabled: testingAgent, name: 'qa-tester' },
|
|
64
|
+
review_agent: { enabled: reviewAgent, name: 'temida' }
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Prefill answers from file body or loadConfig-shaped object. */
|
|
70
|
+
function configToAnswers(config) {
|
|
71
|
+
if (!config || typeof config !== 'object') return cloneAnswers();
|
|
72
|
+
const cols = config.columns || {};
|
|
73
|
+
const testingOn = !(cols.testing && cols.testing.enabled === false);
|
|
74
|
+
const reviewOn = !(cols.review && cols.review.enabled === false);
|
|
75
|
+
const wf = (config.workflow && typeof config.workflow === 'object')
|
|
76
|
+
? config.workflow
|
|
77
|
+
: config;
|
|
78
|
+
return {
|
|
79
|
+
testing: testingOn,
|
|
80
|
+
testingAgent: testingOn && agentEnabled(wf.testing_agent),
|
|
81
|
+
review: reviewOn,
|
|
82
|
+
reviewAgent: reviewOn && agentEnabled(wf.review_agent)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isInteractive(stdin = process.stdin, stdout = process.stdout) {
|
|
87
|
+
return Boolean(stdin && stdin.isTTY && stdout && stdout.isTTY);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function configPath(cwd = process.cwd()) {
|
|
91
|
+
return path.join(cwd, CONFIG_REL);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function configExists(cwd = process.cwd()) {
|
|
95
|
+
try {
|
|
96
|
+
await fs.access(configPath(cwd));
|
|
97
|
+
return true;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error && error.code === 'ENOENT') return false;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function configInvalid(message, hint) {
|
|
105
|
+
const err = new Error(message);
|
|
106
|
+
err.code = 'CONFIG_INVALID';
|
|
107
|
+
err.hint = hint;
|
|
108
|
+
return err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read existing file. Throws CONFIG_INVALID on bad JSON/shape (never silent).
|
|
113
|
+
* Returns null on ENOENT.
|
|
114
|
+
*/
|
|
115
|
+
async function readConfigFile(cwd = process.cwd()) {
|
|
116
|
+
const filePath = configPath(cwd);
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error && error.code === 'ENOENT') return null;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw configInvalid(
|
|
129
|
+
`backlog/kanbango.json is not valid JSON: ${error.message}`,
|
|
130
|
+
'Fix JSON syntax or remove the file, then run kanban config'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
134
|
+
throw configInvalid(
|
|
135
|
+
'backlog/kanbango.json root must be an object',
|
|
136
|
+
'Use { "workflow": { ... }, "columns": { ... } }'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function writeConfigFile(body, cwd = process.cwd()) {
|
|
143
|
+
const filePath = configPath(cwd);
|
|
144
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
145
|
+
await fs.writeFile(filePath, JSON.stringify(body, null, 2) + '\n', 'utf-8');
|
|
146
|
+
return filePath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderFrame(answers, cursor) {
|
|
150
|
+
const lines = [
|
|
151
|
+
'kanbango project config',
|
|
152
|
+
'Space toggle · ↑↓/jk move · Enter save · q cancel',
|
|
153
|
+
''
|
|
154
|
+
];
|
|
155
|
+
for (let i = 0; i < ITEMS.length; i++) {
|
|
156
|
+
const item = ITEMS[i];
|
|
157
|
+
const disabled = item.dependsOn && !answers[item.dependsOn];
|
|
158
|
+
const on = disabled ? false : Boolean(answers[item.key]);
|
|
159
|
+
const mark = on ? '[x]' : '[ ]';
|
|
160
|
+
const pointer = i === cursor ? '>' : ' ';
|
|
161
|
+
const suffix = disabled ? ' (off — enable column first)' : '';
|
|
162
|
+
lines.push(`${pointer} ${mark} ${item.label}${suffix}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push('Save writes backlog/kanbango.json');
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applyToggle(answers, key) {
|
|
170
|
+
const next = cloneAnswers(answers);
|
|
171
|
+
const item = ITEMS.find((entry) => entry.key === key);
|
|
172
|
+
if (!item) return next;
|
|
173
|
+
if (item.dependsOn && !next[item.dependsOn]) return next;
|
|
174
|
+
const wasOn = next[key];
|
|
175
|
+
next[key] = !wasOn;
|
|
176
|
+
if (key === 'testing' && !next.testing) next.testingAgent = false;
|
|
177
|
+
if (key === 'review' && !next.review) next.reviewAgent = false;
|
|
178
|
+
if (key === 'testing' && next.testing && !wasOn) next.testingAgent = true;
|
|
179
|
+
if (key === 'review' && next.review && !wasOn) next.reviewAgent = true;
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isIgnorableStreamError(error) {
|
|
184
|
+
return Boolean(error && IGNORABLE_STREAM_CODES.has(error.code));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function restoreRawMode(stdin, previousRaw) {
|
|
188
|
+
if (typeof stdin.setRawMode !== 'function') return;
|
|
189
|
+
try {
|
|
190
|
+
stdin.setRawMode(previousRaw);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (!isIgnorableStreamError(error)) throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safePause(stdin) {
|
|
197
|
+
if (typeof stdin.pause !== 'function') return;
|
|
198
|
+
try {
|
|
199
|
+
stdin.pause();
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (!isIgnorableStreamError(error)) throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function paintFrame(stdout, answers, cursor) {
|
|
206
|
+
if (typeof stdout.write !== 'function') return;
|
|
207
|
+
if (stdout.isTTY) stdout.write('\x1b[2J\x1b[H');
|
|
208
|
+
stdout.write(renderFrame(answers, cursor) + '\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parse one key. action: move|toggle|save|cancel|noop */
|
|
212
|
+
function handleKey(ch, code, seqTail) {
|
|
213
|
+
if (code === 3) return { action: 'cancel' };
|
|
214
|
+
if (ch === 'q' || ch === 'Q') return { action: 'cancel' };
|
|
215
|
+
if (ch === '\r' || ch === '\n') return { action: 'save' };
|
|
216
|
+
if (ch === ' ') return { action: 'toggle' };
|
|
217
|
+
if (ch === 'j') return { action: 'move', delta: 1 };
|
|
218
|
+
if (ch === 'k') return { action: 'move', delta: -1 };
|
|
219
|
+
if (ch === '\x1b' && seqTail[0] === '[' && (seqTail[1] === 'A' || seqTail[1] === 'B')) {
|
|
220
|
+
return { action: 'move', delta: seqTail[1] === 'A' ? -1 : 1, consume: 2 };
|
|
221
|
+
}
|
|
222
|
+
return { action: 'noop' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function applyKeyResult(state, result) {
|
|
226
|
+
if (result.action === 'toggle') {
|
|
227
|
+
state.answers = applyToggle(state.answers, ITEMS[state.cursor].key);
|
|
228
|
+
return 'paint';
|
|
229
|
+
}
|
|
230
|
+
if (result.action === 'move') {
|
|
231
|
+
state.cursor = (state.cursor + result.delta + ITEMS.length) % ITEMS.length;
|
|
232
|
+
return 'paint';
|
|
233
|
+
}
|
|
234
|
+
return result.action;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function makeSessionCleanup(stdin, getRawState, onData) {
|
|
238
|
+
return function cleanup() {
|
|
239
|
+
if (typeof stdin.removeListener === 'function') {
|
|
240
|
+
stdin.removeListener('data', onData);
|
|
241
|
+
}
|
|
242
|
+
const raw = getRawState();
|
|
243
|
+
if (raw.hasRaw && raw.rawWasSet) {
|
|
244
|
+
restoreRawMode(stdin, raw.previousRaw);
|
|
245
|
+
raw.rawWasSet = false;
|
|
246
|
+
}
|
|
247
|
+
safePause(stdin);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function processWizardChunk(chunk, state, finish, stdout) {
|
|
252
|
+
const s = Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk);
|
|
253
|
+
let i = 0;
|
|
254
|
+
while (i < s.length) {
|
|
255
|
+
const result = handleKey(s[i], s.charCodeAt(i), s.slice(i + 1));
|
|
256
|
+
i += 1 + (result.consume || 0);
|
|
257
|
+
const outcome = applyKeyResult(state, result);
|
|
258
|
+
if (outcome === 'cancel') {
|
|
259
|
+
finish(null);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (outcome === 'save') {
|
|
263
|
+
finish(cloneAnswers(state.answers));
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (outcome === 'paint') paintFrame(stdout, state.answers, state.cursor);
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function enableRawInput(stdin, raw) {
|
|
272
|
+
if (raw.hasRaw) {
|
|
273
|
+
raw.previousRaw = Boolean(stdin.isRaw);
|
|
274
|
+
stdin.setRawMode(true);
|
|
275
|
+
raw.rawWasSet = true;
|
|
276
|
+
}
|
|
277
|
+
if (typeof stdin.resume === 'function') stdin.resume();
|
|
278
|
+
if (typeof stdin.setEncoding === 'function') stdin.setEncoding('utf-8');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function attachWizardSession(stdin, stdout, initial) {
|
|
282
|
+
const state = { answers: cloneAnswers(initial), cursor: 0 };
|
|
283
|
+
let settled = false;
|
|
284
|
+
const raw = {
|
|
285
|
+
hasRaw: typeof stdin.setRawMode === 'function',
|
|
286
|
+
rawWasSet: false,
|
|
287
|
+
previousRaw: false
|
|
288
|
+
};
|
|
289
|
+
let resolveFn;
|
|
290
|
+
let rejectFn;
|
|
291
|
+
let cleanup;
|
|
292
|
+
|
|
293
|
+
function finish(value) {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
cleanup();
|
|
297
|
+
resolveFn(value);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function fail(error) {
|
|
301
|
+
if (settled) return;
|
|
302
|
+
settled = true;
|
|
303
|
+
cleanup();
|
|
304
|
+
rejectFn(error);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function onData(chunk) {
|
|
308
|
+
processWizardChunk(chunk, state, finish, stdout);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
cleanup = makeSessionCleanup(stdin, () => raw, onData);
|
|
312
|
+
|
|
313
|
+
function start(resolve, reject) {
|
|
314
|
+
resolveFn = resolve;
|
|
315
|
+
rejectFn = reject;
|
|
316
|
+
try {
|
|
317
|
+
enableRawInput(stdin, raw);
|
|
318
|
+
stdin.on('data', onData);
|
|
319
|
+
paintFrame(stdout, state.answers, state.cursor);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
fail(error);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { start, cleanup };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Run checkbox TUI. Resolves to answers, or null on cancel.
|
|
330
|
+
* Requires TTY-like stdin/stdout. Always restores raw mode + removes listeners.
|
|
331
|
+
*/
|
|
332
|
+
function runWizard({
|
|
333
|
+
stdin = process.stdin,
|
|
334
|
+
stdout = process.stdout,
|
|
335
|
+
initial = DEFAULT_ANSWERS
|
|
336
|
+
} = {}) {
|
|
337
|
+
if (!isInteractive(stdin, stdout)) {
|
|
338
|
+
const err = new Error('Interactive terminal required');
|
|
339
|
+
err.code = 'NOT_TTY';
|
|
340
|
+
err.hint = 'Run kanban config in a terminal (stdin and stdout must be TTY)';
|
|
341
|
+
return Promise.reject(err);
|
|
342
|
+
}
|
|
343
|
+
const session = attachWizardSession(stdin, stdout, initial);
|
|
344
|
+
return new Promise((resolve, reject) => session.start(resolve, reject));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function saveAnswers(answers, cwd, log) {
|
|
348
|
+
const body = answersToConfig(answers);
|
|
349
|
+
const filePath = await writeConfigFile(body, cwd);
|
|
350
|
+
const rel = path.relative(cwd, filePath) || CONFIG_REL;
|
|
351
|
+
log(`✓ Wrote ${rel}`);
|
|
352
|
+
return { wrote: true, path: filePath, body };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function wizardSaveOrCancel(run, cancelMessage, cwd, log) {
|
|
356
|
+
const answers = await run();
|
|
357
|
+
if (answers === null) {
|
|
358
|
+
log(cancelMessage);
|
|
359
|
+
return { wrote: false, reason: 'cancelled' };
|
|
360
|
+
}
|
|
361
|
+
return saveAnswers(answers, cwd, log);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function resolveIo(opts = {}) {
|
|
365
|
+
return {
|
|
366
|
+
cwd: opts.cwd || process.cwd(),
|
|
367
|
+
stdin: opts.stdin || process.stdin,
|
|
368
|
+
stdout: opts.stdout || process.stdout,
|
|
369
|
+
log: opts.log || console.log
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function requireInteractive(stdin, stdout) {
|
|
374
|
+
if (isInteractive(stdin, stdout)) return;
|
|
375
|
+
const err = new Error('Interactive terminal required');
|
|
376
|
+
err.code = 'NOT_TTY';
|
|
377
|
+
err.hint = NON_TTY_HINT;
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* init / mcp-init: prompt only when file missing + TTY.
|
|
383
|
+
* Never overwrites existing file. Never blocks non-TTY.
|
|
384
|
+
*/
|
|
385
|
+
async function maybeCreateConfigOnInit(opts = {}) {
|
|
386
|
+
const { cwd, stdin, stdout, log } = resolveIo(opts);
|
|
387
|
+
if (await configExists(cwd)) {
|
|
388
|
+
return { wrote: false, reason: 'exists' };
|
|
389
|
+
}
|
|
390
|
+
if (!isInteractive(stdin, stdout)) {
|
|
391
|
+
log(NON_TTY_HINT);
|
|
392
|
+
return { wrote: false, reason: 'non_tty' };
|
|
393
|
+
}
|
|
394
|
+
return wizardSaveOrCancel(
|
|
395
|
+
() => runWizard({ stdin, stdout, initial: DEFAULT_ANSWERS }),
|
|
396
|
+
'• Config wizard cancelled — no kanbango.json written',
|
|
397
|
+
cwd,
|
|
398
|
+
log
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* kanban config: always require TTY. Prefill from existing valid file.
|
|
404
|
+
* Malformed existing file → throw CONFIG_INVALID (do not open TUI / do not replace).
|
|
405
|
+
*/
|
|
406
|
+
async function runConfigCommand(opts = {}) {
|
|
407
|
+
const { cwd, stdin, stdout, log } = resolveIo(opts);
|
|
408
|
+
requireInteractive(stdin, stdout);
|
|
409
|
+
|
|
410
|
+
let initial = cloneAnswers();
|
|
411
|
+
const existing = await readConfigFile(cwd);
|
|
412
|
+
if (existing) initial = configToAnswers(existing);
|
|
413
|
+
|
|
414
|
+
return wizardSaveOrCancel(
|
|
415
|
+
() => runWizard({ stdin, stdout, initial }),
|
|
416
|
+
'• Config wizard cancelled — file unchanged',
|
|
417
|
+
cwd,
|
|
418
|
+
log
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
module.exports = {
|
|
423
|
+
DEFAULT_ANSWERS,
|
|
424
|
+
ITEMS,
|
|
425
|
+
CONFIG_REL,
|
|
426
|
+
NON_TTY_HINT,
|
|
427
|
+
answersToConfig,
|
|
428
|
+
configToAnswers,
|
|
429
|
+
cloneAnswers,
|
|
430
|
+
isInteractive,
|
|
431
|
+
configPath,
|
|
432
|
+
configExists,
|
|
433
|
+
readConfigFile,
|
|
434
|
+
writeConfigFile,
|
|
435
|
+
renderFrame,
|
|
436
|
+
applyToggle,
|
|
437
|
+
handleKey,
|
|
438
|
+
runWizard,
|
|
439
|
+
maybeCreateConfigOnInit,
|
|
440
|
+
runConfigCommand
|
|
441
|
+
};
|
package/index.html
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
--active: #e85d04;
|
|
12
12
|
--planned: #2563eb;
|
|
13
13
|
--icebox: #64748b;
|
|
14
|
+
--testing: #ca8a04;
|
|
15
|
+
--review: #7c3aed;
|
|
14
16
|
--done: #16a34a;
|
|
15
17
|
--bg: #e8eaef;
|
|
16
18
|
--card: #ffffff;
|
|
@@ -285,6 +287,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
285
287
|
}
|
|
286
288
|
.col-head.icebox { color: var(--icebox); }
|
|
287
289
|
.col-head.planned { color: var(--planned); }
|
|
290
|
+
.col-head.testing { color: var(--testing); }
|
|
291
|
+
.col-head.review { color: var(--review); }
|
|
288
292
|
.col-head.done { color: var(--done); }
|
|
289
293
|
.ch-count {
|
|
290
294
|
background: rgba(0,0,0,0.07);
|
|
@@ -327,6 +331,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
327
331
|
box-shadow: 0 2px 6px rgba(232,93,4,0.10);
|
|
328
332
|
}
|
|
329
333
|
.card.col-active.selected { box-shadow: 0 0 0 2px var(--active); }
|
|
334
|
+
.card.col-testing { border-left-color: var(--testing); }
|
|
335
|
+
.card.col-review { border-left-color: var(--review); }
|
|
330
336
|
.card.col-done { border-left-color: var(--done); opacity: 0.7; }
|
|
331
337
|
.card.col-icebox {
|
|
332
338
|
background: #edf0f7;
|
|
@@ -353,8 +359,24 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
353
359
|
.prog-fill { height: 100%; border-radius: 2px; }
|
|
354
360
|
.prog-fill.planned { background: var(--planned); }
|
|
355
361
|
.prog-fill.active { background: var(--active); }
|
|
362
|
+
.prog-fill.testing { background: var(--testing); }
|
|
363
|
+
.prog-fill.review { background: var(--review); }
|
|
356
364
|
.prog-fill.done { background: var(--done); }
|
|
357
365
|
.prog-fill.icebox { background: var(--icebox); }
|
|
366
|
+
.wf-badge {
|
|
367
|
+
font-size: 9px;
|
|
368
|
+
font-weight: 700;
|
|
369
|
+
text-transform: uppercase;
|
|
370
|
+
letter-spacing: 0.4px;
|
|
371
|
+
padding: 1px 5px;
|
|
372
|
+
border-radius: 4px;
|
|
373
|
+
margin-left: auto;
|
|
374
|
+
}
|
|
375
|
+
.wf-badge.running { background: #fef3c7; color: #92400e; }
|
|
376
|
+
.wf-badge.pass { background: #dcfce7; color: #166534; }
|
|
377
|
+
.wf-badge.fail { background: #fee2e2; color: #991b1b; }
|
|
378
|
+
.wf-badge.blocked { background: #e2e8f0; color: #475569; }
|
|
379
|
+
.wf-badge.idle { background: #f1f5f9; color: #64748b; }
|
|
358
380
|
.prog-txt { font-size: 10px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
359
381
|
|
|
360
382
|
.card-actions {
|
|
@@ -774,20 +796,7 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
774
796
|
</div>
|
|
775
797
|
<div class="now-cards" id="now-cards"></div>
|
|
776
798
|
</div>
|
|
777
|
-
<div id="board-cols">
|
|
778
|
-
<div class="col" data-col="icebox">
|
|
779
|
-
<div class="col-head icebox">Icebox <span class="ch-count" id="cnt-icebox">0</span></div>
|
|
780
|
-
<div class="col-body" id="col-icebox"></div>
|
|
781
|
-
</div>
|
|
782
|
-
<div class="col" data-col="planned">
|
|
783
|
-
<div class="col-head planned">Planned <span class="ch-count" id="cnt-planned">0</span></div>
|
|
784
|
-
<div class="col-body" id="col-planned"></div>
|
|
785
|
-
</div>
|
|
786
|
-
<div class="col" data-col="done">
|
|
787
|
-
<div class="col-head done">Done <span class="ch-count" id="cnt-done">0</span></div>
|
|
788
|
-
<div class="col-body" id="col-done"></div>
|
|
789
|
-
</div>
|
|
790
|
-
</div>
|
|
799
|
+
<div id="board-cols"></div>
|
|
791
800
|
</section>
|
|
792
801
|
|
|
793
802
|
<aside id="paper">
|
|
@@ -800,14 +809,25 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
800
809
|
<script src="/vendor/fenced-text.js"></script>
|
|
801
810
|
<script src="/vendor/mermaid.min.js"></script>
|
|
802
811
|
<script>
|
|
803
|
-
// Board columns exclude active — active lives in NOW only.
|
|
804
|
-
|
|
812
|
+
// Board columns exclude active — active lives in NOW only. Filled from /api/config.
|
|
813
|
+
let BOARD_COLS = [
|
|
805
814
|
{ id: "icebox", label: "Icebox" },
|
|
806
815
|
{ id: "planned", label: "Planned" },
|
|
816
|
+
{ id: "testing", label: "Testing" },
|
|
817
|
+
{ id: "review", label: "Review" },
|
|
807
818
|
{ id: "done", label: "Done" }
|
|
808
819
|
];
|
|
809
|
-
// Move order: icebox ↔ planned ↔ active(NOW) ↔ done
|
|
810
|
-
|
|
820
|
+
// Move order: icebox ↔ planned ↔ active(NOW) ↔ testing ↔ review ↔ done
|
|
821
|
+
let MOVE_ORDER = ["icebox", "planned", "active", "testing", "review", "done"];
|
|
822
|
+
let COLUMN_TRANSITIONS = {
|
|
823
|
+
icebox: ["planned"],
|
|
824
|
+
planned: ["active", "icebox", "testing"],
|
|
825
|
+
active: ["planned", "testing", "icebox"],
|
|
826
|
+
testing: ["active", "review"],
|
|
827
|
+
review: ["active", "done"],
|
|
828
|
+
done: ["active"]
|
|
829
|
+
};
|
|
830
|
+
let boardConfig = null;
|
|
811
831
|
|
|
812
832
|
let allTasks = [];
|
|
813
833
|
let allEpicEntities = [];
|
|
@@ -820,14 +840,66 @@ let dirtyEdits = {};
|
|
|
820
840
|
let dirtyEpicEdits = {};
|
|
821
841
|
let showArchived = false;
|
|
822
842
|
|
|
843
|
+
function applyBoardConfig(cfg) {
|
|
844
|
+
if (!cfg || typeof cfg !== "object") return;
|
|
845
|
+
boardConfig = cfg;
|
|
846
|
+
if (Array.isArray(cfg.board_columns) && cfg.board_columns.length) {
|
|
847
|
+
BOARD_COLS = cfg.board_columns.map((c) => ({ id: c.id, label: c.label || c.id }));
|
|
848
|
+
}
|
|
849
|
+
if (Array.isArray(cfg.move_order) && cfg.move_order.length) {
|
|
850
|
+
MOVE_ORDER = cfg.move_order.slice();
|
|
851
|
+
}
|
|
852
|
+
if (cfg.transitions && typeof cfg.transitions === "object") {
|
|
853
|
+
COLUMN_TRANSITIONS = cfg.transitions;
|
|
854
|
+
}
|
|
855
|
+
rebuildBoardColsDom();
|
|
856
|
+
rebuildNewTaskColSelect();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function rebuildBoardColsDom() {
|
|
860
|
+
const host = document.getElementById("board-cols");
|
|
861
|
+
if (!host) return;
|
|
862
|
+
host.innerHTML = BOARD_COLS.map((col) => `
|
|
863
|
+
<div class="col" data-col="${escHtml(col.id)}">
|
|
864
|
+
<div class="col-head ${escHtml(col.id)}">${escHtml(col.label)} <span class="ch-count" id="cnt-${escHtml(col.id)}">0</span></div>
|
|
865
|
+
<div class="col-body" id="col-${escHtml(col.id)}"></div>
|
|
866
|
+
</div>
|
|
867
|
+
`).join("");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function rebuildNewTaskColSelect() {
|
|
871
|
+
const sel = document.getElementById("ne-col");
|
|
872
|
+
if (!sel) return;
|
|
873
|
+
const opts = [
|
|
874
|
+
{ id: "icebox", label: "Icebox" },
|
|
875
|
+
{ id: "planned", label: "Planned" },
|
|
876
|
+
{ id: "active", label: "NOW (active)" }
|
|
877
|
+
];
|
|
878
|
+
for (const col of BOARD_COLS) {
|
|
879
|
+
if (col.id === "icebox" || col.id === "planned" || col.id === "done") continue;
|
|
880
|
+
opts.push({ id: col.id, label: col.label });
|
|
881
|
+
}
|
|
882
|
+
opts.push({ id: "done", label: "Done" });
|
|
883
|
+
const prev = sel.value || "planned";
|
|
884
|
+
sel.innerHTML = opts.map((o) =>
|
|
885
|
+
`<option value="${escHtml(o.id)}"${o.id === prev || (o.id === "planned" && !opts.some((x) => x.id === prev)) ? " selected" : ""}>${escHtml(o.label)}</option>`
|
|
886
|
+
).join("");
|
|
887
|
+
}
|
|
888
|
+
|
|
823
889
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
824
890
|
|
|
825
891
|
async function load() {
|
|
826
892
|
const q = showArchived ? "?include_archived=true" : "";
|
|
827
|
-
const [boardRes, epicsRes] = await Promise.all([
|
|
893
|
+
const [boardRes, epicsRes, configRes] = await Promise.all([
|
|
828
894
|
fetch("/api/board" + q),
|
|
829
|
-
fetch("/api/epics" + q)
|
|
895
|
+
fetch("/api/epics" + q),
|
|
896
|
+
fetch("/api/config")
|
|
830
897
|
]);
|
|
898
|
+
if (!configRes.ok) {
|
|
899
|
+
toast('Board config failed to load — using default columns', true);
|
|
900
|
+
} else {
|
|
901
|
+
applyBoardConfig(await configRes.json());
|
|
902
|
+
}
|
|
831
903
|
allTasks = await boardRes.json();
|
|
832
904
|
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
833
905
|
refreshEpicDatalist();
|
|
@@ -1003,10 +1075,13 @@ function currentEpicRef() {
|
|
|
1003
1075
|
}
|
|
1004
1076
|
|
|
1005
1077
|
function getNeighborCols(colId) {
|
|
1078
|
+
const allowed = COLUMN_TRANSITIONS[colId] || [];
|
|
1006
1079
|
const idx = MOVE_ORDER.indexOf(colId);
|
|
1080
|
+
const behind = idx > 0 ? MOVE_ORDER.slice(0, idx).reverse() : [];
|
|
1081
|
+
const ahead = idx >= 0 ? MOVE_ORDER.slice(idx + 1) : [];
|
|
1007
1082
|
return {
|
|
1008
|
-
prev:
|
|
1009
|
-
next:
|
|
1083
|
+
prev: behind.find((col) => allowed.includes(col)) || null,
|
|
1084
|
+
next: ahead.find((col) => allowed.includes(col)) || null
|
|
1010
1085
|
};
|
|
1011
1086
|
}
|
|
1012
1087
|
|
|
@@ -1035,6 +1110,12 @@ function renderCard(task, colId) {
|
|
|
1035
1110
|
const idBadge = el("span", "card-id");
|
|
1036
1111
|
idBadge.textContent = shortId;
|
|
1037
1112
|
top.appendChild(idBadge);
|
|
1113
|
+
const wfStatus = task.workflow && task.workflow.status;
|
|
1114
|
+
if (wfStatus && (colId === "testing" || colId === "review" || wfStatus === "running")) {
|
|
1115
|
+
const badge = el("span", `wf-badge ${wfStatus}`);
|
|
1116
|
+
badge.textContent = wfStatus;
|
|
1117
|
+
top.appendChild(badge);
|
|
1118
|
+
}
|
|
1038
1119
|
card.appendChild(top);
|
|
1039
1120
|
|
|
1040
1121
|
const titleEl = el("div", "card-title");
|
|
@@ -1267,8 +1348,19 @@ function renderTaskPaperView(paper, task) {
|
|
|
1267
1348
|
cmd.textContent = ev.test_command || "(no command)";
|
|
1268
1349
|
card.appendChild(cmd);
|
|
1269
1350
|
const meta = el("div", "ev-meta");
|
|
1270
|
-
|
|
1351
|
+
const bits = [];
|
|
1352
|
+
if (ev.stage) bits.push(ev.stage);
|
|
1353
|
+
if (ev.agent) bits.push(ev.agent);
|
|
1354
|
+
if (ev.verdict) bits.push(ev.verdict);
|
|
1355
|
+
bits.push(`exit ${ev.exit_code == null ? "?" : ev.exit_code}`);
|
|
1356
|
+
if (ev.created) bits.push(ev.created);
|
|
1357
|
+
meta.textContent = bits.join(" · ");
|
|
1271
1358
|
card.appendChild(meta);
|
|
1359
|
+
if (ev.summary) {
|
|
1360
|
+
const sum = el("div", "ev-diff");
|
|
1361
|
+
sum.textContent = ev.summary;
|
|
1362
|
+
card.appendChild(sum);
|
|
1363
|
+
}
|
|
1272
1364
|
if (ev.diff) {
|
|
1273
1365
|
const diff = el("div", "ev-diff");
|
|
1274
1366
|
diff.textContent = ev.diff;
|
package/index.js
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
const kanban = require('./kanban.js');
|
|
12
12
|
const plan = require('./plan.js');
|
|
13
|
+
const workflow = require('./workflow.js');
|
|
13
14
|
const guiRegistry = require('./gui-registry.js');
|
|
14
15
|
const playbook = require('./agent-playbook.js');
|
|
15
16
|
|
|
16
17
|
module.exports = {
|
|
17
18
|
kanban,
|
|
18
19
|
plan,
|
|
20
|
+
workflow,
|
|
19
21
|
guiRegistry,
|
|
20
22
|
playbook,
|
|
21
23
|
};
|