@tenonhq/dovetail-dashboard 0.0.13 → 0.0.14

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.
@@ -0,0 +1,354 @@
1
+ /* /claude-plans page logic.
2
+ * - Fetches initial state from REST.
3
+ * - Subscribes to /api/claude-plans/stream (SSE) for live updates.
4
+ * - Renders markdown via marked + DOMPurify, Mermaid via mermaid.run().
5
+ */
6
+
7
+ (function () {
8
+ "use strict";
9
+
10
+ if (window.mermaid && typeof window.mermaid.initialize === "function") {
11
+ window.mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
12
+ }
13
+ if (window.marked && typeof window.marked.setOptions === "function") {
14
+ window.marked.setOptions({ breaks: true, gfm: true });
15
+ }
16
+
17
+ var state = {
18
+ plans: new Map(), // slug -> plan
19
+ artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
20
+ selectedSlug: null,
21
+ activeTab: "plan"
22
+ };
23
+
24
+ var els = {
25
+ storage: document.getElementById("cp-storage"),
26
+ list: document.getElementById("cp-list"),
27
+ count: document.getElementById("cp-count"),
28
+ railEmpty: document.getElementById("cp-rail-empty"),
29
+ detailEmpty: document.getElementById("cp-detail-empty"),
30
+ detailBody: document.getElementById("cp-detail-body"),
31
+ detailTitle: document.getElementById("cp-detail-title"),
32
+ detailStatus: document.getElementById("cp-detail-status"),
33
+ detailStamp: document.getElementById("cp-detail-stamp"),
34
+ artifactCount: document.getElementById("cp-artifact-count"),
35
+ planPanel: document.getElementById("cp-tab-plan"),
36
+ artifactsPanel: document.getElementById("cp-tab-artifacts"),
37
+ tabs: document.querySelectorAll(".cp-tab")
38
+ };
39
+
40
+ function sortedPlans() {
41
+ return Array.from(state.plans.values()).sort(function (a, b) {
42
+ return (b.updated_at || "").localeCompare(a.updated_at || "");
43
+ });
44
+ }
45
+
46
+ function sortedArtifacts(slug) {
47
+ var map = state.artifacts.get(slug);
48
+ if (!map) return [];
49
+ return Array.from(map.values()).sort(function (a, b) {
50
+ return (a.created_at || "").localeCompare(b.created_at || "");
51
+ });
52
+ }
53
+
54
+ function fmtTime(iso) {
55
+ if (!iso) return "";
56
+ var d = new Date(iso);
57
+ if (isNaN(d.getTime())) return iso;
58
+ return d.toLocaleString();
59
+ }
60
+
61
+ function showError(msg) {
62
+ var el = document.createElement("div");
63
+ el.style.cssText = "position:fixed;bottom:16px;right:16px;background:var(--danger);color:#fff;padding:8px 14px;border-radius:4px;font-size:13px;z-index:9999";
64
+ el.textContent = msg;
65
+ document.body.appendChild(el);
66
+ setTimeout(function () { el.remove(); }, 4000);
67
+ }
68
+
69
+ function renderMarkdown(md, target) {
70
+ if (!window.marked || !window.DOMPurify) {
71
+ target.textContent = md;
72
+ return;
73
+ }
74
+ var html = window.marked.parse(md || "");
75
+ target.innerHTML = window.DOMPurify.sanitize(html);
76
+ }
77
+
78
+ function renderMermaid(source, target) {
79
+ target.classList.add("cp-mermaid");
80
+ target.textContent = "";
81
+ if (!window.mermaid || typeof window.mermaid.render !== "function") {
82
+ target.textContent = source;
83
+ return;
84
+ }
85
+ var id = "mmd-" + Math.random().toString(36).slice(2, 10);
86
+ try {
87
+ window.mermaid.render(id, source).then(
88
+ function (out) { target.innerHTML = out.svg; },
89
+ function (err) {
90
+ target.classList.remove("cp-mermaid");
91
+ target.classList.add("cp-mermaid-error");
92
+ target.textContent = "mermaid error: " + (err && err.message ? err.message : String(err));
93
+ }
94
+ );
95
+ } catch (err) {
96
+ target.classList.remove("cp-mermaid");
97
+ target.classList.add("cp-mermaid-error");
98
+ target.textContent = "mermaid error: " + err.message;
99
+ }
100
+ }
101
+
102
+ function renderRail() {
103
+ var plans = sortedPlans();
104
+ els.count.textContent = String(plans.length);
105
+ if (plans.length === 0) {
106
+ els.railEmpty.style.display = "block";
107
+ els.list.innerHTML = "";
108
+ return;
109
+ }
110
+ els.railEmpty.style.display = "none";
111
+ els.list.innerHTML = "";
112
+ plans.forEach(function (plan) {
113
+ var artifactCount = (state.artifacts.get(plan.slug) || new Map()).size;
114
+ var li = document.createElement("li");
115
+ li.className = "cp-list-item" + (plan.slug === state.selectedSlug ? " active" : "");
116
+ li.tabIndex = 0;
117
+ li.dataset.slug = plan.slug;
118
+ li.innerHTML =
119
+ '<div class="cp-list-row">' +
120
+ ' <span class="cp-list-title"></span>' +
121
+ ' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
122
+ '</div>' +
123
+ '<div class="cp-list-meta">' +
124
+ ' <span class="cp-list-meta-slug"></span>' +
125
+ ' <span class="cp-artifact-badge">' + artifactCount + ' artifact' + (artifactCount === 1 ? '' : 's') + '</span>' +
126
+ '</div>';
127
+ li.querySelector(".cp-list-title").textContent = plan.title;
128
+ li.querySelector(".cp-list-meta-slug").textContent = plan.slug;
129
+ li.addEventListener("click", function () { selectPlan(plan.slug); });
130
+ li.addEventListener("keydown", function (e) {
131
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectPlan(plan.slug); }
132
+ });
133
+ var delBtn = document.createElement("button");
134
+ delBtn.className = "cp-list-delete";
135
+ delBtn.title = "Delete plan";
136
+ delBtn.textContent = "×";
137
+ delBtn.addEventListener("click", function (e) {
138
+ e.stopPropagation();
139
+ if (!delBtn.dataset.confirm) {
140
+ delBtn.dataset.confirm = "1";
141
+ delBtn.textContent = "?";
142
+ setTimeout(function () {
143
+ delBtn.textContent = "×";
144
+ delete delBtn.dataset.confirm;
145
+ }, 2000);
146
+ return;
147
+ }
148
+ fetch("/api/claude-plans/" + encodeURIComponent(plan.slug), { method: "DELETE" })
149
+ .then(function (r) {
150
+ if (!r.ok) throw new Error("HTTP " + r.status);
151
+ removePlan(plan.slug);
152
+ })
153
+ .catch(function () {
154
+ delBtn.textContent = "×";
155
+ delete delBtn.dataset.confirm;
156
+ showError("Failed to delete plan. Try again.");
157
+ });
158
+ });
159
+ li.querySelector(".cp-list-row").appendChild(delBtn);
160
+ els.list.appendChild(li);
161
+ });
162
+ }
163
+
164
+ function renderDetail() {
165
+ if (!state.selectedSlug || !state.plans.has(state.selectedSlug)) {
166
+ els.detailEmpty.style.display = "block";
167
+ els.detailBody.hidden = true;
168
+ return;
169
+ }
170
+ var plan = state.plans.get(state.selectedSlug);
171
+ var artifacts = sortedArtifacts(state.selectedSlug);
172
+
173
+ els.detailEmpty.style.display = "none";
174
+ els.detailBody.hidden = false;
175
+ els.detailTitle.textContent = plan.title;
176
+ els.detailStatus.textContent = plan.status;
177
+ els.detailStatus.className = "cp-status-pill cp-status-" + plan.status;
178
+ els.detailStamp.textContent = "updated " + fmtTime(plan.updated_at);
179
+ els.artifactCount.textContent = String(artifacts.length);
180
+
181
+ var existingPrBadge = document.getElementById("cp-pr-badge");
182
+ if (existingPrBadge) existingPrBadge.remove();
183
+ if (plan.pr_url) {
184
+ var prBadge = document.createElement("a");
185
+ prBadge.id = "cp-pr-badge";
186
+ prBadge.className = "cp-pr-badge";
187
+ prBadge.href = plan.pr_url;
188
+ prBadge.target = "_blank";
189
+ prBadge.rel = "noopener noreferrer";
190
+ prBadge.textContent = plan.pr_title
191
+ ? "PR #" + plan.pr_number + " — " + plan.pr_title
192
+ : "PR #" + (plan.pr_number || "");
193
+ els.detailStamp.insertAdjacentElement("afterend", prBadge);
194
+ }
195
+
196
+ if (plan.content_html) {
197
+ els.planPanel.innerHTML = window.DOMPurify
198
+ ? window.DOMPurify.sanitize(plan.content_html)
199
+ : plan.content_html;
200
+ } else {
201
+ renderMarkdown(plan.content_md, els.planPanel);
202
+ }
203
+
204
+ els.artifactsPanel.innerHTML = "";
205
+ if (artifacts.length === 0) {
206
+ var empty = document.createElement("div");
207
+ empty.className = "cp-detail-empty";
208
+ empty.style.padding = "40px 0";
209
+ empty.textContent = "No artifacts yet. Call push_artifact or push_diagram from Claude.";
210
+ els.artifactsPanel.appendChild(empty);
211
+ } else {
212
+ artifacts.forEach(function (artifact) {
213
+ var card = document.createElement("div");
214
+ card.className = "cp-artifact-card";
215
+ var head = document.createElement("div");
216
+ head.className = "cp-artifact-head";
217
+ var title = document.createElement("span");
218
+ title.className = "cp-artifact-title";
219
+ title.textContent = artifact.title;
220
+ var kind = document.createElement("span");
221
+ kind.className = "cp-kind-pill";
222
+ kind.textContent = artifact.kind;
223
+ head.appendChild(title);
224
+ head.appendChild(kind);
225
+ card.appendChild(head);
226
+
227
+ var body = document.createElement("div");
228
+ if (artifact.kind === "mermaid") renderMermaid(artifact.content, body);
229
+ else renderMarkdown(artifact.content, body);
230
+ card.appendChild(body);
231
+ els.artifactsPanel.appendChild(card);
232
+ });
233
+ }
234
+ }
235
+
236
+ function setActiveTab(tab) {
237
+ state.activeTab = tab;
238
+ els.tabs.forEach(function (btn) {
239
+ btn.classList.toggle("active", btn.dataset.tab === tab);
240
+ });
241
+ els.planPanel.hidden = tab !== "plan";
242
+ els.artifactsPanel.hidden = tab !== "artifacts";
243
+ }
244
+
245
+ function selectPlan(slug) {
246
+ state.selectedSlug = slug;
247
+ renderRail();
248
+ renderDetail();
249
+ }
250
+
251
+ els.tabs.forEach(function (btn) {
252
+ btn.addEventListener("click", function () { setActiveTab(btn.dataset.tab); });
253
+ });
254
+
255
+ function upsertPlan(plan) {
256
+ state.plans.set(plan.slug, plan);
257
+ renderRail();
258
+ if (state.selectedSlug === plan.slug) renderDetail();
259
+ }
260
+
261
+ function removePlan(slug) {
262
+ state.plans.delete(slug);
263
+ state.artifacts.delete(slug);
264
+ if (state.selectedSlug === slug) state.selectedSlug = null;
265
+ renderRail();
266
+ renderDetail();
267
+ }
268
+
269
+ function upsertArtifact(artifact) {
270
+ var bucket = state.artifacts.get(artifact.plan_slug);
271
+ if (!bucket) {
272
+ bucket = new Map();
273
+ state.artifacts.set(artifact.plan_slug, bucket);
274
+ }
275
+ bucket.set(artifact.slug, artifact);
276
+ renderRail();
277
+ if (state.selectedSlug === artifact.plan_slug) renderDetail();
278
+ }
279
+
280
+ function removeArtifact(planSlug, slug) {
281
+ var bucket = state.artifacts.get(planSlug);
282
+ if (!bucket) return;
283
+ bucket.delete(slug);
284
+ renderRail();
285
+ if (state.selectedSlug === planSlug) renderDetail();
286
+ }
287
+
288
+ async function loadInitial() {
289
+ try {
290
+ var res = await fetch("/api/claude-plans");
291
+ var data = await res.json();
292
+ if (data && Array.isArray(data.plans)) {
293
+ data.plans.forEach(function (p) { state.plans.set(p.slug, p); });
294
+ }
295
+ if (data && data.storage) els.storage.textContent = data.storage;
296
+ } catch (err) {
297
+ console.warn("[claude-plans] failed to load initial state:", err);
298
+ }
299
+ // Preload artifacts for visible plans
300
+ var slugs = Array.from(state.plans.keys());
301
+ await Promise.all(slugs.map(function (slug) {
302
+ return fetch("/api/claude-plans/" + encodeURIComponent(slug))
303
+ .then(function (r) { return r.json(); })
304
+ .then(function (data) {
305
+ if (data && Array.isArray(data.artifacts)) {
306
+ var bucket = new Map();
307
+ data.artifacts.forEach(function (a) { bucket.set(a.slug, a); });
308
+ state.artifacts.set(slug, bucket);
309
+ }
310
+ })
311
+ .catch(function () { /* ignore */ });
312
+ }));
313
+ renderRail();
314
+ if (!state.selectedSlug) {
315
+ var sorted = sortedPlans();
316
+ if (sorted.length > 0) selectPlan(sorted[0].slug);
317
+ }
318
+ }
319
+
320
+ function startStream() {
321
+ var es = new EventSource("/api/claude-plans/stream");
322
+ es.addEventListener("plan:upsert", function (e) {
323
+ try { upsertPlan(JSON.parse(e.data).plan); } catch (_) {}
324
+ });
325
+ es.addEventListener("plan:delete", function (e) {
326
+ try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
327
+ });
328
+ es.addEventListener("artifact:upsert", function (e) {
329
+ try { upsertArtifact(JSON.parse(e.data).artifact); } catch (_) {}
330
+ });
331
+ es.addEventListener("artifact:delete", function (e) {
332
+ try {
333
+ var payload = JSON.parse(e.data);
334
+ removeArtifact(payload.plan_slug, payload.slug);
335
+ } catch (_) {}
336
+ });
337
+ es.addEventListener("plan:focus", function (e) {
338
+ try {
339
+ var data = JSON.parse(e.data);
340
+ if (data.slug) {
341
+ selectPlan(data.slug);
342
+ var item = els.list.querySelector("[data-slug=\"" + data.slug + "\"]");
343
+ if (item) item.scrollIntoView({ behavior: "smooth", block: "nearest" });
344
+ }
345
+ } catch (_) {}
346
+ });
347
+ es.onerror = function () { /* EventSource auto-reconnects */ };
348
+ }
349
+
350
+ document.addEventListener("DOMContentLoaded", function () {
351
+ setActiveTab("plan");
352
+ loadInitial().then(startStream);
353
+ });
354
+ })();
package/public/index.html CHANGED
@@ -14,6 +14,7 @@
14
14
  </div>
