@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/README.md +68 -37
- package/package.json +1 -1
- package/public/app.js +270 -66
- package/public/styles.css +22 -0
- package/server.js +567 -215
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(
|
|
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(
|
|
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(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
158
|
+
if (
|
|
159
|
+
scopedPath &&
|
|
160
|
+
!_dovetailApiUseLegacyPath &&
|
|
161
|
+
isMissingDovetailEndpoint(e)
|
|
162
|
+
) {
|
|
151
163
|
// eslint-disable-next-line no-console
|
|
152
164
|
console.warn(
|
|
153
|
-
"[deprecation] " +
|
|
154
|
-
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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=" +
|
|
301
|
-
|
|
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(
|
|
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(
|
|
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
|
-
"
|
|
400
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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
|
|
618
|
-
var
|
|
709
|
+
var appLabel = scopeLabel(scope);
|
|
710
|
+
var baseName = buildScopedUpdateSetName(activeTask, appLabel);
|
|
711
|
+
var lookupId = activeTask.customId || activeTask.taskId;
|
|
619
712
|
|
|
620
|
-
if (!
|
|
621
|
-
throw new Error(
|
|
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=" +
|
|
627
|
-
|
|
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(
|
|
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(
|
|
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=" +
|
|
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" +
|
|
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=" +
|
|
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" +
|
|
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 =
|
|
810
|
+
var actualName =
|
|
811
|
+
retryData && retryData.name ? retryData.name : "unknown";
|
|
701
812
|
console.error(
|
|
702
|
-
"Update set " +
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
)
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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({
|
|
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 (
|
|
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 (
|
|
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 (
|
|
981
|
-
|
|
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 (
|
|
984
|
-
|
|
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)
|
|
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", {
|
|
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(
|
|
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")
|
|
1120
|
-
|
|
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({
|
|
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) {
|
|
1196
|
-
|
|
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))
|
|
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
|
|
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
|
|
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(
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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(
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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(
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
1425
|
-
|
|
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 (
|
|
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(
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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(
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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))
|
|
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(
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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))
|
|
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 =
|
|
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 () {
|
|
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({
|
|
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(
|
|
2006
|
+
result = todoLib.updateTodo(
|
|
2007
|
+
{ id: id, text: body.text },
|
|
2008
|
+
{ rootDir: TODO_DIR }
|
|
2009
|
+
);
|
|
1664
2010
|
} else {
|
|
1665
|
-
result = todoLib.toggleTodo(
|
|
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(
|
|
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) {
|