karajan-code 3.0.0 → 3.2.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.
Files changed (104) hide show
  1. package/README.md +14 -6
  2. package/docs/README.es.md +1 -1
  3. package/package.json +8 -2
  4. package/packages/hu-board/package.json +3 -1
  5. package/packages/hu-board/public/app.js +81 -4025
  6. package/packages/hu-board/public/index.html +21 -0
  7. package/packages/hu-board/public/styles.css +32 -0
  8. package/packages/hu-board/public/utils/api.js +139 -0
  9. package/packages/hu-board/public/utils/board-view.js +462 -0
  10. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  11. package/packages/hu-board/public/utils/config-editor.js +181 -0
  12. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  13. package/packages/hu-board/public/utils/formatters.js +273 -0
  14. package/packages/hu-board/public/utils/graph-view.js +216 -0
  15. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  16. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  17. package/packages/hu-board/public/utils/log-panel.js +222 -0
  18. package/packages/hu-board/public/utils/modals.js +290 -0
  19. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  20. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  21. package/packages/hu-board/public/utils/project-actions.js +143 -0
  22. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  23. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  24. package/packages/hu-board/public/utils/server-push.js +252 -0
  25. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  26. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  27. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  28. package/packages/hu-board/public/wiki.html +32 -0
  29. package/packages/hu-board/public/wiki.js +53 -0
  30. package/packages/hu-board/src/config-yaml.js +14 -0
  31. package/packages/hu-board/src/db.js +93 -2
  32. package/packages/hu-board/src/format.js +79 -0
  33. package/packages/hu-board/src/plan-mutations.js +4 -4
  34. package/packages/hu-board/src/routes/api.js +43 -10
  35. package/packages/hu-board/src/routes/rag.js +1 -1
  36. package/packages/hu-board/src/routes/wiki.js +43 -0
  37. package/packages/hu-board/src/run-tracker.js +1 -1
  38. package/packages/hu-board/src/server.js +4 -2
  39. package/packages/hu-board/src/sync.js +1 -1
  40. package/scripts/audit-test-diet.mjs +151 -0
  41. package/scripts/git-hooks/post-merge +13 -2
  42. package/src/audit/ai-slop-findings.js +102 -0
  43. package/src/audit/deterministic-summary.js +18 -0
  44. package/src/audit/harness-scorecard.js +52 -3
  45. package/src/audit/harness-section.js +6 -2
  46. package/src/brain/standby-scheduler.js +10 -92
  47. package/src/brain/standby-store.js +12 -168
  48. package/src/checks/ai-trash.js +34 -0
  49. package/src/checks/qmd.js +41 -0
  50. package/src/checks/runner.js +10 -0
  51. package/src/checks/squeezr.js +36 -0
  52. package/src/cli/register-meta.js +36 -2
  53. package/src/cli/register-pipeline.js +3 -0
  54. package/src/cli/register-plan.js +10 -1
  55. package/src/commands/audit.js +6 -4
  56. package/src/commands/board.js +34 -0
  57. package/src/commands/clean.js +62 -0
  58. package/src/commands/doctor.js +6 -0
  59. package/src/commands/init.js +73 -8
  60. package/src/commands/plan/generate.js +9 -3
  61. package/src/commands/qmd.js +86 -0
  62. package/src/commands/resume.js +6 -45
  63. package/src/commands/run.js +6 -50
  64. package/src/commands/sync.js +30 -2
  65. package/src/config/defaults.js +5 -0
  66. package/src/config/schema.js +2 -0
  67. package/src/git/hu-snapshot.js +10 -105
  68. package/src/orchestrator/config-init.js +1 -0
  69. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  70. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
  71. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  72. package/src/orchestrator/hu-sub-pipeline.js +52 -8
  73. package/src/orchestrator/stages/coder-stage.js +8 -2
  74. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  75. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  76. package/src/plan/plan-hu-ops.js +16 -338
  77. package/src/plan/plan-id.js +4 -45
  78. package/src/plan/plan-validation.js +4 -82
  79. package/src/prompts/tool-correctness-judge.js +31 -0
  80. package/src/rag/vec-store.js +17 -253
  81. package/src/review/git-stash-helper.js +104 -0
  82. package/src/review/tdd-discipline.js +90 -0
  83. package/src/roles/audit-role.js +11 -1
  84. package/src/roles/tool-correctness-judge-role.js +78 -0
  85. package/src/session/mutators.js +4 -0
  86. package/src/sync/apply-canvas-patch.js +55 -0
  87. package/src/utils/ai-trash.js +48 -0
  88. package/src/utils/atomic-write.js +6 -43
  89. package/src/utils/cli-ask-question.js +76 -0
  90. package/src/utils/doc-links.js +2 -0
  91. package/src/utils/os-detect.js +10 -0
  92. package/src/utils/paths.js +14 -111
  93. package/src/utils/port-check.js +6 -45
  94. package/src/utils/process.js +4 -188
  95. package/src/utils/qmd-collection.js +72 -0
  96. package/src/utils/qmd-detect.js +18 -0
  97. package/src/utils/qmd-install.js +37 -0
  98. package/src/utils/repo-cleaner.js +79 -0
  99. package/src/utils/run-registry.js +9 -116
  100. package/src/utils/shared-paths.js +12 -41
  101. package/src/utils/squeezr-detect.js +18 -0
  102. package/src/utils/squeezr-install.js +37 -0
  103. package/src/utils/tool-call-extractor.js +56 -0
  104. package/src/utils/vector-store-orphans.js +89 -0