15
15
  <div class="header-right">
16
16
  <button class="btn-refresh" id="refresh-btn" title="Refresh scopes and update sets">Refresh</button>
17
+ <a class="btn-refresh" href="/claude-plans" title="Claude plans and diagrams pushed via MCP">Claude</a>
17
18
  <div class="active-task-chip" id="active-task-chip" style="display:none;"></div>
18
19
  <div class="instance-badge" id="instance-badge">Loading...</div>
19
20
  </div>
package/public/styles.css CHANGED
@@ -717,6 +717,11 @@ header h1 span {
717
717
  border-color: var(--danger);
718
718
  }
719
719
 
720
+ .toast.warn {
721
+ border-color: #c88c2a;
722
+ color: #e8b86d;
723
+ }
724
+
720
725
  @keyframes slideIn {
721
726
  from {
722
727
  opacity: 0;
@@ -817,3 +822,24 @@ header h1 span {
817
822
  color: var(--text-muted);
818
823
  white-space: nowrap;
819
824
  }
825
+
826
+ .recent-edit-dismiss {
827
+ background: none;
828
+ border: none;
829
+ color: var(--text-muted);
830
+ font-size: 16px;
831
+ line-height: 1;
832
+ padding: 0 2px;
833
+ cursor: pointer;
834
+ opacity: 0;
835
+ transition: opacity 0.15s, color 0.15s;
836
+ flex-shrink: 0;
837
+ }
838
+
839
+ .recent-edit-row:hover .recent-edit-dismiss {
840
+ opacity: 1;
841
+ }
842
+
843
+ .recent-edit-dismiss:hover {
844
+ color: var(--danger);
845
+ }
package/server.js CHANGED
@@ -23,6 +23,11 @@ const recentEditsLimiter = RateLimit({
23
23
  windowMs: 15 * 60 * 1000,
24
24
  max: 100,
25
25
  });
26
+ // Rate limiter for claude-plans destructive operations
27
+ const claudePlansLimiter = RateLimit({
28
+ windowMs: 15 * 60 * 1000,
29
+ max: 60,
30
+ });
26
31
  const SN_PASSWORD = process.env.SN_PASSWORD || "";
27
32
  const BASE_URL = `https://${SN_INSTANCE}`;
28
33
 
@@ -279,6 +284,7 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
279
284
  }
280
285
 
281
286
  enriched.push({
287
+ sys_id: edit.sys_id,
282
288
  tableName: edit.tableName,
283
289
  name: edit.name,
284
290
  scope: edit.scope,
@@ -293,6 +299,28 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
293
299
  }
294
300
  });
