codetrap 0.1.6 → 0.1.8

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 (60) hide show
  1. package/README.md +159 -51
  2. package/docs/installation.md +113 -29
  3. package/package.json +4 -3
  4. package/plugins/codetrap-agent/.codex-plugin/plugin.json +1 -2
  5. package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +19 -17
  6. package/plugins/codetrap-agent/hooks.json +2 -2
  7. package/{skills → plugins/codetrap-agent/skills}/codetrap-add/SKILL.md +10 -4
  8. package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +14 -3
  9. package/plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md +52 -9
  10. package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +74 -6
  11. package/{skills → plugins/codetrap-agent/skills}/codetrap-search/SKILL.md +6 -5
  12. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +31 -5
  13. package/scripts/search-policy-sweep.ts +131 -0
  14. package/src/commands/workflow.ts +186 -68
  15. package/src/db/connection.ts +6 -6
  16. package/src/db/embedding-queries.ts +230 -48
  17. package/src/db/queries.ts +0 -25
  18. package/src/db/repository.ts +32 -21
  19. package/src/db/schema.ts +80 -0
  20. package/src/index.ts +32 -7
  21. package/src/lib/command-requests.ts +134 -1
  22. package/src/lib/config.ts +57 -7
  23. package/src/lib/constants.ts +1 -1
  24. package/src/lib/doctor.ts +96 -6
  25. package/src/lib/embed-output.ts +26 -0
  26. package/src/lib/embedder.ts +118 -3
  27. package/src/lib/embedding-health.ts +3 -1
  28. package/src/lib/embedding-job.ts +3 -0
  29. package/src/lib/embedding-management.ts +65 -0
  30. package/src/lib/embedding-runtime.ts +177 -0
  31. package/src/lib/output-json.ts +0 -2
  32. package/src/lib/scope-context.ts +17 -11
  33. package/src/lib/scope-migration.ts +2 -1
  34. package/src/lib/scope.ts +4 -6
  35. package/src/lib/search-eval.ts +136 -23
  36. package/src/lib/search-policy-sweep.ts +563 -0
  37. package/src/lib/search-policy.ts +0 -4
  38. package/src/lib/search-service.ts +14 -15
  39. package/src/lib/session-candidate-document.ts +175 -0
  40. package/src/lib/session-candidate-scope.ts +6 -0
  41. package/src/lib/session-capture.ts +298 -32
  42. package/src/lib/session-codec.ts +1 -8
  43. package/src/lib/session-operations.ts +111 -51
  44. package/src/lib/session-review.ts +327 -0
  45. package/src/lib/session-store.ts +177 -55
  46. package/src/lib/store.ts +79 -11
  47. package/src/lib/string-list.ts +3 -0
  48. package/src/lib/text-lines.ts +7 -0
  49. package/src/lib/trap-search-document.ts +2 -1
  50. package/src/lib/value-types.ts +3 -0
  51. package/src/web/client-review.ts +171 -0
  52. package/src/web/client-script.ts +1543 -0
  53. package/src/web/client-shell.ts +414 -0
  54. package/src/web/client-text.ts +447 -0
  55. package/src/web/project-registry.ts +3 -5
  56. package/src/web/server.ts +184 -111
  57. package/src/web/static.ts +581 -484
  58. package/skills/codetrap-capture-external/SKILL.md +0 -62
  59. package/skills/codetrap-check/SKILL.md +0 -69
  60. package/src/lib/embedding-index.ts +0 -53
