kanbango 3.6.2 → 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
@@ -796,28 +796,7 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
796
796
  </div>
797
797
  <div class="now-cards" id="now-cards"></div>
798
798
  </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>
799
+ <div id="board-cols"></div>
821
800
  </section>
822
801
 
823
802
  <aside id="paper">
@@ -830,8 +809,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
830
809
  <script src="/vendor/fenced-text.js"></script>
831
810
  <script src="/vendor/mermaid.min.js"></script>
832
811
  <script>
833
- // Board columns exclude active — active lives in NOW only.
834
- const BOARD_COLS = [
812
+ // Board columns exclude active — active lives in NOW only. Filled from /api/config.
813
+ let BOARD_COLS = [
835
814
  { id: "icebox", label: "Icebox" },
836
815
  { id: "planned", label: "Planned" },
837
816
  { id: "testing", label: "Testing" },
@@ -839,7 +818,16 @@ const BOARD_COLS = [
839
818
  { id: "done", label: "Done" }
840
819
  ];
841
820
  // Move order: icebox ↔ planned ↔ active(NOW) ↔ testing ↔ review ↔ done
842
- const MOVE_ORDER = ["icebox", "planned", "active", "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;
843
831
 
844
832
  let allTasks = [];
845
833
  let allEpicEntities = [];
@@ -852,14 +840,66 @@ let dirtyEdits = {};
852
840
  let dirtyEpicEdits = {};
853
841
  let showArchived = false;
854
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
+
855
889
  // ── Load & render ─────────────────────────────────────────────────────────────
856
890
 
857
891
  async function load() {
858
892
  const q = showArchived ? "?include_archived=true" : "";
859
- const [boardRes, epicsRes] = await Promise.all([
893
+ const [boardRes, epicsRes, configRes] = await Promise.all([
860
894
  fetch("/api/board" + q),
861
- fetch("/api/epics" + q)
895
+ fetch("/api/epics" + q),
896
+ fetch("/api/config")
862
897
  ]);
898
+ if (!configRes.ok) {
899
+ toast('Board config failed to load — using default columns', true);
900
+ } else {
901
+ applyBoardConfig(await configRes.json());
902
+ }
863
903
  allTasks = await boardRes.json();
864
904
  allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
865
905
  refreshEpicDatalist();
@@ -1034,15 +1074,6 @@ function currentEpicRef() {
1034
1074
  return "";
1035
1075
  }
1036
1076
 
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
1077
  function getNeighborCols(colId) {
1047
1078
  const allowed = COLUMN_TRANSITIONS[colId] || [];
1048
1079
  const idx = MOVE_ORDER.indexOf(colId);