@tenonhq/dovetail-dashboard 0.0.26 → 0.0.28

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.26",
3
+ "version": "0.0.28",
4
4
  "description": "Update Set Dashboard for Dovetail",
5
5
  "main": "server.js",
6
6
  "scripts": {
@@ -1165,18 +1165,18 @@
1165
1165
  /* ─── Topics cloud ─────────────────────────────────────────────────────────── */
1166
1166
 
1167
1167
  .cp-topics {
1168
- margin: 0 24px 12px;
1169
- background: var(--surface-raised);
1170
- border: 1px solid var(--border);
1171
- border-radius: var(--radius-md, 8px);
1172
- box-shadow: var(--shadow-1, 0 1px 2px rgba(0,0,0,0.04));
1168
+ background: transparent;
1169
+ border: none;
1170
+ border-bottom: 1px solid var(--border);
1171
+ box-shadow: none;
1172
+ border-radius: 0;
1173
1173
  }
1174
1174
 
1175
1175
  .cp-topics-summary {
1176
1176
  display: flex;
1177
1177
  align-items: center;
1178
1178
  gap: 8px;
1179
- padding: 10px 14px;
1179
+ padding: 8px 12px;
1180
1180
  cursor: pointer;
1181
1181
  list-style: none;
1182
1182
  user-select: none;
@@ -1234,7 +1234,7 @@
1234
1234
  display: flex;
1235
1235
  flex-wrap: wrap;
1236
1236
  gap: 6px 8px;
1237
- padding: 10px 14px 14px;
1237
+ padding: 8px 12px 10px;
1238
1238
  border-top: 1px solid var(--border-subtle, var(--border));
1239
1239
  line-height: 1.3;
1240
1240
  }
@@ -30,39 +30,39 @@
30
30
  </div>
31
31
  <div class="header-right">
32
32
  <a class="cp-nav-link" href="/">&larr; Update Sets</a>
33
+ <a class="cp-nav-link" href="/prompt-lints">Prompt Lints &rarr;</a>
33
34
  <button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
34
35
  aria-label="Toggle dark mode" title="Toggle dark mode"></button>
35
36
  <span class="cp-storage" id="cp-storage" title="storage root"></span>
36
37
  </div>
37
38
  </header>
38
39
 
39
- <details class="cp-topics" id="cp-topics" open>
40
- <summary class="cp-topics-summary">
41
- <span class="cp-topics-caret" aria-hidden="true">▾</span>
42
- <span class="cp-topics-title">Topics</span>
43
- <span class="cp-topics-count" id="cp-topics-count">0</span>
44
- <button
45
- type="button"
46
- class="cp-topics-clear"
47
- id="cp-topics-clear"
48
- hidden
49
- title="Clear topic filter"
50
- aria-label="Clear topic filter"
51
- >Clear filter</button>
52
- </summary>
53
- <div class="cp-topics-cloud" id="cp-topics-cloud" role="group" aria-label="Plan topics">
54
- <span class="cp-topics-empty" id="cp-topics-empty">
55
- No topics yet. Push a plan and topics will appear here.
56
- </span>
57
- </div>
58
- </details>
59
-
60
40
  <main class="cp-layout">
61
41
  <aside class="cp-rail" id="cp-rail">
62
42
  <div class="cp-rail-header">
63
43
  <span class="cp-rail-title">Plans</span>
64
44
  <span class="cp-count" id="cp-count">0</span>
65
45
  </div>
46
+ <details class="cp-topics" id="cp-topics" open>
47
+ <summary class="cp-topics-summary">
48
+ <span class="cp-topics-caret" aria-hidden="true">▾</span>
49
+ <span class="cp-topics-title">Topics</span>
50
+ <span class="cp-topics-count" id="cp-topics-count">0</span>
51
+ <button
52
+ type="button"
53
+ class="cp-topics-clear"
54
+ id="cp-topics-clear"
55
+ hidden
56
+ title="Clear topic filter"
57
+ aria-label="Clear topic filter"
58
+ >Clear filter</button>
59
+ </summary>
60
+ <div class="cp-topics-cloud" id="cp-topics-cloud" role="group" aria-label="Plan topics">
61
+ <span class="cp-topics-empty" id="cp-topics-empty">
62
+ No topics yet. Push a plan and topics will appear here.
63
+ </span>
64
+ </div>
65
+ </details>
66
66
  <div class="cp-search">
67
67
  <input
68
68
  type="search"
@@ -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">&larr; 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
- // Parse a watcher path like "<root>/<slug>.json" or
899
- // "<root>/<slug>/artifacts/<artifact-slug>.json" into { kind, slug, artifactSlug }.
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
 
@@ -1000,6 +1042,25 @@ app.get(["/claude-plans", "/claude-plans/:slug"], function (req, res) {
1000
1042
  res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
1001
1043
  });
1002
1044
 
1045
+ app.get("/prompt-lints", function (req, res) {
1046
+ res.sendFile(path.join(__dirname, "public", "prompt-lints.html"));
1047
+ });
1048
+
1049
+ app.get("/api/prompt-lints", function (req, res) {
1050
+ try {
1051
+ const filters = {};
1052
+ if (typeof req.query.session_id === "string") filters.session_id = req.query.session_id;
1053
+ if (typeof req.query.plan_slug === "string") filters.plan_slug = req.query.plan_slug;
1054
+ if (typeof req.query.limit === "string") {
1055
+ const n = parseInt(req.query.limit, 10);
1056
+ if (!isNaN(n) && n > 0) filters.limit = Math.min(n, 1000); // mirror getLintEventsSchema cap
1057
+ }
1058
+ res.json({ events: listClaudeLintEvents(filters), storage: CLAUDE_PLANS_DIR });
1059
+ } catch (e) {
1060
+ res.status(500).json({ error: e.message });
1061
+ }
1062
+ });
1063
+
1003
1064
  app.get("/api/claude-plans", function (req, res) {
1004
1065
  try {
1005
1066
  res.json({ plans: listClaudePlans(), storage: CLAUDE_PLANS_DIR });