@tenonhq/dovetail-dashboard 0.0.30 → 0.0.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-dashboard",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -173,9 +173,16 @@
173
173
  }
174
174
  .cp-list-item:hover { background: var(--bg-subtle); }
175
175
  .cp-list-item.active {
176
+ /* Selected plan: filled raised surface + brand-emerald bar + bold.
177
+ --brand flips to a bright emerald (#32b57f) in dark mode, so the bar
178
+ stays legible without using the unreadable deep green on dark grounds. */
176
179
  background: var(--surface-raised);
177
- border-left: 3px solid var(--accent);
180
+ border-left: 4px solid var(--brand);
181
+ padding-left: 12px;
182
+ font-weight: var(--weight-semibold);
183
+ box-shadow: inset 0 0 0 1px var(--border-subtle);
178
184
  }
185
+ .cp-list-item.active:hover { background: var(--surface-raised); }
179
186
  .cp-list-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
180
187
  .cp-list-title { font-weight: var(--weight-semibold); font-size: 13px; color: var(--fg); }
181
188
  .cp-list-meta { font-size: 11px; color: var(--fg-muted); display: flex; gap: 8px; }
@@ -278,7 +285,7 @@
278
285
  padding: 1px 6px;
279
286
  border-radius: var(--radius-xs);
280
287
  font-size: 13px;
281
- color: var(--emerald-700);
288
+ color: var(--brand);
282
289
  }
283
290
  .cp-tab-panel pre {
284
291
  background: var(--neutral-900);
@@ -292,7 +299,7 @@
292
299
  .cp-tab-panel th, .cp-tab-panel td { border: 1px solid var(--border); padding: 7px 11px; }
293
300
  .cp-tab-panel th { background: var(--bg-subtle); }
294
301
  .cp-tab-panel a { color: var(--brand); }
295
- .cp-tab-panel a:hover { color: var(--emerald-700); }
302
+ .cp-tab-panel a:hover { color: var(--brand); }
296
303
  .cp-tab-panel blockquote {
297
304
  border-left: 3px solid var(--accent);
298
305
  padding-left: 12px;
@@ -635,7 +642,7 @@
635
642
  color: var(--fg-muted);
636
643
  text-align: center;
637
644
  }
638
- .cp-c-step-done .cp-c-step-label { color: var(--emerald-700); }
645
+ .cp-c-step-done .cp-c-step-label { color: var(--brand); }
639
646
  .cp-c-step-active .cp-c-step-label { color: var(--fg); font-weight: var(--weight-bold); }
640
647
  .cp-c-step-error .cp-c-step-label { color: var(--error-700); }
641
648
  .cp-c-step-note {
@@ -1312,6 +1319,49 @@
1312
1319
  background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
1313
1320
  }
1314
1321
 
1322
+ /* Merged-PR filter toggle + per-row PR state dots */
1323
+ .cp-rail-filters {
1324
+ padding: 8px 0 4px;
1325
+ }
1326
+ .cp-pr-toggle {
1327
+ display: inline-flex;
1328
+ align-items: center;
1329
+ gap: 6px;
1330
+ padding: 4px 12px;
1331
+ background: var(--surface);
1332
+ color: var(--fg);
1333
+ border: 1px solid var(--border);
1334
+ border-radius: 999px;
1335
+ font-family: inherit;
1336
+ font-size: 12px;
1337
+ font-weight: 500;
1338
+ cursor: pointer;
1339
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
1340
+ }
1341
+ .cp-pr-toggle:hover {
1342
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
1343
+ border-color: var(--accent);
1344
+ }
1345
+ .cp-pr-toggle--active {
1346
+ background: var(--accent);
1347
+ color: var(--accent-fg);
1348
+ border-color: var(--accent);
1349
+ }
1350
+
1351
+ .cp-pr-dot {
1352
+ display: inline-block;
1353
+ width: 8px;
1354
+ height: 8px;
1355
+ border-radius: 50%;
1356
+ flex: 0 0 auto;
1357
+ margin: 0 4px;
1358
+ background: var(--fg-muted);
1359
+ }
1360
+ .cp-pr-dot--merged { background: var(--success-700); }
1361
+ .cp-pr-dot--open { background: var(--warning-700); }
1362
+ .cp-pr-dot--closed { background: var(--danger); }
1363
+ .cp-pr-dot--unknown { background: var(--fg-muted); }
1364
+
1315
1365
  /* ===========================================================================
1316
1366
  * v2 — Stage Map, Questions tab, Dispatch dialog (Phase E)
1317
1367
  * Lightweight additions on top of the existing token system.
@@ -1483,3 +1533,48 @@
1483
1533
  margin-top: 10px;
1484
1534
  font-size: 13px;
1485
1535
  }
1536
+
1537
+ /* ─── Version history (History tab) ─────────────────────────────────────────
1538
+ * Snapshot rows with View (inline expand) + Restore. Uses semantic tokens so
1539
+ * it tracks light/dark; --brand flips to bright emerald in dark mode. */
1540
+ .cp-versions { display: flex; flex-direction: column; gap: 8px; }
1541
+ .cp-version {
1542
+ border: 1px solid var(--border);
1543
+ border-radius: var(--radius-md);
1544
+ background: var(--surface);
1545
+ }
1546
+ .cp-version--current {
1547
+ border-left: 4px solid var(--brand);
1548
+ background: var(--surface-raised);
1549
+ }
1550
+ .cp-version-head {
1551
+ display: flex;
1552
+ align-items: center;
1553
+ gap: 10px;
1554
+ padding: 10px 14px;
1555
+ }
1556
+ .cp-version-label { font-weight: var(--weight-semibold); }
1557
+ .cp-version-status { text-transform: none; }
1558
+ .cp-version-stamp { color: var(--fg-muted); font-size: 12px; }
1559
+ .cp-version-actions { margin-left: auto; display: flex; gap: 8px; }
1560
+ .cp-version-btn {
1561
+ border: 1px solid var(--border-strong);
1562
+ background: var(--surface);
1563
+ color: var(--fg);
1564
+ border-radius: var(--radius-sm);
1565
+ padding: 4px 12px;
1566
+ font-size: 13px;
1567
+ cursor: pointer;
1568
+ transition: background var(--dur-1) var(--ease-out);
1569
+ }
1570
+ .cp-version-btn:hover { background: var(--bg-subtle); }
1571
+ .cp-version-btn:disabled { opacity: 0.6; cursor: default; }
1572
+ .cp-version-btn--primary {
1573
+ border-color: var(--brand);
1574
+ color: var(--brand);
1575
+ font-weight: var(--weight-semibold);
1576
+ }
1577
+ .cp-version-body {
1578
+ border-top: 1px solid var(--border);
1579
+ padding: 14px;
1580
+ }
@@ -15,9 +15,9 @@
15
15
  } catch (e) {}