@@ -0,0 +1,1543 @@
1
+ import { WEB_TEXT_JSON } from "./client-text";
2
+ import { WEB_REVIEW_CLIENT_SCRIPT } from "./client-review";
3
+ import { WEB_SHELL_CLIENT_SCRIPT } from "./client-shell";
4
+
5
+ export function webClientScript(textJson = WEB_TEXT_JSON): string {
6
+ return ` const qs = new URLSearchParams(location.search);
7
+ const token = qs.get("token") || sessionStorage.getItem("codetrap-token") || "";
8
+ if (token) sessionStorage.setItem("codetrap-token", token);
9
+ const savedLocale = localStorage.getItem("codetrap-locale");
10
+ const initialLocale = savedLocale === "zh" ? "zh" : "en";
11
+ const savedSidebarCollapsed = localStorage.getItem("codetrap-sidebar-collapsed") === "true";
12
+ const savedQueueCollapsed = localStorage.getItem("codetrap-queue-collapsed") === "true";
13
+ const EMBEDDING_DEFAULTS = {
14
+ endpoint: "http://127.0.0.1:11434",
15
+ model: "qwen3-embedding:0.6b",
16
+ dimensions: "1024"
17
+ };
18
+
19
+ const TEXT = ${textJson};
20
+
21
+ const state = {
22
+ locale: initialLocale,
23
+ mainView: "review",
24
+ projects: [],
25
+ sessions: [],
26
+ candidateReview: null,
27
+ candidates: [],
28
+ traps: [],
29
+ trapKey: null,
30
+ trapDetails: {},
31
+ trapLoadingKey: null,
32
+ trapSearch: "",
33
+ trapFilters: { scope: "", status: "", category: "", module: "", owner: "" },
34
+ trapSort: "updated",
35
+ insightTraps: [],
36
+ insightFilters: { scope: "", status: "all" },
37
+ embeddingStatus: null,
38
+ embeddingSettings: null,
39
+ embeddingProviderDraft: "ollama",
40
+ embeddingOllama: { ...EMBEDDING_DEFAULTS },
41
+ embeddingReindexing: null,
42
+ projectRoot: null,
43
+ sessionId: null,
44
+ candidateId: null,
45
+ candidateView: "inbox",
46
+ candidateDirty: false,
47
+ sidebarCollapsed: savedSidebarCollapsed,
48
+ queueCollapsed: savedQueueCollapsed,
49
+ options: { categories: [], severities: [], scopes: [] },
50
+ conflicts: []
51
+ };
52
+
53
+ const el = (id) => document.getElementById(id);
54
+ ${WEB_SHELL_CLIENT_SCRIPT}
55
+ ${WEB_REVIEW_CLIENT_SCRIPT}
56
+ function t(key, params = {}) {
57
+ const text = TEXT[state.locale]?.[key] ?? TEXT.en[key] ?? key;
58
+ return Object.entries(params).reduce((value, [name, replacement]) =>
59
+ value.replaceAll("{" + name + "}", String(replacement)), text);
60
+ }
61
+
62
+ function valueLabel(value) {
63
+ const key = "value." + value;
64
+ const label = t(key);
65
+ return label === key ? String(value ?? "") : label;
66
+ }
67
+
68
+ function optionPairs(values) {
69
+ return values.map((value) => [value, valueLabel(value)]);
70
+ }
71
+
72
+ function renderShellText() {
73
+ document.documentElement.lang = state.locale === "zh" ? "zh-CN" : "en";
74
+ document.title = "codetrap " + t("app.subtitle");
75
+ el("app-subtitle").textContent = t("app.subtitle");
76
+ el("refresh").textContent = t("action.refresh");
77
+ el("refresh").title = t("action.refresh");
78
+ el("project-add").textContent = t("action.add");
79
+ el("project-path").placeholder = t("placeholder.projectPath");
80
+ el("sessions-title").textContent = t("section.sessions");
81
+ document.querySelector("[data-main-view='review']").textContent = t("nav.review");
82
+ document.querySelector("[data-main-view='library']").textContent = t("nav.library");
83
+ document.querySelector("[data-main-view='insights']").textContent = t("nav.insights");
84
+ document.querySelector("[data-main-view='embeddings']").textContent = t("nav.embeddings");
85
+ document.querySelectorAll("[data-locale]").forEach((button) => {
86
+ button.classList.toggle("active", button.dataset.locale === state.locale);
87
+ });
88
+ renderSidebarToggle();
89
+ }
90
+
91
+ function setLocale(locale) {
92
+ if (locale !== "en" && locale !== "zh") return;
93
+ state.locale = locale;
94
+ localStorage.setItem("codetrap-locale", locale);
95
+ renderShellText();
96
+ renderProjects();
97
+ renderSessions();
98
+ renderActiveView();
99
+ }
100
+
101
+ async function api(path, options = {}) {
102
+ const headers = { "X-Codetrap-Token": token, ...(options.headers || {}) };
103
+ if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
104
+ const res = await fetch(path, { ...options, headers });
105
+ const text = await res.text();
106
+ const data = text ? JSON.parse(text) : null;
107
+ if (!res.ok) {
108
+ const err = new Error(data?.error || res.statusText);
109
+ err.payload = data;
110
+ throw err;
111
+ }
112
+ return data;
113
+ }
114
+
115
+ function showStatus(message, isError = false) {
116
+ const box = el("status");
117
+ box.textContent = message;
118
+ box.className = "status show" + (isError ? " error" : "");
119
+ clearTimeout(showStatus.timer);
120
+ showStatus.timer = setTimeout(() => box.className = "status", 3200);
121
+ }
122
+
123
+ async function bootstrap() {
124
+ const data = await api("/api/bootstrap");
125
+ state.projects = data.projects;
126
+ state.projectRoot = data.current_project_root || data.projects[0]?.root || null;
127
+ state.options = data.options;
128
+ renderShellText();
129
+ renderProjects();
130
+ await loadSessions();
131
+ renderActiveView();
132
+ }
133
+
134
+ async function loadSessions() {
135
+ if (!state.projectRoot) {
136
+ state.sessions = [];
137
+ state.candidateReview = null;
138
+ state.candidates = [];
139
+ state.traps = [];
140
+ state.insightTraps = [];
141
+ state.embeddingStatus = null;
142
+ state.embeddingSettings = null;
143
+ renderSessions();
144
+ renderActiveView();
145
+ return;
146
+ }
147
+ const data = await api("/api/sessions?project=" + encodeURIComponent(state.projectRoot));
148
+ state.sessions = data.sessions;
149
+ state.candidateReview = data.candidate_review || null;
150
+ state.sessionId = selectedReviewSessionId(state.sessions, state.sessionId);
151
+ renderSessions();
152
+ if (state.mainView === "library") {
153
+ await loadTraps();
154
+ } else if (state.mainView === "insights") {
155
+ await loadInsightTraps();
156
+ } else if (state.mainView === "embeddings") {
157
+ await loadEmbeddings();
158
+ } else {
159
+ await loadCandidates();
160
+ }
161
+ }
162
+
163
+ async function loadCandidates() {
164
+ if (!state.projectRoot || !state.sessionId) {
165
+ state.candidates = [];
166
+ if (state.mainView === "review") {
167
+ renderCandidates();
168
+ renderDetail();
169
+ }
170
+ return;
171
+ }
172
+ const data = await api("/api/candidates?project=" + encodeURIComponent(state.projectRoot) + "&session=" + encodeURIComponent(state.sessionId));
173
+ state.candidates = data.candidates;
174
+ state.candidateId = reviewQueueModel({
175
+ candidates: state.candidates,
176
+ candidateView: state.candidateView,
177
+ candidateId: state.candidateId,
178
+ candidateReview: state.candidateReview
179
+ }).selectedCandidateId;
180
+ if (state.mainView === "review") {
181
+ renderCandidates();
182
+ renderDetail();
183
+ }
184
+ }
185
+
186
+ async function loadTraps() {
187
+ if (!state.projectRoot) {
188
+ state.traps = [];
189
+ state.trapKey = null;
190
+ if (state.mainView === "library") {
191
+ renderLibrary();
192
+ renderTrapDetail();
193
+ }
194
+ return;
195
+ }
196
+ const params = new URLSearchParams({ project: state.projectRoot });
197
+ Object.entries(state.trapFilters).forEach(([key, value]) => {
198
+ if (value) params.set(key, value);
199
+ });
200
+ const data = await api("/api/traps?" + params.toString());
201
+ state.traps = data.traps;
202
+ state.trapDetails = {};
203
+ selectVisibleTrap();
204
+ if (state.mainView === "library") {
205
+ renderLibrary();
206
+ renderTrapDetail();
207
+ }
208
+ }
209
+
210
+ async function loadInsightTraps() {
211
+ if (!state.projectRoot) {
212
+ state.insightTraps = [];
213
+ if (state.mainView === "insights") {
214
+ renderInsightsView();
215
+ renderInsightDetail();
216
+ }
217
+ return;
218
+ }
219
+ const params = new URLSearchParams({ project: state.projectRoot });
220
+ Object.entries(state.insightFilters).forEach(([key, value]) => {
221
+ if (value) params.set(key, value);
222
+ });
223
+ const data = await api("/api/traps?" + params.toString());
224
+ state.insightTraps = data.traps;
225
+ if (state.mainView === "insights") {
226
+ renderInsightsView();
227
+ renderInsightDetail();
228
+ }
229
+ }
230
+
231
+ async function loadEmbeddings() {
232
+ if (!state.projectRoot) {
233
+ state.embeddingStatus = null;
234
+ state.embeddingSettings = null;
235
+ if (state.mainView === "embeddings") {
236
+ renderEmbeddingsView();
237
+ renderEmbeddingsDetail();
238
+ }
239
+ return;
240
+ }
241
+ const data = await api("/api/embeddings?project=" + encodeURIComponent(state.projectRoot));
242
+ state.embeddingStatus = data;
243
+ state.embeddingSettings = data.settings || null;
244
+ syncEmbeddingDraftFromStatus(data);
245
+ if (state.mainView === "embeddings") {
246
+ renderEmbeddingsView();
247
+ renderEmbeddingsDetail();
248
+ }
249
+ }
250
+
251
+ function renderMainViewButtons() {
252
+ document.querySelectorAll("[data-main-view]").forEach((button) => {
253
+ button.classList.toggle("active", button.dataset.mainView === state.mainView);
254
+ });
255
+ }
256
+
257
+ function renderActiveView() {
258
+ renderMainViewButtons();
259
+ if (state.mainView === "library") {
260
+ el("queue-title").textContent = t("title.trapLibrary");
261
+ el("detail-title").textContent = t("title.trapDetail");
262
+ el("candidate-tabs").classList.add("hidden");
263
+ hideReviewSummary();
264
+ renderLibrary();
265
+ renderTrapDetail();
266
+ } else if (state.mainView === "insights") {
267
+ el("queue-title").textContent = t("title.growthInsights");
268
+ el("detail-title").textContent = t("title.insightDetail");
269
+ el("candidate-tabs").classList.add("hidden");
270
+ hideReviewSummary();
271
+ renderInsightsView();
272
+ renderInsightDetail();
273
+ } else if (state.mainView === "embeddings") {
274
+ el("queue-title").textContent = t("title.embeddings");
275
+ el("detail-title").textContent = t("title.embeddingDetail");
276
+ el("candidate-tabs").classList.add("hidden");
277
+ hideReviewSummary();
278
+ renderEmbeddingsView();
279
+ renderEmbeddingsDetail();
280
+ } else {
281
+ el("queue-title").textContent = t("title.candidateInbox");
282
+ el("detail-title").textContent = t("title.candidateDetail");
283
+ el("candidate-tabs").classList.remove("hidden");
284
+ renderCandidates();
285
+ renderDetail();
286
+ }
287
+ }
288
+
289
+ function renderProjects() {
290
+ el("projects").innerHTML = state.projects.length ? state.projects.map((project) => \`
291
+ <button class="row \${project.root === state.projectRoot ? "active" : ""}" data-project="\${escapeAttr(project.root)}">
292
+ <span class="row-title">\${escapeHtml(project.name)}</span>
293
+ <span class="subtle">\${escapeHtml(project.root)}</span>
294
+ </button>
295
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noProjects")) + '</div>';
296
+ document.querySelectorAll("[data-project]").forEach((button) => {
297
+ button.addEventListener("click", async () => {
298
+ state.projectRoot = button.dataset.project;
299
+ state.sessionId = null;
300
+ state.candidateId = null;
301
+ state.trapKey = null;
302
+ state.trapDetails = {};
303
+ state.insightTraps = [];
304
+ state.embeddingStatus = null;
305
+ state.embeddingSettings = null;
306
+ renderProjects();
307
+ await loadSessions();
308
+ });
309
+ });
310
+ }
311
+
312
+ function renderSessions() {
313
+ el("sessions").innerHTML = state.sessions.length ? state.sessions.map((session) => \`
314
+ <div class="row \${session.id === state.sessionId ? "active" : ""}">
315
+ <button type="button" class="row-main" data-session="\${escapeAttr(session.id)}">
316
+ <span class="row-title">\${escapeHtml(session.goal)}</span>
317
+ <span class="meta">
318
+ <span class="pill">\${escapeHtml(valueLabel(session.status))}</span>
319
+ <span class="pill \${session.pending_count ? "warn" : ""}">\${escapeHtml(t("pill.pending", { count: session.pending_count || 0 }))}</span>
320
+ <span class="pill">\${escapeHtml(t("pill.candidates", { count: session.candidate_count || 0 }))}</span>
321
+ <span class="pill accepted">\${escapeHtml(t("pill.accepted", { count: session.accepted_count || 0 }))}</span>
322
+ </span>
323
+ </button>
324
+ <button type="button" class="row-action danger" data-delete-session="\${escapeAttr(session.id)}">\${escapeHtml(t("action.deleteSession"))}</button>
325
+ </div>
326
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noSessions")) + '</div>';
327
+ document.querySelectorAll("[data-session]").forEach((button) => {
328
+ button.addEventListener("click", async () => {
329
+ state.sessionId = button.dataset.session;
330
+ state.candidateId = null;
331
+ renderSessions();
332
+ await loadCandidates();
333
+ });
334
+ });
335
+ document.querySelectorAll("[data-delete-session]").forEach((button) => {
336
+ button.addEventListener("click", async () => {
337
+ await deleteSession(button.dataset.deleteSession);
338
+ });
339
+ });
340
+ }
341
+
342
+ async function deleteSession(sessionId) {
343
+ if (!sessionId || !confirm(t("prompt.deleteSession", { id: sessionId }))) return;
344
+ try {
345
+ await api("/api/session/delete", {
346
+ method: "POST",
347
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId })
348
+ });
349
+ if (state.sessionId === sessionId) {
350
+ state.sessionId = null;
351
+ state.candidateId = null;
352
+ state.candidates = [];
353
+ }
354
+ await loadSessions();
355
+ showStatus(t("status.sessionDeleted"));
356
+ } catch (error) {
357
+ showStatus(error.message, true);
358
+ }
359
+ }
360
+
361
+ async function cleanupDeletedCandidates() {
362
+ if (!state.sessionId) return;
363
+ try {
364
+ const data = await api("/api/session/cleanup", {
365
+ method: "POST",
366
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId })
367
+ });
368
+ if (data.removed_candidate_ids?.includes(state.candidateId)) {
369
+ state.candidateId = null;
370
+ }
371
+ await loadSessions();
372
+ showStatus(t("status.deletedCandidatesCleaned"));
373
+ } catch (error) {
374
+ showStatus(error.message, true);
375
+ }
376
+ }
377
+
378
+ function renderCandidates() {
379
+ if (state.mainView !== "review") return;
380
+ const model = reviewQueueModel({
381
+ candidates: state.candidates,
382
+ candidateView: state.candidateView,
383
+ candidateId: state.candidateId,
384
+ candidateReview: state.candidateReview
385
+ });
386
+ const pendingCount = model.pendingCount;
387
+ const reviewedCount = model.reviewedCount;
388
+ const sorted = model.visibleCandidates;
389
+ state.candidateId = model.selectedCandidateId;
390
+ const session = state.sessions.find((item) => item.id === state.sessionId);
391
+ el("queue-meta").textContent = session
392
+ ? t("meta.sessionCounts", { goal: session.goal, pending: pendingCount, reviewed: reviewedCount })
393
+ : t("meta.noSession");
394
+ renderReviewSummary(model.summary);
395
+ renderCandidateViewTabs(pendingCount, reviewedCount);
396
+ el("candidates").innerHTML = sorted.length ? sorted.map((candidate) => \`
397
+ <div class="row \${candidate.id === state.candidateId ? "active" : ""} \${candidate.status} \${reviewCssClass(candidate)}">
398
+ <button type="button" class="row-main" data-candidate="\${escapeAttr(candidate.id)}">
399
+ <span class="row-title">\${escapeHtml(candidate.trap.title)}</span>
400
+ <span class="meta">
401
+ <span class="pill \${candidate.status} \${reviewCssClass(candidate)}">\${escapeHtml(reviewLabel(candidate))}</span>
402
+ <span class="pill">\${escapeHtml(t("pill.quality", { score: Number(candidate.quality_score).toFixed(2) }))}</span>
403
+ \${candidate.quality.warnings.length ? '<span class="pill warn">' + escapeHtml(t("pill.warnings", { count: candidate.quality.warnings.length })) + '</span>' : ''}
404
+ </span>
405
+ </button>
406
+ \${renderCandidateRowAction(candidate)}
407
+ </div>
408
+ \`).join("") : '<div class="empty">' + escapeHtml(t(state.candidateView === "inbox" ? "empty.noPending" : "empty.noReviewed")) + '</div>';
409
+ document.querySelectorAll("[data-candidate]").forEach((button) => {
410
+ button.addEventListener("click", () => {
411
+ state.candidateId = button.dataset.candidate;
412
+ state.conflicts = [];
413
+ renderCandidates();
414
+ renderDetail();
415
+ });
416
+ });
417
+ bindTrapJumpButtons();
418
+ }
419
+
420
+ function renderCandidateRowAction(candidate) {
421
+ const review = candidate.review;
422
+ if (!review || review.status !== "accepted") return "";
423
+ return \`<button type="button" class="row-action" data-view-trap-scope="\${escapeAttr(review.scope)}" data-view-trap-id="\${escapeAttr(review.trap_id)}">\${escapeHtml(t("action.viewTrap"))}</button>\`;
424
+ }
425
+
426
+ function renderLibrary() {
427
+ if (state.mainView !== "library") return;
428
+ el("queue-title").textContent = t("title.trapLibrary");
429
+ el("candidate-tabs").classList.add("hidden");
430
+ el("candidates").innerHTML = \`
431
+ <div class="library-tools">
432
+ <input id="trap-search" placeholder="\${escapeAttr(t("placeholder.searchTraps"))}" value="\${escapeAttr(state.trapSearch)}">
433
+ <div class="filter-grid">
434
+ \${filterSelect("trap-filter-scope", t("label.scope"), state.trapFilters.scope, [["", t("option.projectGlobal")], ...optionPairs(state.options.scopes)])}
435
+ \${filterSelect("trap-filter-status", t("label.status"), state.trapFilters.status, [["", valueLabel("active")], ["all", valueLabel("all")], ["archived", valueLabel("archived")], ["superseded", valueLabel("superseded")]])}
436
+ \${filterSelect("trap-filter-category", t("label.category"), state.trapFilters.category, [["", t("option.allCategories")], ...optionPairs(state.options.categories)])}
437
+ \${filterSelect("trap-sort", t("label.sort"), state.trapSort, [["updated", t("sort.updated")], ["severity", t("sort.severity")], ["hits", t("sort.hits")], ["category", t("sort.category")], ["title", t("sort.title")]])}
438
+ <div class="field"><label for="trap-filter-module">\${escapeHtml(t("label.module"))}</label><input id="trap-filter-module" value="\${escapeAttr(state.trapFilters.module)}" placeholder="\${escapeAttr(t("placeholder.anyModule"))}"></div>
439
+ <div class="field"><label for="trap-filter-owner">\${escapeHtml(t("label.owner"))}</label><input id="trap-filter-owner" value="\${escapeAttr(state.trapFilters.owner)}" placeholder="\${escapeAttr(t("placeholder.anyOwner"))}"></div>
440
+ <button type="button" id="trap-filter-clear" class="ghost">\${escapeHtml(t("action.clearFilters"))}</button>
441
+ </div>
442
+ </div>
443
+ <div id="library-insights"></div>
444
+ <div id="trap-rows" class="trap-rows"></div>
445
+ \`;
446
+ bindLibraryControls();
447
+ renderTrapResults();
448
+ }
449
+
450
+ function filterSelect(id, label, value, options) {
451
+ return \`<div class="field"><label for="\${id}">\${label}</label><select id="\${id}">\${options.map(([optionValue, optionLabel]) => \`<option value="\${escapeAttr(optionValue)}" \${optionValue === value ? "selected" : ""}>\${escapeHtml(optionLabel)}</option>\`).join("")}</select></div>\`;
452
+ }
453
+
454
+ function bindLibraryControls() {
455
+ const search = el("trap-search");
456
+ if (search) {
457
+ search.addEventListener("input", () => {
458
+ state.trapSearch = search.value;
459
+ state.trapKey = null;
460
+ renderTrapResults();
461
+ renderTrapDetail();
462
+ });
463
+ }
464
+ bindTrapFilter("trap-filter-scope", "scope");
465
+ bindTrapFilter("trap-filter-status", "status");
466
+ bindTrapFilter("trap-filter-category", "category");
467
+ bindTrapFilter("trap-filter-module", "module");
468
+ bindTrapFilter("trap-filter-owner", "owner");
469
+ const sort = el("trap-sort");
470
+ if (sort) {
471
+ sort.addEventListener("change", () => {
472
+ state.trapSort = sort.value;
473
+ state.trapKey = null;
474
+ renderTrapResults();
475
+ renderTrapDetail();
476
+ });
477
+ }
478
+ const clear = el("trap-filter-clear");
479
+ if (clear) {
480
+ clear.addEventListener("click", async () => {
481
+ state.trapFilters = { scope: "", status: "", category: "", module: "", owner: "" };
482
+ state.trapSearch = "";
483
+ state.trapKey = null;
484
+ await loadTraps();
485
+ });
486
+ }
487
+ }
488
+
489
+ function bindTrapFilter(id, key) {
490
+ const control = el(id);
491
+ if (!control) return;
492
+ const apply = async () => {
493
+ state.trapFilters[key] = control.value.trim();
494
+ state.trapKey = null;
495
+ await loadTraps();
496
+ };
497
+ control.addEventListener("change", apply);
498
+ control.addEventListener("keydown", (event) => {
499
+ if (event.key === "Enter") {
500
+ event.preventDefault();
501
+ apply();
502
+ }
503
+ });
504
+ }
505
+
506
+ function renderTrapResults() {
507
+ const rows = el("trap-rows");
508
+ const insights = el("library-insights");
509
+ if (!rows || !insights) return;
510
+ const visible = visibleTraps();
511
+ selectVisibleTrap(visible);
512
+ el("queue-meta").textContent = state.projectRoot
513
+ ? t("meta.libraryCounts", { shown: visible.length, loaded: state.traps.length, sort: sortLabel(state.trapSort) })
514
+ : t("meta.noProject");
515
+ insights.innerHTML = renderInsights(visible);
516
+ rows.innerHTML = visible.length ? visible.map((trap) => \`
517
+ <button class="row \${trapKey(trap) === state.trapKey ? "active" : ""}" data-trap-key="\${escapeAttr(trapKey(trap))}">
518
+ <span class="row-title">\${escapeHtml(trap.title)}</span>
519
+ <span class="meta">
520
+ <span class="pill \${escapeAttr(trap.severity)}">\${escapeHtml(valueLabel(trap.severity))}</span>
521
+ <span class="pill">\${escapeHtml(valueLabel(trap.category))}</span>
522
+ <span class="pill scope">\${escapeHtml(valueLabel(trap.scope))}</span>
523
+ <span class="pill \${escapeAttr(trap.status)}">\${escapeHtml(valueLabel(trap.status))}</span>
524
+ <span class="pill">\${escapeHtml(t("pill.hits", { count: Number(trap.hit_count || 0) }))}</span>
525
+ </span>
526
+ <span class="subtle">\${escapeHtml(trap.updated_at || trap.created_at || "")}</span>
527
+ </button>
528
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noTrapMatches")) + '</div>';
529
+ document.querySelectorAll("[data-trap-key]").forEach((button) => {
530
+ button.addEventListener("click", () => {
531
+ state.trapKey = button.dataset.trapKey;
532
+ renderTrapResults();
533
+ renderTrapDetail();
534
+ });
535
+ });
536
+ }
537
+
538
+ function renderInsights(traps) {
539
+ const serious = traps.filter((trap) => trap.severity === "error" || trap.severity === "critical").length;
540
+ const topCategory = topValue(traps.map((trap) => trap.category));
541
+ const topModule = topValue(traps.map((trap) => trap.module).filter(Boolean));
542
+ const topTag = topValue(traps.flatMap((trap) => trap.tags || []));
543
+ const mostViewed = [...traps].sort((a, b) => Number(b.hit_count || 0) - Number(a.hit_count || 0))[0];
544
+ return \`<div class="summary-grid">
545
+ \${metric(t("metric.loadedTraps"), traps.length || "0", t("metric.currentFilters"))}
546
+ \${metric(t("metric.highSeverity"), serious || "0", t("metric.errorCritical"))}
547
+ \${metric(t("metric.topCategory"), topCategory ? valueLabel(topCategory) : "-", t("metric.repeatedPattern"))}
548
+ \${metric(t("metric.focusArea"), topModule || topTag || "-", topModule ? t("metric.module") : t("metric.tag"))}
549
+ \${metric(t("metric.mostViewed"), mostViewed ? "#" + mostViewed.id : "-", mostViewed ? mostViewed.title : t("metric.noHits"))}
550
+ </div>\`;
551
+ }
552
+
553
+ function renderInsightsView() {
554
+ if (state.mainView !== "insights") return;
555
+ const traps = state.insightTraps;
556
+ const serious = traps.filter((trap) => trap.severity === "error" || trap.severity === "critical").length;
557
+ const topCategory = topValue(traps.map((trap) => trap.category));
558
+ const topModule = topValue(traps.map((trap) => trap.module).filter(Boolean));
559
+ const topTag = topValue(traps.flatMap((trap) => trap.tags || []));
560
+ const mostViewed = sortTraps(traps, "hits")[0];
561
+ el("queue-title").textContent = t("title.growthInsights");
562
+ el("candidate-tabs").classList.add("hidden");
563
+ el("queue-meta").textContent = state.projectRoot
564
+ ? t("meta.insightCounts", { count: traps.length, status: valueLabel(state.insightFilters.status || "all") })
565
+ : t("meta.noProject");
566
+ el("candidates").innerHTML = \`
567
+ <div class="library-tools">
568
+ <div class="filter-grid">
569
+ \${filterSelect("insight-filter-scope", t("label.scope"), state.insightFilters.scope, [["", t("option.projectGlobal")], ...optionPairs(state.options.scopes)])}
570
+ \${filterSelect("insight-filter-status", t("label.status"), state.insightFilters.status, [["all", valueLabel("all")], ["active", valueLabel("active")], ["archived", valueLabel("archived")], ["superseded", valueLabel("superseded")]])}
571
+ </div>
572
+ </div>
573
+ <div class="summary-grid">
574
+ \${metric(t("metric.confirmedTraps"), traps.length || "0", t("metric.selectedScope"))}
575
+ \${metric(t("metric.highSeverity"), serious || "0", t("metric.errorCritical"))}
576
+ \${metric(t("metric.topCategory"), topCategory ? valueLabel(topCategory) : "-", t("metric.largestPattern"))}
577
+ \${metric(t("metric.focusArea"), topModule || topTag || "-", topModule ? t("metric.module") : t("metric.tag"))}
578
+ \${metric(t("metric.mostViewed"), mostViewed ? "#" + mostViewed.id : "-", mostViewed ? mostViewed.title : t("metric.noHits"))}
579
+ </div>
580
+ <div class="insight-grid">
581
+ \${renderInsightRankBlock(t("insight.categories"), topValues(traps.map((trap) => trap.category), 6, true), traps.length)}
582
+ \${renderInsightRankBlock(t("insight.modules"), topValues(traps.map((trap) => trap.module).filter(Boolean), 6), traps.length)}
583
+ \${renderInsightRankBlock(t("insight.tags"), topValues(traps.flatMap((trap) => trap.tags || []), 8), traps.length)}
584
+ \${renderInsightRankBlock(t("insight.severityMix"), topValues(traps.map((trap) => trap.severity), 5, true), traps.length)}
585
+ </div>
586
+ \`;
587
+ bindInsightControls();
588
+ }
589
+
590
+ function renderInsightDetail() {
591
+ if (state.mainView !== "insights") return;
592
+ const traps = state.insightTraps;
593
+ const recent = sortTraps(traps, "updated").slice(0, 8);
594
+ const mostViewed = sortTraps(traps, "hits").filter((trap) => Number(trap.hit_count || 0) > 0).slice(0, 8);
595
+ const seriousRecent = sortTraps(traps.filter((trap) => trap.severity === "error" || trap.severity === "critical"), "updated").slice(0, 8);
596
+ el("detail-title").textContent = t("title.insightDetail");
597
+ el("detail-meta").textContent = state.projectRoot ? (state.insightFilters.scope ? valueLabel(state.insightFilters.scope) : t("option.projectGlobal")) : t("meta.selectProject");
598
+ el("detail").innerHTML = \`
599
+ <div class="scroll">
600
+ <div class="section">
601
+ <div class="title">\${escapeHtml(t("title.recentTraps"))}</div>
602
+ \${renderInsightTrapRows(recent)}
603
+ </div>
604
+ <div class="section">
605
+ <div class="title">\${escapeHtml(t("title.mostViewed"))}</div>
606
+ \${renderInsightTrapRows(mostViewed)}
607
+ </div>
608
+ <div class="section">
609
+ <div class="title">\${escapeHtml(t("title.recentHighSeverity"))}</div>
610
+ \${renderInsightTrapRows(seriousRecent)}
611
+ </div>
612
+ </div>
613
+ \`;
614
+ bindTrapJumpButtons();
615
+ }
616
+
617
+ function bindInsightControls() {
618
+ const scope = el("insight-filter-scope");
619
+ if (scope) {
620
+ scope.addEventListener("change", async () => {
621
+ state.insightFilters.scope = scope.value;
622
+ await loadInsightTraps();
623
+ });
624
+ }
625
+ const status = el("insight-filter-status");
626
+ if (status) {
627
+ status.addEventListener("change", async () => {
628
+ state.insightFilters.status = status.value;
629
+ await loadInsightTraps();
630
+ });
631
+ }
632
+ }
633
+
634
+ function renderInsightRankBlock(label, items, total) {
635
+ return \`<div class="insight-block">
636
+ <div class="title">\${escapeHtml(label)}</div>
637
+ <div class="rank-list">
638
+ \${items.length ? items.map((item) => renderRankRow(item, total)).join("") : '<div class="empty">' + escapeHtml(t("empty.noData")) + '</div>'}
639
+ </div>
640
+ </div>\`;
641
+ }
642
+
643
+ function renderRankRow(item, total) {
644
+ const width = total > 0 ? Math.max(6, Math.round((item.count / total) * 100)) : 0;
645
+ return \`<div class="rank-row">
646
+ <div class="rank-label">\${escapeHtml(item.label)}</div>
647
+ <div class="rank-count">\${item.count}</div>
648
+ <div class="bar-track"><div class="bar-fill" style="width:\${width}%"></div></div>
649
+ </div>\`;
650
+ }
651
+
652
+ function renderInsightTrapRows(traps) {
653
+ return traps.length ? traps.map((trap) => \`
654
+ <button type="button" class="row" data-view-trap-scope="\${escapeAttr(trap.scope)}" data-view-trap-id="\${escapeAttr(trap.id)}">
655
+ <span class="row-title">\${escapeHtml(trap.title)}</span>
656
+ <span class="meta">
657
+ <span class="pill \${escapeAttr(trap.severity)}">\${escapeHtml(valueLabel(trap.severity))}</span>
658
+ <span class="pill">\${escapeHtml(valueLabel(trap.category))}</span>
659
+ <span class="pill scope">\${escapeHtml(valueLabel(trap.scope))}</span>
660
+ <span class="pill \${escapeAttr(trap.status)}">\${escapeHtml(valueLabel(trap.status))}</span>
661
+ <span class="pill">\${escapeHtml(t("pill.hits", { count: Number(trap.hit_count || 0) }))}</span>
662
+ </span>
663
+ <span class="subtle">\${escapeHtml(trap.updated_at || trap.created_at || "")}</span>
664
+ </button>
665
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noTraps")) + '</div>';
666
+ }
667
+
668
+ function syncEmbeddingDraftFromStatus(status) {
669
+ const settings = status?.settings || null;
670
+ const runtime = status?.runtime || null;
671
+ const provider = settings?.provider || runtime?.provider || "ollama";
672
+ state.embeddingProviderDraft = provider === "jina" ? "jina" : "ollama";
673
+ if (state.embeddingProviderDraft === "ollama") {
674
+ state.embeddingOllama = {
675
+ endpoint: settings?.endpoint || EMBEDDING_DEFAULTS.endpoint,
676
+ model: settings?.model || runtime?.model || EMBEDDING_DEFAULTS.model,
677
+ dimensions: String(settings?.dimensions || runtime?.dimensions || EMBEDDING_DEFAULTS.dimensions)
678
+ };
679
+ }
680
+ }
681
+
682
+ function renderEmbeddingsView() {
683
+ if (state.mainView !== "embeddings") return;
684
+ const status = state.embeddingStatus;
685
+ const runtime = status?.runtime || null;
686
+ const project = status?.project || null;
687
+ const global = status?.global || null;
688
+ const provider = runtime?.provider || state.embeddingProviderDraft || "";
689
+ const providerLabel = provider ? valueLabel(provider) : t("embedding.notConfigured");
690
+ el("queue-title").textContent = t("title.embeddings");
691
+ el("candidate-tabs").classList.add("hidden");
692
+ el("queue-meta").textContent = state.projectRoot && status
693
+ ? t("meta.embeddingCounts", {
694
+ provider: providerLabel,
695
+ projectFresh: project?.fresh ?? 0,
696
+ projectTotal: project?.total ?? 0,
697
+ globalFresh: global?.fresh ?? 0,
698
+ globalTotal: global?.total ?? 0
699
+ })
700
+ : t(state.projectRoot ? "embedding.notConfigured" : "meta.noProject");
701
+
702
+ if (!state.projectRoot) {
703
+ el("candidates").innerHTML = '<div class="empty">' + escapeHtml(t("meta.selectProject")) + '</div>';
704
+ return;
705
+ }
706
+
707
+ el("candidates").innerHTML = \`
708
+ <div class="summary-grid">
709
+ \${metric(t("metric.activeProvider"), providerLabel, runtimeStateLabel(runtime))}
710
+ \${metric(t("metric.activeProfile"), shortProfileId(runtime?.profile_id), runtime?.profile_id || t("embedding.noProfile"))}
711
+ \${metric(t("metric.projectFresh"), embeddingFreshValue(project), embeddingNeedsReindex(project))}
712
+ \${metric(t("metric.globalFresh"), embeddingFreshValue(global), embeddingNeedsReindex(global))}
713
+ </div>
714
+ <form class="settings-form" id="embedding-form">
715
+ <div class="status-line">
716
+ <span class="status-dot \${runtime?.available ? "available" : "unavailable"}" aria-hidden="true"></span>
717
+ <span class="pill \${runtime?.available ? "accepted" : "warn"}">\${escapeHtml(runtimeStateLabel(runtime))}</span>
718
+ \${runtime?.profile_id ? '<span class="pill scope">' + escapeHtml(t("embedding.activeProfile")) + '</span>' : ''}
719
+ </div>
720
+ <div class="segmented" id="embedding-provider-tabs" aria-label="\${escapeAttr(t("label.provider"))}">
721
+ <button type="button" data-embedding-provider="ollama" class="\${state.embeddingProviderDraft === "ollama" ? "active" : ""}">\${escapeHtml(valueLabel("ollama"))}</button>
722
+ <button type="button" data-embedding-provider="jina" class="\${state.embeddingProviderDraft === "jina" ? "active" : ""}">\${escapeHtml(valueLabel("jina"))}</button>
723
+ </div>
724
+ <div class="provider-fields \${state.embeddingProviderDraft === "ollama" ? "" : "hidden"}">
725
+ <div class="field"><label for="embedding-endpoint">\${escapeHtml(t("label.endpoint"))}</label><input id="embedding-endpoint" value="\${escapeAttr(state.embeddingOllama.endpoint)}" placeholder="\${escapeAttr(t("placeholder.endpoint"))}"></div>
726
+ <div class="field"><label for="embedding-model">\${escapeHtml(t("label.model"))}</label><input id="embedding-model" value="\${escapeAttr(state.embeddingOllama.model)}" placeholder="\${escapeAttr(t("placeholder.model"))}"></div>
727
+ <div class="field"><label for="embedding-dimensions">\${escapeHtml(t("label.dimensions"))}</label><input id="embedding-dimensions" type="number" min="1" step="1" value="\${escapeAttr(state.embeddingOllama.dimensions)}"></div>
728
+ </div>
729
+ <div class="warning \${state.embeddingProviderDraft === "jina" ? "" : "hidden"}">\${escapeHtml(t("hint.jinaEnv"))}</div>
730
+ <button type="submit" class="primary">\${escapeHtml(t("action.useProvider"))}</button>
731
+ </form>
732
+ <div class="section">
733
+ <div class="title">\${escapeHtml(t("title.reindex"))}</div>
734
+ <div class="subtle">\${escapeHtml(t("hint.reindexAfterSwitch"))}</div>
735
+ <div class="actions" style="padding:0;border-top:0;background:transparent">
736
+ <button type="button" id="embedding-reindex-project" \${state.embeddingReindexing ? "disabled" : ""}>\${escapeHtml(t("action.reindexProject"))}</button>
737
+ <button type="button" id="embedding-reindex-global" \${state.embeddingReindexing ? "disabled" : ""}>\${escapeHtml(t("action.reindexGlobal"))}</button>
738
+ </div>
739
+ </div>
740
+ \`;
741
+ bindEmbeddingsControls();
742
+ }
743
+
744
+ function renderEmbeddingsDetail() {
745
+ if (state.mainView !== "embeddings") return;
746
+ const status = state.embeddingStatus;
747
+ const runtime = status?.runtime || null;
748
+ el("detail-title").textContent = t("title.embeddingDetail");
749
+ el("detail-meta").textContent = state.projectRoot
750
+ ? t("meta.embeddingDetail", {
751
+ profile: runtime?.profile_id || t("embedding.noProfile"),
752
+ state: runtimeStateLabel(runtime)
753
+ })
754
+ : t("meta.selectProject");
755
+
756
+ if (!state.projectRoot) {
757
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("meta.selectProject")) + '</div>';
758
+ return;
759
+ }
760
+ if (!status) {
761
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noData")) + '</div>';
762
+ return;
763
+ }
764
+
765
+ el("detail").innerHTML = \`
766
+ <div class="scroll">
767
+ <div class="section">
768
+ <div class="title">\${escapeHtml(t("title.currentProfile"))}</div>
769
+ <div class="detail-kv">
770
+ \${kv(t("label.provider"), runtime?.provider ? valueLabel(runtime.provider) : t("embedding.notConfigured"))}
771
+ \${kv(t("label.model"), runtime?.model || "-")}
772
+ \${kv(t("label.dimensions"), runtime?.dimensions ?? "-")}
773
+ \${kv(t("label.profileId"), runtime?.profile_id || t("embedding.noProfile"))}
774
+ \${kv(t("label.available"), runtimeStateLabel(runtime))}
775
+ \${kv(t("label.setupAction"), runtime?.setup_action?.command || "-")}
776
+ </div>
777
+ \${runtime?.setup_action ? '<div class="warning">' + escapeHtml(runtime.setup_action.reason) + '</div>' : ''}
778
+ </div>
779
+ \${renderEmbeddingScopeDetail("project", status.project)}
780
+ \${renderEmbeddingScopeDetail("global", status.global)}
781
+ </div>
782
+ \`;
783
+ }
784
+
785
+ function bindEmbeddingsControls() {
786
+ document.querySelectorAll("[data-embedding-provider]").forEach((button) => {
787
+ button.addEventListener("click", () => {
788
+ state.embeddingProviderDraft = button.dataset.embeddingProvider === "jina" ? "jina" : "ollama";
789
+ renderEmbeddingsView();
790
+ });
791
+ });
792
+ const endpoint = el("embedding-endpoint");
793
+ if (endpoint) endpoint.addEventListener("input", () => state.embeddingOllama.endpoint = endpoint.value);
794
+ const model = el("embedding-model");
795
+ if (model) model.addEventListener("input", () => state.embeddingOllama.model = model.value);
796
+ const dimensions = el("embedding-dimensions");
797
+ if (dimensions) dimensions.addEventListener("input", () => state.embeddingOllama.dimensions = dimensions.value);
798
+ const form = el("embedding-form");
799
+ if (form) {
800
+ form.addEventListener("submit", async (event) => {
801
+ event.preventDefault();
802
+ await useEmbeddingProvider();
803
+ });
804
+ }
805
+ const project = el("embedding-reindex-project");
806
+ if (project) project.addEventListener("click", () => reindexEmbeddings("project"));
807
+ const global = el("embedding-reindex-global");
808
+ if (global) global.addEventListener("click", () => reindexEmbeddings("global"));
809
+ }
810
+
811
+ async function useEmbeddingProvider() {
812
+ if (!state.projectRoot) return;
813
+ const body = {
814
+ projectRoot: state.projectRoot,
815
+ provider: state.embeddingProviderDraft
816
+ };
817
+ if (state.embeddingProviderDraft === "ollama") {
818
+ const dimensions = Number.parseInt(state.embeddingOllama.dimensions, 10);
819
+ if (!Number.isInteger(dimensions) || dimensions <= 0) {
820
+ showStatus(t("status.invalidDimensions"), true);
821
+ return;
822
+ }
823
+ body.endpoint = state.embeddingOllama.endpoint || EMBEDDING_DEFAULTS.endpoint;
824
+ body.model = state.embeddingOllama.model || EMBEDDING_DEFAULTS.model;
825
+ body.dimensions = dimensions;
826
+ }
827
+ try {
828
+ const data = await api("/api/embeddings/use", {
829
+ method: "POST",
830
+ body: JSON.stringify(body)
831
+ });
832
+ state.embeddingSettings = data.settings || data.embeddings || null;
833
+ state.embeddingStatus = {
834
+ project_root: data.project_root,
835
+ settings: state.embeddingSettings,
836
+ ...data.status
837
+ };
838
+ syncEmbeddingDraftFromStatus(state.embeddingStatus);
839
+ renderEmbeddingsView();
840
+ renderEmbeddingsDetail();
841
+ showStatus(t("status.embeddingProviderSaved"));
842
+ } catch (error) {
843
+ showStatus(error.message, true);
844
+ }
845
+ }
846
+
847
+ async function reindexEmbeddings(scope) {
848
+ if (!state.projectRoot || state.embeddingReindexing) return;
849
+ state.embeddingReindexing = scope;
850
+ renderEmbeddingsView();
851
+ try {
852
+ const data = await api("/api/embeddings/reindex", {
853
+ method: "POST",
854
+ body: JSON.stringify({ projectRoot: state.projectRoot, scope })
855
+ });
856
+ state.embeddingStatus = {
857
+ project_root: data.project_root,
858
+ settings: state.embeddingSettings,
859
+ ...data.status
860
+ };
861
+ renderEmbeddingsView();
862
+ renderEmbeddingsDetail();
863
+ showStatus(t("status.embeddingsReindexed", {
864
+ generated: data.result?.generated ?? 0,
865
+ skipped: data.result?.skipped ?? 0
866
+ }));
867
+ } catch (error) {
868
+ showStatus(error.message, true);
869
+ } finally {
870
+ state.embeddingReindexing = null;
871
+ renderEmbeddingsView();
872
+ }
873
+ }
874
+
875
+ function renderEmbeddingScopeDetail(scope, status) {
876
+ const title = scope === "project" ? t("title.projectEmbeddings") : t("title.globalEmbeddings");
877
+ if (!status) {
878
+ return \`<div class="section"><div class="title">\${escapeHtml(title)}</div><div class="empty">\${escapeHtml(t("empty.noData"))}</div></div>\`;
879
+ }
880
+ return \`<div class="section">
881
+ <div class="title">\${escapeHtml(title)}</div>
882
+ <div class="detail-kv">
883
+ \${kv(t("label.total"), status.total)}
884
+ \${kv(t("label.fresh"), status.fresh)}
885
+ \${kv(t("label.stale"), status.stale)}
886
+ \${kv(t("label.missing"), status.missing)}
887
+ </div>
888
+ <div class="title">\${escapeHtml(t("title.storedProfiles"))}</div>
889
+ <div class="profile-list">\${renderEmbeddingProfiles(status.profiles || [])}</div>
890
+ </div>\`;
891
+ }
892
+
893
+ function renderEmbeddingProfiles(profiles) {
894
+ return profiles.length ? profiles.map((profile) => \`
895
+ <div class="profile-row">
896
+ <div class="row-title">\${escapeHtml(profile.id)}</div>
897
+ <div class="meta">
898
+ <span class="pill">\${escapeHtml(valueLabel(profile.provider))}</span>
899
+ <span class="pill">\${escapeHtml(profile.model)}</span>
900
+ <span class="pill">\${escapeHtml(String(profile.dimensions))}d</span>
901
+ <span class="pill">\${escapeHtml(t("label.count"))}: \${escapeHtml(profile.embedding_count)}</span>
902
+ </div>
903
+ <div class="subtle">\${escapeHtml(profile.updated_at || "-")}</div>
904
+ </div>
905
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noProfiles")) + '</div>';
906
+ }
907
+
908
+ function runtimeStateLabel(runtime) {
909
+ if (!runtime?.provider) return t("embedding.notConfigured");
910
+ return runtime.available ? t("embedding.available") : t("embedding.unavailable");
911
+ }
912
+
913
+ function embeddingFreshValue(status) {
914
+ if (!status) return "-";
915
+ return String(status.fresh || 0) + "/" + String(status.total || 0);
916
+ }
917
+
918
+ function embeddingNeedsReindex(status) {
919
+ if (!status || status.total === 0) return t("embedding.noProfile");
920
+ return status.fresh === status.total ? t("embedding.activeProfile") : t("embedding.profileNeedsReindex");
921
+ }
922
+
923
+ function shortProfileId(profileId) {
924
+ if (!profileId) return "-";
925
+ const parts = profileId.split(":");
926
+ return parts.length >= 4 ? parts[0] + " / " + parts[1] : profileId;
927
+ }
928
+
929
+ function metric(label, value, detail) {
930
+ return \`<div class="metric"><div class="metric-label">\${escapeHtml(label)}</div><div class="metric-value">\${escapeHtml(value)}</div><div class="subtle">\${escapeHtml(detail)}</div></div>\`;
931
+ }
932
+
933
+ function topValue(values) {
934
+ const counts = new Map();
935
+ values.forEach((value) => {
936
+ if (!value) return;
937
+ counts.set(value, (counts.get(value) || 0) + 1);
938
+ });
939
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))[0]?.[0] || "";
940
+ }
941
+
942
+ function topValues(values, limit, translateValues = false) {
943
+ const counts = new Map();
944
+ values.forEach((value) => {
945
+ if (!value) return;
946
+ counts.set(value, (counts.get(value) || 0) + 1);
947
+ });
948
+ return [...counts.entries()]
949
+ .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
950
+ .slice(0, limit)
951
+ .map(([label, count]) => ({ label: translateValues ? valueLabel(label) : label, count }));
952
+ }
953
+
954
+ function visibleTraps() {
955
+ const query = state.trapSearch.trim().toLowerCase();
956
+ const traps = query ? state.traps.filter((trap) => trapSearchText(trap).includes(query)) : state.traps;
957
+ return sortTraps(traps, state.trapSort);
958
+ }
959
+
960
+ function sortTraps(traps, sortKey) {
961
+ const sorted = [...traps];
962
+ sorted.sort((a, b) => {
963
+ if (sortKey === "severity") return severityRank(b.severity) - severityRank(a.severity) || byUpdatedDesc(a, b) || byTitle(a, b);
964
+ if (sortKey === "hits") return Number(b.hit_count || 0) - Number(a.hit_count || 0) || byUpdatedDesc(a, b) || byTitle(a, b);
965
+ if (sortKey === "category") return byText(a.category, b.category) || byTitle(a, b);
966
+ if (sortKey === "title") return byTitle(a, b);
967
+ return byUpdatedDesc(a, b) || byTitle(a, b);
968
+ });
969
+ return sorted;
970
+ }
971
+
972
+ function sortLabel(sortKey) {
973
+ return sortKey === "severity" ? t("sortLabel.severity")
974
+ : sortKey === "hits" ? t("sortLabel.hits")
975
+ : sortKey === "category" ? t("sortLabel.category")
976
+ : sortKey === "title" ? t("sortLabel.title")
977
+ : t("sortLabel.updated");
978
+ }
979
+
980
+ function byUpdatedDesc(a, b) {
981
+ return byText(b.updated_at || b.created_at || "", a.updated_at || a.created_at || "");
982
+ }
983
+
984
+ function byTitle(a, b) {
985
+ return byText(a.title, b.title);
986
+ }
987
+
988
+ function byText(a, b) {
989
+ return String(a || "").localeCompare(String(b || ""));
990
+ }
991
+
992
+ function severityRank(severity) {
993
+ return severity === "critical" ? 4 : severity === "error" ? 3 : severity === "warning" ? 2 : severity === "info" ? 1 : 0;
994
+ }
995
+
996
+ function trapSearchText(trap) {
997
+ return [
998
+ trap.title,
999
+ trap.category,
1000
+ trap.severity,
1001
+ trap.status,
1002
+ trap.scope,
1003
+ trap.context,
1004
+ trap.mistake,
1005
+ trap.fix,
1006
+ trap.module,
1007
+ trap.owner,
1008
+ ...(trap.tags || []),
1009
+ ...(trap.path_globs || []),
1010
+ ].filter(Boolean).join(" ").toLowerCase();
1011
+ }
1012
+
1013
+ function selectVisibleTrap(traps = visibleTraps()) {
1014
+ if (!traps.some((trap) => trapKey(trap) === state.trapKey)) {
1015
+ state.trapKey = traps[0] ? trapKey(traps[0]) : null;
1016
+ }
1017
+ }
1018
+
1019
+ function currentTrap() {
1020
+ return state.traps.find((trap) => trapKey(trap) === state.trapKey) || null;
1021
+ }
1022
+
1023
+ function trapKey(trap) {
1024
+ return trap.scope + ":" + trap.id;
1025
+ }
1026
+
1027
+ function bindTrapJumpButtons() {
1028
+ document.querySelectorAll("[data-view-trap-scope][data-view-trap-id]").forEach((button) => {
1029
+ if (button.dataset.jumpBound === "true") return;
1030
+ button.dataset.jumpBound = "true";
1031
+ button.addEventListener("click", async (event) => {
1032
+ event.stopPropagation();
1033
+ const id = Number.parseInt(button.dataset.viewTrapId, 10);
1034
+ if (!button.dataset.viewTrapScope || !Number.isInteger(id)) return;
1035
+ await jumpToTrap(button.dataset.viewTrapScope, id);
1036
+ });
1037
+ });
1038
+ }
1039
+
1040
+ async function jumpToTrap(scope, id) {
1041
+ const key = scope + ":" + id;
1042
+ state.mainView = "library";
1043
+ state.candidateId = null;
1044
+ state.trapSearch = "";
1045
+ state.trapFilters = { scope, status: "all", category: "", module: "", owner: "" };
1046
+ state.trapKey = key;
1047
+ renderMainViewButtons();
1048
+ await loadTraps();
1049
+ if (state.traps.some((trap) => trapKey(trap) === key)) {
1050
+ state.trapKey = key;
1051
+ renderTrapResults();
1052
+ renderTrapDetail();
1053
+ showStatus(t("status.openedTrap", { id }));
1054
+ } else {
1055
+ showStatus(t("status.trapNotInLibrary", { id }), true);
1056
+ }
1057
+ }
1058
+
1059
+ function renderCandidateViewTabs(pendingCount, reviewedCount) {
1060
+ document.querySelectorAll("[data-candidate-view]").forEach((button) => {
1061
+ const view = button.dataset.candidateView;
1062
+ const count = view === "inbox" ? pendingCount : reviewedCount;
1063
+ button.classList.toggle("active", view === state.candidateView);
1064
+ button.textContent = t(view === "inbox" ? "tab.inbox" : "tab.reviewed", { count });
1065
+ });
1066
+ }
1067
+
1068
+ function renderReviewSummary(summary = visibleReviewSummary(state.candidateReview)) {
1069
+ const target = el("review-summary");
1070
+ if (!target) return;
1071
+ if (!summary) {
1072
+ target.classList.add("hidden");
1073
+ target.innerHTML = "";
1074
+ return;
1075
+ }
1076
+ target.classList.remove("hidden");
1077
+ target.innerHTML = \`
1078
+ <div class="review-banner">
1079
+ <strong>\${escapeHtml(t("reviewSummary.pending", { count: summary.pending_count }))}</strong>
1080
+ <span>\${escapeHtml(t("reviewSummary.sessions", { count: summary.pending_session_count }))}</span>
1081
+ <span>\${escapeHtml(t("reviewSummary.quality", { high: summary.high_quality_pending_count, edit: summary.needs_edit_count }))}</span>
1082
+ </div>
1083
+ \`;
1084
+ }
1085
+
1086
+ function hideReviewSummary() {
1087
+ const target = el("review-summary");
1088
+ if (!target) return;
1089
+ target.classList.add("hidden");
1090
+ target.innerHTML = "";
1091
+ }
1092
+
1093
+ function renderTrapDetail() {
1094
+ if (state.mainView !== "library") return;
1095
+ const trap = currentTrap();
1096
+ el("detail-title").textContent = t("title.trapDetail");
1097
+ el("detail-meta").textContent = trap ? "#" + trap.id + " / " + valueLabel(trap.scope) : t("meta.selectTrap");
1098
+ if (!trap) {
1099
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noTrapSelected")) + '</div>';
1100
+ return;
1101
+ }
1102
+
1103
+ const key = trapKey(trap);
1104
+ const details = state.trapDetails[key];
1105
+ if (!details) {
1106
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.loadingTrapDetails")) + '</div>';
1107
+ ensureTrapDetail(trap);
1108
+ return;
1109
+ }
1110
+
1111
+ const detailTrap = details.trap;
1112
+ el("detail").innerHTML = \`
1113
+ <div class="scroll">
1114
+ <div class="section">
1115
+ <div class="meta">
1116
+ <span class="pill scope">\${escapeHtml(valueLabel(details.scope))}</span>
1117
+ <span class="pill \${escapeAttr(detailTrap.severity)}">\${escapeHtml(valueLabel(detailTrap.severity))}</span>
1118
+ <span class="pill">\${escapeHtml(valueLabel(detailTrap.category))}</span>
1119
+ <span class="pill \${escapeAttr(detailTrap.status)}">\${escapeHtml(valueLabel(detailTrap.status))}</span>
1120
+ <span class="pill">\${escapeHtml(t("pill.hits", { count: Number(detailTrap.hit_count || 0) }))}</span>
1121
+ </div>
1122
+ <div class="title" style="font-size:16px">\${escapeHtml(detailTrap.title)}</div>
1123
+ </div>
1124
+ <div class="section">
1125
+ \${textBlock(t("label.context"), detailTrap.context)}
1126
+ \${textBlock(t("label.mistake"), detailTrap.mistake)}
1127
+ \${textBlock(t("label.fix"), detailTrap.fix)}
1128
+ </div>
1129
+ <div class="section">
1130
+ <div class="detail-kv">
1131
+ \${kv(t("label.tags"), (detailTrap.tags || []).join(", ") || "-")}
1132
+ \${kv(t("label.pathGlobs"), (detailTrap.path_globs || []).join(", ") || "-")}
1133
+ \${kv(t("label.module"), detailTrap.module || "-")}
1134
+ \${kv(t("label.owner"), detailTrap.owner || "-")}
1135
+ \${kv(t("label.created"), detailTrap.created_at || "-")}
1136
+ \${kv(t("label.updated"), detailTrap.updated_at || "-")}
1137
+ \${kv(t("label.stateKey"), detailTrap.state_key || "-")}
1138
+ \${kv(t("label.supersedes"), detailTrap.supersedes_id ?? "-")}
1139
+ \${kv(t("label.validFrom"), detailTrap.valid_from || "-")}
1140
+ \${kv(t("label.validUntil"), detailTrap.valid_until || "-")}
1141
+ </div>
1142
+ </div>
1143
+ \${renderTrapCode(t("title.before"), detailTrap.before_code)}
1144
+ \${renderTrapCode(t("title.after"), detailTrap.after_code)}
1145
+ <div class="section">
1146
+ <div class="title">\${escapeHtml(t("title.evidence"))}</div>
1147
+ \${details.evidence.length ? details.evidence.map(renderEvidence).join("") : '<div class="empty">' + escapeHtml(t("empty.noEvidence")) + '</div>'}
1148
+ </div>
1149
+ </div>
1150
+ \`;
1151
+ }
1152
+
1153
+ async function ensureTrapDetail(trap) {
1154
+ const key = trapKey(trap);
1155
+ if (state.trapDetails[key] || state.trapLoadingKey === key) return;
1156
+ state.trapLoadingKey = key;
1157
+ try {
1158
+ const params = new URLSearchParams({
1159
+ project: state.projectRoot,
1160
+ id: String(trap.id),
1161
+ scope: trap.scope,
1162
+ });
1163
+ state.trapDetails[key] = await api("/api/trap?" + params.toString());
1164
+ if (state.mainView === "library" && state.trapKey === key) renderTrapDetail();
1165
+ } catch (error) {
1166
+ showStatus(error.message, true);
1167
+ } finally {
1168
+ if (state.trapLoadingKey === key) state.trapLoadingKey = null;
1169
+ }
1170
+ }
1171
+
1172
+ function textBlock(label, value) {
1173
+ return \`<div class="text-block"><label>\${escapeHtml(label)}</label><div class="content">\${escapeHtml(value || "-")}</div></div>\`;
1174
+ }
1175
+
1176
+ function kv(label, value) {
1177
+ return \`<div class="kv"><div class="kv-label">\${escapeHtml(label)}</div><div class="kv-value">\${escapeHtml(value)}</div></div>\`;
1178
+ }
1179
+
1180
+ function renderTrapCode(label, value) {
1181
+ if (!value) return "";
1182
+ return \`<div class="section"><div class="title">\${escapeHtml(label)}</div><pre class="code-block"><code>\${escapeHtml(value)}</code></pre></div>\`;
1183
+ }
1184
+
1185
+ function renderDetail() {
1186
+ if (state.mainView !== "review") return;
1187
+ const candidate = state.candidates.find((item) => item.id === state.candidateId);
1188
+ el("detail-meta").textContent = candidate ? candidate.id + " / " + valueLabel(candidate.status) : t("meta.selectCandidate");
1189
+ if (!candidate) {
1190
+ state.candidateDirty = false;
1191
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noCandidateSelected")) + '</div>';
1192
+ return;
1193
+ }
1194
+ state.candidateDirty = false;
1195
+ const disabled = candidate.status !== "proposed" ? "disabled" : "";
1196
+ el("detail").innerHTML = \`
1197
+ <div class="scroll">
1198
+ \${renderReviewNotice(candidate)}
1199
+ <form class="section" id="candidate-form">
1200
+ <div class="form-grid">
1201
+ \${field("title", t("label.title"), candidate.trap.title, disabled)}
1202
+ \${selectField("category", t("label.category"), candidate.trap.category, state.options.categories, disabled)}
1203
+ \${selectField("scope", t("label.scope"), candidate.trap.scope, state.options.scopes, disabled)}
1204
+ \${selectField("severity", t("label.severity"), candidate.trap.severity || "warning", state.options.severities, disabled)}
1205
+ \${field("tags", t("label.tags"), (candidate.trap.tags || []).join(", "), disabled)}
1206
+ \${field("path_globs", t("label.pathGlobs"), (candidate.trap.path_globs || []).join(", "), disabled)}
1207
+ \${field("module", t("label.module"), candidate.trap.module || "", disabled)}
1208
+ \${field("owner", t("label.owner"), candidate.trap.owner || "", disabled)}
1209
+ \${textarea("context", t("label.context"), candidate.trap.context, disabled)}
1210
+ \${textarea("mistake", t("label.mistake"), candidate.trap.mistake, disabled)}
1211
+ \${textarea("fix", t("label.fix"), candidate.trap.fix, disabled)}
1212
+ </div>
1213
+ </form>
1214
+ <div class="section">
1215
+ <div class="meta">
1216
+ <span class="pill">\${escapeHtml(t("pill.quality", { score: Number(candidate.quality_score).toFixed(2) }))}</span>
1217
+ <span class="pill">\${escapeHtml(t("pill.conflict", { status: valueLabel(candidate.quality.conflict_status) }))}</span>
1218
+ <span class="pill">\${escapeHtml(t("pill.action", { action: valueLabel(candidate.quality.suggested_action) }))}</span>
1219
+ </div>
1220
+ \${candidate.quality.warnings.map((warning) => '<div class="warning">' + escapeHtml(warning) + '</div>').join("")}
1221
+ </div>
1222
+ <div class="section">
1223
+ <div class="title">\${escapeHtml(t("title.evidence"))}</div>
1224
+ \${candidate.evidence.length ? candidate.evidence.map(renderEvidence).join("") : '<div class="empty">' + escapeHtml(t("empty.noEvidence")) + '</div>'}
1225
+ </div>
1226
+ \${renderConflicts()}
1227
+ </div>
1228
+ \${renderDetailActions(candidate, disabled)}
1229
+ \`;
1230
+ bindDetailActions(candidate);
1231
+ bindCandidateFormDirty(candidate);
1232
+ bindTrapJumpButtons();
1233
+ }
1234
+
1235
+ function renderReviewNotice(candidate) {
1236
+ const review = candidate.review;
1237
+ if (!review || review.status === "pending") return "";
1238
+ if (review.status === "accepted_missing") {
1239
+ return \`<div class="section"><div class="warning">
1240
+ <div class="meta">
1241
+ <span class="pill accepted-missing">\${escapeHtml(reviewLabel(candidate))}</span>
1242
+ <button type="button" class="ghost" data-clean-deleted-candidates>\${escapeHtml(t("action.cleanDeletedCandidates"))}</button>
1243
+ </div>
1244
+ </div></div>\`;
1245
+ }
1246
+ if (review.status === "accepted") {
1247
+ return \`<div class="section"><div class="evidence review-note">
1248
+ <div class="meta">
1249
+ <span class="pill accepted">\${escapeHtml(reviewLabel(candidate))}</span>
1250
+ <span class="pill">\${escapeHtml(valueLabel(review.trap_status))}</span>
1251
+ <button type="button" class="ghost" data-view-trap-scope="\${escapeAttr(review.scope)}" data-view-trap-id="\${escapeAttr(review.trap_id)}">\${escapeHtml(t("action.viewTrap"))}</button>
1252
+ </div>
1253
+ <div class="subtle">\${escapeHtml(review.trap_title)}</div>
1254
+ </div></div>\`;
1255
+ }
1256
+ if (review.status === "rejected") {
1257
+ return \`<div class="section"><div class="evidence">
1258
+ <div class="meta"><span class="pill rejected">\${escapeHtml(reviewLabel(candidate))}</span></div>
1259
+ \${review.rejection_reason ? '<div class="subtle">' + escapeHtml(review.rejection_reason) + '</div>' : ''}
1260
+ </div></div>\`;
1261
+ }
1262
+ return "";
1263
+ }
1264
+
1265
+ function renderDetailActions(candidate, disabled) {
1266
+ if (candidate.status !== "proposed") {
1267
+ const review = candidate.review;
1268
+ const viewTrap = review?.status === "accepted"
1269
+ ? \`<button type="button" data-view-trap-scope="\${escapeAttr(review.scope)}" data-view-trap-id="\${escapeAttr(review.trap_id)}">\${escapeHtml(t("action.viewTrap"))}</button>\`
1270
+ : "";
1271
+ const cleanDeleted = review?.status === "accepted_missing"
1272
+ ? \`<button type="button" data-clean-deleted-candidates>\${escapeHtml(t("action.cleanDeletedCandidates"))}</button>\`
1273
+ : "";
1274
+ return \`<div class="actions"><span class="pill \${reviewCssClass(candidate)}">\${escapeHtml(reviewLabel(candidate))}</span>\${viewTrap}\${cleanDeleted}</div>\`;
1275
+ }
1276
+ return \`<div class="actions">
1277
+ <button id="save" class="primary" \${disabled}>\${escapeHtml(t("action.save"))}</button>
1278
+ <button id="accept" \${disabled}>\${escapeHtml(t("action.accept"))}</button>
1279
+ <button id="reject" class="danger" \${disabled}>\${escapeHtml(t("action.reject"))}</button>
1280
+ <button id="accept-anyway" \${disabled}>\${escapeHtml(t("action.acceptAnyway"))}</button>
1281
+ <input id="supersedes" placeholder="\${escapeAttr(t("placeholder.supersedesId"))}" style="width:150px" \${disabled}>
1282
+ <button id="supersede" \${disabled}>\${escapeHtml(t("action.supersede"))}</button>
1283
+ <span id="candidate-draft-state" class="action-hint">\${escapeHtml(t("hint.acceptUsesCurrentDraft"))}</span>
1284
+ </div>\`;
1285
+ }
1286
+
1287
+ function bindCandidateFormDirty(candidate) {
1288
+ const form = el("candidate-form");
1289
+ if (!form || candidate.status !== "proposed") return;
1290
+ const markDirty = () => {
1291
+ state.candidateDirty = true;
1292
+ renderCandidateDraftState();
1293
+ };
1294
+ form.addEventListener("input", markDirty);
1295
+ form.addEventListener("change", markDirty);
1296
+ renderCandidateDraftState();
1297
+ }
1298
+
1299
+ function renderCandidateDraftState() {
1300
+ const draft = el("candidate-draft-state");
1301
+ if (!draft) return;
1302
+ draft.textContent = state.candidateDirty ? t("hint.unsavedDraftAccepted") : t("hint.acceptUsesCurrentDraft");
1303
+ draft.classList.toggle("dirty", state.candidateDirty);
1304
+ }
1305
+
1306
+ function bindDetailActions(candidate) {
1307
+ document.querySelectorAll("[data-clean-deleted-candidates]").forEach((button) => {
1308
+ button.addEventListener("click", cleanupDeletedCandidates);
1309
+ });
1310
+ const save = el("save");
1311
+ if (!save) return;
1312
+ save.addEventListener("click", async () => {
1313
+ try {
1314
+ const data = await api("/api/candidate/save", {
1315
+ method: "POST",
1316
+ body: JSON.stringify(candidatePayload(candidate.id))
1317
+ });
1318
+ state.candidateDirty = false;
1319
+ await syncAfterMutation(data.candidate.id);
1320
+ showStatus(t("status.candidateSaved"));
1321
+ } catch (error) {
1322
+ showStatus(error.message, true);
1323
+ }
1324
+ });
1325
+ el("accept").addEventListener("click", () => acceptCandidate({}));
1326
+ el("accept-anyway").addEventListener("click", () => acceptCandidate({ acceptAnyway: true }));
1327
+ el("supersede").addEventListener("click", () => {
1328
+ const value = Number.parseInt(el("supersedes").value, 10);
1329
+ if (Number.isNaN(value)) return showStatus(t("status.supersedesRequired"), true);
1330
+ acceptCandidate({ supersedesId: value });
1331
+ });
1332
+ el("reject").addEventListener("click", async () => {
1333
+ const reason = prompt(t("prompt.rejectReason")) || "";
1334
+ try {
1335
+ const data = await api("/api/candidate/reject", {
1336
+ method: "POST",
1337
+ body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId, candidateId: candidate.id, reason })
1338
+ });
1339
+ await syncAfterMutation(data.candidate.id);
1340
+ showStatus(t("status.candidateRejected"));
1341
+ } catch (error) {
1342
+ showStatus(error.message, true);
1343
+ }
1344
+ });
1345
+ }
1346
+
1347
+ async function acceptCandidate(extra) {
1348
+ try {
1349
+ const payload = el("candidate-form")
1350
+ ? candidatePayload(state.candidateId, extra)
1351
+ : {
1352
+ projectRoot: state.projectRoot,
1353
+ sessionId: state.sessionId,
1354
+ candidateId: state.candidateId,
1355
+ ...extra
1356
+ };
1357
+ const data = await api("/api/candidate/accept", {
1358
+ method: "POST",
1359
+ body: JSON.stringify(payload)
1360
+ });
1361
+ await syncAfterMutation(data.candidate.id);
1362
+ state.conflicts = [];
1363
+ state.candidateDirty = false;
1364
+ showStatus(t("status.candidateAccepted"));
1365
+ } catch (error) {
1366
+ if (error.payload?.possible_conflicts) {
1367
+ state.conflicts = error.payload.possible_conflicts;
1368
+ showStatus(t("status.possibleConflict"), true);
1369
+ await loadCandidates();
1370
+ state.conflicts = error.payload.possible_conflicts;
1371
+ renderDetail();
1372
+ } else {
1373
+ showStatus(error.message, true);
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ function candidatePayload(candidateId, extra = {}) {
1379
+ return reviewCandidateMutationPayload({
1380
+ projectRoot: state.projectRoot,
1381
+ sessionId: state.sessionId,
1382
+ candidateId,
1383
+ trap: reviewCandidateTrapDraft(candidateFormFields()),
1384
+ extra
1385
+ });
1386
+ }
1387
+
1388
+ function candidateFormFields() {
1389
+ const form = new FormData(el("candidate-form"));
1390
+ return {
1391
+ title: form.get("title"),
1392
+ category: form.get("category"),
1393
+ scope: form.get("scope"),
1394
+ severity: form.get("severity"),
1395
+ tags: form.get("tags"),
1396
+ path_globs: form.get("path_globs"),
1397
+ module: form.get("module"),
1398
+ owner: form.get("owner"),
1399
+ context: form.get("context"),
1400
+ mistake: form.get("mistake"),
1401
+ fix: form.get("fix")
1402
+ };
1403
+ }
1404
+
1405
+ function replaceCandidate(candidate) {
1406
+ state.candidates = state.candidates.map((item) => item.id === candidate.id ? candidate : item);
1407
+ renderCandidates();
1408
+ renderDetail();
1409
+ }
1410
+
1411
+ async function syncAfterMutation(candidateId) {
1412
+ state.candidateId = candidateId;
1413
+ await loadSessions();
1414
+ }
1415
+
1416
+ async function refreshAll() {
1417
+ try {
1418
+ await bootstrap();
1419
+ showStatus(t("status.refreshed"));
1420
+ } catch (error) {
1421
+ showStatus(error.message, true);
1422
+ }
1423
+ }
1424
+
1425
+ el("refresh").addEventListener("click", refreshAll);
1426
+ el("sidebar-toggle").addEventListener("click", () => {
1427
+ setSidebarCollapsed(!state.sidebarCollapsed);
1428
+ });
1429
+ el("queue-toggle").addEventListener("click", () => {
1430
+ setQueueCollapsed(!state.queueCollapsed);
1431
+ });
1432
+ document.querySelectorAll("[data-locale]").forEach((button) => {
1433
+ button.addEventListener("click", () => setLocale(button.dataset.locale));
1434
+ });
1435
+ document.querySelectorAll("[data-main-view]").forEach((button) => {
1436
+ button.addEventListener("click", async () => {
1437
+ state.mainView = button.dataset.mainView;
1438
+ state.candidateId = null;
1439
+ state.trapKey = null;
1440
+ renderActiveView();
1441
+ if (state.mainView === "library") {
1442
+ await loadTraps();
1443
+ } else if (state.mainView === "insights") {
1444
+ await loadInsightTraps();
1445
+ } else if (state.mainView === "embeddings") {
1446
+ await loadEmbeddings();
1447
+ } else {
1448
+ await loadCandidates();
1449
+ }
1450
+ });
1451
+ });
1452
+ document.querySelectorAll("[data-candidate-view]").forEach((button) => {
1453
+ button.addEventListener("click", () => {
1454
+ state.candidateView = button.dataset.candidateView;
1455
+ state.candidateId = null;
1456
+ state.conflicts = [];
1457
+ renderCandidates();
1458
+ renderDetail();
1459
+ });
1460
+ });
1461
+ el("project-form").addEventListener("submit", async (event) => {
1462
+ event.preventDefault();
1463
+ try {
1464
+ const path = el("project-path").value.trim();
1465
+ if (!path) return;
1466
+ const data = await api("/api/projects", { method: "POST", body: JSON.stringify({ path }) });
1467
+ state.projects = data.projects;
1468
+ state.projectRoot = data.project.root;
1469
+ state.sessionId = null;
1470
+ state.candidateId = null;
1471
+ state.trapKey = null;
1472
+ state.trapDetails = {};
1473
+ state.insightTraps = [];
1474
+ state.embeddingStatus = null;
1475
+ state.embeddingSettings = null;
1476
+ el("project-path").value = "";
1477
+ renderProjects();
1478
+ await loadSessions();
1479
+ } catch (error) {
1480
+ showStatus(error.message, true);
1481
+ }
1482
+ });
1483
+
1484
+ function field(name, label, value, disabled) {
1485
+ return \`<div class="field"><label for="\${name}">\${label}</label><input id="\${name}" name="\${name}" value="\${escapeAttr(value || "")}" \${disabled}></div>\`;
1486
+ }
1487
+
1488
+ function textarea(name, label, value, disabled) {
1489
+ return \`<div class="field full"><label for="\${name}">\${label}</label><textarea id="\${name}" name="\${name}" \${disabled}>\${escapeHtml(value || "")}</textarea></div>\`;
1490
+ }
1491
+
1492
+ function selectField(name, label, value, options, disabled) {
1493
+ return \`<div class="field"><label for="\${name}">\${label}</label><select id="\${name}" name="\${name}" \${disabled}>\${options.map((option) => \`<option value="\${escapeAttr(option)}" \${option === value ? "selected" : ""}>\${escapeHtml(valueLabel(option))}</option>\`).join("")}</select></div>\`;
1494
+ }
1495
+
1496
+ function renderEvidence(evidence) {
1497
+ return \`<div class="evidence">
1498
+ <div class="meta">
1499
+ <span class="pill">\${escapeHtml(valueLabel(evidence.source_type))}</span>
1500
+ \${evidence.source_ref ? '<span class="pill">' + escapeHtml(evidence.source_ref) + '</span>' : ''}
1501
+ </div>
1502
+ <div class="subtle">\${escapeHtml((evidence.related_files || []).join(", "))}</div>
1503
+ <div>\${escapeHtml(evidence.note || "")}</div>
1504
+ </div>\`;
1505
+ }
1506
+
1507
+ function renderConflicts() {
1508
+ if (!state.conflicts.length) return "";
1509
+ return \`<div class="section"><div class="title">\${escapeHtml(t("title.possibleConflicts"))}</div>\${state.conflicts.map((conflict) => \`
1510
+ <div class="conflict">
1511
+ <div class="meta"><span class="pill danger">#\${conflict.trap_id}</span><span class="pill">\${escapeHtml(valueLabel(conflict.scope))}</span><span class="pill warn">\${escapeHtml(conflict.reason)}</span></div>
1512
+ <strong>\${escapeHtml(conflict.title)}</strong>
1513
+ <div class="subtle">\${escapeHtml(conflict.context)}</div>
1514
+ <div>\${escapeHtml(conflict.fix)}</div>
1515
+ </div>\`).join("")}</div>\`;
1516
+ }
1517
+
1518
+ function reviewLabel(candidate) {
1519
+ const review = candidate.review;
1520
+ if (!review || review.status === "pending") return t("review.pending");
1521
+ if (review.status === "accepted") return t("review.accepted", { id: review.trap_id });
1522
+ if (review.status === "accepted_missing") {
1523
+ return review.trap_id === undefined ? t("review.acceptedLinkMissing") : t("review.acceptedDeleted", { id: review.trap_id });
1524
+ }
1525
+ if (review.status === "rejected") return t("review.rejected");
1526
+ return valueLabel(candidate.status);
1527
+ }
1528
+
1529
+ function reviewCssClass(candidate) {
1530
+ return String(candidate.review?.status || candidate.status).replace(/_/g, "-");
1531
+ }
1532
+
1533
+ function escapeHtml(value) {
1534
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
1535
+ }
1536
+
1537
+ function escapeAttr(value) {
1538
+ return escapeHtml(value);
1539
+ }
1540
+
1541
+ initShellResizers();
1542
+ refreshAll();`;
1543
+ }