295
301
 
302
+ // POST /api/recent-edits/dismiss — remove one entry from the local recent edits file
303
+ app.post("/api/recent-edits/dismiss", function (req, res) {
304
+ try {
305
+ var sys_id = req.body.sys_id;
306
+ var tableName = req.body.tableName;
307
+ if (!sys_id || !tableName) {
308
+ return res.status(400).json({ error: "sys_id and tableName required" });
309
+ }
310
+ var edits = [];
311
+ if (fs.existsSync(RECENT_EDITS_FILE)) {
312
+ edits = JSON.parse(fs.readFileSync(RECENT_EDITS_FILE, "utf8"));
313
+ }
314
+ var filtered = edits.filter(function (e) {
315
+ return !(e.sys_id === sys_id && e.tableName === tableName);
316
+ });
317
+ fs.writeFileSync(RECENT_EDITS_FILE, JSON.stringify(filtered, null, 2));
318
+ res.json({ ok: true });
319
+ } catch (e) {
320
+ res.status(500).json({ error: e.message });
321
+ }
322
+ });
323
+
296
324
  // GET /api/update-sets/:scope — list in-progress update sets for a scope
297
325
  app.get("/api/update-sets/:scope", async (req, res) => {
298
326
  try {
@@ -772,14 +800,244 @@ app.post("/api/clickup/deselect-task", function (req, res) {
772
800
  }
773
801
  });
774
802
 
803
+ // --- Claude Plans Panel ---
804
+ // Reads JSON records written by @tenonhq/dovetail-claude-plans into
805
+ // ~/.dovetail/claude-plans/ and streams updates to the /claude-plans page.
806
+ // Storage layout:
807
+ // <root>/<plan-slug>.json
808
+ // <root>/<plan-slug>/artifacts/<artifact-slug>.json
809
+ const os = require("os");
810
+ const chokidar = require("chokidar");
811
+
812
+ const CLAUDE_PLANS_DIR =
813
+ process.env.DOVE_CLAUDE_PLANS_DIR ||
814
+ path.join(os.homedir(), ".dovetail", "claude-plans");
815
+
816
+ // Slugs are written by @tenonhq/dovetail-claude-plans' slugify() — kebab-case,
817
+ // max 64 chars. We re-validate on the read side so a request like `..%2Ffoo`
818
+ // cannot escape CLAUDE_PLANS_DIR via path.join.
819
+ const CLAUDE_PLAN_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
820
+
821
+ function isValidSlug(slug) {
822
+ return typeof slug === "string" && CLAUDE_PLAN_SLUG.test(slug);
823
+ }
824
+
825
+ function planFilePath(slug) {
826
+ return path.join(CLAUDE_PLANS_DIR, slug + ".json");
827
+ }
828
+
829
+ function artifactsDirFor(slug) {
830
+ return path.join(CLAUDE_PLANS_DIR, slug, "artifacts");
831
+ }
832
+
833
+ function safeReadJson(filePath) {
834
+ try {
835
+ if (!fs.existsSync(filePath)) return null;
836
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
837
+ } catch (e) {
838
+ return null;
839
+ }
840
+ }
841
+
842
+ function listClaudePlans() {
843
+ if (!fs.existsSync(CLAUDE_PLANS_DIR)) return [];
844
+ const entries = fs.readdirSync(CLAUDE_PLANS_DIR);
845
+ const plans = [];
846
+ for (let i = 0; i < entries.length; i++) {
847
+ const name = entries[i];
848
+ if (!name.endsWith(".json")) continue;
849
+ const plan = safeReadJson(path.join(CLAUDE_PLANS_DIR, name));
850
+ if (plan) plans.push(plan);
851
+ }
852
+ plans.sort(function (a, b) {
853
+ return (b.updated_at || "").localeCompare(a.updated_at || "");
854
+ });
855
+ return plans;
856
+ }
857
+
858
+ function listClaudeArtifacts(slug) {
859
+ if (!isValidSlug(slug)) return [];
860
+ const dir = artifactsDirFor(slug);
861
+ if (!fs.existsSync(dir)) return [];
862
+ const entries = fs.readdirSync(dir);
863
+ const artifacts = [];
864
+ for (let i = 0; i < entries.length; i++) {
865
+ if (!entries[i].endsWith(".json")) continue;
866
+ const a = safeReadJson(path.join(dir, entries[i]));
867
+ if (a) artifacts.push(a);
868
+ }
869
+ artifacts.sort(function (a, b) {
870
+ return (a.created_at || "").localeCompare(b.created_at || "");
871
+ });
872
+ return artifacts;
873
+ }
874
+
875
+ // Parse a watcher path like "<root>/<slug>.json" or
876
+ // "<root>/<slug>/artifacts/<artifact-slug>.json" into { kind, slug, artifactSlug }.
877
+ function classifyPath(filePath) {
878
+ const rel = path.relative(CLAUDE_PLANS_DIR, filePath);
879
+ if (!rel || rel.startsWith("..")) return null;
880
+ const parts = rel.split(path.sep);
881
+ if (parts.length === 1 && parts[0] === ".focus") {
882
+ return { kind: "focus" };
883
+ }
884
+ if (parts.length === 1 && parts[0].endsWith(".json")) {
885
+ return { kind: "plan", slug: parts[0].slice(0, -5) };
886
+ }
887
+ if (parts.length === 3 && parts[1] === "artifacts" && parts[2].endsWith(".json")) {
888
+ return { kind: "artifact", slug: parts[0], artifactSlug: parts[2].slice(0, -5) };
889
+ }
890
+ return null;
891
+ }
892
+
893
+ // SSE fan-out. Each connected client is a response object held open until the
894
+ // client disconnects; broadcastClaudePlanEvent writes a single SSE frame to all.
895
+ const claudePlanSseClients = new Set();
896
+
897
+ function broadcastClaudePlanEvent(event, data) {
898
+ const frame = "event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n";
899
+ for (const res of claudePlanSseClients) {
900
+ try {
901
+ res.write(frame);
902
+ } catch (err) {
903
+ claudePlanSseClients.delete(res);
904
+ }
905
+ }
906
+ }
907
+
908
+ function handleWatcherChange(event, filePath) {
909
+ const info = classifyPath(filePath);
910
+ if (!info) return;
911
+ if (info.kind === "focus") {
912
+ const focus = safeReadJson(filePath);
913
+ if (focus && isValidSlug(focus.slug)) {
914
+ broadcastClaudePlanEvent("plan:focus", { slug: focus.slug });
915
+ }
916
+ return;
917
+ }
918
+ if (info.kind === "plan") {
919
+ if (event === "unlink") {
920
+ broadcastClaudePlanEvent("plan:delete", { slug: info.slug });
921
+ return;
922
+ }
923
+ const plan = safeReadJson(filePath);
924
+ if (plan) broadcastClaudePlanEvent("plan:upsert", { plan: plan });
925
+ return;
926
+ }
927
+ if (info.kind === "artifact") {
928
+ if (event === "unlink") {
929
+ broadcastClaudePlanEvent("artifact:delete", {
930
+ plan_slug: info.slug,
931
+ slug: info.artifactSlug
932
+ });
933
+ return;
934
+ }
935
+ const artifact = safeReadJson(filePath);
936
+ if (artifact) broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
937
+ }
938
+ }
939
+
940
+ // Start the watcher lazily — create the storage dir if missing so chokidar has
941
+ // something to watch. ignoreInitial avoids replaying every file on boot
942
+ // (clients fetch initial state via GET /api/claude-plans).
943
+ let claudePlanWatcher = null;
944
+ function startClaudePlanWatcher() {
945
+ if (claudePlanWatcher) return;
946
+ try {
947
+ fs.mkdirSync(CLAUDE_PLANS_DIR, { recursive: true });
948
+ } catch (err) {
949
+ console.warn("[claude-plans] could not create storage dir:", err.message);
950
+ }
951
+ claudePlanWatcher = chokidar.watch(CLAUDE_PLANS_DIR, {
952
+ ignoreInitial: true,
953
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
954
+ depth: 3
955
+ });
956
+ claudePlanWatcher.on("add", function (p) { handleWatcherChange("add", p); });
957
+ claudePlanWatcher.on("change", function (p) { handleWatcherChange("change", p); });
958
+ claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
959
+ }
960
+
961
+ app.get("/claude-plans", function (req, res) {
962
+ res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
963
+ });
964
+
965
+ app.get("/api/claude-plans", function (req, res) {
966
+ try {
967
+ res.json({ plans: listClaudePlans(), storage: CLAUDE_PLANS_DIR });
968
+ } catch (e) {
969
+ res.status(500).json({ error: e.message });
970
+ }
971
+ });
972
+
973
+ app.get("/api/claude-plans/stream", function (req, res) {
974
+ res.set({
975
+ "Content-Type": "text/event-stream",
976
+ "Cache-Control": "no-cache",
977
+ Connection: "keep-alive",
978
+ "X-Accel-Buffering": "no"
979
+ });
980
+ res.flushHeaders();
981
+ res.write("event: hello\ndata: {}\n\n");
982
+ claudePlanSseClients.add(res);
983
+
984
+ const heartbeat = setInterval(function () {
985
+ try {
986
+ res.write(": heartbeat\n\n");
987
+ } catch (err) {
988
+ clearInterval(heartbeat);
989
+ claudePlanSseClients.delete(res);
990
+ }
991
+ }, 25000);
992
+
993
+ req.on("close", function () {
994
+ clearInterval(heartbeat);
995
+ claudePlanSseClients.delete(res);
996
+ });
997
+ });
998
+
999
+ // :slug must avoid the static "stream" route above; Express matches in order.
1000
+ app.get("/api/claude-plans/:slug", function (req, res) {
1001
+ try {
1002
+ const slug = req.params.slug;
1003
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1004
+ const plan = safeReadJson(planFilePath(slug));
1005
+ if (!plan) return res.status(404).json({ error: "plan not found" });
1006
+ res.json({ plan: plan, artifacts: listClaudeArtifacts(slug) });
1007
+ } catch (e) {
1008
+ res.status(500).json({ error: e.message });
1009
+ }
1010
+ });
1011
+
1012
+ app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1013
+ try {
1014
+ var slug = req.params.slug;
1015
+ if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1016
+ var baseDir = path.resolve(CLAUDE_PLANS_DIR);
1017
+ var planFile = path.resolve(baseDir, slug + ".json");
1018
+ if (!planFile.startsWith(baseDir + path.sep)) return res.status(400).json({ error: "invalid slug" });
1019
+ if (!fs.existsSync(planFile)) return res.status(404).json({ error: "plan not found" });
1020
+ fs.unlinkSync(planFile);
1021
+ var artifactsDir = path.resolve(baseDir, slug);
1022
+ if (artifactsDir.startsWith(baseDir + path.sep) && fs.existsSync(artifactsDir)) {
1023
+ fs.rmSync(artifactsDir, { recursive: true, force: true });
1024
+ }
1025
+ res.json({ deleted: true });
1026
+ } catch (e) {
1027
+ res.status(500).json({ error: e.message });
1028
+ }
1029
+ });
1030
+
775
1031
  // Only start the server when run directly (not when require()-d).
776
1032
  // Callers like dashboardCommand.ts and allScopesCommands.ts use
777
1033
  // spawn("node", [serverPath]) which sets require.main === module.
778
1034
  if (require.main === module) {
1035
+ startClaudePlanWatcher();
779
1036
  app.listen(PORT, "127.0.0.1", function () {
780
1037
  console.log("\n Dovetail Update Set Dashboard");
781
1038
  console.log(" Instance: " + SN_INSTANCE);
782
1039
  console.log(" Project: " + PROJECT_ROOT);
783
- console.log(" Dashboard: http://localhost:" + PORT + "\n");
1040
+ console.log(" Dashboard: http://localhost:" + PORT);
1041
+ console.log(" Claude: http://localhost:" + PORT + "/claude-plans\n");
784
1042
  });
785
1043
  }