16
16
  })();
17
17
  </script>
18
- <link rel="stylesheet" href="tokens.css" />
19
- <link rel="stylesheet" href="styles.css" />
20
- <link rel="stylesheet" href="claude-plans.css" />
18
+ <link rel="stylesheet" href="/tokens.css" />
19
+ <link rel="stylesheet" href="/styles.css" />
20
+ <link rel="stylesheet" href="/claude-plans.css" />
21
21
  </head>
22
22
  <body class="cp-body">
23
23
  <header>
@@ -84,6 +84,15 @@
84
84
  hidden
85
85
  >×</button>
86
86
  </div>
87
+ <div class="cp-rail-filters">
88
+ <button
89
+ type="button"
90
+ class="cp-pr-toggle"
91
+ id="cp-merged-toggle"
92
+ aria-pressed="false"
93
+ title="Show only plans whose linked PR is merged"
94
+ >Merged PRs only</button>
95
+ </div>
87
96
  <div class="cp-rail-empty" id="cp-rail-empty">
88
97
  Waiting for plans. Run <code>push_plan</code> from a Claude Code
89
98
  session to populate this list.
@@ -118,11 +127,15 @@
118
127
  <button class="cp-tab" data-tab="questions">
119
128
  Q&amp;A <span class="cp-tab-count" id="cp-question-count">0</span>
120
129
  </button>
130
+ <button class="cp-tab" data-tab="versions">
131
+ History <span class="cp-tab-count" id="cp-version-count">0</span>
132
+ </button>
121
133
  </nav>
122
134
  <div class="cp-tab-panel" id="cp-tab-plan"></div>
123
135
  <div class="cp-tab-panel" id="cp-tab-prompts" hidden></div>
124
136
  <div class="cp-tab-panel" id="cp-tab-artifacts" hidden></div>
125
137
  <div class="cp-tab-panel" id="cp-tab-questions" hidden></div>
138
+ <div class="cp-tab-panel" id="cp-tab-versions" hidden></div>
126
139
  </article>
127
140
  </section>
128
141
  </main>
@@ -130,6 +143,6 @@
130
143
  <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
131
144
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.5/dist/purify.min.js"></script>
132
145
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js"></script>
133
- <script src="claude-plans.js"></script>
146
+ <script src="/claude-plans.js"></script>
134
147
  </body>
135
148
  </html>
@@ -37,7 +37,9 @@
37
37
  selectedSlug: null,
38
38
  activeTab: "plan",
39
39
  query: "", // lowercased search query; "" means show all
40
- topicFilter: null // active topic label, null = no topic filter
40
+ topicFilter: null, // active topic label, null = no topic filter
41
+ prStatus: new Map(), // pr_url -> { state, merged } (live from gh, lazy)
42
+ mergedOnly: false // when true, show only plans with a merged PR
41
43
  };
42
44
 
43
45
  var els = {
@@ -62,15 +64,45 @@
62
64
  promptsPanel: document.getElementById("cp-tab-prompts"),
63
65
  questionsPanel: document.getElementById("cp-tab-questions"),
64
66
  questionCount: document.getElementById("cp-question-count"),
67
+ versionsPanel: document.getElementById("cp-tab-versions"),
68
+ versionCount: document.getElementById("cp-version-count"),
65
69
  stageMap: document.getElementById("cp-stage-map"),
66
70
  tabs: document.querySelectorAll(".cp-tab"),
67
71
  topics: document.getElementById("cp-topics"),
68
72
  topicsCloud: document.getElementById("cp-topics-cloud"),
69
73
  topicsCount: document.getElementById("cp-topics-count"),
70
74
  topicsEmpty: document.getElementById("cp-topics-empty"),
71
- topicsClear: document.getElementById("cp-topics-clear")
75
+ topicsClear: document.getElementById("cp-topics-clear"),
76
+ mergedToggle: document.getElementById("cp-merged-toggle")
72
77
  };
73
78
 
79
+ // Merged === true only when gh resolved the plan's PR as MERGED.
80
+ function planPrMerged(plan) {
81
+ if (!plan.pr_url) return false;
82
+ var st = state.prStatus.get(plan.pr_url);
83
+ return !!(st && st.merged);
84
+ }
85
+
86
+ function planMatchesPr(plan) {
87
+ if (!state.mergedOnly) return true;
88
+ return planPrMerged(plan);
89
+ }
90
+
91
+ // A small colored dot in the rail row reflecting the PR's merge state.
92
+ // Rendered only when the plan links a PR and gh has returned a status.
93
+ function prDotMarkup(plan) {
94
+ if (!plan.pr_url) return "";
95
+ var st = state.prStatus.get(plan.pr_url);
96
+ if (!st) return "";
97
+ var cls = "cp-pr-dot";
98
+ if (st.merged) cls += " cp-pr-dot--merged";
99
+ else if (st.state === "OPEN") cls += " cp-pr-dot--open";
100
+ else if (st.state === "CLOSED") cls += " cp-pr-dot--closed";
101
+ else cls += " cp-pr-dot--unknown";
102
+ var label = st.merged ? "merged" : (st.state || "unknown").toLowerCase();
103
+ return '<span class="' + cls + '" title="PR ' + label + '"></span>';
104
+ }
105
+
74
106
  function planMatchesTopic(plan, topic) {
75
107
  if (!topic) return true;
76
108
  var cats = plan.categories;
@@ -118,9 +150,9 @@
118
150
  var q = state.query;
119
151
  var topic = state.topicFilter;
120
152
  var all = allPlansSorted();
121
- if (!q && !topic) return all;
153
+ if (!q && !topic && !state.mergedOnly) return all;
122
154
  return all.filter(function (p) {
123
- return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
155
+ return planMatchesQuery(p, q) && planMatchesTopic(p, topic) && planMatchesPr(p);
124
156
  });
125
157
  }
