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/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: false,
|
|
12
|
+
testingAgent: false,
|
|
13
|
+
review: false,
|
|
14
|
+
reviewAgent: false
|
|
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
|
@@ -238,6 +238,23 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
238
238
|
border-radius: 10px;
|
|
239
239
|
padding: 1px 7px;
|
|
240
240
|
}
|
|
241
|
+
.now-context {
|
|
242
|
+
font-size: 10px;
|
|
243
|
+
font-weight: 700;
|
|
244
|
+
color: var(--active);
|
|
245
|
+
margin-left: auto;
|
|
246
|
+
letter-spacing: 0;
|
|
247
|
+
text-transform: none;
|
|
248
|
+
}
|
|
249
|
+
.card-blocked {
|
|
250
|
+
font-size: 10px;
|
|
251
|
+
font-weight: 700;
|
|
252
|
+
color: #991b1b;
|
|
253
|
+
background: #fee2e2;
|
|
254
|
+
border-radius: 4px;
|
|
255
|
+
padding: 1px 6px;
|
|
256
|
+
margin-top: 6px;
|
|
257
|
+
}
|
|
241
258
|
.now-cards {
|
|
242
259
|
display: flex;
|
|
243
260
|
flex-wrap: wrap;
|
|
@@ -793,31 +810,11 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
793
810
|
<div class="now-head">
|
|
794
811
|
<span class="now-title">NOW</span>
|
|
795
812
|
<span class="now-count" id="now-count">0</span>
|
|
813
|
+
<span class="now-context" id="now-context"></span>
|
|
796
814
|
</div>
|
|
797
815
|
<div class="now-cards" id="now-cards"></div>
|
|
798
816
|
</div>
|
|
799
|
-
<div id="board-cols">
|
|
800
|
-
<div class="col" data-col="icebox">
|
|
801
|
-
<div class="col-head icebox">Icebox <span class="ch-count" id="cnt-icebox">0</span></div>
|
|
802
|
-
<div class="col-body" id="col-icebox"></div>
|
|
803
|
-
</div>
|
|
804
|
-
<div class="col" data-col="planned">
|
|
805
|
-
<div class="col-head planned">Planned <span class="ch-count" id="cnt-planned">0</span></div>
|
|
806
|
-
<div class="col-body" id="col-planned"></div>
|
|
807
|
-
</div>
|
|
808
|
-
<div class="col" data-col="testing">
|
|
809
|
-
<div class="col-head testing">Testing <span class="ch-count" id="cnt-testing">0</span></div>
|
|
810
|
-
<div class="col-body" id="col-testing"></div>
|
|
811
|
-
</div>
|
|
812
|
-
<div class="col" data-col="review">
|
|
813
|
-
<div class="col-head review">Review <span class="ch-count" id="cnt-review">0</span></div>
|
|
814
|
-
<div class="col-body" id="col-review"></div>
|
|
815
|
-
</div>
|
|
816
|
-
<div class="col" data-col="done">
|
|
817
|
-
<div class="col-head done">Done <span class="ch-count" id="cnt-done">0</span></div>
|
|
818
|
-
<div class="col-body" id="col-done"></div>
|
|
819
|
-
</div>
|
|
820
|
-
</div>
|
|
817
|
+
<div id="board-cols"></div>
|
|
821
818
|
</section>
|
|
822
819
|
|
|
823
820
|
<aside id="paper">
|
|
@@ -830,8 +827,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
830
827
|
<script src="/vendor/fenced-text.js"></script>
|
|
831
828
|
<script src="/vendor/mermaid.min.js"></script>
|
|
832
829
|
<script>
|
|
833
|
-
// Board columns exclude active — active lives in NOW only.
|
|
834
|
-
|
|
830
|
+
// Board columns exclude active — active lives in NOW only. Filled from /api/config.
|
|
831
|
+
let BOARD_COLS = [
|
|
835
832
|
{ id: "icebox", label: "Icebox" },
|
|
836
833
|
{ id: "planned", label: "Planned" },
|
|
837
834
|
{ id: "testing", label: "Testing" },
|
|
@@ -839,10 +836,20 @@ const BOARD_COLS = [
|
|
|
839
836
|
{ id: "done", label: "Done" }
|
|
840
837
|
];
|
|
841
838
|
// Move order: icebox ↔ planned ↔ active(NOW) ↔ testing ↔ review ↔ done
|
|
842
|
-
|
|
839
|
+
let MOVE_ORDER = ["icebox", "planned", "active", "testing", "review", "done"];
|
|
840
|
+
let COLUMN_TRANSITIONS = {
|
|
841
|
+
icebox: ["planned"],
|
|
842
|
+
planned: ["active", "icebox", "testing"],
|
|
843
|
+
active: ["planned", "testing", "icebox"],
|
|
844
|
+
testing: ["active", "review"],
|
|
845
|
+
review: ["active", "done"],
|
|
846
|
+
done: ["active"]
|
|
847
|
+
};
|
|
848
|
+
let boardConfig = null;
|
|
843
849
|
|
|
844
850
|
let allTasks = [];
|
|
845
851
|
let allEpicEntities = [];
|
|
852
|
+
let agentContext = null;
|
|
846
853
|
/** @type {null|string} null=all, "—"=no epic, "E001"=epic id */
|
|
847
854
|
let filterKey = null;
|
|
848
855
|
/** @type {null|{kind:'task'|'epic', id:string}} */
|
|
@@ -852,16 +859,70 @@ let dirtyEdits = {};
|
|
|
852
859
|
let dirtyEpicEdits = {};
|
|
853
860
|
let showArchived = false;
|
|
854
861
|
|
|
862
|
+
function applyBoardConfig(cfg) {
|
|
863
|
+
if (!cfg || typeof cfg !== "object") return;
|
|
864
|
+
boardConfig = cfg;
|
|
865
|
+
if (Array.isArray(cfg.board_columns) && cfg.board_columns.length) {
|
|
866
|
+
BOARD_COLS = cfg.board_columns.map((c) => ({ id: c.id, label: c.label || c.id }));
|
|
867
|
+
}
|
|
868
|
+
if (Array.isArray(cfg.move_order) && cfg.move_order.length) {
|
|
869
|
+
MOVE_ORDER = cfg.move_order.slice();
|
|
870
|
+
}
|
|
871
|
+
if (cfg.transitions && typeof cfg.transitions === "object") {
|
|
872
|
+
COLUMN_TRANSITIONS = cfg.transitions;
|
|
873
|
+
}
|
|
874
|
+
rebuildBoardColsDom();
|
|
875
|
+
rebuildNewTaskColSelect();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function rebuildBoardColsDom() {
|
|
879
|
+
const host = document.getElementById("board-cols");
|
|
880
|
+
if (!host) return;
|
|
881
|
+
host.innerHTML = BOARD_COLS.map((col) => `
|
|
882
|
+
<div class="col" data-col="${escHtml(col.id)}">
|
|
883
|
+
<div class="col-head ${escHtml(col.id)}">${escHtml(col.label)} <span class="ch-count" id="cnt-${escHtml(col.id)}">0</span></div>
|
|
884
|
+
<div class="col-body" id="col-${escHtml(col.id)}"></div>
|
|
885
|
+
</div>
|
|
886
|
+
`).join("");
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function rebuildNewTaskColSelect() {
|
|
890
|
+
const sel = document.getElementById("ne-col");
|
|
891
|
+
if (!sel) return;
|
|
892
|
+
const opts = [
|
|
893
|
+
{ id: "icebox", label: "Icebox" },
|
|
894
|
+
{ id: "planned", label: "Planned" },
|
|
895
|
+
{ id: "active", label: "NOW (active)" }
|
|
896
|
+
];
|
|
897
|
+
for (const col of BOARD_COLS) {
|
|
898
|
+
if (col.id === "icebox" || col.id === "planned" || col.id === "done") continue;
|
|
899
|
+
opts.push({ id: col.id, label: col.label });
|
|
900
|
+
}
|
|
901
|
+
opts.push({ id: "done", label: "Done" });
|
|
902
|
+
const prev = sel.value || "planned";
|
|
903
|
+
sel.innerHTML = opts.map((o) =>
|
|
904
|
+
`<option value="${escHtml(o.id)}"${o.id === prev || (o.id === "planned" && !opts.some((x) => x.id === prev)) ? " selected" : ""}>${escHtml(o.label)}</option>`
|
|
905
|
+
).join("");
|
|
906
|
+
}
|
|
907
|
+
|
|
855
908
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
856
909
|
|
|
857
910
|
async function load() {
|
|
858
911
|
const q = showArchived ? "?include_archived=true" : "";
|
|
859
|
-
const [boardRes, epicsRes] = await Promise.all([
|
|
912
|
+
const [boardRes, epicsRes, configRes, contextRes] = await Promise.all([
|
|
860
913
|
fetch("/api/board" + q),
|
|
861
|
-
fetch("/api/epics" + q)
|
|
914
|
+
fetch("/api/epics" + q),
|
|
915
|
+
fetch("/api/config"),
|
|
916
|
+
fetch("/api/context")
|
|
862
917
|
]);
|
|
918
|
+
if (!configRes.ok) {
|
|
919
|
+
toast('Board config failed to load — using default columns', true);
|
|
920
|
+
} else {
|
|
921
|
+
applyBoardConfig(await configRes.json());
|
|
922
|
+
}
|
|
863
923
|
allTasks = await boardRes.json();
|
|
864
924
|
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
925
|
+
agentContext = contextRes.ok ? await contextRes.json() : null;
|
|
865
926
|
refreshEpicDatalist();
|
|
866
927
|
// Drop paper target if entity vanished
|
|
867
928
|
if (paperTarget) {
|
|
@@ -988,6 +1049,17 @@ function makeRailItem({ key, title, idLabel, count, status, on, entity }) {
|
|
|
988
1049
|
function renderNow(tasks) {
|
|
989
1050
|
const active = tasks.filter(t => t.column === "active");
|
|
990
1051
|
document.getElementById("now-count").textContent = String(active.length);
|
|
1052
|
+
const ctxEl = document.getElementById("now-context");
|
|
1053
|
+
if (ctxEl) {
|
|
1054
|
+
const action = agentContext && agentContext.next_action;
|
|
1055
|
+
if (!action) {
|
|
1056
|
+
ctxEl.textContent = "";
|
|
1057
|
+
} else {
|
|
1058
|
+
const bits = ["next_action: " + action];
|
|
1059
|
+
if (agentContext.task_id) bits.push(agentContext.task_id);
|
|
1060
|
+
ctxEl.textContent = bits.join(" · ");
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
991
1063
|
const wrap = document.getElementById("now-cards");
|
|
992
1064
|
wrap.innerHTML = "";
|
|
993
1065
|
if (active.length === 0) {
|
|
@@ -1034,15 +1106,6 @@ function currentEpicRef() {
|
|
|
1034
1106
|
return "";
|
|
1035
1107
|
}
|
|
1036
1108
|
|
|
1037
|
-
const COLUMN_TRANSITIONS = {
|
|
1038
|
-
icebox: ["planned"],
|
|
1039
|
-
planned: ["active", "icebox", "testing"],
|
|
1040
|
-
active: ["planned", "testing", "icebox"],
|
|
1041
|
-
testing: ["active", "review"],
|
|
1042
|
-
review: ["active", "done"],
|
|
1043
|
-
done: ["active"]
|
|
1044
|
-
};
|
|
1045
|
-
|
|
1046
1109
|
function getNeighborCols(colId) {
|
|
1047
1110
|
const allowed = COLUMN_TRANSITIONS[colId] || [];
|
|
1048
1111
|
const idx = MOVE_ORDER.indexOf(colId);
|
|
@@ -1091,6 +1154,15 @@ function renderCard(task, colId) {
|
|
|
1091
1154
|
titleEl.textContent = displayTitle;
|
|
1092
1155
|
card.appendChild(titleEl);
|
|
1093
1156
|
|
|
1157
|
+
const unmet = Array.isArray(task.unmet_dependencies) ? task.unmet_dependencies : [];
|
|
1158
|
+
if (task.blocked || unmet.length > 0) {
|
|
1159
|
+
const blockedEl = el("div", "card-blocked");
|
|
1160
|
+
blockedEl.textContent = unmet.length
|
|
1161
|
+
? "blocked by " + unmet.join(", ")
|
|
1162
|
+
: "blocked";
|
|
1163
|
+
card.appendChild(blockedEl);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1094
1166
|
if (totalT > 0 && colId !== "icebox") {
|
|
1095
1167
|
const pr = el("div", "prog-row");
|
|
1096
1168
|
pr.innerHTML = `
|
|
@@ -1228,6 +1300,13 @@ function renderTaskPaperView(paper, task) {
|
|
|
1228
1300
|
];
|
|
1229
1301
|
if (task.epic_id) badges.push({ text: task.epic_id, cls: "" });
|
|
1230
1302
|
if (task.plan && task.plan.status) badges.push({ text: "plan:" + task.plan.status, cls: "" });
|
|
1303
|
+
const paperUnmet = Array.isArray(task.unmet_dependencies) ? task.unmet_dependencies : [];
|
|
1304
|
+
if (task.blocked || paperUnmet.length > 0) {
|
|
1305
|
+
badges.push({
|
|
1306
|
+
text: paperUnmet.length ? "blocked by " + paperUnmet.join(", ") : "blocked",
|
|
1307
|
+
cls: ""
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1231
1310
|
|
|
1232
1311
|
const actions = [
|
|
1233
1312
|
makePaperBtn("Edit", "btn-sm btn-confirm", () => { paperEditing = true; render(); }),
|
|
@@ -1260,6 +1339,15 @@ function renderTaskPaperView(paper, task) {
|
|
|
1260
1339
|
body.appendChild(renderDetailListSection("Test Cases", task.test_cases));
|
|
1261
1340
|
}
|
|
1262
1341
|
if (task.notes) body.appendChild(renderDetailSection("Notes", task.notes));
|
|
1342
|
+
if (task.depends_on && task.depends_on.length) {
|
|
1343
|
+
body.appendChild(renderDetailListSection("Depends on", task.depends_on));
|
|
1344
|
+
}
|
|
1345
|
+
if (paperUnmet.length) {
|
|
1346
|
+
body.appendChild(renderDetailListSection("blocked by", paperUnmet));
|
|
1347
|
+
}
|
|
1348
|
+
if (task.files && task.files.length) {
|
|
1349
|
+
body.appendChild(renderDetailListSection("Files", task.files));
|
|
1350
|
+
}
|
|
1263
1351
|
|
|
1264
1352
|
const subtasks = task.subtasks || [];
|
|
1265
1353
|
if (subtasks.length > 0) {
|