karajan-code 2.34.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.
- package/README.md +120 -38
- package/bin/kj-rag-mcp.js +75 -0
- package/docs/README.es.md +5 -5
- package/package.json +22 -17
- package/packages/hu-board/package.json +5 -2
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +20 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +453 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +232 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +15 -2
- package/packages/hu-board/src/routes/api.js +27 -9
- package/scripts/audit-test-diet.mjs +151 -0
- package/src/audit/deterministic-summary.js +20 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/audit/injection-findings.js +105 -0
- package/src/checks/hardware.js +150 -0
- package/src/checks/node.js +9 -10
- package/src/checks/runner.js +10 -0
- package/src/cli/register-meta.js +48 -2
- package/src/cli/register-plan.js +10 -1
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +41 -1
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/rag-mcp.js +18 -0
- package/src/commands/sync.js +30 -2
- package/src/commands/telemetry.js +70 -0
- package/src/config/defaults.js +9 -1
- package/src/config/schema.js +2 -0
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/stages/coder-stage.js +11 -2
- package/src/orchestrator/stages/reviewer-stage.js +11 -0
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +9 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sonar/scanner.js +51 -16
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/quota-simulator.js +56 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/telemetry.js +34 -12
- package/src/utils/tool-call-extractor.js +56 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/'/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
|
+
};
|