karajan-code 3.0.0 → 3.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.
Files changed (54) hide show
  1. package/README.md +3 -3
  2. package/docs/README.es.md +1 -1
  3. package/package.json +3 -2
  4. package/packages/hu-board/public/app.js +81 -4025
  5. package/packages/hu-board/public/index.html +20 -0
  6. package/packages/hu-board/public/utils/api.js +139 -0
  7. package/packages/hu-board/public/utils/board-view.js +453 -0
  8. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  9. package/packages/hu-board/public/utils/config-editor.js +181 -0
  10. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  11. package/packages/hu-board/public/utils/formatters.js +232 -0
  12. package/packages/hu-board/public/utils/graph-view.js +216 -0
  13. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  14. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  15. package/packages/hu-board/public/utils/log-panel.js +222 -0
  16. package/packages/hu-board/public/utils/modals.js +290 -0
  17. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  18. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  19. package/packages/hu-board/public/utils/project-actions.js +143 -0
  20. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  21. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  22. package/packages/hu-board/public/utils/server-push.js +252 -0
  23. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  24. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  25. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  26. package/packages/hu-board/src/config-yaml.js +14 -0
  27. package/packages/hu-board/src/db.js +15 -2
  28. package/packages/hu-board/src/routes/api.js +27 -9
  29. package/scripts/audit-test-diet.mjs +151 -0
  30. package/src/audit/harness-scorecard.js +52 -3
  31. package/src/audit/harness-section.js +6 -2
  32. package/src/checks/runner.js +10 -0
  33. package/src/cli/register-meta.js +15 -2
  34. package/src/cli/register-plan.js +10 -1
  35. package/src/commands/clean.js +62 -0
  36. package/src/commands/plan/generate.js +9 -3
  37. package/src/commands/sync.js +30 -2
  38. package/src/config/defaults.js +5 -0
  39. package/src/config/schema.js +2 -0
  40. package/src/orchestrator/config-init.js +1 -0
  41. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  42. package/src/orchestrator/stages/coder-stage.js +8 -2
  43. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  44. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  45. package/src/prompts/tool-correctness-judge.js +31 -0
  46. package/src/review/git-stash-helper.js +104 -0
  47. package/src/review/tdd-discipline.js +90 -0
  48. package/src/roles/tool-correctness-judge-role.js +78 -0
  49. package/src/session/mutators.js +4 -0
  50. package/src/sync/apply-canvas-patch.js +55 -0
  51. package/src/utils/paths.js +12 -0
  52. package/src/utils/repo-cleaner.js +79 -0
  53. package/src/utils/tool-call-extractor.js +56 -0
  54. package/src/utils/vector-store-orphans.js +89 -0
