@tenonhq/dovetail-dashboard 0.0.27 → 0.0.29
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 +15 -0
- package/public/claude-plans.html +4 -1
- package/public/claude-plans.js +46 -5
- package/public/prompt-lints.css +112 -0
- package/public/prompt-lints.html +65 -0
- package/public/prompt-lints.js +182 -0
- package/server.js +70 -3
package/package.json
CHANGED
package/public/claude-plans.css
CHANGED
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
border-color: var(--brand);
|
|
24
24
|
color: var(--brand);
|
|
25
25
|
}
|
|
26
|
+
.cp-lints-badge {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
min-width: 18px;
|
|
31
|
+
height: 18px;
|
|
32
|
+
margin: 0 2px;
|
|
33
|
+
padding: 0 5px;
|
|
34
|
+
background: var(--danger);
|
|
35
|
+
color: var(--fg-on-dark);
|
|
36
|
+
border-radius: var(--radius-pill);
|
|
37
|
+
font-size: 11px;
|
|
38
|
+
font-weight: var(--weight-bold);
|
|
39
|
+
line-height: 1;
|
|
40
|
+
}
|
|
26
41
|
.cp-storage {
|
|
27
42
|
font-size: 11px;
|
|
28
43
|
font-family: var(--font-mono);
|
package/public/claude-plans.html
CHANGED
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
</div>
|
|
31
31
|
<div class="header-right">
|
|
32
32
|
<a class="cp-nav-link" href="/">← Update Sets</a>
|
|
33
|
+
<a class="cp-nav-link" href="/prompt-lints">Prompt Lints
|
|
34
|
+
<span class="cp-lints-badge" id="cp-lints-badge" title="Prompts needing review" hidden>0</span>
|
|
35
|
+
→</a>
|
|
33
36
|
<button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
|
|
34
37
|
aria-label="Toggle dark mode" title="Toggle dark mode"></button>
|
|
35
38
|
<span class="cp-storage" id="cp-storage" title="storage root"></span>
|
|
@@ -42,7 +45,7 @@
|
|
|
42
45
|
<span class="cp-rail-title">Plans</span>
|
|
43
46
|
<span class="cp-count" id="cp-count">0</span>
|
|
44
47
|
</div>
|
|
45
|
-
<details class="cp-topics" id="cp-topics"
|
|
48
|
+
<details class="cp-topics" id="cp-topics">
|
|
46
49
|
<summary class="cp-topics-summary">
|
|
47
50
|
<span class="cp-topics-caret" aria-hidden="true">▾</span>
|
|
48
51
|
<span class="cp-topics-title">Topics</span>
|
package/public/claude-plans.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
var els = {
|
|
44
44
|
storage: document.getElementById("cp-storage"),
|
|
45
|
+
lintsBadge: document.getElementById("cp-lints-badge"),
|
|
45
46
|
list: document.getElementById("cp-list"),
|
|
46
47
|
count: document.getElementById("cp-count"),
|
|
47
48
|
railEmpty: document.getElementById("cp-rail-empty"),
|
|
@@ -219,7 +220,7 @@
|
|
|
219
220
|
if (els.topics) {
|
|
220
221
|
try {
|
|
221
222
|
var stored = localStorage.getItem("cp-topics-collapsed");
|
|
222
|
-
|
|
223
|
+
els.topics.open = stored === "0";
|
|
223
224
|
} catch (_) {}
|
|
224
225
|
els.topics.addEventListener("toggle", function () {
|
|
225
226
|
try {
|
|
@@ -709,6 +710,10 @@
|
|
|
709
710
|
|
|
710
711
|
function selectPlan(slug) {
|
|
711
712
|
state.selectedSlug = slug;
|
|
713
|
+
if (window.history && window.history.replaceState) {
|
|
714
|
+
var url = window.location.pathname + "?plan=" + encodeURIComponent(slug);
|
|
715
|
+
window.history.replaceState(null, "", url);
|
|
716
|
+
}
|
|
712
717
|
renderRail();
|
|
713
718
|
renderDetail();
|
|
714
719
|
}
|
|
@@ -831,6 +836,42 @@
|
|
|
831
836
|
if (state.selectedSlug === planSlug) renderDetail();
|
|
832
837
|
}
|
|
833
838
|
|
|
839
|
+
// A lint event "needs attention" if its score fell below the recording
|
|
840
|
+
// threshold (default 50 when no threshold is supplied) or it flagged any
|
|
841
|
+
// missing tags, antipatterns, or ceremony. The badge surfaces that count.
|
|
842
|
+
function lintNeedsAttention(e) {
|
|
843
|
+
if (!e) return false;
|
|
844
|
+
var threshold = typeof e.threshold === "number" ? e.threshold : 50;
|
|
845
|
+
if (typeof e.score === "number" && e.score < threshold) return true;
|
|
846
|
+
if (Array.isArray(e.missing) && e.missing.length) return true;
|
|
847
|
+
if (Array.isArray(e.antipatterns) && e.antipatterns.length) return true;
|
|
848
|
+
if (Array.isArray(e.ceremony) && e.ceremony.length) return true;
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function setLintsBadge(count) {
|
|
853
|
+
if (!els.lintsBadge) return;
|
|
854
|
+
if (count > 0) {
|
|
855
|
+
els.lintsBadge.textContent = String(count);
|
|
856
|
+
els.lintsBadge.hidden = false;
|
|
857
|
+
} else {
|
|
858
|
+
els.lintsBadge.hidden = true;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function loadLintsBadge() {
|
|
863
|
+
return fetch("/api/prompt-lints")
|
|
864
|
+
.then(function (r) { return r.json(); })
|
|
865
|
+
.then(function (data) {
|
|
866
|
+
var events = data && Array.isArray(data.events) ? data.events : [];
|
|
867
|
+
setLintsBadge(events.filter(lintNeedsAttention).length);
|
|
868
|
+
})
|
|
869
|
+
.catch(function (err) {
|
|
870
|
+
console.warn("[claude-plans] failed to load prompt lints:", err);
|
|
871
|
+
setLintsBadge(0);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
834
875
|
async function loadInitial() {
|
|
835
876
|
try {
|
|
836
877
|
var res = await fetch("/api/claude-plans");
|
|
@@ -863,11 +904,10 @@
|
|
|
863
904
|
}));
|
|
864
905
|
renderRail();
|
|
865
906
|
if (!state.selectedSlug) {
|
|
866
|
-
var
|
|
867
|
-
var pathSlug = pathMatch && pathMatch[1];
|
|
907
|
+
var paramSlug = new URLSearchParams(window.location.search).get("plan");
|
|
868
908
|
var sorted = sortedPlans();
|
|
869
|
-
var target =
|
|
870
|
-
?
|
|
909
|
+
var target = paramSlug && state.plans.has(paramSlug)
|
|
910
|
+
? paramSlug
|
|
871
911
|
: (sorted.length > 0 ? sorted[0].slug : null);
|
|
872
912
|
if (target) selectPlan(target);
|
|
873
913
|
}
|
|
@@ -941,6 +981,7 @@
|
|
|
941
981
|
setupSearch();
|
|
942
982
|
setupTopics();
|
|
943
983
|
setActiveTab("plan");
|
|
984
|
+
loadLintsBadge();
|
|
944
985
|
loadInitial().then(startStream);
|
|
945
986
|
});
|
|
946
987
|
})();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* Standalone Prompt Lints page. Reuses tokens from tokens.css / claude-plans.css. */
|
|
2
|
+
|
|
3
|
+
.pl-main {
|
|
4
|
+
max-width: 1100px;
|
|
5
|
+
margin: 0 auto;
|
|
6
|
+
padding: 24px 20px 64px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.pl-intro {
|
|
10
|
+
color: var(--fg-muted);
|
|
11
|
+
font-size: 14px;
|
|
12
|
+
line-height: 1.5;
|
|
13
|
+
margin-bottom: 16px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.pl-intro code {
|
|
17
|
+
background: var(--bg-subtle);
|
|
18
|
+
border: 1px solid var(--border-subtle);
|
|
19
|
+
border-radius: 4px;
|
|
20
|
+
padding: 1px 5px;
|
|
21
|
+
font-size: 12px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.pl-summary {
|
|
25
|
+
color: var(--fg-subtle);
|
|
26
|
+
font-size: 13px;
|
|
27
|
+
margin-bottom: 12px;
|
|
28
|
+
min-height: 18px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.pl-empty {
|
|
32
|
+
padding: 40px 20px;
|
|
33
|
+
text-align: center;
|
|
34
|
+
color: var(--fg-muted);
|
|
35
|
+
border: 1px dashed var(--border);
|
|
36
|
+
border-radius: 8px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pl-table {
|
|
40
|
+
width: 100%;
|
|
41
|
+
border-collapse: collapse;
|
|
42
|
+
font-size: 13px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.pl-table thead th {
|
|
46
|
+
text-align: left;
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
color: var(--fg-muted);
|
|
49
|
+
padding: 8px 12px;
|
|
50
|
+
border-bottom: 1px solid var(--border-strong);
|
|
51
|
+
position: sticky;
|
|
52
|
+
top: 0;
|
|
53
|
+
background: var(--bg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.pl-table tbody td {
|
|
57
|
+
padding: 8px 12px;
|
|
58
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
59
|
+
vertical-align: top;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.pl-row-new {
|
|
63
|
+
animation: pl-flash 1.2s ease-out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@keyframes pl-flash {
|
|
67
|
+
from { background: var(--accent-bg); }
|
|
68
|
+
to { background: transparent; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.pl-when {
|
|
72
|
+
white-space: nowrap;
|
|
73
|
+
color: var(--fg-muted);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.pl-score {
|
|
77
|
+
font-weight: 700;
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.pl-score-low { color: var(--danger); }
|
|
82
|
+
.pl-score-mid { color: var(--warning-700); }
|
|
83
|
+
.pl-score-high { color: var(--success-700); }
|
|
84
|
+
|
|
85
|
+
.pl-missing {
|
|
86
|
+
max-width: 320px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.pl-tag {
|
|
90
|
+
display: inline-block;
|
|
91
|
+
background: var(--bg-subtle);
|
|
92
|
+
border: 1px solid var(--border-subtle);
|
|
93
|
+
border-radius: 4px;
|
|
94
|
+
padding: 1px 6px;
|
|
95
|
+
margin: 0 4px 4px 0;
|
|
96
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
97
|
+
font-size: 11px;
|
|
98
|
+
color: var(--fg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.pl-source {
|
|
102
|
+
color: var(--fg-muted);
|
|
103
|
+
white-space: nowrap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.pl-excerpt {
|
|
107
|
+
max-width: 420px;
|
|
108
|
+
color: var(--fg-subtle);
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
text-overflow: ellipsis;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Dovetail — Prompt Lints</title>
|
|
7
|
+
<script>
|
|
8
|
+
/* Apply the saved theme before first paint to avoid a flash of light. */
|
|
9
|
+
(function () {
|
|
10
|
+
try {
|
|
11
|
+
var t = localStorage.getItem("cp-theme");
|
|
12
|
+
if (t === "dark" || t === "light") {
|
|
13
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
14
|
+
}
|
|
15
|
+
} catch (e) {}
|
|
16
|
+
})();
|
|
17
|
+
</script>
|
|
18
|
+
<link rel="stylesheet" href="tokens.css" />
|
|
19
|
+
<link rel="stylesheet" href="styles.css" />
|
|
20
|
+
<link rel="stylesheet" href="claude-plans.css" />
|
|
21
|
+
<link rel="stylesheet" href="prompt-lints.css" />
|
|
22
|
+
</head>
|
|
23
|
+
<body class="cp-body">
|
|
24
|
+
<header>
|
|
25
|
+
<div class="header-left">
|
|
26
|
+
<svg class="brand-mark" viewBox="0 0 55 56" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
27
|
+
<path d="M34.3254 13.7847H48.0563H54.9235V0H48.0563H34.3254H20.5946H6.8637H0V13.7847H6.8637H20.5946V27.5693H34.3254V13.7847Z" fill="#00663C"></path>
|
|
28
|
+
<path d="M54.9235 41.354V27.5693H48.0563H34.3254V41.354H20.5946V27.5693H6.8637H0V41.354V55.1387H20.5946H34.3254H54.9235V41.354Z" fill="#00663C"></path>
|
|
29
|
+
</svg>
|
|
30
|
+
<h1><span>Dovetail</span> Prompt Lints</h1>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="header-right">
|
|
33
|
+
<a class="cp-nav-link" href="/claude-plans">← Claude Plans</a>
|
|
34
|
+
<button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
|
|
35
|
+
aria-label="Toggle dark mode" title="Toggle dark mode"></button>
|
|
36
|
+
<span class="cp-storage" id="cp-storage" title="storage root"></span>
|
|
37
|
+
</div>
|
|
38
|
+
</header>
|
|
39
|
+
|
|
40
|
+
<main class="pl-main">
|
|
41
|
+
<div class="pl-intro">
|
|
42
|
+
Turn-0 prompt-lint observations recorded by the <code>UserPromptSubmit</code> hook and
|
|
43
|
+
<code>push_lint_event</code>. Newest first. Updates live.
|
|
44
|
+
</div>
|
|
45
|
+
<div class="pl-summary" id="pl-summary"></div>
|
|
46
|
+
<div class="pl-empty" id="pl-empty">
|
|
47
|
+
No lint events yet. Low-scoring prompts will appear here as you work.
|
|
48
|
+
</div>
|
|
49
|
+
<table class="pl-table" id="pl-table" hidden>
|
|
50
|
+
<thead>
|
|
51
|
+
<tr>
|
|
52
|
+
<th>When</th>
|
|
53
|
+
<th>Score</th>
|
|
54
|
+
<th>Missing tags</th>
|
|
55
|
+
<th>Source</th>
|
|
56
|
+
<th>Prompt excerpt</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody id="pl-tbody"></tbody>
|
|
60
|
+
</table>
|
|
61
|
+
</main>
|
|
62
|
+
|
|
63
|
+
<script src="prompt-lints.js"></script>
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/* Standalone Prompt Lints page. Loads the global lint-event log and live-updates
|
|
2
|
+
* via the shared /api/claude-plans/stream SSE feed (lint:upsert / lint:delete).
|
|
3
|
+
* ES5-style to match the dashboard's other public scripts. */
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
var events = new Map(); // id -> event
|
|
8
|
+
|
|
9
|
+
var els = {
|
|
10
|
+
tbody: document.getElementById("pl-tbody"),
|
|
11
|
+
table: document.getElementById("pl-table"),
|
|
12
|
+
empty: document.getElementById("pl-empty"),
|
|
13
|
+
summary: document.getElementById("pl-summary"),
|
|
14
|
+
storage: document.getElementById("cp-storage")
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Bound the initial load — the global lint log is append-only and can grow
|
|
18
|
+
// large. Live SSE events keep the in-memory view current beyond this window.
|
|
19
|
+
var PAGE_LIMIT = 200;
|
|
20
|
+
|
|
21
|
+
function fmtTime(iso) {
|
|
22
|
+
if (!iso) return "—";
|
|
23
|
+
var d = new Date(iso);
|
|
24
|
+
if (isNaN(d.getTime())) return iso;
|
|
25
|
+
return d.toLocaleString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function scoreClass(score) {
|
|
29
|
+
if (typeof score !== "number") return "";
|
|
30
|
+
if (score < 40) return "pl-score-low";
|
|
31
|
+
if (score < 70) return "pl-score-mid";
|
|
32
|
+
return "pl-score-high";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sorted() {
|
|
36
|
+
return Array.from(events.values()).sort(function (a, b) {
|
|
37
|
+
var c = (b.timestamp || "").localeCompare(a.timestamp || "");
|
|
38
|
+
if (c !== 0) return c;
|
|
39
|
+
return (b.id || "").localeCompare(a.id || "");
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderTagCells(missing) {
|
|
44
|
+
var td = document.createElement("td");
|
|
45
|
+
td.className = "pl-missing";
|
|
46
|
+
if (!missing || !missing.length) {
|
|
47
|
+
td.textContent = "—";
|
|
48
|
+
return td;
|
|
49
|
+
}
|
|
50
|
+
for (var i = 0; i < missing.length; i++) {
|
|
51
|
+
var span = document.createElement("span");
|
|
52
|
+
span.className = "pl-tag";
|
|
53
|
+
span.textContent = missing[i];
|
|
54
|
+
td.appendChild(span);
|
|
55
|
+
}
|
|
56
|
+
return td;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rowFor(e, isNew) {
|
|
60
|
+
var tr = document.createElement("tr");
|
|
61
|
+
if (isNew) tr.className = "pl-row-new";
|
|
62
|
+
|
|
63
|
+
var when = document.createElement("td");
|
|
64
|
+
when.className = "pl-when";
|
|
65
|
+
when.textContent = fmtTime(e.timestamp);
|
|
66
|
+
tr.appendChild(when);
|
|
67
|
+
|
|
68
|
+
var score = document.createElement("td");
|
|
69
|
+
score.className = "pl-score " + scoreClass(e.score);
|
|
70
|
+
score.textContent = (typeof e.score === "number" ? e.score : "?") + "%";
|
|
71
|
+
if (typeof e.threshold === "number") score.title = "threshold " + e.threshold + "%";
|
|
72
|
+
tr.appendChild(score);
|
|
73
|
+
|
|
74
|
+
tr.appendChild(renderTagCells(e.missing));
|
|
75
|
+
|
|
76
|
+
var source = document.createElement("td");
|
|
77
|
+
source.className = "pl-source";
|
|
78
|
+
source.textContent = e.source || "—";
|
|
79
|
+
tr.appendChild(source);
|
|
80
|
+
|
|
81
|
+
var excerpt = document.createElement("td");
|
|
82
|
+
excerpt.className = "pl-excerpt";
|
|
83
|
+
excerpt.textContent = e.prompt_excerpt || "—";
|
|
84
|
+
if (e.prompt_excerpt) excerpt.title = e.prompt_excerpt;
|
|
85
|
+
tr.appendChild(excerpt);
|
|
86
|
+
|
|
87
|
+
return tr;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function render(newId) {
|
|
91
|
+
var list = sorted();
|
|
92
|
+
els.tbody.innerHTML = "";
|
|
93
|
+
if (!list.length) {
|
|
94
|
+
els.empty.hidden = false;
|
|
95
|
+
els.table.hidden = true;
|
|
96
|
+
els.summary.textContent = "";
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
els.empty.hidden = true;
|
|
100
|
+
els.table.hidden = false;
|
|
101
|
+
for (var i = 0; i < list.length; i++) {
|
|
102
|
+
els.tbody.appendChild(rowFor(list[i], list[i].id === newId));
|
|
103
|
+
}
|
|
104
|
+
els.summary.textContent =
|
|
105
|
+
list.length + (list.length === 1 ? " event" : " events") +
|
|
106
|
+
(list.length >= PAGE_LIMIT ? " (latest " + PAGE_LIMIT + ")" : "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadInitial() {
|
|
110
|
+
return fetch("/api/prompt-lints?limit=" + PAGE_LIMIT)
|
|
111
|
+
.then(function (r) { return r.json(); })
|
|
112
|
+
.then(function (data) {
|
|
113
|
+
(data.events || []).forEach(function (e) {
|
|
114
|
+
if (e && e.id) events.set(e.id, e);
|
|
115
|
+
});
|
|
116
|
+
if (els.storage && data.storage) els.storage.textContent = data.storage;
|
|
117
|
+
render(null);
|
|
118
|
+
})
|
|
119
|
+
.catch(function () { render(null); });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function startStream() {
|
|
123
|
+
var es = new EventSource("/api/claude-plans/stream");
|
|
124
|
+
es.addEventListener("lint:upsert", function (msg) {
|
|
125
|
+
try {
|
|
126
|
+
var data = JSON.parse(msg.data);
|
|
127
|
+
if (data && data.lint && data.lint.id) {
|
|
128
|
+
events.set(data.lint.id, data.lint);
|
|
129
|
+
render(data.lint.id);
|
|
130
|
+
}
|
|
131
|
+
} catch (_) {}
|
|
132
|
+
});
|
|
133
|
+
es.addEventListener("lint:delete", function (msg) {
|
|
134
|
+
try {
|
|
135
|
+
var data = JSON.parse(msg.data);
|
|
136
|
+
if (data && data.id) {
|
|
137
|
+
events.delete(data.id);
|
|
138
|
+
render(null);
|
|
139
|
+
}
|
|
140
|
+
} catch (_) {}
|
|
141
|
+
});
|
|
142
|
+
es.onerror = function () { /* EventSource auto-reconnects */ };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Theme toggle — mirrors claude-plans.js */
|
|
146
|
+
function currentTheme() {
|
|
147
|
+
var attr = document.documentElement.getAttribute("data-theme");
|
|
148
|
+
if (attr === "dark" || attr === "light") return attr;
|
|
149
|
+
try {
|
|
150
|
+
var saved = localStorage.getItem("cp-theme");
|
|
151
|
+
if (saved === "dark" || saved === "light") return saved;
|
|
152
|
+
} catch (_) {}
|
|
153
|
+
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
154
|
+
? "dark"
|
|
155
|
+
: "light";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function applyTheme(theme) {
|
|
159
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
160
|
+
try { localStorage.setItem("cp-theme", theme); } catch (_) {}
|
|
161
|
+
var btn = document.getElementById("cp-theme-toggle");
|
|
162
|
+
if (btn) {
|
|
163
|
+
btn.textContent = theme === "dark" ? "☀︎" : "☾";
|
|
164
|
+
btn.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function setupTheme() {
|
|
169
|
+
applyTheme(currentTheme());
|
|
170
|
+
var btn = document.getElementById("cp-theme-toggle");
|
|
171
|
+
if (btn) {
|
|
172
|
+
btn.addEventListener("click", function () {
|
|
173
|
+
applyTheme(currentTheme() === "dark" ? "light" : "dark");
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
179
|
+
setupTheme();
|
|
180
|
+
loadInitial().then(startStream);
|
|
181
|
+
});
|
|
182
|
+
})();
|
package/server.js
CHANGED
|
@@ -834,6 +834,12 @@ function promptsDirFor(slug) {
|
|
|
834
834
|
return path.join(CLAUDE_PLANS_DIR, slug, "prompts");
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
+
// Prompt lint events are global (not nested under a plan). Stored by
|
|
838
|
+
// @tenonhq/dovetail-claude-plans at <root>/_lint-events/<id>.json.
|
|
839
|
+
function lintEventsDir() {
|
|
840
|
+
return path.join(CLAUDE_PLANS_DIR, "_lint-events");
|
|
841
|
+
}
|
|
842
|
+
|
|
837
843
|
function safeReadJson(filePath) {
|
|
838
844
|
try {
|
|
839
845
|
if (!fs.existsSync(filePath)) return null;
|
|
@@ -895,8 +901,32 @@ function listClaudePrompts(slug) {
|
|
|
895
901
|
return prompts;
|
|
896
902
|
}
|
|
897
903
|
|
|
898
|
-
|
|
899
|
-
|
|
904
|
+
function listClaudeLintEvents(filters) {
|
|
905
|
+
const dir = lintEventsDir();
|
|
906
|
+
if (!fs.existsSync(dir)) return [];
|
|
907
|
+
const f = filters || {};
|
|
908
|
+
const entries = fs.readdirSync(dir);
|
|
909
|
+
const events = [];
|
|
910
|
+
for (let i = 0; i < entries.length; i++) {
|
|
911
|
+
if (!entries[i].endsWith(".json")) continue;
|
|
912
|
+
const e = safeReadJson(path.join(dir, entries[i]));
|
|
913
|
+
if (!e) continue;
|
|
914
|
+
if (f.session_id !== undefined && e.session_id !== f.session_id) continue;
|
|
915
|
+
if (f.plan_slug !== undefined && e.plan_slug !== f.plan_slug) continue;
|
|
916
|
+
events.push(e);
|
|
917
|
+
}
|
|
918
|
+
events.sort(function (a, b) {
|
|
919
|
+
const c = (b.timestamp || "").localeCompare(a.timestamp || "");
|
|
920
|
+
if (c !== 0) return c;
|
|
921
|
+
return (b.id || "").localeCompare(a.id || "");
|
|
922
|
+
});
|
|
923
|
+
if (typeof f.limit === "number") return events.slice(0, f.limit);
|
|
924
|
+
return events;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Parse a watcher path like "<root>/<slug>.json",
|
|
928
|
+
// "<root>/<slug>/artifacts/<artifact-slug>.json", or
|
|
929
|
+
// "<root>/_lint-events/<id>.json" into { kind, ... }.
|
|
900
930
|
function classifyPath(filePath) {
|
|
901
931
|
const rel = path.relative(CLAUDE_PLANS_DIR, filePath);
|
|
902
932
|
if (!rel || rel.startsWith("..")) return null;
|
|
@@ -904,6 +934,9 @@ function classifyPath(filePath) {
|
|
|
904
934
|
if (parts.length === 1 && parts[0] === ".focus") {
|
|
905
935
|
return { kind: "focus" };
|
|
906
936
|
}
|
|
937
|
+
if (parts.length === 2 && parts[0] === "_lint-events" && parts[1].endsWith(".json")) {
|
|
938
|
+
return { kind: "lint", id: parts[1].slice(0, -5) };
|
|
939
|
+
}
|
|
907
940
|
if (parts.length === 1 && parts[0].endsWith(".json")) {
|
|
908
941
|
return { kind: "plan", slug: parts[0].slice(0, -5) };
|
|
909
942
|
}
|
|
@@ -972,6 +1005,15 @@ function handleWatcherChange(event, filePath) {
|
|
|
972
1005
|
}
|
|
973
1006
|
const prompt = safeReadJson(filePath);
|
|
974
1007
|
if (prompt) broadcastClaudePlanEvent("prompt:upsert", { prompt: prompt });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (info.kind === "lint") {
|
|
1011
|
+
if (event === "unlink") {
|
|
1012
|
+
broadcastClaudePlanEvent("lint:delete", { id: info.id });
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const lint = safeReadJson(filePath);
|
|
1016
|
+
if (lint) broadcastClaudePlanEvent("lint:upsert", { lint: lint });
|
|
975
1017
|
}
|
|
976
1018
|
}
|
|
977
1019
|
|
|
@@ -996,10 +1038,35 @@ function startClaudePlanWatcher() {
|
|
|
996
1038
|
claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
|
|
997
1039
|
}
|
|
998
1040
|
|
|
999
|
-
app.get(
|
|
1041
|
+
app.get("/claude-plans", function (req, res) {
|
|
1000
1042
|
res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
|
|
1001
1043
|
});
|
|
1002
1044
|
|
|
1045
|
+
// Legacy path-segment deep links (/claude-plans/:slug) redirect to the
|
|
1046
|
+
// query-param form so relative assets resolve against /claude-plans.
|
|
1047
|
+
app.get("/claude-plans/:slug", function (req, res) {
|
|
1048
|
+
res.redirect(301, "/claude-plans?plan=" + encodeURIComponent(req.params.slug));
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
app.get("/prompt-lints", function (req, res) {
|
|
1052
|
+
res.sendFile(path.join(__dirname, "public", "prompt-lints.html"));
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
app.get("/api/prompt-lints", function (req, res) {
|
|
1056
|
+
try {
|
|
1057
|
+
const filters = {};
|
|
1058
|
+
if (typeof req.query.session_id === "string") filters.session_id = req.query.session_id;
|
|
1059
|
+
if (typeof req.query.plan_slug === "string") filters.plan_slug = req.query.plan_slug;
|
|
1060
|
+
if (typeof req.query.limit === "string") {
|
|
1061
|
+
const n = parseInt(req.query.limit, 10);
|
|
1062
|
+
if (!isNaN(n) && n > 0) filters.limit = Math.min(n, 1000); // mirror getLintEventsSchema cap
|
|
1063
|
+
}
|
|
1064
|
+
res.json({ events: listClaudeLintEvents(filters), storage: CLAUDE_PLANS_DIR });
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
res.status(500).json({ error: e.message });
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1003
1070
|
app.get("/api/claude-plans", function (req, res) {
|
|
1004
1071
|
try {
|
|
1005
1072
|
res.json({ plans: listClaudePlans(), storage: CLAUDE_PLANS_DIR });
|