@tenonhq/dovetail-dashboard 0.0.44 → 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,16 +94,18 @@ 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
- }));
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
+ );
101
109
 
102
110
  // Dovetail's core Scripted REST API now lives in the Dovetail scoped application
103
111
  // at /api/cadso/dovetail_core/*. Older instances still expose the previous
@@ -147,11 +155,16 @@ async function snApi(method, endpoint, data) {
147
155
  try {
148
156
  return await snClient({ method: method, url: url, data: data });
149
157
  } catch (e) {
150
- if (scopedPath && !_dovetailApiUseLegacyPath && isMissingDovetailEndpoint(e)) {
158
+ if (
159
+ scopedPath &&
160
+ !_dovetailApiUseLegacyPath &&
161
+ isMissingDovetailEndpoint(e)
162
+ ) {
151
163
  // eslint-disable-next-line no-console
152
164
  console.warn(
153
- "[deprecation] " + url +
154
- " not found. Falling back to legacy /api/cadso/dovetail/* path. Install the Dovetail application's Scripted REST APIs 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."
155
168
  );
156
169
  _dovetailApiUseLegacyPath = true;
157
170
  var legacyUrl = dovetailScopedPath(endpoint);
@@ -174,11 +187,50 @@ function clickupApi(method, endpoint, data) {
174
187
  });
175
188
  }
176
189
 
177
- // Generate update set name from ClickUp task
178
- function generateUpdateSetName(taskId, taskName) {
179
- var sanitized = taskName.replace(/[^a-zA-Z0-9\s\-_]/g, "").trim();
180
- var base = "CU-" + taskId + " — " + sanitized;
181
- 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);
182
234
  }
183
235
 
184
236
  // Generate update set description from task
@@ -246,7 +298,9 @@ app.get("/api/scopes", async (req, res) => {
246
298
  const scopeQuery = scopeKeys.map((s) => `scope=${s}`).join("^OR");
247
299
  const resp = await snApi(
248
300
  "get",
249
- `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`
250
304
  );
251
305
 
252
306
  const scopeRecords = resp.data.result || [];
@@ -275,7 +329,10 @@ app.get("/api/scopes", async (req, res) => {
275
329
  });
276
330
 
277
331
  // GET /api/recent-edits — read local recent edits file, enrich with live SN data
278
- 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
+ );
279
336
 
280
337
  app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
281
338
  try {
@@ -294,11 +351,17 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
294
351
  var edit = edits[i];
295
352
  var updateSetName = "unknown";
296
353
  try {
297
- 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";
298
360
  var snResp = await snApi(
299
361
  "get",
300
- "api/now/table/sys_update_xml?sysparm_query=" + encodeURIComponent(query) +
301
- "&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"
302
365
  );
303
366
  var results = snResp.data.result || [];
304
367
  if (results.length > 0) {
@@ -353,7 +416,9 @@ app.get("/api/update-sets/:scope", async (req, res) => {
353
416
  const query = `application.scope=${scope}^state=in progress^ORDERBYDESCsys_created_on`;
354
417
  const resp = await snApi(
355
418
  "get",
356
- `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`
357
422
  );
358
423
  res.json({ update_sets: resp.data.result || [] });
359
424
  } catch (e) {
@@ -374,7 +439,10 @@ app.post("/api/update-set", async (req, res) => {
374
439
 
375
440
  // Switch to the target scope before creating — ServiceNow uses session scope
376
441
  if (scope) {
377
- await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
442
+ await snApi(
443
+ "get",
444
+ "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope)
445
+ );
378
446
  }
379
447
 
380
448
  const data = {
@@ -395,11 +463,9 @@ app.post("/api/update-set", async (req, res) => {
395
463
  app.patch("/api/update-set/:sysId/close", async (req, res) => {
396
464
  try {
397
465
  const { sysId } = req.params;
398
- const resp = await snApi(
399
- "patch",
400
- `api/now/table/sys_update_set/${sysId}`,
401
- { state: "complete" }
402
- );
466
+ const resp = await snApi("patch", `api/now/table/sys_update_set/${sysId}`, {
467
+ state: "complete",
468
+ });
403
469
  res.json({ update_set: resp.data.result });
404
470
  } catch (e) {
405
471
  res.status(500).json({ error: e.message });
@@ -465,7 +531,9 @@ app.get("/api/clickup/status", function (req, res) {
465
531
  app.get("/api/clickup/me", async function (req, res) {
466
532
  try {
467
533
  if (!CLICKUP_TOKEN) {
468
- 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" });
469
537
  }
470
538
  var resp = await clickupApi("get", "user");
471
539
  var user = resp.data.user || {};
@@ -488,7 +556,9 @@ app.get("/api/clickup/me", async function (req, res) {
488
556
  app.get("/api/clickup/tasks", async function (req, res) {
489
557
  try {
490
558
  if (!CLICKUP_TOKEN) {
491
- 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" });
492
562
  }
493
563
 
494
564
  var teamId = CLICKUP_TEAM_ID;
@@ -516,7 +586,8 @@ app.get("/api/clickup/tasks", async function (req, res) {
516
586
  var byStatus = {};
517
587
  var allStatuses = [];
518
588
  tasks.forEach(function (t) {
519
- var statusName = t.status && t.status.status ? t.status.status : "unknown";
589
+ var statusName =
590
+ t.status && t.status.status ? t.status.status : "unknown";
520
591
  if (!byStatus[statusName]) {
521
592
  byStatus[statusName] = [];
522
593
  allStatuses.push(statusName);
@@ -541,7 +612,11 @@ app.get("/api/clickup/tasks", async function (req, res) {
541
612
  });
542
613
  });
543
614
 
544
- res.json({ tasks: tasks.length, byStatus: byStatus, statuses: allStatuses });
615
+ res.json({
616
+ tasks: tasks.length,
617
+ byStatus: byStatus,
618
+ statuses: allStatuses,
619
+ });
545
620
  } catch (e) {
546
621
  var msg = e.message;
547
622
  if (e.response && e.response.data) {
@@ -555,7 +630,9 @@ app.get("/api/clickup/tasks", async function (req, res) {
555
630
  app.get("/api/clickup/task/:taskId", async function (req, res) {
556
631
  try {
557
632
  if (!CLICKUP_TOKEN) {
558
- 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" });
559
636
  }
560
637
  var resp = await clickupApi("get", "task/" + req.params.taskId);
561
638
  var t = resp.data;
@@ -581,14 +658,26 @@ app.get("/api/clickup/task/:taskId", async function (req, res) {
581
658
  });
582
659
 
583
660
  // POST /api/clickup/select-task — select a task as active
584
- app.post("/api/clickup/select-task", function (req, res) {
661
+ app.post("/api/clickup/select-task", async function (req, res) {
585
662
  try {
586
663
  var body = req.body;
587
664
  if (!body.taskId || !body.taskName) {
588
- 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" });
668
+ }
669
+
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
589
676
  }
590
677
 
591
- var updateSetName = generateUpdateSetName(body.taskId, body.taskName);
678
+ var customId = body.customId || body.taskId;
679
+ var shortDesc = sanitizeTaskName(body.taskName);
680
+ var updateSetName = generateUpdateSetName(devInitials, customId, shortDesc);
592
681
  var description = generateUpdateSetDescription(
593
682
  body.taskName,
594
683
  body.taskDescription || ""
@@ -596,6 +685,9 @@ app.post("/api/clickup/select-task", function (req, res) {
596
685
 
597
686
  var activeTask = {
598
687
  taskId: body.taskId,
688
+ customId: customId,
689
+ devInitials: devInitials,
690
+ shortDesc: shortDesc,
599
691
  taskName: body.taskName,
600
692
  taskDescription: body.taskDescription || "",
601
693
  updateSetName: updateSetName,
@@ -614,17 +706,24 @@ app.post("/api/clickup/select-task", function (req, res) {
614
706
  // Core logic: find or create update set for a scope given an active task
615
707
  // Returns { update_set, created }
616
708
  async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
617
- var baseName = activeTask.updateSetName;
618
- var taskId = activeTask.taskId;
709
+ var appLabel = scopeLabel(scope);
710
+ var baseName = buildScopedUpdateSetName(activeTask, appLabel);
711
+ var lookupId = activeTask.customId || activeTask.taskId;
619
712
 
620
- if (!taskId || typeof taskId !== "string" || taskId.trim() === "") {
621
- 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
+ );
622
717
  }
623
718
 
624
- // 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.
625
722
  var query =
626
- "application.scope=" + scope +
627
- "^nameLIKECU-" + taskId +
723
+ "application.scope=" +
724
+ scope +
725
+ "^nameLIKE" +
726
+ lookupId +
628
727
  "^state=in progress" +
629
728
  "^ORDERBYDESCsys_created_on";
630
729
  var searchResp = await snApi(
@@ -648,7 +747,10 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
648
747
  if (!updateSet) {
649
748
  // Switch to the target scope before creating — ServiceNow uses session scope,
650
749
  // not the application field, when creating update sets via Table API
651
- await snApi("get", "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope));
750
+ await snApi(
751
+ "get",
752
+ "api/cadso/claude/changeScope?scope=" + encodeURIComponent(scope)
753
+ );
652
754
 
653
755
  var createData = {
654
756
  name: baseName,
@@ -658,7 +760,11 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
658
760
  if (activeTask.description) {
659
761
  createData.description = activeTask.description;
660
762
  }
661
- 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
+ );
662
768
  updateSet = createResp.data.result;
663
769
  created = true;
664
770
  }
@@ -667,13 +773,15 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
667
773
  try {
668
774
  await snApi(
669
775
  "get",
670
- "api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
776
+ "api/cadso/claude/changeUpdateSet?sysId=" +
777
+ encodeURIComponent(updateSet.sys_id)
671
778
  );
672
779
 
673
780
  // Verify the switch was successful
674
781
  var verifyResp = await snApi(
675
782
  "get",
676
- "api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
783
+ "api/cadso/claude/currentUpdateSet" +
784
+ (scope ? "?scope=" + encodeURIComponent(scope) : "")
677
785
  );
678
786
  var verifyData = verifyResp.data;
679
787
  if (verifyData && verifyData.result) {
@@ -685,11 +793,13 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
685
793
  console.warn("Update set verification failed, retrying switch...");
686
794
  await snApi(
687
795
  "get",
688
- "api/cadso/claude/changeUpdateSet?sysId=" + encodeURIComponent(updateSet.sys_id)
796
+ "api/cadso/claude/changeUpdateSet?sysId=" +
797
+ encodeURIComponent(updateSet.sys_id)
689
798
  );
690
799
  var retryResp = await snApi(
691
800
  "get",
692
- "api/cadso/claude/currentUpdateSet" + (scope ? "?scope=" + encodeURIComponent(scope) : "")
801
+ "api/cadso/claude/currentUpdateSet" +
802
+ (scope ? "?scope=" + encodeURIComponent(scope) : "")
693
803
  );
694
804
  var retryData = retryResp.data;
695
805
  if (retryData && retryData.result) {
@@ -697,14 +807,22 @@ async function findOrCreateUpdateSet(scope, scopeSysId, activeTask) {
697
807
  }
698
808
  var retrySysId = retryData && retryData.sysId ? retryData.sysId : null;
699
809
  if (retrySysId !== updateSet.sys_id) {
700
- var actualName = retryData && retryData.name ? retryData.name : "unknown";
810
+ var actualName =
811
+ retryData && retryData.name ? retryData.name : "unknown";
701
812
  console.error(
702
- "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
+ "."
703
818
  );
704
819
  }
705
820
  }
706
821
  } catch (changeErr) {
707
- 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
+ );
708
826
  }
709
827
 
710
828
  return { update_set: updateSet, created: created };
@@ -734,7 +852,9 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
734
852
  try {
735
853
  var body = req.body;
736
854
  if (!body.scope || !body.scope_sys_id) {
737
- 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" });
738
858
  }
739
859
 
740
860
  var activeTask = readActiveTask();
@@ -742,7 +862,11 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
742
862
  return res.status(400).json({ error: "No active task selected" });
743
863
  }
744
864
 
745
- 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
+ );
746
870
  persistScopeActivation(body.scope, result.update_set, activeTask);
747
871
 
748
872
  res.json({
@@ -755,6 +879,61 @@ app.post("/api/clickup/activate-scope", async function (req, res) {
755
879
  }
756
880
  });
757
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
+
758
937
  // POST /api/clickup/activate-all-scopes — find or create update sets for all configured scopes
759
938
  app.post("/api/clickup/activate-all-scopes", async function (req, res) {
760
939
  try {
@@ -763,51 +942,107 @@ app.post("/api/clickup/activate-all-scopes", async function (req, res) {
763
942
  return res.status(400).json({ error: "No active task selected" });
764
943
  }
765
944
 
766
- // Read scopes from dove.config.js
767
- delete require.cache[require.resolve(DOVE_CONFIG_PATH)];
768
- var config = require(DOVE_CONFIG_PATH);
769
- 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
+ });
770
951
 
771
- // Resolve scope sys_ids
772
- var scopeQuery = scopeKeys.map(function (s) { return "scope=" + s; }).join("^OR");
773
- var scopeResp = await snApi(
774
- "get",
775
- "api/now/table/sys_scope?sysparm_query=" +
776
- encodeURIComponent(scopeQuery) +
777
- "&sysparm_fields=sys_id,scope,name&sysparm_limit=50"
778
- );
779
- var scopeRecords = scopeResp.data.result || [];
780
- var scopeMap = {};
781
- scopeRecords.forEach(function (r) {
782
- scopeMap[r.scope] = r.sys_id;
783
- });
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
+ }
784
962
 
785
- // Activate each scope sequentially (respects rate limits)
786
- var results = [];
787
- for (var i = 0; i < scopeKeys.length; i++) {
788
- var scope = scopeKeys[i];
789
- var scopeSysId = scopeMap[scope];
790
- if (!scopeSysId) {
791
- results.push({ scope: scope, error: "scope not found on instance" });
792
- 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));
793
969
  }
970
+ resolve(stdout.toString().trim());
971
+ });
972
+ });
973
+ }
794
974
 
795
- try {
796
- var result = await findOrCreateUpdateSet(scope, scopeSysId, activeTask);
797
- persistScopeActivation(scope, result.update_set, activeTask);
798
- // Re-read active task so subsequent iterations see updated scopes
799
- activeTask = readActiveTask();
800
- results.push({
801
- scope: scope,
802
- update_set: result.update_set,
803
- created: result.created,
804
- });
805
- } catch (scopeErr) {
806
- 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;
807
1025
  }
1026
+ return res
1027
+ .status(502)
1028
+ .json({ error: "Failed to set ClickUp status: " + msg });
1029
+ }
1030
+
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 };
808
1038
  }
809
1039
 
810
- res.json({ results: results, activeTask: readActiveTask() });
1040
+ res.json({
1041
+ status: "in progress",
1042
+ scopes: outcome.results,
1043
+ branch: branchResult,
1044
+ activeTask: outcome.activeTask,
1045
+ });
811
1046
  } catch (e) {
812
1047
  res.status(500).json({ error: e.message });
813
1048
  }
@@ -967,21 +1202,45 @@ function classifyPath(filePath) {
967
1202
  if (parts.length === 1 && parts[0] === ".focus") {
968
1203
  return { kind: "focus" };
969
1204
  }
970
- 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
+ ) {
971
1210
  return { kind: "lint", id: parts[1].slice(0, -5) };
972
1211
  }
973
- 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
+ ) {
974
1217
  if (parts[1] === "_active.json") return { kind: "draft-active" };
975
1218
  return { kind: "draft", id: parts[1].slice(0, -5) };
976
1219
  }
977
1220
  if (parts.length === 1 && parts[0].endsWith(".json")) {
978
1221
  return { kind: "plan", slug: parts[0].slice(0, -5) };
979
1222
  }
980
- if (parts.length === 3 && parts[1] === "artifacts" && parts[2].endsWith(".json")) {
981
- 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
+ };
982
1233
  }
983
- if (parts.length === 3 && parts[1] === "prompts" && parts[2].endsWith(".json")) {
984
- 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
+ };
985
1244
  }
986
1245
  return null;
987
1246
  }
@@ -1024,19 +1283,20 @@ function handleWatcherChange(event, filePath) {
1024
1283
  if (event === "unlink") {
1025
1284
  broadcastClaudePlanEvent("artifact:delete", {
1026
1285
  plan_slug: info.slug,
1027
- slug: info.artifactSlug
1286
+ slug: info.artifactSlug,
1028
1287
  });
1029
1288
  return;
1030
1289
  }
1031
1290
  const artifact = safeReadJson(filePath);
1032
- if (artifact) broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
1291
+ if (artifact)
1292
+ broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
1033
1293
  return;
1034
1294
  }
1035
1295
  if (info.kind === "prompt") {
1036
1296
  if (event === "unlink") {
1037
1297
  broadcastClaudePlanEvent("prompt:delete", {
1038
1298
  plan_slug: info.slug,
1039
- slug: info.promptSlug
1299
+ slug: info.promptSlug,
1040
1300
  });
1041
1301
  return;
1042
1302
  }
@@ -1057,7 +1317,9 @@ function handleWatcherChange(event, filePath) {
1057
1317
  // The active-tab pointer changed (a draft was made active, or the active
1058
1318
  // one was deleted and focus advanced). Tell editors which tab is active.
1059
1319
  const ptr = safeReadJson(filePath);
1060
- broadcastClaudePlanEvent("draft:active", { active_id: ptr ? ptr.active_id : null });
1320
+ broadcastClaudePlanEvent("draft:active", {
1321
+ active_id: ptr ? ptr.active_id : null,
1322
+ });
1061
1323
  return;
1062
1324
  }
1063
1325
  if (info.kind === "draft") {
@@ -1086,11 +1348,17 @@ function startClaudePlanWatcher() {
1086
1348
  claudePlanWatcher = chokidar.watch(CLAUDE_PLANS_DIR, {
1087
1349
  ignoreInitial: true,
1088
1350
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
1089
- 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);
1090
1361
  });
1091
- claudePlanWatcher.on("add", function (p) { handleWatcherChange("add", p); });
1092
- claudePlanWatcher.on("change", function (p) { handleWatcherChange("change", p); });
1093
- claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
1094
1362
  }
1095
1363
 
1096
1364
  app.get("/claude-plans", function (req, res) {
@@ -1106,7 +1374,10 @@ app.get("/prompt-editor", claudePlansLimiter, function (req, res) {
1106
1374
  // Legacy path-segment deep links (/claude-plans/:slug) redirect to the
1107
1375
  // query-param form so relative assets resolve against /claude-plans.
1108
1376
  app.get("/claude-plans/:slug", function (req, res) {
1109
- res.redirect(301, "/claude-plans?plan=" + encodeURIComponent(req.params.slug));
1377
+ res.redirect(
1378
+ 301,
1379
+ "/claude-plans?plan=" + encodeURIComponent(req.params.slug)
1380
+ );
1110
1381
  });
1111
1382
 
1112
1383
  app.get("/prompt-lints", function (req, res) {
@@ -1116,13 +1387,18 @@ app.get("/prompt-lints", function (req, res) {
1116
1387
  app.get("/api/prompt-lints", function (req, res) {
1117
1388
  try {
1118
1389
  const filters = {};
1119
- if (typeof req.query.session_id === "string") filters.session_id = req.query.session_id;
1120
- 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;
1121
1394
  if (typeof req.query.limit === "string") {
1122
1395
  const n = parseInt(req.query.limit, 10);
1123
1396
  if (!isNaN(n) && n > 0) filters.limit = Math.min(n, 1000); // mirror getLintEventsSchema cap
1124
1397
  }
1125
- res.json({ events: listClaudeLintEvents(filters), storage: CLAUDE_PLANS_DIR });
1398
+ res.json({
1399
+ events: listClaudeLintEvents(filters),
1400
+ storage: CLAUDE_PLANS_DIR,
1401
+ });
1126
1402
  } catch (e) {
1127
1403
  res.status(500).json({ error: e.message });
1128
1404
  }
@@ -1174,7 +1450,7 @@ function resolvePrStatus(prUrl) {
1174
1450
  prStatusCache.set(prUrl, {
1175
1451
  state: result.state,
1176
1452
  merged: result.merged,
1177
- fetchedAt: Date.now()
1453
+ fetchedAt: Date.now(),
1178
1454
  });
1179
1455
  return result;
1180
1456
  });
@@ -1192,8 +1468,12 @@ function mapWithConcurrency(items, limit, worker) {
1192
1468
  if (index >= items.length) return;
1193
1469
  const i = index++;
1194
1470
  Promise.resolve(worker(items[i]))
1195
- .then(function (r) { results[i] = r; })
1196
- .catch(function () { results[i] = null; })
1471
+ .then(function (r) {
1472
+ results[i] = r;
1473
+ })
1474
+ .catch(function () {
1475
+ results[i] = null;
1476
+ })
1197
1477
  .then(function () {
1198
1478
  completed++;
1199
1479
  if (completed === items.length) resolve(results);
@@ -1235,7 +1515,7 @@ app.get("/api/claude-plans/stream", function (req, res) {
1235
1515
  "Content-Type": "text/event-stream",
1236
1516
  "Cache-Control": "no-cache",
1237
1517
  Connection: "keep-alive",
1238
- "X-Accel-Buffering": "no"
1518
+ "X-Accel-Buffering": "no",
1239
1519
  });
1240
1520
  res.flushHeaders();
1241
1521
  res.write("event: hello\ndata: {}\n\n");
@@ -1260,13 +1540,14 @@ app.get("/api/claude-plans/stream", function (req, res) {
1260
1540
  app.get("/api/claude-plans/:slug", function (req, res) {
1261
1541
  try {
1262
1542
  const slug = req.params.slug;
1263
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1543
+ if (!isValidSlug(slug))
1544
+ return res.status(400).json({ error: "invalid slug" });
1264
1545
  const plan = safeReadJson(planFilePath(slug));
1265
1546
  if (!plan) return res.status(404).json({ error: "plan not found" });
1266
1547
  res.json({
1267
1548
  plan: plan,
1268
1549
  artifacts: listClaudeArtifacts(slug),
1269
- prompts: listClaudePrompts(slug)
1550
+ prompts: listClaudePrompts(slug),
1270
1551
  });
1271
1552
  } catch (e) {
1272
1553
  res.status(500).json({ error: e.message });
@@ -1285,7 +1566,9 @@ function sendTypedError(res, err) {
1285
1566
  // a `code` (e.g. ILLEGAL_TRANSITION, MISSING_AGENT). Anything else is
1286
1567
  // a 500 with the message.
1287
1568
  if (err && err.name === "ZodError") {
1288
- 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 });
1289
1572
  }
1290
1573
  if (err && typeof err.code === "string") {
1291
1574
  var status = 409;
@@ -1296,83 +1579,104 @@ function sendTypedError(res, err) {
1296
1579
  return res.status(status).json({
1297
1580
  error: err.code,
1298
1581
  name: err.name,
1299
- message: err.message
1582
+ message: err.message,
1300
1583
  });
1301
1584
  }
1302
1585
  if (err && /not found/i.test(err.message || "")) {
1303
1586
  return res.status(404).json({ error: "not_found", message: err.message });
1304
1587
  }
1305
- 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) });
1306
1591
  }
1307
1592
 
1308
1593
  // POST /api/claude-plans/:slug/answers — record an answer to a question.
1309
1594
  // Body: { question_id, answer, answered_by? }
1310
- app.post("/api/claude-plans/:slug/answers", claudePlansLimiter, express.json(), function (req, res) {
1311
- try {
1312
- var slug = req.params.slug;
1313
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1314
- var body = req.body || {};
1315
- var result = claudePlansLib.recordAnswer({
1316
- plan_slug: slug,
1317
- question_id: body.question_id,
1318
- answer: body.answer,
1319
- answered_by: body.answered_by || "dashboard"
1320
- });
1321
- res.json(result);
1322
- } catch (e) {
1323
- 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
+ }
1324
1615
  }
1325
- });
1616
+ );
1326
1617
 
1327
1618
  // POST /api/claude-plans/:slug/stage — move the plan to a new stage.
1328
1619
  // Body: { to: PipelineStage, by? }
1329
1620
  // Source is forced to 'dashboard' so the conflict-resolution rule
1330
1621
  // (docs/v2-design.md §4) treats dashboard moves as authoritative.
1331
- app.post("/api/claude-plans/:slug/stage", claudePlansLimiter, express.json(), function (req, res) {
1332
- try {
1333
- var slug = req.params.slug;
1334
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1335
- var body = req.body || {};
1336
- var result = claudePlansLib.setStage({
1337
- plan_slug: slug,
1338
- to: body.to,
1339
- by: body.by || "dashboard",
1340
- source: "dashboard"
1341
- });
1342
- res.json(result);
1343
- } catch (e) {
1344
- 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
+ }
1345
1642
  }
1346
- });
1643
+ );
1347
1644
 
1348
1645
  // POST /api/claude-plans/:slug/dispatch — dry-run or live dispatch.
1349
1646
  // Body: { target_stage, confirm?, token?, by? }
1350
1647
  // The dashboard's flow is: POST without confirm (dry-run) → show
1351
1648
  // resolved command in the UI → POST with confirm:true + token from
1352
1649
  // set_stage response → live spawn.
1353
- app.post("/api/claude-plans/:slug/dispatch", claudePlansLimiter, express.json(), function (req, res) {
1354
- try {
1355
- var slug = req.params.slug;
1356
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1357
- var body = req.body || {};
1358
- var result = claudePlansLib.dispatchStage({
1359
- plan_slug: slug,
1360
- target_stage: body.target_stage,
1361
- confirm: body.confirm === true,
1362
- token: body.token,
1363
- by: body.by || "dashboard"
1364
- });
1365
- res.json(result);
1366
- } catch (e) {
1367
- 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
+ }
1368
1671
  }
1369
- });
1672
+ );
1370
1673
 
1371
1674
  // GET /api/claude-plans/:slug/versions — list saved version snapshots (newest-first).
1372
1675
  app.get("/api/claude-plans/:slug/versions", function (req, res) {
1373
1676
  try {
1374
1677
  var slug = req.params.slug;
1375
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1678
+ if (!isValidSlug(slug))
1679
+ return res.status(400).json({ error: "invalid slug" });
1376
1680
  res.json({ slug: slug, versions: claudePlansLib.listVersions(slug) });
1377
1681
  } catch (e) {
1378
1682
  sendTypedError(res, e);
@@ -1383,7 +1687,8 @@ app.get("/api/claude-plans/:slug/versions", function (req, res) {
1383
1687
  app.get("/api/claude-plans/:slug/versions/:n", function (req, res) {
1384
1688
  try {
1385
1689
  var slug = req.params.slug;
1386
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1690
+ if (!isValidSlug(slug))
1691
+ return res.status(400).json({ error: "invalid slug" });
1387
1692
  var n = parseInt(req.params.n, 10);
1388
1693
  if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
1389
1694
  var version = claudePlansLib.getVersion(slug, n);
@@ -1404,7 +1709,8 @@ app.post(
1404
1709
  function (req, res) {
1405
1710
  try {
1406
1711
  var slug = req.params.slug;
1407
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1712
+ if (!isValidSlug(slug))
1713
+ return res.status(400).json({ error: "invalid slug" });
1408
1714
  var n = parseInt(req.params.n, 10);
1409
1715
  if (!(n > 0)) return res.status(400).json({ error: "invalid version" });
1410
1716
  var plan = claudePlansLib.restoreVersion(slug, n);
@@ -1418,14 +1724,20 @@ app.post(
1418
1724
  app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1419
1725
  try {
1420
1726
  var slug = req.params.slug;
1421
- if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
1727
+ if (!isValidSlug(slug))
1728
+ return res.status(400).json({ error: "invalid slug" });
1422
1729
  var baseDir = path.resolve(CLAUDE_PLANS_DIR);
1423
1730
  var planFile = path.resolve(baseDir, slug + ".json");
1424
- if (!planFile.startsWith(baseDir + path.sep)) return res.status(400).json({ error: "invalid slug" });
1425
- 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" });
1426
1735
  fs.unlinkSync(planFile);
1427
1736
  var artifactsDir = path.resolve(baseDir, slug);
1428
- if (artifactsDir.startsWith(baseDir + path.sep) && fs.existsSync(artifactsDir)) {
1737
+ if (
1738
+ artifactsDir.startsWith(baseDir + path.sep) &&
1739
+ fs.existsSync(artifactsDir)
1740
+ ) {
1429
1741
  fs.rmSync(artifactsDir, { recursive: true, force: true });
1430
1742
  }
1431
1743
  res.json({ deleted: true });
@@ -1451,37 +1763,49 @@ app.get("/api/prompt-drafts", claudePlansLimiter, function (req, res) {
1451
1763
  });
1452
1764
 
1453
1765
  // POST /api/prompt-drafts — create a new draft (tab). Body: { title?, content? }
1454
- app.post("/api/prompt-drafts", claudePlansLimiter, express.json(), function (req, res) {
1455
- try {
1456
- var body = req.body || {};
1457
- var draft = claudePlansLib.createPromptDraft({
1458
- title: body.title,
1459
- content: body.content,
1460
- session_id: body.session_id
1461
- });
1462
- res.json(draft);
1463
- } catch (e) {
1464
- 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
+ }
1465
1782
  }
1466
- });
1783
+ );
1467
1784
 
1468
1785
  // POST /api/prompt-drafts/active — set the active tab. Body: { id }
1469
1786
  // Declared before "/:id" so Express doesn't treat "active" as an id.
1470
- app.post("/api/prompt-drafts/active", claudePlansLimiter, express.json(), function (req, res) {
1471
- try {
1472
- var body = req.body || {};
1473
- if (!isValidDraftId(body.id)) return res.status(400).json({ error: "invalid draft id" });
1474
- res.json(claudePlansLib.setActivePromptDraft(body.id));
1475
- } catch (e) {
1476
- 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
+ }
1477
1800
  }
1478
- });
1801
+ );
1479
1802
 
1480
1803
  // GET /api/prompt-drafts/:id — read one draft.
1481
1804
  app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
1482
1805
  try {
1483
1806
  var id = req.params.id;
1484
- 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" });
1485
1809
  var draft = claudePlansLib.getPromptDraft(id);
1486
1810
  if (!draft) return res.status(404).json({ error: "draft not found" });
1487
1811
  res.json(draft);
@@ -1491,25 +1815,38 @@ app.get("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
1491
1815
  });
1492
1816
 
1493
1817
  // PUT /api/prompt-drafts/:id — autosave title/content. Body: { title?, content? }
1494
- app.put("/api/prompt-drafts/:id", claudePlansLimiter, express.json(), function (req, res) {
1495
- try {
1496
- var id = req.params.id;
1497
- if (!isValidDraftId(id)) return res.status(400).json({ error: "invalid draft id" });
1498
- var body = req.body || {};
1499
- if (body.title === undefined && body.content === undefined) {
1500
- 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);
1501
1840
  }
1502
- res.json(claudePlansLib.updatePromptDraft({ id: id, title: body.title, content: body.content }));
1503
- } catch (e) {
1504
- sendTypedError(res, e);
1505
1841
  }
1506
- });
1842
+ );
1507
1843
 
1508
1844
  // DELETE /api/prompt-drafts/:id — close a tab. Returns the new active id.
1509
1845
  app.delete("/api/prompt-drafts/:id", claudePlansLimiter, function (req, res) {
1510
1846
  try {
1511
1847
  var id = req.params.id;
1512
- 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" });
1513
1850
  res.json(claudePlansLib.deletePromptDraft(id));
1514
1851
  } catch (e) {
1515
1852
  sendTypedError(res, e);
@@ -1542,7 +1879,8 @@ const todoReadLimiter = RateLimit({
1542
1879
  const todoSseClients = new Set();
1543
1880
 
1544
1881
  function broadcastTodos(list) {
1545
- 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";
1546
1884
  for (const res of todoSseClients) {
1547
1885
  try {
1548
1886
  res.write(frame);
@@ -1574,7 +1912,9 @@ function startTodoWatcher() {
1574
1912
  });
1575
1913
  todoWatcher.on("add", emitTodoState);
1576
1914
  todoWatcher.on("change", emitTodoState);
1577
- todoWatcher.on("unlink", function () { broadcastTodos({ schema_version: 1, items: [] }); });
1915
+ todoWatcher.on("unlink", function () {
1916
+ broadcastTodos({ schema_version: 1, items: [] });
1917
+ });
1578
1918
  }
1579
1919
 
1580
1920
  app.get("/todos", todoReadLimiter, function (req, res) {
@@ -1583,7 +1923,10 @@ app.get("/todos", todoReadLimiter, function (req, res) {
1583
1923
 
1584
1924
  app.get("/api/todos", todoReadLimiter, function (req, res) {
1585
1925
  try {
1586
- 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
+ });
1587
1930
  } catch (e) {
1588
1931
  res.status(500).json({ error: e.message });
1589
1932
  }
@@ -1660,9 +2003,15 @@ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
1660
2003
  const body = req.body || {};
1661
2004
  let result;
1662
2005
  if (typeof body.text === "string") {
1663
- 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
+ );
1664
2010
  } else {
1665
- 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
+ );
1666
2015
  }
1667
2016
  broadcastTodos(result.list);
1668
2017
  res.json(result);
@@ -1674,7 +2023,10 @@ app.patch("/api/todos/:id", todoLimiter, function (req, res) {
1674
2023
  // DELETE /api/todos/:id — remove one item.
1675
2024
  app.delete("/api/todos/:id", todoLimiter, function (req, res) {
1676
2025
  try {
1677
- 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
+ );
1678
2030
  broadcastTodos(result.list);
1679
2031
  res.json(result);
1680
2032
  } catch (e) {