@tenonhq/dovetail-dashboard 0.0.43 → 0.0.45

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/server.js CHANGED
@@ -42,9 +42,15 @@ function resolveDovePath(doveName, sincName) {
42
42
  return dovePath;
43
43
  }
44
44
 
45
- const UPDATE_SET_CONFIG = resolveDovePath(".dove-update-sets.json", ".sinc-update-sets.json");
45
+ const UPDATE_SET_CONFIG = resolveDovePath(
46
+ ".dove-update-sets.json",
47
+ ".sinc-update-sets.json"
48
+ );
46
49
  const DOVE_CONFIG_PATH = resolveDovePath("dove.config.js", "sinc.config.js");
47
- const ACTIVE_TASK_FILE = resolveDovePath(".dove-active-task.json", ".sinc-active-task.json");
50
+ const ACTIVE_TASK_FILE = resolveDovePath(
51
+ ".dove-active-task.json",
52
+ ".sinc-active-task.json"
53
+ );
48
54
 
49
55
  const CLICKUP_TOKEN = process.env.CLICKUP_API_TOKEN || "";
50
56
  const CLICKUP_TEAM_ID = process.env.CLICKUP_TEAM_ID || "";
@@ -88,49 +94,80 @@ app.use(express.static(path.join(__dirname, "public")));
88
94
  // Session-persistent ServiceNow client — cookie jar ensures scope changes
89
95
  // (changeScope) persist across subsequent requests in the same session.
90
96
  var snCookieJar = new CookieJar();