126
158
 
@@ -431,7 +463,7 @@
431
463
  renderTopics();
432
464
  var plans = sortedPlans();
433
465
  var totalPlans = state.plans.size;
434
- var isFiltering = !!state.query || !!state.topicFilter;
466
+ var isFiltering = !!state.query || !!state.topicFilter || state.mergedOnly;
435
467
  els.count.textContent = isFiltering
436
468
  ? plans.length + " / " + totalPlans
437
469
  : String(totalPlans);
@@ -444,6 +476,7 @@
444
476
  var pieces = [];
445
477
  if (state.query) pieces.push('"' + state.query + '"');
446
478
  if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
479
+ if (state.mergedOnly) pieces.push("merged PRs only");
447
480
  els.railNoMatchQ.textContent = pieces.join(" + ");
448
481
  }
449
482
  } else {
@@ -464,6 +497,7 @@
464
497
  li.innerHTML =
465
498
  '<div class="cp-list-row">' +
466
499
  ' <span class="cp-list-title"></span>' +
500
+ prDotMarkup(plan) +
467
501
  ' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
468
502
  '</div>' +
469
503
  '<div class="cp-list-meta">' +
@@ -598,6 +632,7 @@
598
632
  // v2 surfaces — Stage Map strip + Questions tab.
599
633
  renderStageMap(plan);
600
634
  renderQuestions(plan);
635
+ renderVersions(plan);
601
636
 
602
637
  addPlanSectionCopyBtns();
603
638
  addTabsCopyAll(plan, artifacts, prompts);
@@ -714,6 +749,7 @@
714
749
  els.artifactsPanel.hidden = tab !== "artifacts";
715
750
  if (els.promptsPanel) els.promptsPanel.hidden = tab !== "prompts";
716
751
  if (els.questionsPanel) els.questionsPanel.hidden = tab !== "questions";
752
+ if (els.versionsPanel) els.versionsPanel.hidden = tab !== "versions";
717
753
  }
718
754
 
