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.
@@ -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
- const BOARD_COLS = [
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
- const MOVE_ORDER = ["icebox", "planned", "active", "done"];
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: idx > 0 ? MOVE_ORDER[idx - 1] : null,
1009
- next: idx >= 0 && idx < MOVE_ORDER.length - 1 ? MOVE_ORDER[idx + 1] : null
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
- meta.textContent = `exit ${ev.exit_code == null ? "?" : ev.exit_code}${ev.created ? " · " + ev.created : ""}`;
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
  };