@tenonhq/dovetail-dashboard 0.0.30 → 0.0.31
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 +1 -1
- package/public/claude-plans.css +43 -0
- package/public/claude-plans.html +9 -0
- package/public/claude-plans.js +78 -6
- package/public/prompt-lints.css +142 -0
- package/public/prompt-lints.html +26 -0
- package/public/prompt-lints.js +174 -6
- package/server.js +95 -0
package/package.json
CHANGED
package/public/claude-plans.css
CHANGED
|
@@ -1312,6 +1312,49 @@
|
|
|
1312
1312
|
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
|
1313
1313
|
}
|
|
1314
1314
|
|
|
1315
|
+
/* Merged-PR filter toggle + per-row PR state dots */
|
|
1316
|
+
.cp-rail-filters {
|
|
1317
|
+
padding: 8px 0 4px;
|
|
1318
|
+
}
|
|
1319
|
+
.cp-pr-toggle {
|
|
1320
|
+
display: inline-flex;
|
|
1321
|
+
align-items: center;
|
|
1322
|
+
gap: 6px;
|
|
1323
|
+
padding: 4px 12px;
|
|
1324
|
+
background: var(--surface);
|
|
1325
|
+
color: var(--fg);
|
|
1326
|
+
border: 1px solid var(--border);
|
|
1327
|
+
border-radius: 999px;
|
|
1328
|
+
font-family: inherit;
|
|
1329
|
+
font-size: 12px;
|
|
1330
|
+
font-weight: 500;
|
|
1331
|
+
cursor: pointer;
|
|
1332
|
+
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
|
1333
|
+
}
|
|
1334
|
+
.cp-pr-toggle:hover {
|
|
1335
|
+
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
1336
|
+
border-color: var(--accent);
|
|
1337
|
+
}
|
|
1338
|
+
.cp-pr-toggle--active {
|
|
1339
|
+
background: var(--accent);
|
|
1340
|
+
color: var(--accent-fg);
|
|
1341
|
+
border-color: var(--accent);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
.cp-pr-dot {
|
|
1345
|
+
display: inline-block;
|
|
1346
|
+
width: 8px;
|
|
1347
|
+
height: 8px;
|
|
1348
|
+
border-radius: 50%;
|
|
1349
|
+
flex: 0 0 auto;
|
|
1350
|
+
margin: 0 4px;
|
|
1351
|
+
background: var(--fg-muted);
|
|
1352
|
+
}
|
|
1353
|
+
.cp-pr-dot--merged { background: var(--success-700); }
|
|
1354
|
+
.cp-pr-dot--open { background: var(--warning-700); }
|
|
1355
|
+
.cp-pr-dot--closed { background: var(--danger); }
|
|
1356
|
+
.cp-pr-dot--unknown { background: var(--fg-muted); }
|
|
1357
|
+
|
|
1315
1358
|
/* ===========================================================================
|
|
1316
1359
|
* v2 — Stage Map, Questions tab, Dispatch dialog (Phase E)
|
|
1317
1360
|
* Lightweight additions on top of the existing token system.
|
package/public/claude-plans.html
CHANGED
|
@@ -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.
|
package/public/claude-plans.js
CHANGED
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
selectedSlug: null,
|
|
38
38
|
activeTab: "plan",
|
|
39
39
|
query: "", // lowercased search query; "" means show all
|
|
40
|
-
topicFilter: null
|
|
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 = {
|
|
@@ -68,9 +70,37 @@
|
|
|
68
70
|
topicsCloud: document.getElementById("cp-topics-cloud"),
|
|
69
71
|
topicsCount: document.getElementById("cp-topics-count"),
|
|
70
72
|
topicsEmpty: document.getElementById("cp-topics-empty"),
|
|
71
|
-
topicsClear: document.getElementById("cp-topics-clear")
|
|
73
|
+
topicsClear: document.getElementById("cp-topics-clear"),
|
|
74
|
+
mergedToggle: document.getElementById("cp-merged-toggle")
|
|
72
75
|
};
|
|
73
76
|
|
|
77
|
+
// Merged === true only when gh resolved the plan's PR as MERGED.
|
|
78
|
+
function planPrMerged(plan) {
|
|
79
|
+
if (!plan.pr_url) return false;
|
|
80
|
+
var st = state.prStatus.get(plan.pr_url);
|
|
81
|
+
return !!(st && st.merged);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function planMatchesPr(plan) {
|
|
85
|
+
if (!state.mergedOnly) return true;
|
|
86
|
+
return planPrMerged(plan);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A small colored dot in the rail row reflecting the PR's merge state.
|
|
90
|
+
// Rendered only when the plan links a PR and gh has returned a status.
|
|
91
|
+
function prDotMarkup(plan) {
|
|
92
|
+
if (!plan.pr_url) return "";
|
|
93
|
+
var st = state.prStatus.get(plan.pr_url);
|
|
94
|
+
if (!st) return "";
|
|
95
|
+
var cls = "cp-pr-dot";
|
|
96
|
+
if (st.merged) cls += " cp-pr-dot--merged";
|
|
97
|
+
else if (st.state === "OPEN") cls += " cp-pr-dot--open";
|
|
98
|
+
else if (st.state === "CLOSED") cls += " cp-pr-dot--closed";
|
|
99
|
+
else cls += " cp-pr-dot--unknown";
|
|
100
|
+
var label = st.merged ? "merged" : (st.state || "unknown").toLowerCase();
|
|
101
|
+
return '<span class="' + cls + '" title="PR ' + label + '"></span>';
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
function planMatchesTopic(plan, topic) {
|
|
75
105
|
if (!topic) return true;
|
|
76
106
|
var cats = plan.categories;
|
|
@@ -118,9 +148,9 @@
|
|
|
118
148
|
var q = state.query;
|
|
119
149
|
var topic = state.topicFilter;
|
|
120
150
|
var all = allPlansSorted();
|
|
121
|
-
if (!q && !topic) return all;
|
|
151
|
+
if (!q && !topic && !state.mergedOnly) return all;
|
|
122
152
|
return all.filter(function (p) {
|
|
123
|
-
return planMatchesQuery(p, q) && planMatchesTopic(p, topic);
|
|
153
|
+
return planMatchesQuery(p, q) && planMatchesTopic(p, topic) && planMatchesPr(p);
|
|
124
154
|
});
|
|
125
155
|
}
|
|
126
156
|
|
|
@@ -431,7 +461,7 @@
|
|
|
431
461
|
renderTopics();
|
|
432
462
|
var plans = sortedPlans();
|
|
433
463
|
var totalPlans = state.plans.size;
|
|
434
|
-
var isFiltering = !!state.query || !!state.topicFilter;
|
|
464
|
+
var isFiltering = !!state.query || !!state.topicFilter || state.mergedOnly;
|
|
435
465
|
els.count.textContent = isFiltering
|
|
436
466
|
? plans.length + " / " + totalPlans
|
|
437
467
|
: String(totalPlans);
|
|
@@ -444,6 +474,7 @@
|
|
|
444
474
|
var pieces = [];
|
|
445
475
|
if (state.query) pieces.push('"' + state.query + '"');
|
|
446
476
|
if (state.topicFilter) pieces.push("topic “" + state.topicFilter + "”");
|
|
477
|
+
if (state.mergedOnly) pieces.push("merged PRs only");
|
|
447
478
|
els.railNoMatchQ.textContent = pieces.join(" + ");
|
|
448
479
|
}
|
|
449
480
|
} else {
|
|
@@ -464,6 +495,7 @@
|
|
|
464
495
|
li.innerHTML =
|
|
465
496
|
'<div class="cp-list-row">' +
|
|
466
497
|
' <span class="cp-list-title"></span>' +
|
|
498
|
+
prDotMarkup(plan) +
|
|
467
499
|
' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
|
|
468
500
|
'</div>' +
|
|
469
501
|
'<div class="cp-list-meta">' +
|
|
@@ -1154,6 +1186,41 @@
|
|
|
1154
1186
|
});
|
|
1155
1187
|
}
|
|
1156
1188
|
|
|
1189
|
+
var prStatusDebounce = null;
|
|
1190
|
+
|
|
1191
|
+
// Resolve live PR merge state from the server (which shells out to gh) and
|
|
1192
|
+
// rerender so dots + the merged filter reflect reality. Best-effort: any
|
|
1193
|
+
// failure leaves prStatus as-is and the toggle simply matches nothing.
|
|
1194
|
+
function loadPrStatus() {
|
|
1195
|
+
return fetch("/api/claude-plans/pr-status")
|
|
1196
|
+
.then(function (r) { return r.json(); })
|
|
1197
|
+
.then(function (data) {
|
|
1198
|
+
var statuses = data && data.statuses ? data.statuses : {};
|
|
1199
|
+
Object.keys(statuses).forEach(function (url) {
|
|
1200
|
+
state.prStatus.set(url, statuses[url]);
|
|
1201
|
+
});
|
|
1202
|
+
renderRail();
|
|
1203
|
+
})
|
|
1204
|
+
.catch(function (err) {
|
|
1205
|
+
console.warn("[claude-plans] failed to load PR status:", err);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function schedulePrStatusRefresh() {
|
|
1210
|
+
if (prStatusDebounce) clearTimeout(prStatusDebounce);
|
|
1211
|
+
prStatusDebounce = setTimeout(loadPrStatus, 500);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function setupMergedToggle() {
|
|
1215
|
+
if (!els.mergedToggle) return;
|
|
1216
|
+
els.mergedToggle.addEventListener("click", function () {
|
|
1217
|
+
state.mergedOnly = !state.mergedOnly;
|
|
1218
|
+
els.mergedToggle.setAttribute("aria-pressed", state.mergedOnly ? "true" : "false");
|
|
1219
|
+
els.mergedToggle.classList.toggle("cp-pr-toggle--active", state.mergedOnly);
|
|
1220
|
+
renderRail();
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1157
1224
|
async function loadInitial() {
|
|
1158
1225
|
try {
|
|
1159
1226
|
var res = await fetch("/api/claude-plans");
|
|
@@ -1185,6 +1252,7 @@
|
|
|
1185
1252
|
.catch(function () { /* ignore */ });
|
|
1186
1253
|
}));
|
|
1187
1254
|
renderRail();
|
|
1255
|
+
loadPrStatus();
|
|
1188
1256
|
if (!state.selectedSlug) {
|
|
1189
1257
|
var paramSlug = new URLSearchParams(window.location.search).get("plan");
|
|
1190
1258
|
var sorted = sortedPlans();
|
|
@@ -1198,7 +1266,10 @@
|
|
|
1198
1266
|
function startStream() {
|
|
1199
1267
|
var es = new EventSource("/api/claude-plans/stream");
|
|
1200
1268
|
es.addEventListener("plan:upsert", function (e) {
|
|
1201
|
-
try {
|
|
1269
|
+
try {
|
|
1270
|
+
upsertPlan(JSON.parse(e.data).plan);
|
|
1271
|
+
schedulePrStatusRefresh();
|
|
1272
|
+
} catch (_) {}
|
|
1202
1273
|
});
|
|
1203
1274
|
es.addEventListener("plan:delete", function (e) {
|
|
1204
1275
|
try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
|
|
@@ -1262,6 +1333,7 @@
|
|
|
1262
1333
|
setupTheme();
|
|
1263
1334
|
setupSearch();
|
|
1264
1335
|
setupTopics();
|
|
1336
|
+
setupMergedToggle();
|
|
1265
1337
|
setActiveTab("plan");
|
|
1266
1338
|
loadLintsBadge();
|
|
1267
1339
|
loadInitial().then(startStream);
|
package/public/prompt-lints.css
CHANGED
|
@@ -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;
|
package/public/prompt-lints.html
CHANGED
|
@@ -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 <40</button>
|
|
53
|
+
<button type="button" class="pl-chip" data-bucket="mid">Mid 40–69</button>
|
|
54
|
+
<button type="button" class="pl-chip" data-bucket="high">High ≥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…"
|
|
63
|
+
autocomplete="off"
|
|
64
|
+
spellcheck="false"
|
|
65
|
+
/>
|
|
66
|
+
<button class="pl-search-clear" id="pl-search-clear" aria-label="Clear search" hidden>×</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>
|
package/public/prompt-lints.js
CHANGED
|
@@ -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
|
|
157
|
+
var total = events.size;
|
|
158
|
+
var list = filtered();
|
|
92
159
|
els.tbody.innerHTML = "";
|
|
93
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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",
|