719
755
  /* ─── v2 bidirectional pipeline (Phase E) ──────────────────────────────────
@@ -975,6 +1011,142 @@
975
1011
  });
976
1012
  }
977
1013
 
1014
+ function renderVersions(plan) {
1015
+ if (!els.versionsPanel) return;
1016
+ var panel = els.versionsPanel;
1017
+ panel.innerHTML = "";
1018
+ fetch("/api/claude-plans/" + encodeURIComponent(plan.slug) + "/versions")
1019
+ .then(function (r) { return r.json(); })
1020
+ .then(function (data) {
1021
+ // Bail if the user switched plans while this fetch was in flight —
1022
+ // otherwise a stale response would paint the wrong plan's history.
1023
+ if (state.selectedSlug !== plan.slug) return;
1024
+ var versions = (data && data.versions) || [];
1025
+ if (els.versionCount) els.versionCount.textContent = String(versions.length);
1026
+ if (versions.length === 0) {
1027
+ var empty = document.createElement("div");
1028
+ empty.className = "cp-detail-empty";
1029
+ empty.style.padding = "40px 0";
1030
+ empty.textContent =
1031
+ "No prior versions yet. A snapshot is saved automatically whenever a " +
1032
+ "push changes this plan's content.";
1033
+ panel.appendChild(empty);
1034
+ return;
1035
+ }
1036
+ var list = document.createElement("div");
1037
+ list.className = "cp-versions";
1038
+ // Synthetic "current" row at the top for orientation (not restorable).
1039
+ list.appendChild(versionRow({
1040
+ version: null,
1041
+ saved_at: plan.updated_at,
1042
+ title: plan.title,
1043
+ status: plan.status
1044
+ }, plan, true));
1045
+ versions.forEach(function (v) {
1046
+ list.appendChild(versionRow(v, plan, false));
1047
+ });
1048
+ panel.appendChild(list);
1049
+ })
1050
+ .catch(function () {
1051
+ panel.innerHTML = "";
1052
+ var err = document.createElement("div");
1053
+ err.className = "cp-detail-empty";
1054
+ err.style.padding = "40px 0";
1055
+ err.textContent = "Could not load version history.";
1056
+ panel.appendChild(err);
1057
+ });
1058
+ }
1059
+
1060
+ function versionRow(v, plan, isCurrent) {
1061
+ var row = document.createElement("div");
1062
+ row.className = "cp-version" + (isCurrent ? " cp-version--current" : "");
1063
+
1064
+ var head = document.createElement("div");
1065
+ head.className = "cp-version-head";
1066
+
1067
+ var label = document.createElement("span");
1068
+ label.className = "cp-version-label";
1069
+ label.textContent = isCurrent ? "Current" : "v" + v.version;
1070
+ head.appendChild(label);
1071
+
1072
+ if (v.status) {
1073
+ var status = document.createElement("span");
1074
+ status.className = "cp-status-pill cp-version-status";
1075
+ status.textContent = v.status;
1076
+ head.appendChild(status);
1077
+ }
1078
+
1079
+ var stamp = document.createElement("span");
1080
+ stamp.className = "cp-stamp cp-version-stamp";
1081
+ stamp.textContent = v.saved_at ? fmtTime(v.saved_at) : "";
1082
+ head.appendChild(stamp);
1083
+
1084
+ var actions = document.createElement("span");
1085
+ actions.className = "cp-version-actions";
1086
+ if (!isCurrent) {
1087
+ var viewBtn = document.createElement("button");
1088
+ viewBtn.type = "button";
1089
+ viewBtn.className = "cp-version-btn";
1090
+ viewBtn.textContent = "View";
1091
+ var body = null;
1092
+ viewBtn.addEventListener("click", function () {
1093
+ if (body && body.parentNode) {
1094
+ body.parentNode.removeChild(body);
1095
+ body = null;
1096
+ viewBtn.textContent = "View";
1097
+ return;
1098
+ }
1099
+ viewBtn.textContent = "Hide";
1100
+ body = document.createElement("div");
1101
+ body.className = "cp-version-body cp-tab-panel";
1102
+ row.appendChild(body);
1103
+ fetch("/api/claude-plans/" + encodeURIComponent(plan.slug) + "/versions/" + v.version)
1104
+ .then(function (r) { return r.json(); })
1105
+ .then(function (snap) {
1106
+ var p = snap && snap.plan;
1107
+ if (!p) { body.textContent = "Snapshot unavailable."; return; }
1108
+ if (p.content_html && window.DOMPurify) {
1109
+ body.innerHTML = window.DOMPurify.sanitize(p.content_html);
1110
+ } else {
1111
+ renderMarkdown(p.content_md || "", body);
1112
+ }
1113
+ })
1114
+ .catch(function () { body.textContent = "Could not load snapshot."; });
1115
+ });
1116
+ actions.appendChild(viewBtn);
1117
+
1118
+ var restoreBtn = document.createElement("button");
1119
+ restoreBtn.type = "button";
1120
+ restoreBtn.className = "cp-version-btn cp-version-btn--primary";
1121
+ restoreBtn.textContent = "Restore";
1122
+ restoreBtn.addEventListener("click", function () {
1123
+ restoreVersion(plan.slug, v.version, restoreBtn);
1124
+ });
1125
+ actions.appendChild(restoreBtn);
1126
+ }
1127
+ head.appendChild(actions);
1128
+ row.appendChild(head);
1129
+ return row;
1130
+ }
1131
+
1132
+ function restoreVersion(slug, version, btn) {
1133
+ if (btn) { btn.disabled = true; btn.textContent = "Restoring…"; }
1134
+ fetch("/api/claude-plans/" + encodeURIComponent(slug) + "/versions/" + version + "/restore", {
1135
+ method: "POST"
1136
+ })
1137
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
1138
+ .then(function (res) {
1139
+ if (!res.ok) throw new Error((res.json && res.json.error) || "restore failed");
1140
+ // The plan-file write fires an SSE plan:upsert → renderDetail →
1141
+ // renderVersions refreshes the list automatically.
1142
+ showToast("Restored v" + version + " as the current plan.");
1143
+ })
1144
+ .catch(function (e) {
1145
+ if (btn) { btn.disabled = false; btn.textContent = "Restore"; }
1146
+ showToast("Restore failed: " + e.message);
1147
+ });
1148
+ }
1149
+
978
1150
  function submitAnswer(slug, questionId, answer) {
979
1151
  v2Post(slug, "answers", { question_id: questionId, answer: answer }).then(function () {
980
1152
  showToast("Answer recorded.");
@@ -1154,6 +1326,41 @@
1154
1326
  });
1155
1327
  }
1156
1328
 
1329
+ var prStatusDebounce = null;
1330
+
1331
+ // Resolve live PR merge state from the server (which shells out to gh) and
1332
+ // rerender so dots + the merged filter reflect reality. Best-effort: any
1333
+ // failure leaves prStatus as-is and the toggle simply matches nothing.
1334
+ function loadPrStatus() {
1335
+ return fetch("/api/claude-plans/pr-status")
1336
+ .then(function (r) { return r.json(); })
1337
+ .then(function (data) {
1338
+ var statuses = data && data.statuses ? data.statuses : {};
1339
+ Object.keys(statuses).forEach(function (url) {
1340
+ state.prStatus.set(url, statuses[url]);
1341
+ });
1342
+ renderRail();
1343
+ })
1344
+ .catch(function (err) {
1345
+ console.warn("[claude-plans] failed to load PR status:", err);
1346
+ });
1347
+ }
1348
+
1349
+ function schedulePrStatusRefresh() {
1350
+ if (prStatusDebounce) clearTimeout(prStatusDebounce);
1351
+ prStatusDebounce = setTimeout(loadPrStatus, 500);
1352
+ }
1353
+
1354
+ function setupMergedToggle() {
1355
+ if (!els.mergedToggle) return;
1356
+ els.mergedToggle.addEventListener("click", function () {
1357
+ state.mergedOnly = !state.mergedOnly;
1358
+ els.mergedToggle.setAttribute("aria-pressed", state.mergedOnly ? "true" : "false");
1359
+ els.mergedToggle.classList.toggle("cp-pr-toggle--active", state.mergedOnly);
1360
+ renderRail();
1361
+ });
1362
+ }
1363
+
1157
1364
  async function loadInitial() {
1158
1365
  try {
1159
1366
  var res = await fetch("/api/claude-plans");
@@ -1185,6 +1392,7 @@
1185
1392
  .catch(function () { /* ignore */ });
1186
1393
  }));
1187
1394
  renderRail();