@@ -0,0 +1,273 @@
1
+ // KJC-TSK-0501 — Pure formatters extracted from app.js (step 1/8).
2
+ // Loaded as a classic script before app.js — all declarations hoist to
3
+ // global scope so app.js consumes them transparently. No ES module syntax
4
+ // here on purpose: app.js still has 16+ inline `onclick="fn(...)"`
5
+ // handlers that rely on global functions. Conversion to `type="module"`
6
+ // will land in a later PR of this split alongside event delegation.
7
+ //
8
+ // Functions here are side-effect-free (no DOM mutation beyond
9
+ // `document.createElement('span')` for HTML-escape, no fetch, no
10
+ // module-level mutable state). Unit-tested directly via
11
+ // packages/hu-board/tests/format.test.js for the three shared helpers.
12
+
13
+ function formatHHMM(iso) {
14
+ if (!iso) return "—";
15
+ const d = new Date(iso);
16
+ if (Number.isNaN(d.getTime())) return "—";
17
+ const hh = String(d.getHours()).padStart(2, "0");
18
+ const mm = String(d.getMinutes()).padStart(2, "0");
19
+ return `${hh}:${mm}`;
20
+ }
21
+
22
+ function shortTask(text, max = 60) {
23
+ if (!text) return "";
24
+ const collapsed = String(text).replace(/\s+/g, " ").trim();
25
+ if (collapsed.length <= max) return collapsed;
26
+ return collapsed.slice(0, max - 1).trimEnd() + "…";
27
+ }
28
+
29
+ function formatSessionLabel(session) {
30
+ if (!session || !session.id) {
31
+ return { title: "(no session)", subtitle: "", idChip: "" };
32
+ }
33
+ const projectName = (session.project_name || "").trim();
34
+ const time = formatHHMM(session.created_at);
35
+ const projectPart = projectName
36
+ ? projectName.length > 40
37
+ ? projectName.slice(0, 39) + "…"
38
+ : projectName
39
+ : session.id;
40
+ const title = time !== "—" ? `${projectPart} · ${time}` : projectPart;
41
+ return {
42
+ title,
43
+ subtitle: shortTask(session.task, 60),
44
+ idChip: session.id,
45
+ };
46
+ }
47
+
48
+ function formatCost(costUsd) {
49
+ if (costUsd === null || costUsd === undefined) return null;
50
+ const n = Number(costUsd);
51
+ if (!Number.isFinite(n)) return null;
52
+ return {
53
+ label: `$${n.toFixed(2)}`,
54
+ tooltip: `Estimated cost: $${n.toFixed(4)}`,
55
+ };
56
+ }
57
+
58
+ function formatProjectCostSummary(cost) {
59
+ if (!cost || typeof cost !== "object") return null;
60
+ const total = Number(cost.totalUsd);
61
+ if (!Number.isFinite(total)) return null;
62
+ const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
63
+ if (total === 0 && byPlan.length === 0) return null;
64
+
65
+ const lines = [`Total: $${total.toFixed(4)}`];
66
+ if (byPlan.length > 0) {
67
+ lines.push("By plan:");
68
+ for (const p of byPlan) {
69
+ const planTotal = Number(p?.totalUsd);
70
+ if (!Number.isFinite(planTotal)) continue;
71
+ const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
72
+ const planLabel = p?.planId || "unassigned";
73
+ lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})`);
74
+ }
75
+ }
76
+ const unk = cost.unknownModelTokens;
77
+ if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
78
+ const unkTotal = unk.tokensIn + unk.tokensOut;
79
+ if (unkTotal > 0) {
80
+ lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
81
+ }
82
+ }
83
+ return {
84
+ label: `Total: $${total.toFixed(2)}`,
85
+ tooltip: lines.join("\n"),
86
+ };
87
+ }
88
+
89
+ function humaniseProjectName(id) {
90
+ if (!id || typeof id !== 'string') return id || '';
91
+ const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
92
+ const words = tail
93
+ .split(/[\s\-]+/)
94
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
95
+ .filter((w) => w.length > 0);
96
+ if (words.length === 0) return id;
97
+ return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(' ');
98
+ }
99
+
100
+ function deriveInitialsFromName(name) {
101
+ const words = String(name || '')
102
+ .split(/[\s\-_/]+/)
103
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
104
+ .filter((w) => w.length > 1);
105
+ return words.map((w) => w[0].toLowerCase()).join('').slice(0, 5) || 'kj';
106
+ }
107
+
108
+ function shortStoryId(story, initials) {
109
+ const id = story?.id || '';
110
+ const m = /_(\d+)(?!.*\d)/.exec(id);
111
+ const num = m ? m[1] : '?';
112
+ return `${initials || 'kj'}-${num}`;
113
+ }
114
+
115
+ function timeAgo(dateStr) {
116
+ if (!dateStr) return '';
117
+ const diff = Date.now() - new Date(dateStr).getTime();
118
+ const seconds = Math.floor(diff / 1000);
119
+ if (seconds < 60) return `${seconds}s ago`;
120
+ const minutes = Math.floor(seconds / 60);
121
+ if (minutes < 60) return `${minutes} min ago`;
122
+ const hours = Math.floor(minutes / 60);
123
+ if (hours < 24) return `${hours}h ago`;
124
+ const days = Math.floor(hours / 24);
125
+ return `${days}d ago`;
126
+ }
127
+
128
+ function formatDuration(ms) {
129
+ if (!ms) return '--';
130
+ const totalSec = Math.floor(ms / 1000);
131
+ const min = Math.floor(totalSec / 60);
132
+ const sec = totalSec % 60;
133
+ if (min === 0) return `${sec}s`;
134
+ return `${min}m ${sec.toString().padStart(2, '0')}s`;
135
+ }
136
+
137
+ function scoreClass(score, max = 60) {
138
+ if (score === null || score === undefined) return '';
139
+ const pct = score / max;
140
+ if (pct >= 0.7) return 'story-card__score--good';
141
+ if (pct >= 0.4) return 'story-card__score--ok';
142
+ return 'story-card__score--bad';
143
+ }
144
+
145
+ function qualityBar(score, max = 60) {
146
+ if (score === null || score === undefined) return '';
147
+ const filled = Math.round((score / max) * 10);
148
+ let html = '<span class="quality-bar">';
149
+ for (let i = 0; i < 10; i++) {
150
+ html += `<span class="quality-bar__segment${i < filled ? ' quality-bar__segment--filled' : ''}"></span>`;
151
+ }
152
+ html += '</span>';
153
+ return html;
154
+ }
155
+
156
+ function esc(str) {
157
+ if (!str) return '';
158
+ const el = document.createElement('span');
159
+ el.textContent = str;
160
+ return el.innerHTML;
161
+ }
162
+
163
+ const EPHEMERAL_HEURISTIC_RE = /^(auto-)?(tmp_|test_|demo_|kj-test-)/i;
164
+
165
+ function isTestIcon(p) {
166
+ if (p.is_test === 1) return '🧪';
167
+ if (p.is_test === 0) return '📌';
168
+ return EPHEMERAL_HEURISTIC_RE.test(p.id || '') ? '🧪?' : '·';
169
+ }
170
+
171
+ function isTestTitle(p) {
172
+ if (p.is_test === 1) return 'Marked as test — auto-cleans 24h after last activity. Click to pin.';
173
+ if (p.is_test === 0) return 'Pinned — never auto-cleans. Click to clear.';
174
+ if (EPHEMERAL_HEURISTIC_RE.test(p.id || '')) {
175
+ return 'Looks ephemeral by id heuristic — will auto-clean after 24h. Click to pin.';
176
+ }
177
+ return 'Real project — never auto-cleans. Click to mark as test.';
178
+ }
179
+
180
+ function truncate(text, max = 100) {
181
+ if (!text) return '';
182
+ return text.length > max ? text.slice(0, max) + '...' : text;
183
+ }
184
+
185
+ function ansi256ToCss(n) {
186
+ if (n < 0 || n > 255) return 'inherit';
187
+ const std = [
188
+ '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
189
+ '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
190
+ ];
191
+ if (n < 16) return std[n];
192
+ if (n < 232) {
193
+ const idx = n - 16;
194
+ const r = Math.floor(idx / 36);
195
+ const g = Math.floor(idx / 6) % 6;
196
+ const b = idx % 6;
197
+ const v = (c) => (c === 0 ? 0 : 55 + c * 40);
198
+ return `rgb(${v(r)},${v(g)},${v(b)})`;
199
+ }
200
+ const v = 8 + (n - 232) * 10;
201
+ return `rgb(${v},${v},${v})`;
202
+ }
203
+
204
+ const ANSI_SGR = {
205
+ 1: 'font-weight:bold',
206
+ 2: 'opacity:0.65',
207
+ 3: 'font-style:italic',
208
+ 4: 'text-decoration:underline',
209
+ 30: 'color:#1f2937', 31: 'color:#f87171', 32: 'color:#4ade80',
210
+ 33: 'color:#fbbf24', 34: 'color:#60a5fa', 35: 'color:#c084fc',
211
+ 36: 'color:#22d3ee', 37: 'color:#e5e7eb',
212
+ 90: 'color:#9ca3af', 91: 'color:#fca5a5', 92: 'color:#86efac',
213
+ 93: 'color:#fde68a', 94: 'color:#93c5fd', 95: 'color:#d8b4fe',
214
+ 96: 'color:#67e8f9', 97: 'color:#f3f4f6',
215
+ };
216
+
217
+ const ESC_CHARS = new Set([0x1B, 0x241B]);
218
+ const HTML_ESC = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
219
+
220
+ function ansiToHtml(text) {
221
+ const src = String(text);
222
+ const N = src.length;
223
+ let out = '';
224
+ let openSpans = 0;
225
+ let i = 0;
226
+
227
+ while (i < N) {
228
+ const code = src.charCodeAt(i);
229
+ if (!ESC_CHARS.has(code)) {
230
+ let j = i + 1;
231
+ while (j < N && !ESC_CHARS.has(src.charCodeAt(j))) j += 1;
232
+ out += HTML_ESC(src.slice(i, j));
233
+ i = j;
234
+ continue;
235
+ }
236
+ if (src[i + 1] !== '[') { i += 1; continue; }
237
+ const endM = src.indexOf('m', i + 2);
238
+ if (endM === -1) { i += 1; continue; }
239
+ const params = src.slice(i + 2, endM).split(';').map((n) => Number(n) || 0);
240
+ let k = 0;
241
+ while (k < params.length) {
242
+ const c = params[k];
243
+ if (c === 0) {
244
+ while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
245
+ k += 1;
246
+ } else if ((c === 38 || c === 48) && params[k + 1] === 2 && params.length >= k + 5) {
247
+ const r = params[k + 2], g = params[k + 3], b = params[k + 4];
248
+ const prop = c === 38 ? 'color' : 'background-color';
249
+ out += `<span style="${prop}:rgb(${r},${g},${b})">`;
250
+ openSpans += 1;
251
+ k += 5;
252
+ } else if ((c === 38 || c === 48) && params[k + 1] === 5 && params.length >= k + 3) {
253
+ const prop = c === 38 ? 'color' : 'background-color';
254
+ out += `<span style="${prop}:${ansi256ToCss(params[k + 2])}">`;
255
+ openSpans += 1;
256
+ k += 3;
257
+ } else if (ANSI_SGR[c]) {
258
+ out += `<span style="${ANSI_SGR[c]}">`;
259
+ openSpans += 1;
260
+ k += 1;
261
+ } else {
262
+ k += 1;
263
+ }
264
+ }
265
+ i = endM + 1;
266
+ }
267
+ while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
268
+ return out;
269
+ }
270
+
271
+ function cssEscape(str) {
272
+ return (window.CSS && CSS.escape) ? CSS.escape(str) : String(str).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
273
+ }
@@ -0,0 +1,216 @@
1
+ // KJC-TSK-0501 step 6/8 — Graph (DAG) view: layout helpers + renderer.
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so
4
+ // function declarations hoist to the script-level lexical environment
5
+ // and app.js can call renderGraph() from the view router.
6
+ //
7
+ // The kanban answers "what's the status of each HU"; the graph answers
8
+ // "what depends on what, and what could run in parallel". It's a
9
+ // leveled topological layout drawn as vanilla SVG — no Cytoscape /
10
+ // D3 / Mermaid dependency because the board ships as a local
11
+ // dashboard and every KB of JS saved is one less thing that can
12
+ // mis-fetch behind a corporate proxy. The layout is basic but honest:
13
+ // roots at the top, children below, siblings side-by-side, curved
14
+ // bezier arrows. Good enough for plans up to ~30 HUs — beyond that
15
+ // we'd want a real DAG library, but that's a problem for another day.
16
+ //
17
+ // Globals consumed from earlier scripts / app.js:
18
+ // - api() (utils/api.js)
19
+ // - esc() (app.js)
20
+ // - renderEmptyState() (app.js)
21
+ // - resolveProjectMeta() (app.js)
22
+ // - projectNameCache, projectInitialsCache (mutable, app.js)
23
+ // - humaniseProjectName(), shortStoryId(), truncate() (app.js)
24
+ // - showStoryDetail() (app.js)
25
+ // - selectedProject (mutable global, app.js)
26
+
27
+ const DAG_NODE_W = 170;
28
+ const DAG_NODE_H = 62;
29
+ const DAG_H_GAP = 28;
30
+ const DAG_V_GAP = 72;
31
+ const DAG_PAD = 32;
32
+
33
+ /**
34
+ * Resolve `hu.blocked_by` HU-ids (e.g. "plan-xxx_001") to full story
35
+ * ids ("<project>::plan-xxx_001") so we can join by the board's row id.
36
+ * Ids that don't resolve are dropped silently — they usually signal a
37
+ * cross-plan dep which we don't render.
38
+ */
39
+ function resolveBlockedBy(rawIds, storyIds) {
40
+ if (!Array.isArray(rawIds) || rawIds.length === 0) return [];
41
+ const resolved = [];
42
+ for (const raw of rawIds) {
43
+ const match = storyIds.find((id) => id.endsWith(`::${raw}`)) || (storyIds.includes(raw) ? raw : null);
44
+ if (match) resolved.push(match);
45
+ }
46
+ return resolved;
47
+ }
48
+
49
+ /**
50
+ * Assign each story a level = 1 + max(level of its deps). Roots get 0.
51
+ * Cycle-guarded with a visited set — KJ's planner shouldn't emit one
52
+ * but a hand-edited plan JSON might.
53
+ */
54
+ function computeDagLevels(stories) {
55
+ const storyIds = stories.map((s) => s.id);
56
+ const byId = new Map();
57
+ for (const s of stories) {
58
+ const rawBlockedBy = s.blocked_by ? JSON.parse(s.blocked_by) : [];
59
+ byId.set(s.id, {
60
+ ...s,
61
+ resolvedBlockedBy: resolveBlockedBy(rawBlockedBy, storyIds),
62
+ });
63
+ }
64
+
65
+ const levels = new Map();
66
+ const visiting = new Set();
67
+ function levelOf(id) {
68
+ if (levels.has(id)) return levels.get(id);
69
+ if (visiting.has(id)) return 0; // cycle → break
70
+ const s = byId.get(id);
71
+ if (!s || s.resolvedBlockedBy.length === 0) { levels.set(id, 0); return 0; }
72
+ visiting.add(id);
73
+ const l = 1 + Math.max(...s.resolvedBlockedBy.map(levelOf));
74
+ visiting.delete(id);
75
+ levels.set(id, l);
76
+ return l;
77
+ }
78
+ for (const id of byId.keys()) levelOf(id);
79
+
80
+ const byLevel = new Map();
81
+ for (const [id, lvl] of levels) {
82
+ if (!byLevel.has(lvl)) byLevel.set(lvl, []);
83
+ byLevel.get(lvl).push(id);
84
+ }
85
+
86
+ return { byId, levels, byLevel };
87
+ }
88
+
89
+ /**
90
+ * Render the graph view. If no project is selected we show a prompt
91
+ * — drawing every project's HUs in one DAG is visual garbage. If the
92
+ * current project has no stories or no blocked_by edges, we show a
93
+ * friendly empty state explaining what the view is for.
94
+ */
95
+ async function renderGraph() {
96
+ const app = document.getElementById('app');
97
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading graph…</p></div>';
98
+
99
+ if (!selectedProject) {
100
+ app.innerHTML = renderEmptyState(
101
+ 'Pick a project first',
102
+ 'The dependency graph is per-project. Choose one in the dropdown above and I will draw its HU DAG.'
103
+ );
104
+ return;
105
+ }
106
+
107
+ try {
108
+ const stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
109
+ if (stories.length === 0) {
110
+ app.innerHTML = renderEmptyState('No HUs in this project', 'Run kj plan to generate some.');
111
+ return;
112
+ }
113
+ // Preload initials so node labels can use short ids synchronously.
114
+ await resolveProjectMeta(selectedProject);
115
+ const projectDisplayName = projectNameCache[selectedProject] || humaniseProjectName(selectedProject);
116
+ const initials = projectInitialsCache[selectedProject] || 'kj';
117
+
118
+ const { byId, byLevel } = computeDagLevels(stories);
119
+ const maxLevel = Math.max(0, ...Array.from(byLevel.keys()));
120
+ const widestLevel = Math.max(0, ...Array.from(byLevel.values()).map((ids) => ids.length));
121
+ const width = widestLevel * (DAG_NODE_W + DAG_H_GAP) + DAG_PAD * 2;
122
+ const height = (maxLevel + 1) * (DAG_NODE_H + DAG_V_GAP) + DAG_PAD * 2;
123
+
124
+ // Assign x,y to each node. Within a level we distribute horizontally
125
+ // evenly; we make no effort to minimise edge crossings (the naive
126
+ // layout is fine for ~20 HUs, which is the realistic ceiling for a
127
+ // single plan).
128
+ const positions = new Map();
129
+ for (const [lvl, ids] of byLevel) {
130
+ const y = DAG_PAD + lvl * (DAG_NODE_H + DAG_V_GAP);
131
+ const rowW = ids.length * DAG_NODE_W + (ids.length - 1) * DAG_H_GAP;
132
+ const xStart = (width - rowW) / 2;
133
+ ids.forEach((id, i) => {
134
+ positions.set(id, { x: xStart + i * (DAG_NODE_W + DAG_H_GAP), y });
135
+ });
136
+ }
137
+
138
+ const edges = [];
139
+ for (const s of byId.values()) {
140
+ for (const parentId of s.resolvedBlockedBy) {
141
+ if (positions.has(parentId) && positions.has(s.id)) {
142
+ edges.push({ from: parentId, to: s.id });
143
+ }
144
+ }
145
+ }
146
+
147
+ const anyEdges = edges.length > 0;
148
+
149
+ const svg = `
150
+ <svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"
151
+ xmlns="http://www.w3.org/2000/svg"
152
+ style="display:block;margin:0 auto;max-width:100%;overflow:visible">
153
+ <defs>
154
+ <marker id="dag-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
155
+ <path d="M0,0 L7,4 L0,8 Z" fill="var(--text-muted)"/>
156
+ </marker>
157
+ </defs>
158
+ ${edges.map((e) => {
159
+ const p1 = positions.get(e.from);
160
+ const p2 = positions.get(e.to);
161
+ const x1 = p1.x + DAG_NODE_W / 2;
162
+ const y1 = p1.y + DAG_NODE_H;
163
+ const x2 = p2.x + DAG_NODE_W / 2;
164
+ const y2 = p2.y;
165
+ const cy = (y1 + y2) / 2;
166
+ return `<path d="M${x1},${y1} C${x1},${cy} ${x2},${cy} ${x2},${y2}"
167
+ stroke="var(--text-muted)" stroke-width="1.5" fill="none"
168
+ marker-end="url(#dag-arrow)" opacity="0.7"/>`;
169
+ }).join('')}
170
+ ${Array.from(byId.values()).map((s) => {
171
+ const p = positions.get(s.id);
172
+ const shortId = shortStoryId(s, initials);
173
+ const title = truncate(s.title || s.original_text || '', 26);
174
+ return `
175
+ <g transform="translate(${p.x},${p.y})"
176
+ class="dag-node status--${s.status}"
177
+ style="cursor:pointer"
178
+ onclick="showStoryDetail('${esc(s.id)}')">
179
+ <title>${esc(s.id)}</title>
180
+ <rect width="${DAG_NODE_W}" height="${DAG_NODE_H}" rx="6"
181
+ fill="var(--bg-secondary)" stroke="var(--border)" stroke-width="1.2"/>
182
+ <rect width="4" height="${DAG_NODE_H}" rx="2" fill="currentColor"/>
183
+ <text x="12" y="20" font-size="11" font-weight="600" fill="var(--text)">
184
+ ${esc(shortId)}
185
+ </text>
186
+ <text x="${DAG_NODE_W - 10}" y="20" text-anchor="end" font-size="10"
187
+ fill="var(--text-muted)">${esc(s.status)}</text>
188
+ <text x="12" y="40" font-size="10.5" fill="var(--text-muted)">
189
+ ${esc(title)}
190
+ </text>
191
+ </g>
192
+ `;
193
+ }).join('')}
194
+ </svg>
195
+ `;
196
+
197
+ app.innerHTML = `
198
+ <div class="section-header">
199
+ <span class="section-header__title">Dependency graph — ${esc(projectDisplayName)}</span>
200
+ <span class="section-header__count">${stories.length} HUs${anyEdges ? ` · ${edges.length} dep${edges.length === 1 ? '' : 's'}` : ' · no dependencies declared'}</span>
201
+ </div>
202
+ <div style="padding:8px;overflow:auto;max-height:calc(100vh - 140px)">
203
+ ${svg}
204
+ </div>
205
+ ${!anyEdges ? `
206
+ <div style="padding:14px 20px;color:var(--text-muted);font-size:0.85rem;max-width:720px;margin:12px auto">
207
+ No HU in this plan declares <code>blocked_by</code>, so the DAG degenerates to a row of isolated nodes.
208
+ Add dependencies via <code>kj plan</code> on generation or the modal <em>Edit</em> form
209
+ (blocked_by editing is on the roadmap).
210
+ </div>
211
+ ` : ''}
212
+ `;
213
+ } catch (err) {
214
+ app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading graph</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
215
+ }
216
+ }