sdtk-wiki-kit 0.2.2 → 0.3.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.
@@ -524,6 +524,47 @@ body.nav-open .page-nav-toggle{display:none}
524
524
  .graph-toolbar-overlay,.graph-toolbar-overlay.compact,.graph-toolbar-overlay.minimized{position:relative;top:auto;left:auto;width:100%;max-width:none;margin-bottom:14px}
525
525
  .graph-focus-overlay,.graph-focus-overlay.minimized{position:relative;top:auto;right:auto;bottom:auto;width:100%;height:auto;max-height:none;pointer-events:auto}
526
526
  }
527
+ /* BK-272 Agent Kaban board */
528
+ .kaban-header{display:flex;align-items:flex-start;flex-wrap:wrap;gap:8px;padding:8px 2px}
529
+ .kaban-feature-info{flex:1;min-width:0}
530
+ .kaban-feature-name{font-size:14px;font-weight:700;color:var(--graph-text)}
531
+ .kaban-meta{font-size:11px;color:var(--graph-muted);margin-top:2px}
532
+ .kaban-liveness{font-size:11px;color:#b84c00;font-weight:700;padding:2px 8px;border:1px solid rgba(184,76,0,.35);border-radius:4px;white-space:nowrap;background:rgba(240,136,62,.07)}
533
+ .kaban-freshness{font-size:10px;color:var(--graph-muted)}
534
+ .kaban-notice{margin:0 0 6px;padding:5px 10px;border-radius:5px;font-size:11px;background:rgba(184,76,0,.06);color:#b84c00;border:1px solid rgba(184,76,0,.18)}
535
+ .kaban-tabs{display:flex;border-bottom:1px solid var(--graph-border);margin-bottom:6px}
536
+ .kaban-tab{background:none;border:none;border-bottom:2px solid transparent;padding:5px 14px;font-size:11px;font-weight:600;color:var(--graph-muted);cursor:pointer;margin-bottom:-1px}
537
+ .kaban-tab:hover{color:var(--graph-text)}
538
+ .kaban-tab.active{color:var(--graph-text);border-bottom-color:#5f89ff}
539
+ .kaban-board-wrap{overflow-x:auto;padding-bottom:8px}
540
+ .kaban-board{display:grid;grid-template-columns:80px repeat(4,minmax(110px,1fr));min-width:540px;column-gap:5px;row-gap:0}
541
+ .kaban-col-header{font-size:9px;text-transform:uppercase;letter-spacing:.06em;font-weight:700;padding:3px 4px 6px;text-align:center;color:var(--graph-muted)}
542
+ .kaban-col-header.kaban-col-inprog{color:#3156c9}
543
+ .kaban-col-header.kaban-col-pending{color:#b84c00}
544
+ .kaban-col-header.kaban-col-done{color:#1a7f37}
545
+ .kaban-swimlane-header{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--graph-muted);padding:7px 4px 3px;text-align:right;border-top:1px solid var(--graph-border)}
546
+ .kaban-cell{min-height:28px;padding:3px 2px;border-top:1px solid var(--graph-border);display:flex;flex-direction:column;gap:3px}
547
+ .kaban-cell.kaban-cell-inprog .kaban-card{border-left:3px solid #3156c9}
548
+ .kaban-cell.kaban-cell-pending .kaban-card{border-left:3px solid #b84c00}
549
+ .kaban-cell.kaban-cell-done .kaban-card{border-left:3px solid #1a7f37}
550
+ .kaban-cell.kaban-cell-todo .kaban-card{border-left:3px solid var(--border)}
551
+ .kaban-card{background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:5px 7px;font-size:11px;color:var(--text);cursor:pointer;line-height:1.4;transition:background .12s}
552
+ .kaban-card:hover{background:var(--surface2)}
553
+ .kaban-card-title{font-weight:600}
554
+ .kaban-card-badges{display:flex;gap:3px;flex-wrap:wrap;margin-top:2px}
555
+ .kaban-badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:1px 4px;border-radius:3px;display:inline-block}
556
+ .kaban-badge-attention{background:rgba(184,76,0,.14);color:#b84c00;border:1px solid rgba(184,76,0,.28)}
557
+ .kaban-badge-gate{background:rgba(100,60,200,.08);color:#6438c8;border:1px solid rgba(100,60,200,.2)}
558
+ .kaban-card-detail{position:fixed;top:10%;left:10%;right:10%;max-height:76vh;z-index:200;background:var(--surface);border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,.7)}
559
+ .kaban-detail-header{display:flex;align-items:flex-start;gap:10px;padding:12px 14px 8px;border-bottom:1px solid var(--border)}
560
+ .kaban-detail-phase{font-size:9px;text-transform:uppercase;letter-spacing:.07em;color:var(--text2);font-weight:700}
561
+ .kaban-detail-title{font-size:14px;font-weight:700;color:var(--text);margin-top:3px}
562
+ .kaban-detail-close{background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer;padding:0 4px;line-height:1;border-radius:3px;flex-shrink:0}
563
+ .kaban-detail-close:hover{color:var(--text);background:var(--surface2)}
564
+ .kaban-detail-body{padding:12px 14px;overflow-y:auto;flex:1;font-size:12px;line-height:1.6}
565
+ .kaban-detail-row{display:flex;gap:8px;margin-bottom:5px}
566
+ .kaban-detail-label{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text2);font-weight:600;min-width:68px;flex-shrink:0}
567
+ .kaban-detail-value{color:var(--text);flex:1;word-break:break-word}
527
568
 
528
569
  </style>
529
570
  </head>
@@ -545,25 +586,33 @@ body.nav-open .page-nav-toggle{display:none}
545
586
  <p>Project-local wiki and knowledge graph - generated __ATLAS_GENERATED__</p>
546
587
  </div>
547
588
  <div class="screen-scroll">
548
- <div class="dash-grid">
549
- <div class="dash-card" style="border-color:#58a6ff">
550
- <span class="num" style="color:#58a6ff" id="stat-total">-</span>
551
- <span class="label">Total Docs</span>
589
+ <div class="kaban-header" id="kaban-header">
590
+ <div class="kaban-feature-info">
591
+ <div class="kaban-feature-name" id="kaban-feature-name">Agent Kaban Board</div>
592
+ <div class="kaban-meta" id="kaban-meta">Loading pipeline data...</div>
552
593
  </div>
553
- <div class="dash-card" style="border-color:#3fb950">
554
- <span class="num" style="color:#3fb950" id="stat-families">-</span>
555
- <span class="label">Families</span>
556
- </div>
557
- <div class="dash-card" style="border-color:#f0883e">
558
- <span class="num" style="color:#f0883e" id="stat-edges">-</span>
559
- <span class="label">Graph Edges</span>
560
- </div>
561
- <div class="dash-card" style="border-color:#d2a8ff">
562
- <span class="num" style="color:#d2a8ff" id="stat-issues">-</span>
563
- <span class="label">BK Issues</span>
594
+ <div class="kaban-liveness" id="kaban-liveness" style="display:none">&#9888; <span id="kaban-liveness-text"></span></div>
595
+ <div class="kaban-freshness" id="kaban-freshness"></div>
596
+ </div>
597
+ <div class="kaban-notice" id="kaban-notice" style="display:none"></div>
598
+ <div class="kaban-tabs">
599
+ <button class="kaban-tab active" id="kaban-tab-pipeline" onclick="switchKabanTab('pipeline')">Pipeline</button>
600
+ <button class="kaban-tab" id="kaban-tab-quality" onclick="switchKabanTab('quality')">Quality Gates</button>
601
+ </div>
602
+ <div class="kaban-board-wrap">
603
+ <div id="kaban-board-pipeline"></div>
604
+ <div id="kaban-board-quality" style="display:none"></div>
605
+ </div>
606
+ </div>
607
+ <div class="kaban-card-detail" id="kaban-card-detail" style="display:none">
608
+ <div class="kaban-detail-header">
609
+ <div style="flex:1;min-width:0">
610
+ <div class="kaban-detail-phase" id="kd-phase"></div>
611
+ <div class="kaban-detail-title" id="kd-title"></div>
564
612
  </div>
613
+ <button class="kaban-detail-close" onclick="closeKabanDetail()" aria-label="Close card detail" title="Close card detail">&times;</button>
565
614
  </div>
566
- <div id="dash-family-rows"></div>
615
+ <div class="kaban-detail-body" id="kd-body"></div>
567
616
  </div>
568
617
  </div>
569
618
  </div>
@@ -811,26 +860,179 @@ const FAMILY_GLYPHS = {
811
860
  };
812
861
  const GRAPH_EDGE_COLORS = { references_path: 'rgba(95,137,255,0.38)', references_wiki_link: 'rgba(46,181,125,0.44)' };
813
862
  const GRAPH_FRAME_MS = 40;
814
- (function initDash() {
815
- const docs = INDEX.documents;
816
- const families = new Set(docs.map(d => d.family));
817
- const issues = new Set(docs.flatMap(d => d.issues));
818
- const edges = GRAPH.edges.filter(e => e.type !== 'same_family');
819
- document.getElementById('stat-total').textContent = docs.length;
820
- document.getElementById('stat-families').textContent = families.size;
821
- document.getElementById('stat-edges').textContent = edges.length;
822
- document.getElementById('stat-issues').textContent = issues.size;
823
- const famCounts = {};
824
- docs.forEach(d => { famCounts[d.family] = (famCounts[d.family] || 0) + 1; });
825
- const rows = document.getElementById('dash-family-rows');
826
- Object.entries(famCounts).sort((a,b)=>b[1]-a[1]).forEach(([fam,cnt]) => {
827
- const color = FAMILY_COLORS[fam] || '#8b949e';
828
- const row = document.createElement('div');
829
- row.className = 'stat-row';
830
- row.innerHTML = `<span class="label"><span class="dot" style="background:${color}"></span> ${fam}</span><span class="val" style="color:${color}">${cnt}</span>`;
831
- rows.appendChild(row);
832
- });
833
- })();
863
+ // BK-272: Agent Kaban board — state, render, and poll
864
+ var kabanState = { data: null, activeTab: 'pipeline', pollTimer: null, lastFetch: null };
865
+ const KABAN_POLL_MS = 3000;
866
+ const KABAN_LIVENESS_MIN_JS = 15;
867
+ const KABAN_COLS = ['todo', 'in_progress', 'pending', 'done'];
868
+ const KABAN_COL_LABELS = { todo: 'Todo', in_progress: 'In Progress', pending: 'Pending', done: 'Done' };
869
+ const KABAN_COL_CSS = { todo: '', in_progress: 'kaban-col-inprog', pending: 'kaban-col-pending', done: 'kaban-col-done' };
870
+ const KABAN_CELL_CSS = { todo: 'kaban-cell-todo', in_progress: 'kaban-cell-inprog', pending: 'kaban-cell-pending', done: 'kaban-cell-done' };
871
+
872
+ function safeKabanHtml(s) {
873
+ return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
874
+ }
875
+ function formatKabanFreshness(dt) {
876
+ if (!dt) return '';
877
+ const d = Math.floor((new Date() - dt) / 1000);
878
+ if (d < 5) return 'just now';
879
+ if (d < 60) return d + 's ago';
880
+ if (d < 3600) return Math.floor(d / 60) + 'm ago';
881
+ return Math.floor(d / 3600) + 'h ago';
882
+ }
883
+ function renderKabanCard(card) {
884
+ const badges = [];
885
+ if (card.attention) badges.push('<span class="kaban-badge kaban-badge-attention">&#9888; Pending</span>');
886
+ if (card.isGate) badges.push('<span class="kaban-badge kaban-badge-gate">GATE</span>');
887
+ const id = safeKabanHtml(card.id);
888
+ const tab = safeKabanHtml(kabanState.activeTab);
889
+ return '<div class="kaban-card" onclick="openKabanDetail(\'' + id + '\',\'' + tab + '\')" role="button" tabindex="0">' +
890
+ '<div class="kaban-card-title">' + safeKabanHtml(card.title || card.id) + '</div>' +
891
+ (badges.length ? '<div class="kaban-card-badges">' + badges.join('') + '</div>' : '') +
892
+ '</div>';
893
+ }
894
+ function renderKabanBoard(containerId, cards, agents) {
895
+ const container = document.getElementById(containerId);
896
+ if (!container) return;
897
+ if (!cards || cards.length === 0) {
898
+ container.innerHTML = '<div style="padding:16px;text-align:center;font-size:11px;color:var(--text2)">No data — SHARED_PLANNING.md or QUALITY_CHECKLIST.md not found in project root.</div>';
899
+ return;
900
+ }
901
+ let html = '<div class="kaban-board">';
902
+ html += '<div class="kaban-col-header"></div>';
903
+ for (const col of KABAN_COLS) {
904
+ html += '<div class="kaban-col-header ' + (KABAN_COL_CSS[col] || '') + '">' + KABAN_COL_LABELS[col] + '</div>';
905
+ }
906
+ for (const agent of agents) {
907
+ html += '<div class="kaban-swimlane-header">' + safeKabanHtml(agent) + '</div>';
908
+ for (const col of KABAN_COLS) {
909
+ const cellCards = cards.filter(function(c){ return c.agent === agent && c.column === col; });
910
+ html += '<div class="kaban-cell ' + KABAN_CELL_CSS[col] + '">';
911
+ for (const c of cellCards) html += renderKabanCard(c);
912
+ html += '</div>';
913
+ }
914
+ }
915
+ const otherCards = cards.filter(function(c){ return !agents.includes(c.agent); });
916
+ if (otherCards.length > 0) {
917
+ html += '<div class="kaban-swimlane-header">OTHER</div>';
918
+ for (const col of KABAN_COLS) {
919
+ const cellCards = otherCards.filter(function(c){ return c.column === col; });
920
+ html += '<div class="kaban-cell ' + KABAN_CELL_CSS[col] + '">';
921
+ for (const c of cellCards) html += renderKabanCard(c);
922
+ html += '</div>';
923
+ }
924
+ }
925
+ html += '</div>';
926
+ container.innerHTML = html;
927
+ }
928
+ function renderKaban(data) {
929
+ if (!data) return;
930
+ const meta = data.meta || {};
931
+ const agents = data.agents || ['PM','BA','ARCH','DEV','QA'];
932
+ const nameEl = document.getElementById('kaban-feature-name');
933
+ if (nameEl) nameEl.textContent = meta.featureName || meta.featureKey || 'Agent Kaban Board';
934
+ const metaEl = document.getElementById('kaban-meta');
935
+ if (metaEl) {
936
+ const parts = [];
937
+ if (meta.featureKey) parts.push(meta.featureKey);
938
+ if (meta.lastUpdated) parts.push('Updated: ' + meta.lastUpdated);
939
+ metaEl.textContent = parts.join(' · ') || 'No pipeline data';
940
+ }
941
+ const freshEl = document.getElementById('kaban-freshness');
942
+ if (freshEl && kabanState.lastFetch) freshEl.textContent = 'Fetched ' + formatKabanFreshness(kabanState.lastFetch);
943
+ const livenessEl = document.getElementById('kaban-liveness');
944
+ const livenessTextEl = document.getElementById('kaban-liveness-text');
945
+ if (livenessEl && meta.lastUpdated) {
946
+ try {
947
+ const lu = new Date(meta.lastUpdated.replace(' ','T'));
948
+ const diffMin = Math.floor((new Date() - lu) / 60000);
949
+ if (diffMin > KABAN_LIVENESS_MIN_JS && !isNaN(diffMin)) {
950
+ if (livenessTextEl) livenessTextEl.textContent = 'No movement for ' + diffMin + ' min';
951
+ livenessEl.style.display = '';
952
+ } else { livenessEl.style.display = 'none'; }
953
+ } catch(_) { livenessEl.style.display = 'none'; }
954
+ } else if (livenessEl) { livenessEl.style.display = 'none'; }
955
+ const noticeEl = document.getElementById('kaban-notice');
956
+ if (noticeEl) {
957
+ const notices = [];
958
+ if (!meta.pipelinePresent) notices.push('SHARED_PLANNING.md not found');
959
+ if (!meta.qualityPresent) notices.push('QUALITY_CHECKLIST.md not found');
960
+ (meta.errors || []).forEach(function(e){ notices.push(e); });
961
+ if (notices.length > 0) { noticeEl.textContent = notices.join(' · '); noticeEl.style.display = ''; }
962
+ else { noticeEl.style.display = 'none'; }
963
+ }
964
+ renderKabanBoard('kaban-board-pipeline', (data.pipeline || {}).cards || [], agents);
965
+ renderKabanBoard('kaban-board-quality', (data.quality || {}).cards || [], agents);
966
+ }
967
+ function openKabanDetail(cardId, tab) {
968
+ if (!kabanState.data) return;
969
+ const src = tab === 'quality' ? (kabanState.data.quality || {}) : (kabanState.data.pipeline || {});
970
+ const card = (src.cards || []).find(function(c){ return c.id === cardId; });
971
+ if (!card) return;
972
+ const phaseEl = document.getElementById('kd-phase');
973
+ const titleEl = document.getElementById('kd-title');
974
+ const bodyEl = document.getElementById('kd-body');
975
+ const detailEl = document.getElementById('kaban-card-detail');
976
+ if (!detailEl) return;
977
+ if (phaseEl) phaseEl.textContent = (card.agent || '') + (card.phase ? ' · ' + card.phase : '');
978
+ if (titleEl) titleEl.textContent = card.title || card.id;
979
+ if (bodyEl) {
980
+ const rows = [];
981
+ function addRow(label, value) {
982
+ if (!value) return;
983
+ rows.push('<div class="kaban-detail-row"><span class="kaban-detail-label">' + safeKabanHtml(label) + '</span><span class="kaban-detail-value">' + safeKabanHtml(value) + '</span></div>');
984
+ }
985
+ addRow('Column', KABAN_COL_LABELS[card.column] || card.column);
986
+ addRow('Status', card.rawStatus);
987
+ addRow('Owner', card.owner);
988
+ addRow('Artifact', card.artifact);
989
+ addRow('Notes', card.notes);
990
+ addRow('Issue', card.issue);
991
+ addRow('Last update', card.lastUpdate);
992
+ addRow('Handoff →', card.handoffNext);
993
+ if (card.isGate) rows.push('<div style="margin-top:8px"><span class="kaban-badge kaban-badge-gate">GATE CHECKPOINT</span></div>');
994
+ if (card.attention) rows.push('<div style="margin-top:4px"><span class="kaban-badge kaban-badge-attention">&#9888; Needs attention</span></div>');
995
+ bodyEl.innerHTML = rows.join('') || '<span style="color:var(--text2)">No additional detail.</span>';
996
+ }
997
+ detailEl.style.display = 'flex';
998
+ }
999
+ function closeKabanDetail() {
1000
+ const el = document.getElementById('kaban-card-detail');
1001
+ if (el) el.style.display = 'none';
1002
+ }
1003
+ function switchKabanTab(tab) {
1004
+ kabanState.activeTab = tab;
1005
+ const ptab = document.getElementById('kaban-tab-pipeline');
1006
+ const qtab = document.getElementById('kaban-tab-quality');
1007
+ const pboard = document.getElementById('kaban-board-pipeline');
1008
+ const qboard = document.getElementById('kaban-board-quality');
1009
+ if (ptab) ptab.classList.toggle('active', tab === 'pipeline');
1010
+ if (qtab) qtab.classList.toggle('active', tab === 'quality');
1011
+ if (pboard) pboard.style.display = tab === 'pipeline' ? '' : 'none';
1012
+ if (qboard) qboard.style.display = tab === 'quality' ? '' : 'none';
1013
+ closeKabanDetail();
1014
+ }
1015
+ async function fetchKabanData() {
1016
+ try {
1017
+ const resp = await fetch('/api/kaban');
1018
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
1019
+ const data = await resp.json();
1020
+ kabanState.data = data;
1021
+ kabanState.lastFetch = new Date();
1022
+ renderKaban(data);
1023
+ } catch(e) {
1024
+ const noticeEl = document.getElementById('kaban-notice');
1025
+ if (noticeEl) { noticeEl.textContent = 'Could not load kaban data: ' + e.message; noticeEl.style.display = ''; }
1026
+ }
1027
+ }
1028
+ function startKabanPoll() {
1029
+ stopKabanPoll();
1030
+ fetchKabanData();
1031
+ kabanState.pollTimer = setInterval(fetchKabanData, KABAN_POLL_MS);
1032
+ }
1033
+ function stopKabanPoll() {
1034
+ if (kabanState.pollTimer) { clearInterval(kabanState.pollTimer); kabanState.pollTimer = null; }
1035
+ }
834
1036
 
835
1037
 
836
1038
  function applySidebarState(collapsed) {
@@ -876,6 +1078,7 @@ function showPanel(name, btn) {
876
1078
  } else {
877
1079
  stopGraphAnimation();
878
1080
  }
1081
+ if (name === 'dash') { startKabanPoll(); } else { stopKabanPoll(); }
879
1082
  }
880
1083
 
881
1084
  let activeFamily = null;
package/bin/sdtk-wiki.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdtk-wiki-kit",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Project-local wiki and knowledge graph toolkit for SDTK workspaces.",
5
5
  "bin": {
6
6
  "sdtk-wiki": "bin/sdtk-wiki.js"
@@ -53,6 +53,7 @@ R1 command model:
53
53
  wiki discover Write a local-only discovery plan from WIKI gap evidence.
54
54
  wiki compile Preview or explicitly apply local wiki compile plans.
55
55
  ask Ask grounded questions over the built SDTK-WIKI graph.
56
+ kaban Open the Agent Kaban board (Dashboard panel) for the current project.
56
57
  search Search generated local wiki pages without premium Ask.
57
58
  lint Write a report-first, non-destructive wiki lint report.
58
59
  update Package-only updater; no wiki/.sdtk/wiki/.sdtk/atlas files are mutated in R1.
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ // BK-272: sdtk-wiki kaban — open the viewer on the Dashboard (Kaban board) panel.
4
+ // Follows the same flow as cmdAtlasOpen; Dashboard is the default active panel.
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { resolveWikiConfig } = require("../lib/wiki-config");
9
+ const { openViewer, runBuild } = require("../lib/wiki-runner");
10
+ const { parseFlags } = require("../lib/args");
11
+ const { OPEN_FLAG_DEFS } = require("../lib/wiki-flags");
12
+
13
+ const KABAN_FLAG_DEFS = {
14
+ ...OPEN_FLAG_DEFS,
15
+ "project-path": { type: "string", alias: "project" },
16
+ };
17
+
18
+ function hasHelp(args) {
19
+ return args.includes("-h") || args.includes("--help");
20
+ }
21
+
22
+ async function cmdKaban(args) {
23
+ if (hasHelp(args)) {
24
+ console.log(`Usage:
25
+ sdtk-wiki kaban [--project <dir>] [--port <n>] [--no-open]
26
+
27
+ Purpose:
28
+ Open the SDTK-WIKI viewer showing the Agent Kaban board (Dashboard panel).
29
+ The board reads SHARED_PLANNING.md and QUALITY_CHECKLIST.md from the project
30
+ directory and polls /api/kaban every 3 s while the Dashboard panel is active.
31
+
32
+ Options:
33
+ --project, --project-path <dir> Project root containing SHARED_PLANNING.md
34
+ and QUALITY_CHECKLIST.md (default: cwd)
35
+ --port <n> Local server port (default: from config or 7654)
36
+ --no-open Print the viewer URL without opening a browser
37
+ -h, --help Show this help and exit
38
+
39
+ Example:
40
+ sdtk-wiki kaban --project /path/to/project
41
+ sdtk-wiki kaban --no-open`);
42
+ return 0;
43
+ }
44
+
45
+ const { flags } = parseFlags(args, KABAN_FLAG_DEFS);
46
+ const config = resolveWikiConfig(flags);
47
+
48
+ const viewerPath = path.join(config.outputDir, "viewer.html");
49
+ const legacyViewerPath = path.join(config.legacyAtlasDir || "", "viewer.html");
50
+
51
+ const hasViewer =
52
+ fs.existsSync(viewerPath) || fs.existsSync(legacyViewerPath);
53
+
54
+ if (!hasViewer) {
55
+ console.log("[kaban] No SDTK-WIKI viewer found. Running initial build...");
56
+ try {
57
+ await runBuild(config);
58
+ } catch (err) {
59
+ console.error("[kaban] Build failed: " + err.message);
60
+ console.error("[kaban] Run: sdtk-wiki atlas build");
61
+ return 1;
62
+ }
63
+ }
64
+
65
+ // Use legacy atlas dir if the wiki output dir has no viewer yet
66
+ const activeConfig = fs.existsSync(viewerPath)
67
+ ? config
68
+ : { ...config, outputDir: config.legacyAtlasDir };
69
+
70
+ const noOpen = !!flags["no-open"];
71
+ const { server } = await openViewer(activeConfig, noOpen);
72
+
73
+ if (noOpen) {
74
+ if (server) server.close();
75
+ return 0;
76
+ }
77
+
78
+ console.log("[kaban] Dashboard (Kaban board) is the active panel.");
79
+ console.log("[kaban] Edit SHARED_PLANNING.md or QUALITY_CHECKLIST.md to see live updates.");
80
+ console.log("[kaban] Press Ctrl+C to stop the viewer server.");
81
+ await new Promise(() => {});
82
+ return 0;
83
+ }
84
+
85
+ module.exports = { cmdKaban };
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { cmdAtlas } = require("./commands/atlas");
4
4
  const { cmdAsk } = require("./commands/ask");
5
+ const { cmdKaban } = require("./commands/kaban");
5
6
  const { cmdHelp } = require("./commands/help");
6
7
  const { cmdInit } = require("./commands/init");
7
8
  const { cmdLint } = require("./commands/lint");
@@ -47,6 +48,7 @@ const COMMANDS = new Set([
47
48
  "atlas",
48
49
  "wiki",
49
50
  "ask",
51
+ "kaban",
50
52
  "lint",
51
53
  "search",
52
54
  "ingest",
@@ -82,6 +84,8 @@ async function run(argv) {
82
84
  return cmdWiki(args);
83
85
  case "ask":
84
86
  return cmdAsk(args);
87
+ case "kaban":
88
+ return cmdKaban(args);
85
89
  case "lint":
86
90
  return cmdLint(args);
87
91
  case "search":
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+
3
+ // BK-272: Agent Kaban — pure parser (no I/O). Caller passes file text.
4
+ // Spec §D3: view model; §D4: column derivation rules; §D5: liveness.
5
+
6
+ const KABAN_LIVENESS_MIN = 15;
7
+ const COLUMNS = ["todo", "in_progress", "pending", "done"];
8
+ const AGENT_ORDER = ["PM", "BA", "ARCH", "DEV", "QA"];
9
+ const DONE_STATUSES = new Set(["done", "complete", "example complete"]);
10
+
11
+ // Normalize agent identifier to canonical form (PM/BA/ARCH/DEV/QA)
12
+ function normalizeAgent(raw) {
13
+ if (!raw) return null;
14
+ const s = raw.replace(/[`*@]/g, "").trim().toLowerCase();
15
+ if (!s) return null;
16
+ if (s.startsWith("pm")) return "PM";
17
+ if (s.startsWith("ba")) return "BA";
18
+ if (s.startsWith("arch")) return "ARCH";
19
+ if (s.startsWith("dev")) return "DEV";
20
+ if (s.startsWith("qa")) return "QA";
21
+ return null;
22
+ }
23
+
24
+ // Extract a header field matching **FieldName:** `value` or FieldName: value
25
+ function getHeaderField(text) {
26
+ for (let i = 1; i < arguments.length; i++) {
27
+ const fieldName = arguments[i];
28
+ const patterns = [
29
+ new RegExp("\\*\\*" + fieldName + "[:\\s]*\\*\\*\\s*[:`]?\\s*`?([^`\\n]+)`?", "i"),
30
+ new RegExp("^\\s*\\*\\*" + fieldName + ":\\*\\*\\s+`?([^`\\n]+)`?", "im"),
31
+ new RegExp("^\\s*" + fieldName + ":\\s+([^\\n]+)", "im"),
32
+ ];
33
+ for (const re of patterns) {
34
+ const m = text.match(re);
35
+ if (m) return m[1].trim().replace(/`/g, "").replace(/\*\*/g, "");
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // Parse a markdown table block from an array of pipe-delimited lines.
42
+ // Returns { headerCols: string[], rows: string[][] }
43
+ function parseMarkdownTable(tableLines) {
44
+ const rows = [];
45
+ let headerCols = null;
46
+ for (const line of tableLines) {
47
+ const t = line.trim();
48
+ if (!t.startsWith("|")) continue;
49
+ const cells = t.slice(1, t.endsWith("|") ? -1 : undefined)
50
+ .split("|")
51
+ .map((c) => c.trim());
52
+ if (headerCols === null) {
53
+ headerCols = cells.map((c) =>
54
+ c.replace(/\*\*/g, "").replace(/`/g, "").toLowerCase().trim()
55
+ );
56
+ continue;
57
+ }
58
+ if (cells.every((c) => /^[-: ]+$/.test(c) || c === "")) continue;
59
+ rows.push(cells);
60
+ }
61
+ return { headerCols: headerCols || [], rows };
62
+ }
63
+
64
+ // Find the first table in text whose header columns match all required patterns.
65
+ function findTable(text, requiredColPatterns) {
66
+ const allLines = text.split("\n");
67
+ let i = 0;
68
+ while (i < allLines.length) {
69
+ const line = allLines[i].trim();
70
+ if (!line.startsWith("|")) {
71
+ i++;
72
+ continue;
73
+ }
74
+ const tableLines = [];
75
+ while (i < allLines.length && allLines[i].trim().startsWith("|")) {
76
+ tableLines.push(allLines[i]);
77
+ i++;
78
+ }
79
+ if (tableLines.length < 2) continue;
80
+ const parsed = parseMarkdownTable(tableLines);
81
+ const matches = requiredColPatterns.every((p) =>
82
+ parsed.headerCols.some((h) => p.test(h))
83
+ );
84
+ if (matches) return parsed;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // Parse planning file header (featureKey, featureName, lastUpdated)
90
+ function parsePlanningHeader(text) {
91
+ return {
92
+ featureKey: getHeaderField(text, "Current Feature", "Feature key"),
93
+ featureName: getHeaderField(text, "Feature Name"),
94
+ lastUpdated: getHeaderField(text, "Last Updated"),
95
+ pipelineStatus: getHeaderField(text, "Pipeline Status", "Status"),
96
+ };
97
+ }
98
+
99
+ // Parse the pipeline status table into phase objects.
100
+ // Handles both canonical shape (8 cols) and simplified demo shape (5 cols).
101
+ function parsePipelinePhases(text) {
102
+ const result = findTable(text, [/phase/, /status/]);
103
+ if (!result) return [];
104
+ const { headerCols, rows } = result;
105
+
106
+ const phaseIdx = headerCols.findIndex((h) => h.includes("phase"));
107
+ const statusIdx = headerCols.findIndex((h) => h.includes("status"));
108
+ const ownerIdx = headerCols.findIndex((h) => h.includes("owner"));
109
+ const artifactIdx = headerCols.findIndex(
110
+ (h) => h.includes("artifact") || h.includes("output")
111
+ );
112
+ const notesIdx = headerCols.findIndex(
113
+ (h) => h.includes("notes") || h.includes("blockers")
114
+ );
115
+
116
+ if (phaseIdx < 0 || statusIdx < 0) return [];
117
+
118
+ const phases = [];
119
+ for (const cells of rows) {
120
+ const phase = (cells[phaseIdx] || "")
121
+ .replace(/\*\*/g, "")
122
+ .replace(/`/g, "")
123
+ .trim();
124
+ const rawStatus = (cells[statusIdx] || "")
125
+ .replace(/\*\*/g, "")
126
+ .replace(/`/g, "")
127
+ .trim();
128
+ const owner =
129
+ ownerIdx >= 0 ? (cells[ownerIdx] || "").trim() : "";
130
+ const artifact =
131
+ artifactIdx >= 0
132
+ ? (cells[artifactIdx] || "").replace(/`/g, "").trim()
133
+ : "";
134
+ const notes =
135
+ notesIdx >= 0
136
+ ? (cells[notesIdx] || "").replace(/\*\*/g, "").replace(/`/g, "").trim()
137
+ : "";
138
+
139
+ if (!phase || !rawStatus) continue;
140
+
141
+ phases.push({
142
+ phase,
143
+ rawStatus,
144
+ owner,
145
+ artifact,
146
+ notes,
147
+ agent: normalizeAgent(owner),
148
+ });
149
+ }
150
+ return phases;
151
+ }
152
+
153
+ // Extract blocker strings; returns [] if sentinel NO BLOCKERS or section absent.
154
+ function parseBlockers(text) {
155
+ const m = text.match(
156
+ /##\s+CURRENT BLOCKERS[^\n]*([\s\S]*?)(?=\n##\s+|$)/i
157
+ );
158
+ if (!m) return [];
159
+ const section = m[1];
160
+ if (section.includes("NO BLOCKERS")) return [];
161
+ return section
162
+ .split("\n")
163
+ .map((l) => l.trim())
164
+ .filter((l) => l && l !== "```" && !l.startsWith("#"));
165
+ }
166
+
167
+ // Extract OPEN open questions from the OQ table.
168
+ function parseOpenQuestions(text) {
169
+ const result = findTable(text, [/q-id|^id$/, /status/]);
170
+ if (!result) return [];
171
+ const { headerCols, rows } = result;
172
+
173
+ const idIdx = headerCols.findIndex((h) => h === "q-id" || h === "id");
174
+ const statusIdx = headerCols.findIndex((h) => h.includes("status"));
175
+ const ownerIdx = headerCols.findIndex((h) => h.includes("owner"));
176
+ const summaryIdx = headerCols.findIndex((h) => h.includes("summary"));
177
+
178
+ return rows
179
+ .filter((cells) => {
180
+ const st = (statusIdx >= 0 ? cells[statusIdx] || "" : "").trim().toUpperCase();
181
+ return st.includes("OPEN");
182
+ })
183
+ .map((cells) => ({
184
+ id: idIdx >= 0 ? (cells[idIdx] || "").trim() : "",
185
+ status: (statusIdx >= 0 ? cells[statusIdx] || "" : "").trim(),
186
+ owner: ownerIdx >= 0 ? (cells[ownerIdx] || "").trim() : "",
187
+ summary: summaryIdx >= 0 ? (cells[summaryIdx] || "").trim() : "",
188
+ }));
189
+ }
190
+
191
+ // Parse activity log lines for timestamps and @handoff hints.
192
+ function parseActivityEntries(text) {
193
+ const re = /(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2})[^|\n]*\|[^|\n]*\|[^|\n]*\|[^|\n]*?(\@\w+)?(?=\n|$)/g;
194
+ const entries = [];
195
+ let m;
196
+ while ((m = re.exec(text)) !== null) {
197
+ entries.push({ ts: m[1].trim(), handoffNext: m[2] ? m[2].trim() : null });
198
+ }
199
+ return entries;
200
+ }
201
+
202
+ // Derive pipeline card column per spec §D4.
203
+ function derivePipelineColumn(rawStatus, hasOpenOQ, hasBlocker, trustMismatch) {
204
+ const s = rawStatus.toLowerCase();
205
+ const isDone = DONE_STATUSES.has(s);
206
+ if (isDone && !trustMismatch && !hasOpenOQ && !hasBlocker) {
207
+ return { column: "done", attention: false };
208
+ }
209
+ if (hasOpenOQ || hasBlocker || trustMismatch) {
210
+ return { column: "pending", attention: true };
211
+ }
212
+ if (s === "in_progress" || s === "in progress") {
213
+ return { column: "in_progress", attention: false };
214
+ }
215
+ return { column: "todo", attention: false };
216
+ }
217
+
218
+ // Derive quality card column per spec §D4.
219
+ function deriveQualityColumn(checked, phaseHasBlocker, phaseHasOpenOQ, hasIssue, phaseStatus) {
220
+ if (checked) return { column: "done", attention: false };
221
+ if (phaseHasBlocker || phaseHasOpenOQ || hasIssue) {
222
+ return { column: "pending", attention: true };
223
+ }
224
+ const s = (phaseStatus || "").toLowerCase();
225
+ if (s === "in_progress" || s === "in progress") {
226
+ return { column: "in_progress", attention: false };
227
+ }
228
+ return { column: "todo", attention: false };
229
+ }
230
+
231
+ // Derive agent from QUALITY_CHECKLIST.md phase section heading.
232
+ function deriveAgentFromPhaseName(name) {
233
+ const u = name.toUpperCase();
234
+ if (u.includes("PM") || u.includes("CLOSURE")) return "PM";
235
+ if (u.includes("BA") || u.includes("BUSINESS ANALYSIS")) return "BA";
236
+ if (u.includes("ARCH") || u.includes("ARCHITECTURE")) return "ARCH";
237
+ if (u.includes("DEV") || u.includes("DEVELOPMENT") || u.includes("IMPLEMENTATION")) return "DEV";
238
+ if (u.includes("QA") || u.includes("QUALITY")) return "QA";
239
+ return "PM";
240
+ }
241
+
242
+ // Parse QUALITY_CHECKLIST.md into phase objects with criteria + gate rows.
243
+ function parseQualityCriteria(text) {
244
+ const phases = [];
245
+ const lines = text.split("\n");
246
+ let currentPhase = null;
247
+
248
+ for (const line of lines) {
249
+ const phaseMatch = line.match(
250
+ /^#{1,3}\s+PHASE\s+(\d+[+]?)[\s:]+(.+?)\s*(?:CHECKLIST)?\s*$/i
251
+ );
252
+ if (phaseMatch) {
253
+ currentPhase = {
254
+ phaseNum: phaseMatch[1],
255
+ phaseName: phaseMatch[2].replace(/\(.*?\)/g, "").trim(),
256
+ agent: deriveAgentFromPhaseName(phaseMatch[2]),
257
+ criteria: [],
258
+ gate: null,
259
+ };
260
+ phases.push(currentPhase);
261
+ continue;
262
+ }
263
+ if (!currentPhase) continue;
264
+
265
+ const t = line.trim();
266
+ if (!t.startsWith("|")) continue;
267
+ if (t.replace(/[\-:|]/g, "").trim() === "") continue;
268
+
269
+ const cells = t
270
+ .slice(1, t.endsWith("|") ? -1 : undefined)
271
+ .split("|")
272
+ .map((c) => c.trim());
273
+ if (cells.length < 3) continue;
274
+
275
+ const numRaw = cells[0].replace(/\*\*/g, "").trim();
276
+ if (!numRaw || numRaw === "#" || numRaw === "num") continue;
277
+
278
+ const criteriaRaw = cells[1] || "";
279
+ const statusRaw = cells[2] || "";
280
+ const verifiedByRaw = cells[3] || "";
281
+ const notesRaw = cells[4] || "";
282
+
283
+ const criteria = criteriaRaw.replace(/\*\*/g, "").replace(/`/g, "").trim();
284
+ if (!criteria) continue;
285
+
286
+ const verifiedByClean = verifiedByRaw.replace(/\*\*/g, "").trim();
287
+ const isGate = verifiedByClean.toLowerCase().includes("gate");
288
+
289
+ const checked =
290
+ statusRaw.includes("[x]") ||
291
+ statusRaw.toLowerCase() === "done" ||
292
+ statusRaw.toLowerCase() === "complete";
293
+
294
+ const issue = notesRaw.replace(/\*\*/g, "").replace(/`/g, "").trim();
295
+ const agent = normalizeAgent(verifiedByClean) || currentPhase.agent;
296
+
297
+ const item = {
298
+ criteria,
299
+ checked,
300
+ isGate,
301
+ issue,
302
+ agent,
303
+ phaseNum: currentPhase.phaseNum,
304
+ phaseName: currentPhase.phaseName,
305
+ };
306
+
307
+ if (isGate) {
308
+ currentPhase.gate = item;
309
+ } else {
310
+ currentPhase.criteria.push(item);
311
+ }
312
+ }
313
+ return phases;
314
+ }
315
+
316
+ // Fuzzy-match a phase name in a map to find the closest entry.
317
+ function findMatchingEntry(map, targetName) {
318
+ const tn = targetName.toLowerCase().replace(/^\d+[+]?\.\s*/, "").replace(/\s*checklist\s*$/i, "").trim();
319
+ for (const [key, val] of Object.entries(map)) {
320
+ const k = key.replace(/^\d+[+]?\.\s*/, "").trim();
321
+ const firstWord = tn.split(/\s+/)[0];
322
+ if (k.includes(tn) || tn.includes(k) || k.startsWith(firstWord)) {
323
+ return val;
324
+ }
325
+ }
326
+ return null;
327
+ }
328
+
329
+ // Main parser — pure, no I/O.
330
+ // Input: { planningText: string|null, qualityText: string|null, now: Date }
331
+ // Output: { meta, agents, pipeline: { cards }, quality: { cards } }
332
+ function parseKaban({ planningText, qualityText, now }) {
333
+ const nowDate = now instanceof Date ? now : new Date();
334
+ const errors = [];
335
+
336
+ const meta = {
337
+ featureKey: null,
338
+ featureName: null,
339
+ lastUpdated: null,
340
+ generatedAt: nowDate.toISOString(),
341
+ pipelinePresent: !!planningText,
342
+ qualityPresent: !!qualityText,
343
+ };
344
+
345
+ const pipeline = { cards: [] };
346
+ const quality = { cards: [] };
347
+
348
+ let phases = [];
349
+ let blockers = [];
350
+ let openQuestions = [];
351
+ let activityEntries = [];
352
+
353
+ // --- Parse SHARED_PLANNING.md ---
354
+ if (planningText) {
355
+ try {
356
+ const header = parsePlanningHeader(planningText);
357
+ meta.featureKey = header.featureKey;
358
+ meta.featureName = header.featureName;
359
+ meta.lastUpdated = header.lastUpdated;
360
+ phases = parsePipelinePhases(planningText);
361
+ blockers = parseBlockers(planningText);
362
+ openQuestions = parseOpenQuestions(planningText);
363
+ activityEntries = parseActivityEntries(planningText);
364
+ } catch (e) {
365
+ errors.push("planning parse error: " + e.message);
366
+ }
367
+ }
368
+
369
+ // --- Parse QUALITY_CHECKLIST.md ---
370
+ let qualityPhases = [];
371
+ const qualityGateByPhaseName = {};
372
+
373
+ if (qualityText) {
374
+ try {
375
+ const qHeader = parsePlanningHeader(qualityText);
376
+ if (!meta.featureKey) meta.featureKey = qHeader.featureKey;
377
+ if (!meta.lastUpdated) meta.lastUpdated = qHeader.lastUpdated;
378
+ qualityPhases = parseQualityCriteria(qualityText);
379
+ for (const qp of qualityPhases) {
380
+ qualityGateByPhaseName[qp.phaseName.toLowerCase()] = qp.gate;
381
+ }
382
+ } catch (e) {
383
+ errors.push("quality parse error: " + e.message);
384
+ }
385
+ }
386
+
387
+ // --- Enrich pipeline phases with OQ/blocker flags ---
388
+ const enrichedPhases = phases.map((phase) => {
389
+ const phaseKey = phase.phase.toLowerCase();
390
+ const hasOpenOQ = openQuestions.some((oq) => {
391
+ const oqAgent = normalizeAgent(oq.owner);
392
+ const firstWord = phaseKey.split(/[\s.]+/)[0];
393
+ return (
394
+ oqAgent === phase.agent ||
395
+ (oq.id || "").toLowerCase().includes(firstWord) ||
396
+ (oq.summary || "").toLowerCase().includes(firstWord)
397
+ );
398
+ });
399
+ const hasBlocker =
400
+ blockers.length > 0 &&
401
+ blockers.some((b) => {
402
+ const bl = b.toLowerCase();
403
+ const firstWord = phaseKey.split(/[\s.]+/)[0];
404
+ return (
405
+ bl.includes(firstWord) ||
406
+ (phase.agent && bl.includes(phase.agent.toLowerCase()))
407
+ );
408
+ });
409
+ return { ...phase, hasOpenOQ, hasBlocker };
410
+ });
411
+
412
+ // --- Build pipeline cards ---
413
+ for (const phase of enrichedPhases) {
414
+ const phaseKey = phase.phase.toLowerCase();
415
+
416
+ const matchedGate = findMatchingEntry(qualityGateByPhaseName, phaseKey);
417
+ const statusLower = phase.rawStatus.toLowerCase();
418
+ const isDone = DONE_STATUSES.has(statusLower);
419
+ const trustMismatch =
420
+ matchedGate !== null && isDone && matchedGate !== null && !matchedGate.checked;
421
+
422
+ const { column, attention } = derivePipelineColumn(
423
+ phase.rawStatus,
424
+ phase.hasOpenOQ,
425
+ phase.hasBlocker,
426
+ trustMismatch
427
+ );
428
+
429
+ const agentLower = (phase.agent || "").toLowerCase();
430
+ const relevantActivity = activityEntries.filter(
431
+ (e) => e.handoffNext && e.handoffNext.toLowerCase().includes(agentLower)
432
+ );
433
+ const lastActivity =
434
+ relevantActivity.length > 0
435
+ ? relevantActivity[relevantActivity.length - 1]
436
+ : activityEntries.length > 0
437
+ ? activityEntries[activityEntries.length - 1]
438
+ : null;
439
+
440
+ pipeline.cards.push({
441
+ id: "phase-" + phaseKey.replace(/[^a-z0-9]/g, "-"),
442
+ title: phase.phase
443
+ .replace(/^\*{0,2}\d+[+]?\.\s*\*{0,2}/, "")
444
+ .replace(/\*\*/g, "")
445
+ .trim(),
446
+ agent: phase.agent || "OTHER",
447
+ column,
448
+ rawStatus: phase.rawStatus,
449
+ owner: phase.owner,
450
+ artifact: phase.artifact,
451
+ notes: phase.notes,
452
+ attention,
453
+ lastUpdate: lastActivity ? lastActivity.ts : null,
454
+ handoffNext: lastActivity ? lastActivity.handoffNext : null,
455
+ });
456
+ }
457
+
458
+ // --- Build quality cards ---
459
+ const phaseInfoByName = {};
460
+ for (const p of enrichedPhases) {
461
+ phaseInfoByName[p.phase.toLowerCase()] = {
462
+ rawStatus: p.rawStatus,
463
+ hasBlocker: p.hasBlocker,
464
+ hasOpenOQ: p.hasOpenOQ,
465
+ };
466
+ }
467
+
468
+ for (const qPhase of qualityPhases) {
469
+ const phaseInfo = findMatchingEntry(phaseInfoByName, qPhase.phaseName);
470
+ const phaseStatus = phaseInfo ? phaseInfo.rawStatus : "";
471
+ const phaseHasBlocker = phaseInfo ? phaseInfo.hasBlocker : false;
472
+ const phaseHasOpenOQ = phaseInfo ? phaseInfo.hasOpenOQ : false;
473
+
474
+ const allItems = [
475
+ ...qPhase.criteria,
476
+ ...(qPhase.gate ? [qPhase.gate] : []),
477
+ ];
478
+
479
+ for (let i = 0; i < allItems.length; i++) {
480
+ const item = allItems[i];
481
+ // Issue is meaningful if it's not just an @-mention (those are handoff hints)
482
+ const hasIssue =
483
+ item.issue &&
484
+ item.issue.length > 0 &&
485
+ !item.issue.trim().startsWith("@");
486
+
487
+ const { column, attention } = deriveQualityColumn(
488
+ item.checked,
489
+ phaseHasBlocker,
490
+ phaseHasOpenOQ,
491
+ hasIssue,
492
+ phaseStatus
493
+ );
494
+
495
+ quality.cards.push({
496
+ id: "q-" + qPhase.phaseNum + "-" + i,
497
+ title: item.criteria,
498
+ agent: item.agent || qPhase.agent || "OTHER",
499
+ phase: qPhase.phaseName,
500
+ column,
501
+ checked: item.checked,
502
+ isGate: item.isGate,
503
+ issue: item.issue,
504
+ attention,
505
+ });
506
+ }
507
+ }
508
+
509
+ // --- Derive agents list (canonical order first, extras appended) ---
510
+ const seenAgents = new Set();
511
+ for (const c of [...pipeline.cards, ...quality.cards]) {
512
+ if (c.agent && c.agent !== "OTHER") seenAgents.add(c.agent);
513
+ }
514
+ const agents = AGENT_ORDER.filter((a) => seenAgents.has(a));
515
+ for (const a of seenAgents) {
516
+ if (!AGENT_ORDER.includes(a)) agents.push(a);
517
+ }
518
+ if (agents.length === 0) agents.push(...AGENT_ORDER);
519
+
520
+ meta.errors = errors;
521
+ return { meta, agents, pipeline, quality };
522
+ }
523
+
524
+ module.exports = {
525
+ parseKaban,
526
+ KABAN_LIVENESS_MIN,
527
+ COLUMNS,
528
+ AGENT_ORDER,
529
+ };
@@ -9,6 +9,7 @@ const { spawn, execFile } = require("child_process");
9
9
  const { CliError, DependencyError } = require("./errors");
10
10
  const { resolveBuilderPath } = require("./wiki-config");
11
11
  const { openBrowser } = require("./browser-open");
12
+ const { parseKaban } = require("./wiki-kaban-parse");
12
13
 
13
14
  const HEALTH_CHECK_RETRIES = 20;
14
15
  const HEALTH_CHECK_INTERVAL_MS = 300;
@@ -248,6 +249,51 @@ function startWikiServer(host, port, outputDir, projectPath) {
248
249
  return;
249
250
  }
250
251
 
252
+ if (url === "/api/kaban" || url === "/api/kaban/") {
253
+ const projectRoot = projectPath || outputDir;
254
+ let planningText = null;
255
+ let qualityText = null;
256
+
257
+ const planningFile = path.resolve(projectRoot, "SHARED_PLANNING.md");
258
+ const qualityFile = path.resolve(projectRoot, "QUALITY_CHECKLIST.md");
259
+ const inRoot = (f) =>
260
+ f.startsWith(projectRoot + path.sep) || f === projectRoot;
261
+
262
+ try {
263
+ if (inRoot(planningFile) && fs.existsSync(planningFile)) {
264
+ planningText = fs.readFileSync(planningFile, "utf-8");
265
+ }
266
+ } catch (_) {}
267
+ try {
268
+ if (inRoot(qualityFile) && fs.existsSync(qualityFile)) {
269
+ qualityText = fs.readFileSync(qualityFile, "utf-8");
270
+ }
271
+ } catch (_) {}
272
+
273
+ let viewModel;
274
+ try {
275
+ viewModel = parseKaban({ planningText, qualityText, now: new Date() });
276
+ } catch (e) {
277
+ viewModel = {
278
+ meta: {
279
+ errors: ["Server error: " + e.message],
280
+ pipelinePresent: false,
281
+ qualityPresent: false,
282
+ },
283
+ agents: [],
284
+ pipeline: { cards: [] },
285
+ quality: { cards: [] },
286
+ };
287
+ }
288
+
289
+ res.writeHead(200, {
290
+ "Content-Type": "application/json",
291
+ "Cache-Control": "no-cache",
292
+ });
293
+ res.end(JSON.stringify(viewModel));
294
+ return;
295
+ }
296
+
251
297
  if (url.startsWith("/api/note")) {
252
298
  const qs = new URL(url, `http://${host}:${port}`).searchParams;
253
299
  const notePath = qs.get("path");