1395
+ loadPrStatus();
1188
1396
  if (!state.selectedSlug) {
1189
1397
  var paramSlug = new URLSearchParams(window.location.search).get("plan");
1190
1398
  var sorted = sortedPlans();
@@ -1198,7 +1406,10 @@
1198
1406
  function startStream() {
1199
1407
  var es = new EventSource("/api/claude-plans/stream");
1200
1408
  es.addEventListener("plan:upsert", function (e) {
1201
- try { upsertPlan(JSON.parse(e.data).plan); } catch (_) {}
1409
+ try {
1410
+ upsertPlan(JSON.parse(e.data).plan);
1411
+ schedulePrStatusRefresh();
1412
+ } catch (_) {}
1202
1413
  });
1203
1414
  es.addEventListener("plan:delete", function (e) {
1204
1415
  try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
@@ -1262,6 +1473,7 @@
1262
1473
  setupTheme();
1263
1474
  setupSearch();
1264
1475
  setupTopics();
1476
+ setupMergedToggle();
1265
1477
  setActiveTab("plan");
1266
1478
  loadLintsBadge();
1267
1479
  loadInitial().then(startStream);
@@ -21,6 +21,148 @@
21
21
  font-size: 12px;
22
22
  }
23
23
 
24
+ /* ─── Filter toolbar ──────────────────────────────────────────────────────── */
25
+
26
+ .pl-filters {
27
+ display: flex;
28
+ flex-wrap: wrap;
29
+ align-items: center;
30
+ gap: 16px;
31
+ margin-bottom: 14px;
32
+ }
33
+
34
+ .pl-filter-group {
35
+ display: inline-flex;
36
+ flex-wrap: wrap;
37
+ align-items: center;
38
+ gap: 6px;
39
+ }
40
+
41
+ .pl-filter-group:empty {
42
+ display: none;
43
+ }
44
+
45
+ /* Segmented control (status) */
46
+ .pl-seg {
47
+ padding: 4px 12px;
48
+ background: var(--surface);
49
+ color: var(--fg);
50
+ border: 1px solid var(--border);
51
+ font-family: inherit;
52
+ font-size: 13px;
53
+ font-weight: 500;
54
+ cursor: pointer;
55
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
56
+ }
57
+ .pl-seg:first-child { border-radius: 999px 0 0 999px; }
58
+ .pl-seg:last-child { border-radius: 0 999px 999px 0; }
59
+ .pl-seg + .pl-seg { border-left: none; }
60
+ .pl-seg:hover {
61
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
62
+ }
63
+ .pl-seg--active {
64
+ background: var(--accent);
65
+ color: var(--accent-fg);
66
+ border-color: var(--accent);
67
+ }
68
+
69
+ /* Toggle chips (score buckets, sources) */
70
+ .pl-chip {
71
+ display: inline-flex;
72
+ align-items: baseline;
73
+ gap: 6px;
74
+ padding: 4px 10px;
75
+ background: var(--surface);
76
+ color: var(--fg);
77
+ border: 1px solid var(--border);
78
+ border-radius: 999px;
79
+ font-family: inherit;
80
+ font-size: 13px;
81
+ font-weight: 500;
82
+ cursor: pointer;
83
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
84
+ }
85
+ .pl-chip:hover {
86
+ background: color-mix(in srgb, var(--accent) 10%, var(--surface));
87
+ border-color: var(--accent);
88
+ }
89
+ .pl-chip--active {
90
+ background: var(--accent);
91
+ color: var(--accent-fg);
92
+ border-color: var(--accent);
93
+ }
94
+ .pl-chip-count {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ min-width: 18px;
99
+ padding: 0 5px;
100
+ background: color-mix(in srgb, currentColor 12%, transparent);
101
+ border-radius: 999px;
102
+ font-size: 10px;
103
+ font-weight: 700;
104
+ }
105
+
106
+ .pl-search {
107
+ position: relative;
108
+ display: inline-flex;
109
+ align-items: center;
110
+ margin-left: auto;
111
+ }
112
+ .pl-search-input {
113
+ width: 220px;
114
+ padding: 5px 28px 5px 10px;
115
+ background: var(--surface);
116
+ color: var(--fg);
117
+ border: 1px solid var(--border);
118
+ border-radius: 999px;
119
+ font-family: inherit;
120
+ font-size: 13px;
121
+ }
122
+ .pl-search-input::placeholder { color: var(--fg-muted); }
123
+ .pl-search-input:focus {
124
+ outline: none;
125
+ border-color: var(--accent);
126
+ }
127
+ .pl-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }
128
+ .pl-search-clear {
129
+ position: absolute;
130
+ right: 8px;
131
+ background: none;
132
+ border: none;
133
+ color: var(--fg-muted);
134
+ font-size: 16px;
135
+ line-height: 1;
136
+ cursor: pointer;
137
+ }
138
+ .pl-search-clear:hover { color: var(--fg); }
139
+
140
+ /* Status badges */
141
+ .pl-status {
142
+ display: inline-block;
143
+ border-radius: 999px;
144
+ padding: 1px 8px;
145
+ font-size: 11px;
146
+ font-weight: 600;
147
+ white-space: nowrap;
148
+ }
149
+ .pl-status--needs {
150
+ background: color-mix(in srgb, var(--danger) 14%, transparent);
151
+ color: var(--danger);
152
+ }
153
+ .pl-status--good {
154
+ background: color-mix(in srgb, var(--success-700) 14%, transparent);
155
+ color: var(--success-700);
156
+ }
157
+
158
+ .pl-no-match {
159
+ padding: 40px 20px;
160
+ text-align: center;
161
+ color: var(--fg-muted);
162
+ border: 1px dashed var(--border);
163
+ border-radius: 8px;
164
+ }
165
+
24
166
  .pl-summary {
25
167
  color: var(--fg-subtle);
26
168
  font-size: 13px;
@@ -42,15 +42,41 @@
42
42
  Turn-0 prompt-lint observations recorded by the <code>UserPromptSubmit</code> hook and
43
43
  <code>push_lint_event</code>. Newest first. Updates live.
44
44
  </div>
45
+ <div class="pl-filters" id="pl-filters">
46
+ <div class="pl-filter-group" role="group" aria-label="Status">
47
+ <button type="button" class="pl-seg pl-seg--active" data-status="all">All</button>
48
+ <button type="button" class="pl-seg" data-status="needs-improvement">Needs improvement</button>
49
+ <button type="button" class="pl-seg" data-status="good">Good</button>
50
+ </div>
51
+ <div class="pl-filter-group" role="group" aria-label="Score">
52
+ <button type="button" class="pl-chip" data-bucket="low">Low &lt;40</button>
53
+ <button type="button" class="pl-chip" data-bucket="mid">Mid 40&ndash;69</button>
54
+ <button type="button" class="pl-chip" data-bucket="high">High &ge;70</button>
55
+ </div>
56
+ <div class="pl-filter-group" id="pl-sources" role="group" aria-label="Source"></div>
57
+ <div class="pl-search">
58
+ <input
59
+ type="search"
60
+ class="pl-search-input"
61
+ id="pl-search-input"
62
+ placeholder="Search excerpt / tags&hellip;"
63
+ autocomplete="off"
64
+ spellcheck="false"
65
+ />
66
+ <button class="pl-search-clear" id="pl-search-clear" aria-label="Clear search" hidden>&times;</button>
67
+ </div>
68
+ </div>
45
69
  <div class="pl-summary" id="pl-summary"></div>
46
70
  <div class="pl-empty" id="pl-empty">
47
71
  No lint events yet. Low-scoring prompts will appear here as you work.
48
72
  </div>
73
+ <div class="pl-no-match" id="pl-no-match" hidden>No events match the current filters.</div>
49
74
  <table class="pl-table" id="pl-table" hidden>
50
75
  <thead>
51
76
  <tr>
52
77
  <th>When</th>
53
78
  <th>Score</th>
79
+ <th>Status</th>
54
80
  <th>Missing tags</th>
55
81
  <th>Source</th>
56
82
  <th>Prompt excerpt</th>
@@ -6,12 +6,22 @@
6
6
 
7
7
  var events = new Map(); // id -> event
8
8
 
9
+ // Active filters. status: "all" | "needs-improvement" | "good".
10
+ // buckets: map of "low"/"mid"/"high" -> true (multi-select, empty = all).
11
+ // source: a single source string, or null = all. query: lowercased substring.
12
+ var filters = { status: "all", buckets: {}, source: null, query: "" };
13
+
9
14
  var els = {
10
15
  tbody: document.getElementById("pl-tbody"),
11
16
  table: document.getElementById("pl-table"),
12
17
  empty: document.getElementById("pl-empty"),
18
+ noMatch: document.getElementById("pl-no-match"),
13
19
  summary: document.getElementById("pl-summary"),
14
- storage: document.getElementById("cp-storage")
20
+ storage: document.getElementById("cp-storage"),
21
+ filters: document.getElementById("pl-filters"),
22
+ sources: document.getElementById("pl-sources"),
23
+ searchInput: document.getElementById("pl-search-input"),
24
+ searchClear: document.getElementById("pl-search-clear")
15
25
  };
16
26
 
17
27
  // Bound the initial load — the global lint log is append-only and can grow
@@ -32,6 +42,38 @@
32
42
  return "pl-score-high";
33
43
  }
34
44
 
45
+ function scoreBucket(score) {
46
+ if (typeof score !== "number") return null;
47
+ if (score < 40) return "low";
48
+ if (score < 70) return "mid";
49
+ return "high";
50
+ }
51
+
52
+ // Classify an event as "needs-improvement" / "good" / "unknown". Prefer the
53
+ // hook's outcome ("nag" = under-specified, "format" = strong); fall back to
54
+ // score-vs-threshold for legacy events recorded before outcome existed.
55
+ function lintStatus(e) {
56
+ if (e.outcome === "nag") return "needs-improvement";
57
+ if (e.outcome === "format") return "good";
58
+ if (typeof e.score !== "number") return "unknown";
59
+ var threshold = typeof e.threshold === "number" ? e.threshold : 50;
60
+ if (e.score < threshold) return "needs-improvement";
61
+ if (e.score >= 70) return "good";
62
+ return "unknown";
63
+ }
64
+
65
+ function matchesFilters(e) {
66
+ if (filters.status !== "all" && lintStatus(e) !== filters.status) return false;
67
+ var activeBuckets = Object.keys(filters.buckets);
68
+ if (activeBuckets.length && filters.buckets[scoreBucket(e.score)] !== true) return false;
69
+ if (filters.source !== null && (e.source || "") !== filters.source) return false;
70
+ if (filters.query) {
71
+ var hay = ((e.prompt_excerpt || "") + " " + (e.missing || []).join(" ")).toLowerCase();
72
+ if (hay.indexOf(filters.query) === -1) return false;
73
+ }
74
+ return true;
75
+ }
76
+
35
77
  function sorted() {
36
78
  return Array.from(events.values()).sort(function (a, b) {
37
79
  var c = (b.timestamp || "").localeCompare(a.timestamp || "");
@@ -40,6 +82,10 @@
40
82
  });
41
83
  }
42
84
 
85
+ function filtered() {
86
+ return sorted().filter(matchesFilters);
87
+ }
88
+
43
89
  function renderTagCells(missing) {
44
90
  var td = document.createElement("td");
45
91
  td.className = "pl-missing";
@@ -71,6 +117,17 @@
71
117
  if (typeof e.threshold === "number") score.title = "threshold " + e.threshold + "%";
72
118
  tr.appendChild(score);
73
119
 
120
+ var statusCell = document.createElement("td");
121
+ var status = lintStatus(e);
122
+ if (status === "needs-improvement") {
123
+ statusCell.innerHTML = '<span class="pl-status pl-status--needs">Needs work</span>';
124
+ } else if (status === "good") {
125
+ statusCell.innerHTML = '<span class="pl-status pl-status--good">Good</span>';
126
+ } else {
127
+ statusCell.textContent = "—";
128
+ }
129
+ tr.appendChild(statusCell);
130
+
74
131
  tr.appendChild(renderTagCells(e.missing));
75
132
 
76
133
  var source = document.createElement("td");
@@ -87,23 +144,131 @@
87
144
  return tr;
88
145
  }
89
146
 
147
+ function isFiltering() {
148
+ return (
149
+ filters.status !== "all" ||
150
+ Object.keys(filters.buckets).length > 0 ||
151
+ filters.source !== null ||
152
+ !!filters.query
153
+ );
154
+ }
155
+
90
156
  function render(newId) {
91
- var list = sorted();
157
+ var total = events.size;
158
+ var list = filtered();
92
159
  els.tbody.innerHTML = "";
93
- if (!list.length) {
160
+
161
+ if (!total) {
94
162
  els.empty.hidden = false;
163
+ els.noMatch.hidden = true;
95
164
  els.table.hidden = true;
96
165
  els.summary.textContent = "";
97
166
  return;
98
167
  }
168
+
99
169
  els.empty.hidden = true;
170
+
171
+ if (!list.length) {
172
+ els.noMatch.hidden = false;
173
+ els.table.hidden = true;
174
+ els.summary.textContent = "0 of " + total + (total === 1 ? " event" : " events");
175
+ return;
176
+ }
177
+
178
+ els.noMatch.hidden = true;
100
179
  els.table.hidden = false;
101
180
  for (var i = 0; i < list.length; i++) {
102
181
  els.tbody.appendChild(rowFor(list[i], list[i].id === newId));
103
182
  }
104
- els.summary.textContent =
105
- list.length + (list.length === 1 ? " event" : " events") +
106
- (list.length >= PAGE_LIMIT ? " (latest " + PAGE_LIMIT + ")" : "");
183
+
184
+ var noun = total === 1 ? " event" : " events";
185
+ if (isFiltering()) {
186
+ els.summary.textContent = list.length + " of " + total + noun;
187
+ } else {
188
+ els.summary.textContent =
189
+ total + noun + (total >= PAGE_LIMIT ? " (latest " + PAGE_LIMIT + ")" : "");
190
+ }
191
+ }
192
+
193
+ /* ─── Filter UI ─────────────────────────────────────────────────────────── */
194
+
195
+ function renderSources() {
196
+ if (!els.sources) return;
197
+ var counts = {};
198
+ events.forEach(function (e) {
199
+ var s = e.source || "—";
200
+ counts[s] = (counts[s] || 0) + 1;
201
+ });
202
+ var labels = Object.keys(counts).sort();
203
+ els.sources.innerHTML = "";
204
+ // A lone source carries no signal — hide the group until there's a choice.
205
+ if (labels.length < 2) return;
206
+ for (var i = 0; i < labels.length; i++) {
207
+ els.sources.appendChild(sourceChip(labels[i], counts[labels[i]]));
208
+ }
209
+ }
210
+
211
+ function sourceChip(label, count) {
212
+ var value = label === "—" ? "" : label;
213
+ var chip = document.createElement("button");
214
+ chip.type = "button";
215
+ chip.className = "pl-chip" + (filters.source === value ? " pl-chip--active" : "");
216
+ chip.appendChild(document.createTextNode(label + " "));
217
+ var c = document.createElement("span");
218
+ c.className = "pl-chip-count";
219
+ c.textContent = String(count);
220
+ chip.appendChild(c);
221
+ chip.addEventListener("click", function () {
222
+ filters.source = filters.source === value ? null : value;
223
+ renderSources();
224
+ render(null);
225
+ });
226
+ return chip;
227
+ }
228
+
229
+ function setupFilters() {
230
+ var segs = els.filters ? els.filters.querySelectorAll(".pl-seg") : [];
231
+ for (var i = 0; i < segs.length; i++) {
232
+ (function (btn) {
233
+ btn.addEventListener("click", function () {
234
+ filters.status = btn.getAttribute("data-status");
235
+ for (var j = 0; j < segs.length; j++) {
236
+ segs[j].classList.toggle("pl-seg--active", segs[j] === btn);
237
+ }
238
+ render(null);
239
+ });
240
+ })(segs[i]);
241
+ }
242
+
243
+ var buckets = els.filters ? els.filters.querySelectorAll(".pl-chip[data-bucket]") : [];
244
+ for (var k = 0; k < buckets.length; k++) {
245
+ (function (btn) {
246
+ btn.addEventListener("click", function () {
247
+ var key = btn.getAttribute("data-bucket");
248
+ if (filters.buckets[key]) delete filters.buckets[key];
249
+ else filters.buckets[key] = true;
250
+ btn.classList.toggle("pl-chip--active", !!filters.buckets[key]);
251
+ render(null);
252
+ });
253
+ })(buckets[k]);
254
+ }
255
+
256
+ if (els.searchInput) {
257
+ els.searchInput.addEventListener("input", function () {
258
+ filters.query = els.searchInput.value.trim().toLowerCase();
259
+ if (els.searchClear) els.searchClear.hidden = !els.searchInput.value;
260
+ render(null);
261
+ });
262
+ }
263
+ if (els.searchClear) {
264
+ els.searchClear.addEventListener("click", function () {
265
+ els.searchInput.value = "";
266
+ filters.query = "";
267
+ els.searchClear.hidden = true;
268
+ render(null);
269
+ els.searchInput.focus();
270
+ });
271
+ }
107
272
  }
108
273
 
109
274
  function loadInitial() {
@@ -114,6 +279,7 @@
114
279
  if (e && e.id) events.set(e.id, e);
115
280
  });
116
281
  if (els.storage && data.storage) els.storage.textContent = data.storage;
282
+ renderSources();
117
283
  render(null);
118
284
  })