91
- var snClient = wrapper(axios.create({
92
- baseURL: BASE_URL,
93
- auth: { username: SN_USER, password: SN_PASSWORD },
94
- headers: {
95
- "Content-Type": "application/json",
96
- "Accept": "application/json",
97
- },
98
- jar: snCookieJar,
99
- withCredentials: true,
100
- }));
101
-
102
- // Dovetail Scripted REST API rebrand: the API path moved from /api/cadso/claude/*
103
- // to /api/cadso/dovetail/*. snApi rewrites legacy /api/cadso/claude/* URLs to the
104
- // new path on first call; if that 404s (instance hasn't been re-imported yet) we
105
- // latch back to the legacy path for the rest of the session and warn once.
106
- var _dovetailApiUseLegacyClaudePath = false;
97
+ var snClient = wrapper(
98
+ axios.create({
99
+ baseURL: BASE_URL,
100
+ auth: { username: SN_USER, password: SN_PASSWORD },
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ Accept: "application/json",
104
+ },
105
+ jar: snCookieJar,
106
+ withCredentials: true,
107
+ })
108
+ );
109
+
110
+ // Dovetail's core Scripted REST API now lives in the Dovetail scoped application
111
+ // at /api/cadso/dovetail_core/*. Older instances still expose the previous
112
+ // global-scope path /api/cadso/dovetail/*. Dashboard call sites still pass the
113
+ // original /api/cadso/claude/* paths; snApi maps them to dovetail_core first and,
114
+ // on a missing-endpoint error (404, or the 400 "Requested URI does not represent
115
+ // any resource" SN returns for an absent Scripted REST resource), latches to the
116
+ // legacy /api/cadso/dovetail/* path for the rest of the session and warns once.
117
+ // Mirrors packages/core/src/snClient.ts so the dashboard and the dove CLI agree.
118
+ var _dovetailApiUseLegacyPath = false;
119
+ var _SN_MISSING_ENDPOINT_BODY = "Requested URI does not represent any resource";
120
+
121
+ // True when an error means the Dovetail Scripted REST endpoint is absent on this
122
+ // instance: a 404, or the 400 body SN returns for an unknown scripted-REST URI.
123
+ function isMissingDovetailEndpoint(e) {
124
+ var status = e && e.response && e.response.status;
125
+ if (status === 404) return true;
126
+ if (status === 400) {
127
+ var data = e && e.response && e.response.data;
128
+ var body = "";
129
+ try {
130
+ body = typeof data === "string" ? data : JSON.stringify(data || "");
131
+ } catch (_e) {
132
+ body = "";
133
+ }
134
+ return body.indexOf(_SN_MISSING_ENDPOINT_BODY) !== -1;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ // Map a dashboard call-site path (api/cadso/{claude,dovetail,dovetail_core}/<op>)
140
+ // to the active Dovetail scoped-API path. Returns null for non-scoped endpoints
141
+ // (e.g. api/now/table/*), which pass through unchanged.
142
+ function dovetailScopedPath(endpoint) {
143
+ var match = endpoint.match(
144
+ /^\/?api\/cadso\/(?:dovetail_core|dovetail|claude)\/(.*)$/
145
+ );
146
+ if (!match) return null;
147
+ var service = _dovetailApiUseLegacyPath ? "dovetail" : "dovetail_core";
148
+ return "api/cadso/" + service + "/" + match[1];
149
+ }
107
150
 
108
151
  async function snApi(method, endpoint, data) {
109
152
  await waitForRateLimit();
110
- // Rewrite legacy /api/cadso/claude/* call sites to the new dovetail path,
111
- // unless we've already discovered this instance only speaks the legacy path.
112
- var rewritten = endpoint;
113
- var isDovetailScopedApi = endpoint.indexOf("api/cadso/claude/") === 0
114
- || endpoint.indexOf("/api/cadso/claude/") === 0
115
- || endpoint.indexOf("api/cadso/dovetail/") === 0
116
- || endpoint.indexOf("/api/cadso/dovetail/") === 0;
117
- if (isDovetailScopedApi) {
118
- rewritten = _dovetailApiUseLegacyClaudePath
119
- ? endpoint.replace("api/cadso/dovetail/", "api/cadso/claude/")
120
- : endpoint.replace("api/cadso/claude/", "api/cadso/dovetail/");
121
- }
153
+ var scopedPath = dovetailScopedPath(endpoint);
154
+ var url = scopedPath || endpoint;
122
155
  try {
123
- return await snClient({ method: method, url: rewritten, data: data });
156
+ return await snClient({ method: method, url: url, data: data });
124
157
  } catch (e) {
125
- var status = e && e.response && e.response.status;
126
- if (isDovetailScopedApi && !_dovetailApiUseLegacyClaudePath && status === 404) {
158
+ if (
159
+ scopedPath &&
160
+ !_dovetailApiUseLegacyPath &&
161
+ isMissingDovetailEndpoint(e)
162
+ ) {
127
163
  // eslint-disable-next-line no-console
128
164
  console.warn(
129
- "[deprecation] " + rewritten +
130
- " returned 404. Falling back to legacy /api/cadso/claude/* path. Re-import the Dovetail Scripted REST API XML on your ServiceNow instance to silence this warning.",
165
+ "[deprecation] " +
166
+ url +
167
+ " not found. Falling back to legacy /api/cadso/dovetail/* path. Install the Dovetail application's Scripted REST APIs to silence this warning."
131
168
  );
132
- _dovetailApiUseLegacyClaudePath = true;
133
- var legacyUrl = rewritten.replace("api/cadso/dovetail/", "api/cadso/claude/");
169
+ _dovetailApiUseLegacyPath = true;
170
+ var legacyUrl = dovetailScopedPath(endpoint);
134
171
  return await snClient({ method: method, url: legacyUrl, data: data });
135
172
  }
136
173
  throw e;
@@ -150,11 +187,50 @@ function clickupApi(method, endpoint, data) {
150
187
  });
151
188
  }
152
189
 
153
- // Generate update set name from ClickUp task
154
- function generateUpdateSetName(taskId, taskName) {
155
- var sanitized = taskName.replace(/[^a-zA-Z0-9\s\-_]/g, "").trim();
156
- var base = "CU-" + taskId + " — " + sanitized;
157
- return base.substring(0, 80);
190
+ // Scope -> "App" label used in generated update-set names. Mirrors the
191
+ // override table in .claude/skills/sn-move-update-set so both tools agree.
192
+ var SCOPE_LABEL_OVERRIDES = {
193
+ x_cadso_journey: "Journey",
194
+ x_cadso_core: "Core",
195
+ x_cadso_automate: "Automate",
196
+ x_cadso_text_spoke: "Text",
197
+ x_cadso_email_spok: "Email",
198
+ };
199
+
200
+ function scopeLabel(scope) {
201
+ if (SCOPE_LABEL_OVERRIDES[scope]) return SCOPE_LABEL_OVERRIDES[scope];
202
+ var stripped = scope.replace(/^x_cadso_/, "");
203
+ return stripped
204
+ .split(/[_-]/)
205
+ .filter(Boolean)
206
+ .map(function (w) {
207
+ return w.charAt(0).toUpperCase() + w.slice(1);
208
+ })
209
+ .join(" ");
210
+ }
211
+
212
+ function sanitizeTaskName(taskName) {
213
+ return taskName.replace(/[^a-zA-Z0-9\s\-_]/g, "").trim();
214
+ }
215
+
216
+ // Task-level base name (no App segment yet — that's added per-scope by
217
+ // buildScopedUpdateSetName, since one task can span multiple scopes/apps).
218
+ function generateUpdateSetName(devInitials, taskId, shortDesc) {
219
+ var parts = [];
220
+ if (devInitials) parts.push(devInitials);
221
+ parts.push(taskId);
222
+ parts.push(shortDesc);
223
+ return parts.join(" | ").substring(0, 80);
224
+ }
225
+
226
+ // Full per-scope update-set name: {DEVINITIALS} | {DEV-ID} | {App} | {Short Desc}
227
+ function buildScopedUpdateSetName(activeTask, appLabel) {
228
+ var parts = [];
229
+ if (activeTask.devInitials) parts.push(activeTask.devInitials);
230
+ parts.push(activeTask.customId || activeTask.taskId);
231
+ parts.push(appLabel);
232
+ parts.push(activeTask.shortDesc || activeTask.taskName);
233
+ return parts.join(" | ").substring(0, 80);
158
234
  }
159
235
 
160
236
  // Generate update set description from task
@@ -222,7 +298,9 @@ app.get("/api/scopes", async (req, res) => {
222
298
  const scopeQuery = scopeKeys.map((s) => `scope=${s}`).join("^OR");
223
299
  const resp = await snApi(
224
300
  "get",
225
- `api/now/table/sys_scope?sysparm_query=${encodeURIComponent(scopeQuery)}&sysparm_fields=sys_id,scope,name&sysparm_limit=50`
301
+ `api/now/table/sys_scope?sysparm_query=${encodeURIComponent(
302
+ scopeQuery
303
+ )}&sysparm_fields=sys_id,scope,name&sysparm_limit=50`
226
304
  );
227
305
 
228
306
  const scopeRecords = resp.data.result || [];
@@ -251,7 +329,10 @@ app.get("/api/scopes", async (req, res) => {
251
329
  });
252
330
 
253
331
  // GET /api/recent-edits — read local recent edits file, enrich with live SN data
254
- var RECENT_EDITS_FILE = resolveDovePath(".dove-recent-edits.json", ".sinc-recent-edits.json");
332
+ var RECENT_EDITS_FILE = resolveDovePath(
333
+ ".dove-recent-edits.json",
334
+ ".sinc-recent-edits.json"
335
+ );
255
336
 
256
337
  app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
257
338
  try {
@@ -270,11 +351,17 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
270
351
  var edit = edits[i];
271
352
  var updateSetName = "unknown";
272
353
  try {
273
- var query = "name=" + edit.tableName + "_" + edit.sys_id + "^ORDERBYDESCsys_created_on";
354
+ var query =
355
+ "name=" +
356
+ edit.tableName +
357
+ "_" +
358
+ edit.sys_id +
359
+ "^ORDERBYDESCsys_created_on";
274
360
  var snResp = await snApi(
275
361
  "get",
276
- "api/now/table/sys_update_xml?sysparm_query=" + encodeURIComponent(query) +
277
- "&sysparm_fields=update_set,update_set.name&sysparm_limit=1"
362
+ "api/now/table/sys_update_xml?sysparm_query=" +
363
+ encodeURIComponent(query) +
364
+ "&sysparm_fields=update_set,update_set.name&sysparm_limit=1"
278
365
  );
279
366
  var results = snResp.data.result || [];
280
367
  if (results.length > 0) {
@@ -329,7 +416,9 @@ app.get("/api/update-sets/:scope", async (req, res) => {
329
416
  const query = `application.scope=${scope}^state=in progress^ORDERBYDESCsys_created_on`;
330
417
  const resp = await snApi(
331
418
  "get",
332
- `api/now/table/sys_update_set?sysparm_query=${encodeURIComponent(query)}&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50`
419
+ `api/now/table/sys_update_set?sysparm_query=${encodeURIComponent(
420
+ query
421
+ )}&sysparm_fields=sys_id,name,state,application,sys_created_on,description&sysparm_limit=50`
333
422
  );
334
423
  res.json({ update_sets: resp.data.result || [] });
335
424
  } catch (e) {
@@ -350,7 +439,10 @@ app.post("/api/update-set", async (req, res) => {
350
439
 
351
440
  // Switch to the target scope before creating — ServiceNow uses session scope
352
441
  if (scope) {
353
- await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
442
+ await snApi(
443
+ "get",
444
+ "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope)
445
+ );
354
446
  }
355
447
 
356
448
  const data = {
@@ -371,11 +463,9 @@ app.post("/api/update-set", async (req, res) => {
371
463
  app.patch("/api/update-set/:sysId/close", async (req, res) => {
372
464
  try {
373
465
  const { sysId } = req.params;
374
- const resp = await snApi(
375
- "patch",
376
- `api/now/table/sys_update_set/${sysId}`,
377
- { state: "complete" }
378
- );
466
+ const resp = await snApi("patch", `api/now/table/sys_update_set/${sysId}`, {
467
+ state: "complete",
468
+ });
379
469
  res.json({ update_set: resp.data.result });
380
470
  } catch (e) {
381
471
  res.status(500).json({ error: e.message });
@@ -441,7 +531,9 @@ app.get("/api/clickup/status", function (req, res) {
441
531
  app.get("/api/clickup/me", async function (req, res) {
442
532
  try {
443
533
  if (!CLICKUP_TOKEN) {
444
- return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
534
+ return res
535
+ .status(400)
536
+ .json({ error: "CLICKUP_API_TOKEN not configured" });
445
537
  }
446
538
  var resp = await clickupApi("get", "user");
447
539
  var user = resp.data.user || {};
@@ -464,7 +556,9 @@ app.get("/api/clickup/me", async function (req, res) {
464
556
  app.get("/api/clickup/tasks", async function (req, res) {
465
557
  try {
466
558
  if (!CLICKUP_TOKEN) {
467
- return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
559
+ return res
560
+ .status(400)
561
+ .json({ error: "CLICKUP_API_TOKEN not configured" });
468
562
  }
469
563
 
470
564
  var teamId = CLICKUP_TEAM_ID;
@@ -492,7 +586,8 @@ app.get("/api/clickup/tasks", async function (req, res) {
492
586
  var byStatus = {};
493
587
  var allStatuses = [];
494
588
  tasks.forEach(function (t) {
495
- var statusName = t.status && t.status.status ? t.status.status : "unknown";
589
+ var statusName =
590
+ t.status && t.status.status ? t.status.status : "unknown";
496
591
  if (!byStatus[statusName]) {
497
592
  byStatus[statusName] = [];
498
593
  allStatuses.push(statusName);
@@ -517,7 +612,11 @@ app.get("/api/clickup/tasks", async function (req, res) {
517
612
  });
518
613
  });
519
614
 
520
- res.json({ tasks: tasks.length, byStatus: byStatus, statuses: allStatuses });
615
+ res.json({
616
+ tasks: tasks.length,
617
+ byStatus: byStatus,
618
+ statuses: allStatuses,
619
+ });
521
620
  } catch (e) {
522
621
  var msg = e.message;
523
622
  if (e.response && e.response.data) {
@@ -531,7 +630,9 @@ app.get("/api/clickup/tasks", async function (req, res) {
531
630
  app.get("/api/clickup/task/:taskId", async function (req, res) {
532
631
  try {
533
632
  if (!CLICKUP_TOKEN) {
534
- return res.status(400).json({ error: "CLICKUP_API_TOKEN not configured" });
633
+ return res
634
+ .status(400)
635
+ .json({ error: "CLICKUP_API_TOKEN not configured" });
535
636
  }
536
637
  var resp = await clickupApi("get", "task/" + req.params.taskId);
537
638
  var t = resp.data;
@@ -557,14 +658,26 @@ app.get("/api/clickup/task/:taskId", async function (req, res) {
557
658
  });
558
659
 
559
660
  // POST /api/clickup/select-task — select a task as active
560
- app.post("/api/clickup/select-task", function (req, res) {
661
+ app.post("/api/clickup/select-task", async function (req, res) {
561
662
  try {
562
663
  var body = req.body;
563
664
  if (!body.taskId || !body.taskName) {
564
- return res.status(400).json({ error: "taskId and taskName are required" });
665
+ return res
666
+ .status(400)
667
+ .json({ error: "taskId and taskName are required" });
565
668
  }
566
669
 
567
- var updateSetName = generateUpdateSetName(body.taskId, body.taskName);
670
+ var devInitials = "";
671
+ try {
672
+ var meResp = await clickupApi("get", "user");
673
+ devInitials = (meResp.data.user && meResp.data.user.initials) || "";
674
+ } catch (meErr) {
675
+ // Non-fatal — naming falls back to no-initials prefix
676
+ }
677
+
678
+ var customId = body.customId || body.taskId;
679
+ var shortDesc = sanitizeTaskName(body.taskName);
680
+ var updateSetName = generateUpdateSetName(devInitials, customId, shortDesc);
568
681
  var description = generateUpdateSetDescription(
569
682
  body.taskName,
570
683
  body.taskDescription || ""
@@ -572,6 +685,9 @@ app.post("/api/clickup/select-task", function (req, res) {
572
685
 
573
686
  var activeTask = {
574
687
  taskId: body.taskId,
688
+ customId: customId,
689
+ devInitials: devInitials,
690
+ shortDesc: shortDesc,
575
691
  taskName: body.taskName,
576
692
  taskDescription: body.taskDescription || "",
577
693
  updateSetName: updateSetName,
@@ -590,17 +706,24 @@ app.post("/api/clickup/select-task", function (req, res) {
590
706
  // Core logic: find or create update set for a scope given an active task
591
707
  // Returns { update_set, created }
592
708
  async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
593
- var baseName = activeTask.updateSetName;
594
- var taskId = activeTask.taskId;
709
+ var appLabel = scopeLabel(scope);
710
+ var baseName = buildScopedUpdateSetName(activeTask, appLabel);
711
+ var lookupId = activeTask.customId || activeTask.taskId;
595
712
 
596
- if (!taskId || typeof taskId !== "string" || taskId.trim() === "") {
597
- throw new Error("Cannot search for update sets: activeTask has an empty or missing taskId");
713
+ if (!lookupId || typeof lookupId !== "string" || lookupId.trim() === "") {
714
+ throw new Error(
715
+ "Cannot search for update sets: activeTask has an empty or missing task id"
716
+ );
598
717
  }
599
718
 
600
- // Query ServiceNow for existing update sets matching this task in this scope
719
+ // Query ServiceNow for existing update sets matching this task in this scope.
720
+ // Lookup is on the task id substring only (not the full name) so a re-run
721
+ // still finds a set created before a naming-format change.
601
722
  var query =
602
- "application.scope=" + scope +
603
- "^nameLIKECU-" + taskId +
723
+ "application.scope=" +
724
+ scope +
725
+ "^nameLIKE" +
726
+ lookupId +
604
727
  "^state=in progress" +
605
728
  "^ORDERBYDESCsys_created_on";
606
729
  var searchResp = await snApi(
@@ -624,7 +747,10 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
624
747
  if (!updateSet) {
625
748
  // Switch to the target scope before creating — ServiceNow uses session scope,
626
749
  // not the application field, when creating update sets via Table API
627
- await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
750
+ await snApi(
751
+ "get",
752
+ "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope)
753
+ );
628
754
 
629
755
  var createData = {
630
756
  name: baseName,
@@ -634,7 +760,11 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
634
760
  if (activeTask.description) {
635
761
  createData.description = activeTask.description;
636
762
  }
637
- var createResp = await snApi("post", "api/now/table/sys_update_set", createData);
763
+ var createResp = await snApi(
764
+ "post",
765
+ "api/now/table/sys_update_set",
766
+ createData
767
+ );
638
768
  updateSet = createResp.data.result;
639
769
  created = true;
640
770
  }
@@ -643,13 +773,15 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
643
773
  try {
644
774
  await snApi(
645
775
  "get",
646
- "api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
776
+ "api/cadso/claude/changeUpdateSet?sysId=" +
777
+ encodeURIComponent(updateSet.sys_id)
647
778
  );
648
779
 
649
780
  // Verify the switch was successful
650
781
  var verifyResp = await snApi(
651
782
  "get",
652
- "api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
783
+ "api/cadso/claude/currentUpdateSet" +
784
+ (scope ? "?scope=" + encodeURIComponent(scope) : "")
653
785
  );
654
786
  var verifyData = verifyResp.data;
655
787
  if (verifyData && verifyData.result) {
@@ -661,11 +793,13 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
661
793
  console.warn("Update set verification failed, retrying switch...");
662
794
  await snApi(
663
795
  "get",
664
- "api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
796
+ "api/cadso/claude/changeUpdateSet?sysId=" +
797
+ encodeURIComponent(updateSet.sys_id)
665
798
  );
666
799
  var retryResp = await snApi(
667
800
  "get",
668
- "api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
801
+ "api/cadso/claude/currentUpdateSet" +
802
+ (scope ? "?scope=" + encodeURIComponent(scope) : "")
669
803
  );
670
804
  var retryData = retryResp.data;
671
805
  if (retryData && retryData.result) {
@@ -673,14 +807,22 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
673
807
  }
674
808
  var retrySysId = retryData && retryData.sysId ? retryData.sysId : null;
675
809
  if (retrySysId !== updateSet.sys_id) {
676
- var actualName = retryData && retryData.name ? retryData.name : "unknown";
810
+ var actualName =
811
+ retryData && retryData.name ? retryData.name : "unknown";
677
812
  console.error(
678
- "Update set " + updateSet.name + " was created but could not be activated. Current update set is " + actualName + "."
813
+ "Update set " +
814
+ updateSet.name +
815
+ " was created but could not be activated. Current update set is " +
816
+ actualName +
817
+ "."
679
818
  );
680
819
  }
681
820
  }
682
821
  } catch (changeErr) {
683
- console.error("Warning: Could not auto-switch update set on instance:", changeErr.message);
822
+ console.error(
823
+ "Warning: Could not auto-switch update set on instance:",
824
+ changeErr.message
825
+ );
684
826
  }
685
827
 
686
828
  return { update_set: updateSet, created: created };
@@ -710,7 +852,9 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
710
852
  try {
711
853
  var body = req.body;
712
854
  if (!body.scope || !body.scope_sys_id) {
713
- return res.status(400).json({ error: "scope and scope_sys_id are required" });
855
+ return res
856
+ .status(400)
857
+ .json({ error: "scope and scope_sys_id are required" });
714
858
  }
715
859
 
716
860
  var activeTask = readActiveTask();
@@ -718,7 +862,11 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
718
862
  return res.status(400).json({ error: "No active task selected" });
719
863
  }
720
864
 
721
- var result = await findOrCreateUpdateSet(body.scope, body.scope_sys_id, activeTask);
865
+ var result = await findOrCreateUpdateSet(
866
+ body.scope,
867
+ body.scope_sys_id,
868
+ activeTask
869
+ );
722
870
  persistScopeActivation(body.scope, result.update_set, activeTask);
723
871
 
724
872
  res.json({
@@ -731,6 +879,61 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
731
879
  }
732
880
  });
733
881
 
882
+ // Shared core: find-or-create an update set in every scope from dove.config.js
883
+ // for the given active task. Used by both /activate-all-scopes and /start-task.
884
+ // Returns { results, activeTask }.
885
+ async function activateAllConfiguredScopes(activeTask) {
886
+ // Read scopes from dove.config.js
887
+ delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
888
+ var config = require(DOVE_CONFIG_PATH);
889
+ var scopeKeys = Object.keys(config.scopes || {});
890
+
891
+ // Resolve scope sys_ids
892
+ var scopeQuery = scopeKeys
893
+ .map(function (s) {
894
+ return "scope=" + s;
895
+ })
896
+ .join("^OR");
897
+ var scopeResp = await snApi(
898
+ "get",
899
+ "api/now/table/sys_scope?sysparm_query=" +
900
+ encodeURIComponent(scopeQuery) +
901
+ "&sysparm_fields=sys_id,scope,name&sysparm_limit=50"
902
+ );
903
+ var scopeRecords = scopeResp.data.result || [];
904
+ var scopeMap = {};
905
+ scopeRecords.forEach(function (r) {
906
+ scopeMap[r.scope] = r.sys_id;
907
+ });
908
+
909
+ // Activate each scope sequentially (respects rate limits)
910
+ var results = [];
911
+ for (var i = 0; i < scopeKeys.length; i++) {
912
+ var scope = scopeKeys[i];
913
+ var scopeSysId = scopeMap[scope];
914
+ if (!scopeSysId) {
915
+ results.push({ scope: scope, error: "scope not found on instance" });
916
+ continue;
917
+ }
918
+
919
+ try {
920
+ var result = await findOrCreateUpdateSet(scope, scopeSysId, activeTask);
921
+ persistScopeActivation(scope, result.update_set, activeTask);
922
+ // Re-read active task so subsequent iterations see updated scopes
923
+ activeTask = readActiveTask();
924
+ results.push({
925
+ scope: scope,
926
+ update_set: result.update_set,
927
+ created: result.created,
928
+ });
929
+ } catch (scopeErr) {
930
+ results.push({ scope: scope, error: scopeErr.message });
931
+ }
932
+ }
933
+
934
+ return { results: results, activeTask: readActiveTask() };
935
+ }
936
+
734
937
  // POST /api/clickup/activate-all-scopes — find or create update sets for all configured scopes
735
938
  app.post("/api/clickup/activate-all-scopes", async function (req, res) {
736
939
  try {
@@ -739,51 +942,107 @@ app.post("/api/clickup/activate-all-scopes", async function (req, res) {
739
942
  return res.status(400).json({ error: "No active task selected" });
740
943
  }
741
944
 
742
- // Read scopes from dove.config.js
743
- delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
744
- var config = require(DOVE_CONFIG_PATH);
745
- var scopeKeys = Object.keys(config.scopes || {});
945
+ var outcome = await activateAllConfiguredScopes(activeTask);
946
+ res.json({ results: outcome.results, activeTask: outcome.activeTask });
947
+ } catch (e) {
948
+ res.status(500).json({ error: e.message });
949
+ }
950
+ });
746
951
 
747
- // Resolve scope sys_ids
748
- var scopeQuery = scopeKeys.map(function (s) { return "scope=" + s; }).join("^OR");
749
- var scopeResp = await snApi(
750
- "get",
751
- "api/now/table/sys_scope?sysparm_query=" +
752
- encodeURIComponent(scopeQuery) +
753
- "&sysparm_fields=sys_id,scope,name&sysparm_limit=50"
754
- );
755
- var scopeRecords = scopeResp.data.result || [];
756
- var scopeMap = {};
757
- scopeRecords.forEach(function (r) {
758
- scopeMap[r.scope] = r.sys_id;
759
- });
952
+ // Slugify text for use in a branch name segment: lowercase, non-alphanumerics
953
+ // collapsed to single hyphens, trimmed, capped so the full branch name stays
954
+ // reasonable.
955
+ function slugify(text) {
956
+ return String(text)
957
+ .toLowerCase()
958
+ .replace(/[^a-z0-9]+/g, "-")
959
+ .replace(/^-+|-+$/g, "")
960
+ .substring(0, 50);
961
+ }
760
962
 
761
- // Activate each scope sequentially (respects rate limits)
762
- var results = [];
763
- for (var i = 0; i < scopeKeys.length; i++) {
764
- var scope = scopeKeys[i];
765
- var scopeSysId = scopeMap[scope];
766
- if (!scopeSysId) {
767
- results.push({ scope: scope, error: "scope not found on instance" });
768
- continue;
963
+ function execFilePromise(cmd, args, opts) {
964
+ return new Promise(function (resolve, reject) {
965
+ execFile(cmd, args, opts || {}, function (err, stdout, stderr) {
966
+ if (err) {
967
+ var detail = (stderr || err.message || "").toString().trim();
968
+ return reject(new Error(detail || err.message));
769
969
  }
970
+ resolve(stdout.toString().trim());
971
+ });
972
+ });
973
+ }
770
974
 
771
- try {
772
- var result = await findOrCreateUpdateSet(scope, scopeSysId, activeTask);
773
- persistScopeActivation(scope, result.update_set, activeTask);
774
- // Re-read active task so subsequent iterations see updated scopes
775
- activeTask = readActiveTask();
776
- results.push({
777
- scope: scope,
778
- update_set: result.update_set,
779
- created: result.created,
780
- });
781
- } catch (scopeErr) {
782
- results.push({ scope: scope, error: scopeErr.message });
975
+ // Cut (or switch to, if it already exists) the working branch for a task in
976
+ // the repo this dashboard process is running from: dev/{git-username}/{DEV-ID}/{short-desc}
977
+ async function createTaskBranch(activeTask) {
978
+ var username = "";
979
+ try {
980
+ username = await execFilePromise("git", ["config", "user.name"], {
981
+ cwd: PROJECT_ROOT,
982
+ });
983
+ } catch (userErr) {
984
+ // Fall through to the "dev" fallback below
985
+ }
986
+ var usernameSlug = slugify(username) || "dev";
987
+ var taskIdSlug = slugify(activeTask.customId || activeTask.taskId);
988
+ var descSlug = slugify(activeTask.shortDesc || activeTask.taskName);
989
+ var branchName = "dev/" + usernameSlug + "/" + taskIdSlug + "/" + descSlug;
990
+
991
+ try {
992
+ await execFilePromise("git", ["checkout", "-b", branchName], {
993
+ cwd: PROJECT_ROOT,
994
+ });
995
+ return { branch: branchName, cwd: PROJECT_ROOT, created: true };
996
+ } catch (createErr) {
997
+ // Branch may already exist from a prior Start Task click on this ticket.
998
+ await execFilePromise("git", ["checkout", branchName], {
999
+ cwd: PROJECT_ROOT,
1000
+ });
1001
+ return { branch: branchName, cwd: PROJECT_ROOT, created: false };
1002
+ }
1003
+ }
1004
+
1005
+ // POST /api/clickup/start-task — mark the active task in-progress in ClickUp,
1006
+ // create/find the per-scope update sets, and cut the working branch in the
1007
+ // repo this dashboard is running from. One deliberate action, not tied to
1008
+ // mere task selection.
1009
+ app.post("/api/clickup/start-task", async function (req, res) {
1010
+ try {
1011
+ var activeTask = readActiveTask();
1012
+ if (!activeTask) {
1013
+ return res.status(400).json({ error: "No active task selected" });
1014
+ }
1015
+
1016
+ try {
1017
+ await clickupApi("put", "task/" + activeTask.taskId, {
1018
+ status: "in progress",
1019
+ });
1020
+ } catch (statusErr) {
1021
+ var msg = statusErr.message;
1022
+ if (statusErr.response && statusErr.response.data) {
1023
+ msg =
1024
+ statusErr.response.data.err || statusErr.response.data.error || msg;
783
1025
  }
1026
+ return res
1027
+ .status(502)
1028
+ .json({ error: "Failed to set ClickUp status: " + msg });
784
1029
  }
785
1030
 
786
- res.json({ results: results, activeTask: readActiveTask() });
1031
+ var outcome = await activateAllConfiguredScopes(activeTask);
1032
+
1033
+ var branchResult = null;
1034
+ try {
1035
+ branchResult = await createTaskBranch(outcome.activeTask);
1036
+ } catch (branchErr) {
1037
+ branchResult = { error: branchErr.message };
1038
+ }
1039
+
1040
+ res.json({
1041
+ status: "in progress",
1042
+ scopes: outcome.results,
1043
+ branch: branchResult,
1044
+ activeTask: outcome.activeTask,
1045
+ });
787
1046
  } catch (e) {
788
1047
  res.status(500).json({ error: e.message });
789
1048
  }
@@ -943,21 +1202,45 @@ function classifyPath(filePath) {
943
1202
  if (parts.length === 1 && parts[0] === ".focus") {
944
1203
  return { kind: "focus" };
945
1204
  }
946
- if (parts.length === 2 && parts[0] === "_lint-events" && parts[1].endsWith(".json")) {
1205
+ if (
1206
+ parts.length === 2 &&
1207
+ parts[0] === "_lint-events" &&
1208
+ parts[1].endsWith(".json")
1209
+ ) {
947
1210
  return { kind: "lint", id: parts[1].slice(0, -5) };
948
1211
  }
949
- if (parts.length === 2 && parts[0] === "_prompt-drafts" && parts[1].endsWith(".json")) {
1212
+ if (
1213
+ parts.length === 2 &&
1214
+ parts[0] === "_prompt-drafts" &&
1215
+ parts[1].endsWith(".json")
1216
+ ) {
950
1217
  if (parts[1] === "_active.json") return { kind: "draft-active" };
951
1218
  return { kind: "draft", id: parts[1].slice(0, -5) };
952
1219
  }
953
1220
  if (parts.length === 1 && parts[0].endsWith(".json")) {
954
1221
  return { kind: "plan", slug: parts[0].slice(0, -5) };
955
1222
  }
956
- if (parts.length === 3 && parts[1] === "artifacts" && parts[2].endsWith(".json")) {
957
- return { kind: "artifact", slug: parts[0], artifactSlug: parts[2].slice(0, -5) };
1223
+ if (
1224
+ parts.length === 3 &&
1225
+ parts[1] === "artifacts" &&
1226
+ parts[2].endsWith(".json")
1227
+ ) {
1228
+ return {
1229
+ kind: "artifact",
1230
+ slug: parts[0],
1231
+ artifactSlug: parts[2].slice(0, -5),
1232
+ };
958
1233
  }
959
- if (parts.length === 3 && parts[1] === "prompts" && parts[2].endsWith(".json")) {
960
- return { kind: "prompt", slug: parts[0], promptSlug: parts[2].slice(0, -5) };
1234
+ if (
1235
+ parts.length === 3 &&
1236
+ parts[1] === "prompts" &&
1237
+ parts[2].endsWith(".json")
1238
+ ) {
1239
+ return {
1240
+ kind: "prompt",
1241
+ slug: parts[0],
1242
+ promptSlug: parts[2].slice(0, -5),
1243
+ };
961
1244
  }
962
1245
  return null;
963
1246
  }
@@ -1000,19 +1283,20 @@ function handleWatcherChange(event, filePath) {
1000
1283
  if (event === "unlink") {
1001
1284
  broadcastClaudePlanEvent("artifact:delete", {
1002
1285
  plan_slug: info.slug,
1003
- slug: info.artifactSlug
1286
+ slug: info.artifactSlug,
1004
1287
  });
1005
1288
  return;
1006
1289
  }
1007
1290
  const artifact = safeReadJson(filePath);
1008
- if (artifact) broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
1291
+ if (artifact)
1292
+ broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
1009
1293
  return;
1010
1294
  }
1011
1295
  if (info.kind === "prompt") {
1012
1296
  if (event === "unlink") {
1013
1297
  broadcastClaudePlanEvent("prompt:delete", {
1014
1298
  plan_slug: info.slug,
1015
- slug: info.promptSlug
1299
+ slug: info.promptSlug,
1016
1300
  });
1017
1301
  return;
1018
1302
  }
@@ -1033,7 +1317,9 @@ function handleWatcherChange(event, filePath) {
1033
1317
  // The active-tab pointer changed (a draft was made active, or the active
1034
1318
  // one was deleted and focus advanced). Tell editors which tab is active.
1035
1319
  const ptr = safeReadJson(filePath);
1036
- broadcastClaudePlanEvent("draft:active", { active_id: ptr ? ptr.active_id : null });
1320
+ broadcastClaudePlanEvent("draft:active", {
1321
+ active_id: ptr ? ptr.active_id : null,
1322
+ });
1037
1323
  return;
1038
1324
  }
1039
1325
  if (info.kind === "draft") {
@@ -1062,11 +1348,17 @@ function startClaudePlanWatcher() {
1062
1348
  claudePlanWatcher = chokidar.watch(CLAUDE_PLANS_DIR, {
1063
1349
  ignoreInitial: true,
1064
1350
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
1065
- depth: 3
1351
+ depth: 3,
1352
+ });
1353
+ claudePlanWatcher.on("add", function (p) {
1354
+ handleWatcherChange("add", p);
1355
+ });
1356
+ claudePlanWatcher.on("change", function (p) {
1357
+ handleWatcherChange("change", p);
1358
+ });
1359
+ claudePlanWatcher.on("unlink", function (p) {
1360
+ handleWatcherChange("unlink", p);
1066
1361
  });
1067
- claudePlanWatcher.on("add", function (p) { handleWatcherChange("add", p); });
1068
- claudePlanWatcher.on("change", function (p) { handleWatcherChange("change", p); });
1069
- claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
1070
1362
  }
1071
1363
 
1072
1364
  app.get("/claude-plans", function (req, res) {
@@ -1082,7 +1374,10 @@ app.get("/prompt-editor", claudePlansLimiter, function (req, res) {
1082
1374
  // Legacy path-segment deep links (/claude-plans/:slug) redirect to the
1083
1375
  // query-param form so relative assets resolve against /claude-plans.
1084
1376
  app.get("/claude-plans/:slug", function (req, res) {
1085
- res.redirect(301, "/claude-plans?plan=" + encodeURIComponent(req.params.slug));
1377
+ res.redirect(
1378
+ 301,
1379
+ "/claude-plans?plan=" + encodeURIComponent(req.params.slug)
1380
+ );
1086
1381
  });
1087
1382
 
1088
1383
  app.get("/prompt-lints", function (req, res) {
@@ -1092,13 +1387,18 @@ app.get("/prompt-lints", function (req, res) {
1092
1387
  app.get("/api/prompt-lints", function (req, res) {
1093
1388
  try {
1094
1389
  const filters = {};
1095
- if (typeof req.query.session_id === "string") filters.session_id = req.query.session_id;
1096
- if (typeof req.query.plan_slug === "string") filters.plan_slug = req.query.plan_slug;
1390
+ if (typeof req.query.session_id === "string")
1391
+ filters.session_id = req.query.session_id;
1392
+ if (typeof req.query.plan_slug === "string")
1393
+ filters.plan_slug = req.query.plan_slug;
1097
1394
  if (typeof req.query.limit === "string") {
1098
1395
  const n = parseInt(req.query.limit, 10);
1099
1396
  if (!isNaN(n) && n > 0) filters.limit = Math.min(n, 1000); // mirror getLintEventsSchema cap
1100
1397
  }
1101
- res.json({ events: listClaudeLintEvents(filters), storage: CLAUDE_PLANS_DIR });
1398
+ res.json({
1399
+ events: listClaudeLintEvents(filters),
1400
+ storage: CLAUDE_PLANS_DIR,
1401
+ });
1102
1402
  } catch (e) {
1103
1403
  res.status(500).json({ error: e.message });
1104
1404
  }
@@ -1150,7 +1450,7 @@ function resolvePrStatus(prUrl) {
1150
1450
  prStatusCache.set(prUrl, {
1151
1451
  state: result.state,
1152
1452
  merged: result.merged,
1153
- fetchedAt: Date.now()
1453
+ fetchedAt: Date.now(),
1154
1454
  });
1155
1455
  return result;
1156
1456
  });
@@ -1168,8 +1468,12 @@ function mapWithConcurrency(items, limit, worker) {
1168
1468
  if (index >= items.length) return;
1169
1469
  const i = index++;
1170
1470
  Promise.resolve(worker(items[i]))
1171
- .then(function (r) { results[i] = r; })
1172
- .catch(function () { results[i] = null; })
1471
+ .then(function (r) {
1472
+ results[i] = r;
1473
+ })
1474
+ .catch(function () {
1475
+ results[i] = null;
1476
+ })
1173
1477
  .then(function () {
1174
1478
  completed++;
1175
1479
  if (completed === items.length) resolve(results);
@@ -1211,7 +1515,7 @@ app.get("/api/claude-plans/stream", function (req, res) {
1211
1515
  "Content-Type": "text/event-stream",
1212
1516
  "Cache-Control": "no-cache",
1213
1517
  Connection: "keep-alive",
1214
- "X-Accel-Buffering": "no"
1518
+ "X-Accel-Buffering": "no",
1215
1519
  });
1216
1520
  res.flushHeaders();
1217
1521
  res.write("event: hello\ndata: {}\n\n");
@@ -1236,13 +1540,14 @@ app.get("/api/claude-plans/stream", function (req, res) {
1236
1540
  app.get("/api/claude-plans/:slug", function (req, res) {
1237
1541
  try {
1238
1542
  const slug = req.params.slug;
1239
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1543
+ if (!isValidSlug(slug))
1544
+ return res.status(400).json({ error: "invalid slug" });
1240
1545
  const plan = safeReadJson(planFilePath(slug));
1241
1546
  if (!plan) return res.status(404).json({ error: "plan not found" });
1242
1547
  res.json({
1243
1548
  plan: plan,
1244
1549
  artifacts: listClaudeArtifacts(slug),
1245
- prompts: listClaudePrompts(slug)
1550
+ prompts: listClaudePrompts(slug),
1246
1551
  });
1247
1552
  } catch (e) {
1248
1553
  res.status(500).json({ error: e.message });
@@ -1261,7 +1566,9 @@ function sendTypedError(res, err) {
1261
1566
  // a `code` (e.g. ILLEGAL_TRANSITION, MISSING_AGENT). Anything else is
1262
1567
  // a 500 with the message.
1263
1568
  if (err && err.name === "ZodError") {
1264
- return res.status(400).json({ error: "validation_failed", details: err.issues });
1569
+ return res
1570
+ .status(400)
1571
+ .json({ error: "validation_failed", details: err.issues });
1265
1572
  }
1266
1573
  if (err && typeof err.code === "string") {
1267
1574
  var status = 409;
@@ -1272,83 +1579,104 @@ function sendTypedError(res, err) {
1272
1579
  return res.status(status).json({
1273
1580
  error: err.code,
1274
1581
  name: err.name,
1275
- message: err.message
1582
+ message: err.message,
1276
1583
  });
1277
1584
  }
1278
1585
  if (err && /not found/i.test(err.message || "")) {
1279
1586
  return res.status(404).json({ error: "not_found", message: err.message });
1280
1587
  }
1281
- return res.status(500).json({ error: "internal", message: (err && err.message) || String(err) });
1588
+ return res
1589
+ .status(500)
1590
+ .json({ error: "internal", message: (err && err.message) || String(err) });
1282
1591
  }
1283
1592
 
1284
1593
  // POST /api/claude-plans/:slug/answers — record an answer to a question.
1285
1594
  // Body: { question_id, answer, answered_by? }
1286
- app.post("/api/claude-plans/:slug/answers", claudePlansLimiter, express.json(), function (req, res) {
1287
- try {
1288
- var slug = req.params.slug;
1289
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1290
- var body = req.body || {};
1291
- var result = claudePlansLib.recordAnswer({
1292
- plan_slug: slug,
1293
- question_id: body.question_id,
1294
- answer: body.answer,
1295
- answered_by: body.answered_by || "dashboard"
1296
- });
1297
- res.json(result);
1298
- } catch (e) {
1299
- sendTypedError(res, e);
1595
+ app.post(
1596
+ "/api/claude-plans/:slug/answers",
1597
+ claudePlansLimiter,
1598
+ express.json(),
1599
+ function (req, res) {
1600
+ try {
1601
+ var slug = req.params.slug;
1602
+ if (!isValidSlug(slug))
1603
+ return res.status(400).json({ error: "invalid slug" });
1604
+ var body = req.body || {};
1605
+ var result = claudePlansLib.recordAnswer({
1606
+ plan_slug: slug,
1607
+ question_id: body.question_id,
1608
+ answer: body.answer,
1609
+ answered_by: body.answered_by || "dashboard",
1610
+ });
1611
+ res.json(result);
1612
+ } catch (e) {
1613
+ sendTypedError(res, e);
1614
+ }
1300
1615
  }
1301
- });
1616
+ );
1302
1617
 
1303
1618
  // POST /api/claude-plans/:slug/stage — move the plan to a new stage.
1304
1619
  // Body: { to: PipelineStage, by? }
1305
1620
  // Source is forced to 'dashboard' so the conflict-resolution rule
1306
1621
  // (docs/v2-design.md §4) treats dashboard moves as authoritative.
1307
- app.post("/api/claude-plans/:slug/stage", claudePlansLimiter, express.json(), function (req, res) {
1308
- try {
1309
- var slug = req.params.slug;
1310
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1311
- var body = req.body || {};
1312
- var result = claudePlansLib.setStage({
1313
- plan_slug: slug,
1314
- to: body.to,
1315
- by: body.by || "dashboard",
1316
- source: "dashboard"
1317
- });
1318
- res.json(result);
1319
- } catch (e) {
1320
- sendTypedError(res, e);
1622
+ app.post(
1623
+ "/api/claude-plans/:slug/stage",
1624
+ claudePlansLimiter,
1625
+ express.json(),
1626
+ function (req, res) {
1627
+ try {
1628
+ var slug = req.params.slug;
1629
+ if (!isValidSlug(slug))
1630
+ return res.status(400).json({ error: "invalid slug" });
1631
+ var body = req.body || {};
1632
+ var result = claudePlansLib.setStage({
1633
+ plan_slug: slug,
1634
+ to: body.to,
1635
+ by: body.by || "dashboard",
1636
+ source: "dashboard",
1637
+ });
1638
+ res.json(result);
1639
+ } catch (e) {
1640
+ sendTypedError(res, e);
1641
+ }
1321
1642
  }
1322
- });
1643
+ );
1323
1644
 
1324
1645
  // POST /api/claude-plans/:slug/dispatch — dry-run or live dispatch.
1325
1646
  // Body: { target_stage, confirm?, token?, by? }
1326
1647
  // The dashboard's flow is: POST without confirm (dry-run) → show
1327
1648
  // resolved command in the UI → POST with confirm:true + token from
1328
1649
  // set_stage response → live spawn.
1329
- app.post("/api/claude-plans/:slug/dispatch", claudePlansLimiter, express.json(), function (req, res) {
1330
- try {
1331
- var slug = req.params.slug;
1332
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1333
- var body = req.body || {};
1334
- var result = claudePlansLib.dispatchStage({
1335
- plan_slug: slug,
1336
- target_stage: body.target_stage,
1337
- confirm: body.confirm === true,
1338
- token: body.token,
1339
- by: body.by || "dashboard"
1340
- });
1341
- res.json(result);
1342
- } catch (e) {
1343
- sendTypedError(res, e);
1650
+ app.post(
1651
+ "/api/claude-plans/:slug/dispatch",
1652
+ claudePlansLimiter,
1653
+ express.json(),
1654
+ function (req, res) {
1655
+ try {
1656
+ var slug = req.params.slug;
1657
+ if (!isValidSlug(slug))
1658
+ return res.status(400).json({ error: "invalid slug" });
1659
+ var body = req.body || {};
1660
+ var result = claudePlansLib.dispatchStage({
1661
+ plan_slug: slug,
1662
+ target_stage: body.target_stage,
1663
+ confirm: body.confirm === true,
1664
+ token: body.token,
1665
+ by: body.by || "dashboard",
1666
+ });
1667
+ res.json(result);
1668
+ } catch (e) {
1669
+ sendTypedError(res, e);
1670
+ }
1344
1671
  }
1345
- });
1672
+ );
1346
1673
 
1347
1674
  // GET /api/claude-plans/:slug/versions — list saved version snapshots (newest-first).
1348
1675
  app.get("/api/claude-plans/:slug/versions", function (req, res) {
1349
1676
  try {
1350
1677
  var slug = req.params.slug;
1351
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1678
+ if (!isValidSlug(slug))
1679
+ return res.status(400).json({ error: "invalid slug" });
1352
1680
  res.json({ slug: slug, versions: claudePlansLib.listVersions(slug) });
1353
1681
  } catch (e) {
1354
1682
  sendTypedError(res, e);
@@ -1359,7 +1687,8 @@ app.get("/api/claude-plans/:slug/versions", function (req, res) {
1359
1687
  app.get("/api/claude-plans/:slug/versions/:n", function (req, res) {
1360
1688
  try {
1361
1689
  var slug = req.params.slug;
1362
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1690
+ if (!isValidSlug(slug))
1691
+ return res.status(400).json({ error: "invalid slug" });
1363
1692
  var n = parseInt(req.params.n, 10);
1364
1693
  if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
1365
1694
  var version = claudePlansLib.getVersion(slug, n);
@@ -1380,7 +1709,8 @@ app.post(
1380
1709
  function (req, res) {
1381
1710
  try {
1382
1711
  var slug = req.params.slug;
1383
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1712
+ if (!isValidSlug(slug))
1713
+ return res.status(400).json({ error: "invalid slug" });
1384
1714
  var n = parseInt(req.params.n, 10);
1385
1715
  if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
1386
1716
  var plan = claudePlansLib.restoreVersion(slug, n);
@@ -1394,14 +1724,20 @@ app.post(
1394
1724
  app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1395
1725
  try {
1396
1726
  var slug = req.params.slug;
1397
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1727
+ if (!isValidSlug(slug))
1728
+ return res.status(400).json({ error: "invalid slug" });
1398
1729
  var baseDir = path.resolve(CLAUDE_PLANS_DIR);
1399
1730
  var planFile = path.resolve(baseDir, slug + ".json");
1400
- if (!planFile.startsWith(baseDir + path.sep)) return res.status(400).json({ error: "invalid slug" });
1401
- if (!fs.existsSync(planFile)) return res.status(404).json({ error: "plan not found" });
1731
+ if (!planFile.startsWith(baseDir + path.sep))
1732
+ return res.status(400).json({ error: "invalid slug" });
1733
+ if (!fs.existsSync(planFile))
1734
+ return res.status(404).json({ error: "plan not found" });
1402
1735
  fs.unlinkSync(planFile);
1403
1736
  var artifactsDir = path.resolve(baseDir, slug);
1404
- if (artifactsDir.startsWith(baseDir + path.sep) && fs.existsSync(artifactsDir)) {
1737
+ if (
1738
+ artifactsDir.startsWith(baseDir + path.sep) &&
1739
+ fs.existsSync(artifactsDir)
1740
+ ) {
1405
1741
  fs.rmSync(artifactsDir, { recursive: true, force: true });
1406
1742
  }
1407
1743
  res.json({ deleted: true });
@@ -1427,37 +1763,49 @@ app.get("/api/prompt-drafts", claudePlansLimiter, function (req, res) {
1427
1763
  });
1428
1764
 
1429
1765
  // POST /api/prompt-drafts — create a new draft (tab). Body: { title?, content? }
1430
- app.post("/api/prompt-drafts", claudePlansLimiter, express.json(), function (req, res) {
1431
- try {
1432
- var body = req.body || {};
1433
- var draft = claudePlansLib.createPromptDraft({
1434
- title: body.title,
1435
- content: body.content,
1436
- session_id: body.session_id
1437
- });
1438
- res.json(draft);
1439
- } catch (e) {
1440
- sendTypedError(res, e);
1766
+ app.post(
1767
+ "/api/prompt-drafts",
1768
+ claudePlansLimiter,
1769
+ express.json(),
1770
+ function (req, res) {
1771
+ try {
1772
+ var body = req.body || {};
1773
+ var draft = claudePlansLib.createPromptDraft({
1774
+ title: body.title,
1775
+ content: body.content,
1776
+ session_id: body.session_id,
1777
+ });
1778
+ res.json(draft);
1779
+ } catch (e) {
1780
+ sendTypedError(res, e);
1781
+ }
1441
1782
  }
1442
- });
1783
+ );
1443
1784
 
1444
1785
  // POST /api/prompt-drafts/active — set the active tab. Body: { id }
1445
1786
  // Declared before "/:id" so Express doesn't treat "active" as an id.
1446
- app.post("/api/prompt-drafts/active", claudePlansLimiter, express.json(), function (req, res) {
1447
- try {
1448
- var body = req.body || {};
1449
- if (!isValidDraftId(body.id)) return res.status(400).json({ error: "invalid draft id" });
1450
- res.json(claudePlansLib.setActivePromptDraft(body.id));
1451
- } catch (e) {
1452
- sendTypedError(res, e);
1787
+ app.post(
1788
+ "/api/prompt-drafts/active",
1789
+ claudePlansLimiter,
1790
+ express.json(),
1791
+ function (req, res) {
1792
+ try {
1793
+ var body = req.body || {};
1794
+ if (!isValidDraftId(body.id))
1795
+ return res.status(400).json({ error: "invalid draft id" });
1796
+ res.json(claudePlansLib.setActivePromptDraft(body.id));
1797
+ } catch (e) {
1798
+ sendTypedError(res, e);
1799
+ }
1453
1800
  }
1454
- });
1801
+ );
1455
1802
 
1456
1803
  // GET /api/prompt-drafts/:id — read one draft.
1457
1804
  app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
1458
1805
  try {
1459
1806
  var id = req.params.id;
1460
- if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
1807
+ if (!isValidDraftId(id))
1808
+ return res.status(400).json({ error: "invalid draft id" });
1461
1809
  var draft = claudePlansLib.getPromptDraft(id);
1462
1810
  if (!draft) return res.status(404).json({ error: "draft not found" });
1463
1811
  res.json(draft);
@@ -1467,25 +1815,38 @@ app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
1467
1815
  });
1468
1816
 
1469
1817
  // PUT /api/prompt-drafts/:id — autosave title/content. Body: { title?, content? }
1470
- app.put("/api/prompt-drafts/:id", claudePlansLimiter, express.json(), function (req, res) {
1471
- try {
1472
- var id = req.params.id;
1473
- if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
1474
- var body = req.body || {};
1475
- if (body.title === undefined && body.content === undefined) {
1476
- return res.status(400).json({ error: "nothing to update" });
1818
+ app.put(
1819
+ "/api/prompt-drafts/:id",
1820
+ claudePlansLimiter,
1821
+ express.json(),
1822
+ function (req, res) {
1823
+ try {
1824
+ var id = req.params.id;
1825
+ if (!isValidDraftId(id))
1826
+ return res.status(400).json({ error: "invalid draft id" });
1827
+ var body = req.body || {};
1828
+ if (body.title === undefined && body.content === undefined) {
1829
+ return res.status(400).json({ error: "nothing to update" });
1830
+ }
1831
+ res.json(
1832
+ claudePlansLib.updatePromptDraft({
1833
+ id: id,
1834
+ title: body.title,
1835
+ content: body.content,
1836
+ })
1837
+ );
1838
+ } catch (e) {
1839
+ sendTypedError(res, e);
1477
1840
  }
1478
- res.json(claudePlansLib.updatePromptDraft({ id: id, title: body.title, content: body.content }));
1479
- } catch (e) {
1480
- sendTypedError(res, e);
1481
1841
  }
1482
- });
1842
+ );
1483
1843
 
1484
1844
  // DELETE /api/prompt-drafts/:id — close a tab. Returns the new active id.
1485
1845
  app.delete("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
1486
1846
  try {
1487
1847
  var id = req.params.id;
1488
- if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
1848
+ if (!isValidDraftId(id))
1849
+ return res.status(400).json({ error: "invalid draft id" });
1489
1850
  res.json(claudePlansLib.deletePromptDraft(id));
1490
1851
  } catch (e) {
1491
1852
  sendTypedError(res, e);
@@ -1518,7 +1879,8 @@ const todoReadLimiter = RateLimit({
1518
1879
  const todoSseClients = new Set();
1519
1880
 
1520
1881
  function broadcastTodos(list) {
1521
- const frame = "event: todos:update\ndata: " + JSON.stringify({ list: list }) + "\n\n";
1882
+ const frame =
1883
+ "event: todos:update\ndata: " + JSON.stringify({ list: list }) + "\n\n";
1522
1884
  for (const res of todoSseClients) {
1523
1885
  try {
1524
1886
  res.write(frame);
@@ -1550,7 +1912,9 @@ function startTodoWatcher() {
1550
1912
  });
1551
1913
  todoWatcher.on("add", emitTodoState);
1552
1914
  todoWatcher.on("change", emitTodoState);
1553
- todoWatcher.on("unlink", function () { broadcastTodos({ schema_version: 1, items: [] }); });
1915
+ todoWatcher.on("unlink", function () {
1916
+ broadcastTodos({ schema_version: 1, items: [] });
1917
+ });
1554
1918
  }
1555
1919
 
1556
1920
  app.get("/todos", todoReadLimiter, function (req, res) {
@@ -1559,7 +1923,10 @@ app.get("/todos", todoReadLimiter, function (req, res) {
1559
1923
 
1560
1924
  app.get("/api/todos", todoReadLimiter, function (req, res) {
1561
1925
  try {
1562
- res.json({ list: todoLib.loadList({ rootDir: TODO_DIR }), storage: TODO_DIR });
1926
+ res.json({
1927
+ list: todoLib.loadList({ rootDir: TODO_DIR }),
1928
+ storage: TODO_DIR,
1929
+ });
1563
1930
  } catch (e) {
1564
1931
  res.status(500).json({ error: e.message });
1565
1932
  }
@@ -1636,9 +2003,15 @@ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
1636
2003
  const body = req.body || {};
1637
2004
  let result;
1638
2005
  if (typeof body.text === "string") {
1639
- result = todoLib.updateTodo({ id: id, text: body.text }, { rootDir: TODO_DIR });
2006
+ result = todoLib.updateTodo(
2007
+ { id: id, text: body.text },
2008
+ { rootDir: TODO_DIR }
2009
+ );
1640
2010
  } else {
1641
- result = todoLib.toggleTodo({ id: id, done: body.done }, { rootDir: TODO_DIR });
2011
+ result = todoLib.toggleTodo(
2012
+ { id: id, done: body.done },
2013
+ { rootDir: TODO_DIR }
2014
+ );
1642
2015
  }
1643
2016
  broadcastTodos(result.list);
1644
2017
  res.json(result);
@@ -1650,7 +2023,10 @@ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
1650
2023
  // DELETE /api/todos/:id — remove one item.
1651
2024
  app.delete("/api/todos/:id", todoLimiter, function (req, res) {
1652
2025
  try {
1653
- const result = todoLib.removeTodo({ id: req.params.id }, { rootDir: TODO_DIR });
2026
+ const result = todoLib.removeTodo(
2027
+ { id: req.params.id },
2028
+ { rootDir: TODO_DIR }
2029
+ );
1654
2030
  broadcastTodos(result.list);
1655
2031
  res.json(result);
1656
2032
  } catch (e) {