@@ -0,0 +1,232 @@
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 humaniseProjectName(id) {
49
+ if (!id || typeof id !== 'string') return id || '';
50
+ const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
51
+ const words = tail
52
+ .split(/[\s\-]+/)
53
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
54
+ .filter((w) => w.length > 0);
55
+ if (words.length === 0) return id;
56
+ return words.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join(' ');
57
+ }
58
+
59
+ function deriveInitialsFromName(name) {
60
+ const words = String(name || '')
61
+ .split(/[\s\-_/]+/)
62
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
63
+ .filter((w) => w.length > 1);
64
+ return words.map((w) => w[0].toLowerCase()).join('').slice(0, 5) || 'kj';
65
+ }
66
+
67
+ function shortStoryId(story, initials) {
68
+ const id = story?.id || '';
69
+ const m = /_(\d+)(?!.*\d)/.exec(id);
70
+ const num = m ? m[1] : '?';
71
+ return `${initials || 'kj'}-${num}`;
72
+ }
73
+
74
+ function timeAgo(dateStr) {
75
+ if (!dateStr) return '';
76
+ const diff = Date.now() - new Date(dateStr).getTime();
77
+ const seconds = Math.floor(diff / 1000);
78
+ if (seconds < 60) return `${seconds}s ago`;
79
+ const minutes = Math.floor(seconds / 60);
80
+ if (minutes < 60) return `${minutes} min ago`;
81
+ const hours = Math.floor(minutes / 60);
82
+ if (hours < 24) return `${hours}h ago`;
83
+ const days = Math.floor(hours / 24);
84
+ return `${days}d ago`;
85
+ }
86
+
87
+ function formatDuration(ms) {
88
+ if (!ms) return '--';
89
+ const totalSec = Math.floor(ms / 1000);
90
+ const min = Math.floor(totalSec / 60);
91
+ const sec = totalSec % 60;
92
+ if (min === 0) return `${sec}s`;
93
+ return `${min}m ${sec.toString().padStart(2, '0')}s`;
94
+ }
95
+
96
+ function scoreClass(score, max = 60) {
97
+ if (score === null || score === undefined) return '';
98
+ const pct = score / max;
99
+ if (pct >= 0.7) return 'story-card__score--good';
100
+ if (pct >= 0.4) return 'story-card__score--ok';
101
+ return 'story-card__score--bad';
102
+ }
103
+
104
+ function qualityBar(score, max = 60) {
105
+ if (score === null || score === undefined) return '';
106
+ const filled = Math.round((score / max) * 10);
107
+ let html = '<span class="quality-bar">';
108
+ for (let i = 0; i < 10; i++) {
109
+ html += `<span class="quality-bar__segment${i < filled ? ' quality-bar__segment--filled' : ''}"></span>`;
110
+ }
111
+ html += '</span>';
112
+ return html;
113
+ }
114
+
115
+ function esc(str) {
116
+ if (!str) return '';
117
+ const el = document.createElement('span');
118
+ el.textContent = str;
119
+ return el.innerHTML;
120
+ }
121
+
122
+ const EPHEMERAL_HEURISTIC_RE = /^(auto-)?(tmp_|test_|demo_|kj-test-)/i;
123
+
124
+ function isTestIcon(p) {
125
+ if (p.is_test === 1) return '🧪';
126
+ if (p.is_test === 0) return '📌';
127
+ return EPHEMERAL_HEURISTIC_RE.test(p.id || '') ? '🧪?' : '·';
128
+ }
129
+
130
+ function isTestTitle(p) {
131
+ if (p.is_test === 1) return 'Marked as test — auto-cleans 24h after last activity. Click to pin.';
132
+ if (p.is_test === 0) return 'Pinned — never auto-cleans. Click to clear.';
133
+ if (EPHEMERAL_HEURISTIC_RE.test(p.id || '')) {
134
+ return 'Looks ephemeral by id heuristic — will auto-clean after 24h. Click to pin.';
135
+ }
136
+ return 'Real project — never auto-cleans. Click to mark as test.';
137
+ }
138
+
139
+ function truncate(text, max = 100) {
140
+ if (!text) return '';
141
+ return text.length > max ? text.slice(0, max) + '...' : text;
142
+ }
143
+
144
+ function ansi256ToCss(n) {
145
+ if (n < 0 || n > 255) return 'inherit';
146
+ const std = [
147
+ '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0',
148
+ '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
149
+ ];
150
+ if (n < 16) return std[n];
151
+ if (n < 232) {
152
+ const idx = n - 16;
153
+ const r = Math.floor(idx / 36);
154
+ const g = Math.floor(idx / 6) % 6;
155
+ const b = idx % 6;
156
+ const v = (c) => (c === 0 ? 0 : 55 + c * 40);
157
+ return `rgb(${v(r)},${v(g)},${v(b)})`;
158
+ }
159
+ const v = 8 + (n - 232) * 10;
160
+ return `rgb(${v},${v},${v})`;
161
+ }
162
+
163
+ const ANSI_SGR = {
164
+ 1: 'font-weight:bold',
165
+ 2: 'opacity:0.65',
166
+ 3: 'font-style:italic',
167
+ 4: 'text-decoration:underline',
168
+ 30: 'color:#1f2937', 31: 'color:#f87171', 32: 'color:#4ade80',
169
+ 33: 'color:#fbbf24', 34: 'color:#60a5fa', 35: 'color:#c084fc',
170
+ 36: 'color:#22d3ee', 37: 'color:#e5e7eb',
171
+ 90: 'color:#9ca3af', 91: 'color:#fca5a5', 92: 'color:#86efac',
172
+ 93: 'color:#fde68a', 94: 'color:#93c5fd', 95: 'color:#d8b4fe',
173
+ 96: 'color:#67e8f9', 97: 'color:#f3f4f6',
174
+ };
175
+
176
+ const ESC_CHARS = new Set([0x1B, 0x241B]);
177
+ const HTML_ESC = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
178
+
179
+ function ansiToHtml(text) {
180
+ const src = String(text);
181
+ const N = src.length;
182
+ let out = '';
183
+ let openSpans = 0;
184
+ let i = 0;
185
+
186
+ while (i < N) {
187
+ const code = src.charCodeAt(i);
188
+ if (!ESC_CHARS.has(code)) {
189
+ let j = i + 1;
190
+ while (j < N && !ESC_CHARS.has(src.charCodeAt(j))) j += 1;
191
+ out += HTML_ESC(src.slice(i, j));
192
+ i = j;
193
+ continue;
194
+ }
195
+ if (src[i + 1] !== '[') { i += 1; continue; }
196
+ const endM = src.indexOf('m', i + 2);
197
+ if (endM === -1) { i += 1; continue; }
198
+ const params = src.slice(i + 2, endM).split(';').map((n) => Number(n) || 0);
199
+ let k = 0;
200
+ while (k < params.length) {
201
+ const c = params[k];
202
+ if (c === 0) {
203
+ while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
204
+ k += 1;
205
+ } else if ((c === 38 || c === 48) && params[k + 1] === 2 && params.length >= k + 5) {
206
+ const r = params[k + 2], g = params[k + 3], b = params[k + 4];
207
+ const prop = c === 38 ? 'color' : 'background-color';
208
+ out += `<span style="${prop}:rgb(${r},${g},${b})">`;
209
+ openSpans += 1;
210
+ k += 5;
211
+ } else if ((c === 38 || c === 48) && params[k + 1] === 5 && params.length >= k + 3) {
212
+ const prop = c === 38 ? 'color' : 'background-color';
213
+ out += `<span style="${prop}:${ansi256ToCss(params[k + 2])}">`;
214
+ openSpans += 1;
215
+ k += 3;
216
+ } else if (ANSI_SGR[c]) {
217
+ out += `<span style="${ANSI_SGR[c]}">`;
218
+ openSpans += 1;
219
+ k += 1;
220
+ } else {
221
+ k += 1;
222
+ }
223
+ }
224
+ i = endM + 1;
225
+ }
226
+ while (openSpans > 0) { out += '</span>'; openSpans -= 1; }
227
+ return out;
228
+ }
229
+
230
+ function cssEscape(str) {
231
+ return (window.CSS && CSS.escape) ? CSS.escape(str) : String(str).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
232
+ }
@@ -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
+ }
@@ -0,0 +1,224 @@
1
+ // KJC-TSK-0501 step 8a/8 — HU action handlers (per-card and per-modal actions).
2
+ //
3
+ // Classic script (no exports). Loaded by index.html before app.js so the
4
+ // `window.*` assignments are in place by the time the kanban mounts.
5
+ // Inline `onclick="..."` attributes emitted by board-view.js / dashboard-
6
+ // view.js / story-detail-view.js resolve these handlers via `window`.
7
+ //
8
+ // Globals consumed from earlier scripts / app.js:
9
+ // - showConfirm() (utils/modals.js)
10
+ // - showError() (utils/modals.js)
11
+ // - closeModal() (app.js → window.closeModal)
12
+ // - renderBoard() (app.js)
13
+ // - openGenericLogPanel() (utils/log-panel.js)
14
+ // - lastOpenedLog (mutable in app.js — assignment crosses scripts
15
+ // transparently because both share the same realm)
16
+
17
+ /**
18
+ * PR4: launch a single HU from the per-card ▶ button. Confirms with
19
+ * the user (so an accidental click doesn't kick a run) and POSTs to
20
+ * the new /api/hus/:huId/run endpoint, which resolves the planId
21
+ * server-side from the SQLite row.
22
+ *
23
+ * Exposed on `window` because the inline onclick on the card calls it.
24
+ */
25
+ window.runSingleHuFromCard = async function runSingleHuFromCard(huId, title) {
26
+ const friendlyTitle = (title || huId).replace(/&#39;/g, "'");
27
+ const ok = await showConfirm(
28
+ `¿Lanzar solo esta HU?\n\n${friendlyTitle}\n\nKarajan ejecutará únicamente esta HU; las demás del plan no se tocarán.`,
29
+ { title: 'Lanzar HU individual', okLabel: 'Lanzar', cancelLabel: 'Cancelar' }
30
+ );
31
+ if (!ok) return;
32
+ try {
33
+ const res = await fetch(`/api/hus/${encodeURIComponent(huId)}/run`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({}),
37
+ });
38
+ if (!res.ok) {
39
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
40
+ await showError(msg, { title: 'No se pudo lanzar la HU' });
41
+ return;
42
+ }
43
+ const body = await res.json();
44
+ // Surface the log path so the user can tail it from the View log button
45
+ // AND auto-open the viewer so the click produces visible feedback. Without
46
+ // the open, the user clicks Lanzar, sees nothing change, and concludes the
47
+ // run never started — the run IS started, the log IS being written, the
48
+ // UI just wasn't showing it.
49
+ //
50
+ // The tail URL must be derived from body.logPath, not constructed from
51
+ // huId. When `kj run --plan ... --hu <id>` is launched, runPlan writes to
52
+ // <runsDir>/<planId>--hu-<localHuId>.log
53
+ // — not <runsDir>/hu-<huId>.log. The previous code guessed the wrong
54
+ // filename and the viewer would have hit a non-existent file even if
55
+ // it had been opened.
56
+ if (body.logPath) {
57
+ const logBasename = body.logPath.split('/').pop().replace(/\.log$/, '');
58
+ const tailUrl = (offset) => `/api/runs/${encodeURIComponent(logBasename)}/log?offset=${offset || 0}`;
59
+ lastOpenedLog = { id: `hu-${huId}`, label: `HU ${huId}`, tailUrl };
60
+ openGenericLogPanel({ id: logBasename, label: `HU ${huId}`, tailUrl });
61
+ }
62
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
63
+ await renderBoard();
64
+ } catch (err) {
65
+ await showError(err.message || String(err), { title: 'Fallo al lanzar la HU' });
66
+ }
67
+ };
68
+
69
+ /**
70
+ * KJC-TSK-0394 step 2: devolver una HU al estado `pending` desde
71
+ * cualquier estado no-terminal (coding/reviewing/blocked zombi) o
72
+ * terminal (done/failed) que el usuario quiera relanzar limpio.
73
+ *
74
+ * No tocamos el campo `result`: si una HU acaba en done+fail y el
75
+ * usuario hace Reset → status: pending, result: fail (badge ✗ persiste
76
+ * como historial). El siguiente run sobreescribirá result cuando
77
+ * termine.
78
+ *
79
+ * El backend ya acepta {status: 'pending'} en ALLOWED_STORY_STATUSES
80
+ * (ver routes/api.js); plan-mutations::setHuStatus refleja el cambio
81
+ * al fichero del plan y resincroniza la BBDD.
82
+ */
83
+ /**
84
+ * Cambio libre de status desde el dropdown del modal. PATCH al backend
85
+ * con el status nuevo + confirmación (algunos targets como `done` /
86
+ * `failed` son blast-radius alto: no se puede deshacer sin Reset).
87
+ *
88
+ * El propio backend valida que `newStatus` esté en ALLOWED_STORY_
89
+ * STATUSES, así que si el dropdown trae basura el servidor rechaza con
90
+ * 400 — no necesitamos re-validar aquí.
91
+ */
92
+ window.changeHuStatusFromModal = async function changeHuStatusFromModal(storyId, newStatus, oldStatus) {
93
+ // Opción "Cambiar a…" placeholder: el usuario abrió el dropdown sin
94
+ // elegir nada. Selecciona el placeholder otra vez para no quedar en
95
+ // un estado ambiguo.
96
+ if (!newStatus) return;
97
+ const select = document.getElementById('hu-status-select');
98
+ const ok = await showConfirm(
99
+ `Cambiar el status de esta HU de "${oldStatus}" a "${newStatus}"?\n\nEl plan file y el board se actualizan al instante. El campo result (badge ✓/✗) se conserva como historial.`,
100
+ { title: 'Cambiar status', okLabel: 'Cambiar', cancelLabel: 'Cancelar' }
101
+ );
102
+ if (!ok) { if (select) select.value = ''; return; }
103
+ try {
104
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
105
+ method: 'PATCH',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ status: newStatus }),
108
+ });
109
+ if (!res.ok) {
110
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
111
+ await showError(msg, { title: 'No se pudo cambiar el status' });
112
+ if (select) select.value = '';
113
+ return;
114
+ }
115
+ closeModal();
116
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
117
+ await renderBoard();
118
+ } catch (err) {
119
+ await showError(err.message || String(err), { title: 'Fallo al cambiar el status' });
120
+ if (select) select.value = '';
121
+ }
122
+ };
123
+
124
+ // KJC-TSK-0406: persist coder_model + reviewer_model overrides from the
125
+ // modal. Vacío → null (re-asignación automática por triage en siguiente
126
+ // run). Cada modelo es independiente.
127
+ window.saveHuModels = async function saveHuModels(storyId) {
128
+ const coderInput = document.getElementById('hu-coder-model');
129
+ const reviewerInput = document.getElementById('hu-reviewer-model');
130
+ const patch = {
131
+ coder_model: coderInput?.value?.trim() || null,
132
+ reviewer_model: reviewerInput?.value?.trim() || null,
133
+ };
134
+ try {
135
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
136
+ method: 'PATCH',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify(patch),
139
+ });
140
+ if (!res.ok) {
141
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
142
+ await showError(msg, { title: 'No se pudieron guardar los modelos' });
143
+ return;
144
+ }
145
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
146
+ await renderBoard();
147
+ } catch (err) {
148
+ await showError(err.message || String(err), { title: 'Fallo guardando modelos' });
149
+ }
150
+ };
151
+
152
+ // KJC-PRP-0002 PR6: persist `hu.assignee` (free-form handle of whoever owns
153
+ // this HU on a team-shared board). Empty string → null so the API knows to
154
+ // clear, not store "". The modal only renders the input when the project is
155
+ // `is_shared = 1`, so non-team boards never trigger this path.
156
+ window.saveHuAssignee = async function saveHuAssignee(storyId) {
157
+ const input = document.getElementById('hu-assignee');
158
+ const value = input?.value?.trim() || null;
159
+ try {
160
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
161
+ method: 'PATCH',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ assignee: value }),
164
+ });
165
+ if (!res.ok) {
166
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
167
+ await showError(msg, { title: 'No se pudo guardar el asignado' });
168
+ return;
169
+ }
170
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
171
+ await renderBoard();
172
+ } catch (err) {
173
+ await showError(err.message || String(err), { title: 'Fallo guardando asignado' });
174
+ }
175
+ };
176
+
177
+ // KJC-TSK-0408: deshace los cambios de una HU restaurando el snapshot
178
+ // git tomado pre-run. ATENCIÓN: es destructivo (git reset --hard) —
179
+ // confirmamos siempre antes de invocar.
180
+ window.undoHuChanges = async function undoHuChanges(storyId) {
181
+ const ok = await showConfirm(
182
+ '⚠️ Esta acción es destructiva.\n\nSe descartan los cambios de ficheros que esta HU hizo durante su última ejecución y se restaura el estado pre-run. La HU vuelve a pending y podrás relanzarla con otros modelos.\n\n¿Continuar?',
183
+ { title: 'Deshacer cambios de la HU', okLabel: 'Deshacer', cancelLabel: 'Cancelar' }
184
+ );
185
+ if (!ok) return;
186
+ try {
187
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}/undo`, { method: 'POST' });
188
+ if (!res.ok) {
189
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
190
+ await showError(msg, { title: 'No se pudo deshacer la HU' });
191
+ return;
192
+ }
193
+ closeModal();
194
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
195
+ await renderBoard();
196
+ } catch (err) {
197
+ await showError(err.message || String(err), { title: 'Fallo al deshacer la HU' });
198
+ }
199
+ };
200
+
201
+ window.resetHuToPending = async function resetHuToPending(storyId) {
202
+ const ok = await showConfirm(
203
+ '¿Devolver esta HU a pending?\n\nEl estado se cambia a pending y se podrá relanzar. El resultado de la última ejecución (✓/✗/~) se conserva como historial hasta que vuelvas a ejecutarla.',
204
+ { title: 'Reset HU', okLabel: 'Reset', cancelLabel: 'Cancelar' }
205
+ );
206
+ if (!ok) return;
207
+ try {
208
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
209
+ method: 'PATCH',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ status: 'pending' }),
212
+ });
213
+ if (!res.ok) {
214
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
215
+ await showError(msg, { title: 'No se pudo resetear la HU' });
216
+ return;
217
+ }
218
+ closeModal();
219
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
220
+ await renderBoard();
221
+ } catch (err) {
222
+ await showError(err.message || String(err), { title: 'Fallo al resetear la HU' });
223
+ }
224
+ };