119
285
  .catch(function () { render(null); });
@@ -126,6 +292,7 @@
126
292
  var data = JSON.parse(msg.data);
127
293
  if (data && data.lint && data.lint.id) {
128
294
  events.set(data.lint.id, data.lint);
295
+ renderSources();
129
296
  render(data.lint.id);
130
297
  }
131
298
  } catch (_) {}
@@ -177,6 +344,7 @@
177
344
 
178
345
  document.addEventListener("DOMContentLoaded", function () {
179
346
  setupTheme();
347
+ setupFilters();
180
348
  loadInitial().then(startStream);
181
349
  });
182
350
  })();
package/server.js CHANGED
@@ -4,6 +4,7 @@ const axios = require("axios");
4
4
  const { wrapper } = require("axios-cookiejar-support");
5
5
  const { CookieJar } = require("tough-cookie");
6
6
  const fs = require("fs");
7
+ const { execFile } = require("child_process");
7
8
  const RateLimit = require("express-rate-limit");
8
9
 
9
10
  // Everything resolves from CWD — run this from your Dovetail project directory
@@ -1075,6 +1076,100 @@ app.get("/api/claude-plans", function (req, res) {
1075
1076
  }
1076
1077
  });
1077
1078
 
1079
+ // Live GitHub merge state for plan-linked PRs. Plans store pr_url but no merge
1080
+ // status, so we resolve it on demand via the `gh` CLI (authenticated locally).
1081
+ // Results are cached: a MERGED state is terminal and cached forever; anything
1082
+ // else is cached briefly so an open PR that later merges is picked up.
1083
+ const PR_URL_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+$/;
1084
+ const PR_STATUS_TTL_MS = 60 * 1000;
1085
+ const prStatusCache = new Map(); // pr_url -> { state, merged, fetchedAt }
1086
+
1087
+ function ghPrState(prUrl) {
1088
+ return new Promise(function (resolve) {
1089
+ execFile(
1090
+ "gh",
1091
+ ["pr", "view", prUrl, "--json", "state,mergedAt"],
1092
+ { timeout: 5000 },
1093
+ function (err, stdout) {
1094
+ if (err) return resolve({ state: "unknown", merged: false });
1095
+ try {
1096
+ const data = JSON.parse(stdout);
1097
+ const state = data.state || "unknown";
1098
+ resolve({ state: state, merged: state === "MERGED" });
1099
+ } catch (e) {
1100
+ resolve({ state: "unknown", merged: false });
1101
+ }
1102
+ }
1103
+ );
1104
+ });
1105
+ }
1106
+
1107
+ function resolvePrStatus(prUrl) {
1108
+ const cached = prStatusCache.get(prUrl);
1109
+ const now = Date.now();
1110
+ if (cached && (cached.merged || now - cached.fetchedAt < PR_STATUS_TTL_MS)) {
1111
+ return Promise.resolve({ state: cached.state, merged: cached.merged });
1112
+ }
1113
+ return ghPrState(prUrl).then(function (result) {
1114
+ prStatusCache.set(prUrl, {
1115
+ state: result.state,
1116
+ merged: result.merged,
1117
+ fetchedAt: Date.now()
1118
+ });
1119
+ return result;
1120
+ });
1121
+ }
1122
+
1123
+ // Run promise-returning tasks with bounded concurrency so a large plan set
1124
+ // doesn't spawn dozens of `gh` processes at once.
1125
+ function mapWithConcurrency(items, limit, worker) {
1126
+ return new Promise(function (resolve) {
1127
+ const results = new Array(items.length);
1128
+ let index = 0;
1129
+ let completed = 0;
1130
+ if (!items.length) return resolve(results);
1131
+ function runNext() {
1132
+ if (index >= items.length) return;
1133
+ const i = index++;
1134
+ Promise.resolve(worker(items[i]))
1135
+ .then(function (r) { results[i] = r; })
1136
+ .catch(function () { results[i] = null; })
1137
+ .then(function () {
1138
+ completed++;
1139
+ if (completed === items.length) resolve(results);
1140
+ else runNext();
1141
+ });
1142
+ }
1143
+ for (let k = 0; k < Math.min(limit, items.length); k++) runNext();
1144
+ });
1145
+ }
1146
+
1147
+ // Place before "/api/claude-plans/:slug" so Express doesn't treat "pr-status"
1148
+ // as a slug. Always resolves to a 200 with whatever statuses we could gather —
1149
+ // a missing/unauthenticated `gh` degrades to {} rather than failing the page.
1150
+ app.get("/api/claude-plans/pr-status", function (req, res) {
1151
+ try {
1152
+ const urls = [];
1153
+ const seen = {};
1154
+ listClaudePlans().forEach(function (plan) {
1155
+ const url = plan && plan.pr_url;
1156
+ if (typeof url === "string" && PR_URL_RE.test(url) && !seen[url]) {
1157
+ seen[url] = true;
1158
+ urls.push(url);
1159
+ }
1160
+ });
1161
+ mapWithConcurrency(urls, 5, resolvePrStatus).then(function (resolved) {
1162
+ const statuses = {};
1163
+ for (let i = 0; i < urls.length; i++) {
1164
+ if (resolved[i]) statuses[urls[i]] = resolved[i];
1165
+ }
1166
+ res.json({ statuses: statuses });
1167
+ });
1168
+ } catch (e) {
1169
+ res.status(500).json({ error: e.message });
1170
+ }
1171
+ });
1172
+
1078
1173
  app.get("/api/claude-plans/stream", function (req, res) {
1079
1174
  res.set({
1080
1175
  "Content-Type": "text/event-stream",
@@ -1213,6 +1308,53 @@ app.post("/api/claude-plans/:slug/dispatch", claudePlansLimiter, express.json(),
1213
1308
  }
1214
1309
  });
1215
1310
 
1311
+ // GET /api/claude-plans/:slug/versions — list saved version snapshots (newest-first).
1312
+ app.get("/api/claude-plans/:slug/versions", function (req, res) {
1313
+ try {
1314
+ var slug = req.params.slug;
1315
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1316
+ res.json({ slug: slug, versions: claudePlansLib.listVersions(slug) });
1317
+ } catch (e) {
1318
+ sendTypedError(res, e);
1319
+ }
1320
+ });
1321
+
1322
+ // GET /api/claude-plans/:slug/versions/:n — read one full version snapshot.
1323
+ app.get("/api/claude-plans/:slug/versions/:n", function (req, res) {
1324
+ try {
1325
+ var slug = req.params.slug;
1326
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1327
+ var n = parseInt(req.params.n, 10);
1328
+ if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
1329
+ var version = claudePlansLib.getVersion(slug, n);
1330
+ if (!version) return res.status(404).json({ error: "version not found" });
1331
+ res.json(version);
1332
+ } catch (e) {
1333
+ sendTypedError(res, e);
1334
+ }
1335
+ });
1336
+
1337
+ // POST /api/claude-plans/:slug/versions/:n/restore — re-push a prior version as
1338
+ // the new current record. Non-destructive (pre-restore current is snapshotted).
1339
+ // The plan-file write triggers the chokidar watcher → plan:upsert SSE, so
1340
+ // connected dashboards refresh without a manual broadcast.
1341
+ app.post(
1342
+ "/api/claude-plans/:slug/versions/:n/restore",
1343
+ claudePlansLimiter,
1344
+ function (req, res) {
1345
+ try {
1346
+ var slug = req.params.slug;
1347
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1348
+ var n = parseInt(req.params.n, 10);
1349
+ if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
1350
+ var plan = claudePlansLib.restoreVersion(slug, n);
1351
+ res.json({ plan: plan });
1352
+ } catch (e) {
1353
+ sendTypedError(res, e);
1354
+ }
1355
+ }
1356
+ );
1357
+
1216
1358
  app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1217
1359
  try {
1218
1360
  var slug = req.params.slug;