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.
@@ -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
- const BOARD_COLS = [
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
- const MOVE_ORDER = ["icebox", "planned", "active", "testing", "review", "done"];
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) {