syntaur 0.2.0 → 0.3.0
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/dashboard/dist/assets/{_basePickBy-CHKX1r7P.js → _basePickBy-BhaCV7eH.js} +1 -1
- package/dashboard/dist/assets/{_baseUniq-CTxTc4MS.js → _baseUniq-CDPcqrs2.js} +1 -1
- package/dashboard/dist/assets/{arc-BUo5zftd.js → arc-BP0RxLwl.js} +1 -1
- package/dashboard/dist/assets/{architectureDiagram-2XIMDMQ5-CrJLm-P0.js → architectureDiagram-2XIMDMQ5-BDzvaeJp.js} +1 -1
- package/dashboard/dist/assets/{blockDiagram-WCTKOSBZ-BK60lBBJ.js → blockDiagram-WCTKOSBZ-ZeL9mROo.js} +1 -1
- package/dashboard/dist/assets/{c4Diagram-IC4MRINW-C7oJEvA0.js → c4Diagram-IC4MRINW-7S5bvFLp.js} +1 -1
- package/dashboard/dist/assets/channel-CcB_wcgb.js +1 -0
- package/dashboard/dist/assets/{chunk-4BX2VUAB-CjUPlzHz.js → chunk-4BX2VUAB-Ca7R4nv5.js} +1 -1
- package/dashboard/dist/assets/{chunk-55IACEB6-6HmWguiO.js → chunk-55IACEB6-flEv13FB.js} +1 -1
- package/dashboard/dist/assets/{chunk-FMBD7UC4-CLuJnd1b.js → chunk-FMBD7UC4-CfcYWBM6.js} +1 -1
- package/dashboard/dist/assets/{chunk-JSJVCQXG-B4d62qWV.js → chunk-JSJVCQXG-Dw4yL0VS.js} +1 -1
- package/dashboard/dist/assets/{chunk-KX2RTZJC-AsEKRPq2.js → chunk-KX2RTZJC-B2cDe40G.js} +1 -1
- package/dashboard/dist/assets/{chunk-NQ4KR5QH-DQhHHvwY.js → chunk-NQ4KR5QH-LZVm0IWg.js} +1 -1
- package/dashboard/dist/assets/{chunk-QZHKN3VN-Ds1TtI3E.js → chunk-QZHKN3VN-Dg0EeHNI.js} +1 -1
- package/dashboard/dist/assets/{chunk-WL4C6EOR-C7jE3-cR.js → chunk-WL4C6EOR-v3rXNwXc.js} +1 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-BJr38z2g.js +1 -0
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BJr38z2g.js +1 -0
- package/dashboard/dist/assets/clone-Cfs2GUGt.js +1 -0
- package/dashboard/dist/assets/{cose-bilkent-S5V4N54A-C9ka5v1m.js → cose-bilkent-S5V4N54A-D-3JzLoS.js} +1 -1
- package/dashboard/dist/assets/{dagre-KLK3FWXG-BbgPQBKy.js → dagre-KLK3FWXG-d_mbczhU.js} +1 -1
- package/dashboard/dist/assets/{diagram-E7M64L7V-DpdeZFD4.js → diagram-E7M64L7V-BUyAp8pW.js} +1 -1
- package/dashboard/dist/assets/{diagram-IFDJBPK2-FlHLQzOV.js → diagram-IFDJBPK2-C8doXcyQ.js} +1 -1
- package/dashboard/dist/assets/{diagram-P4PSJMXO-B22NkEF_.js → diagram-P4PSJMXO-BUSmHa55.js} +1 -1
- package/dashboard/dist/assets/{erDiagram-INFDFZHY-zSqmtDid.js → erDiagram-INFDFZHY-Bn5_0LPU.js} +1 -1
- package/dashboard/dist/assets/{flowDiagram-PKNHOUZH-BP_0XmVV.js → flowDiagram-PKNHOUZH-CnEjerQM.js} +1 -1
- package/dashboard/dist/assets/{ganttDiagram-A5KZAMGK-8uRyYgZV.js → ganttDiagram-A5KZAMGK-CL94fbyy.js} +1 -1
- package/dashboard/dist/assets/{gitGraphDiagram-K3NZZRJ6-JFqg8sv4.js → gitGraphDiagram-K3NZZRJ6-4i_PeG8V.js} +1 -1
- package/dashboard/dist/assets/{graph-a-PAH599.js → graph-BtoFhoAd.js} +1 -1
- package/dashboard/dist/assets/index-DZUGYrvE.css +1 -0
- package/dashboard/dist/assets/index-Dv_-SxuL.js +481 -0
- package/dashboard/dist/assets/{infoDiagram-LFFYTUFH-C3kq7Nbv.js → infoDiagram-LFFYTUFH-CdUsuNgZ.js} +1 -1
- package/dashboard/dist/assets/{ishikawaDiagram-PHBUUO56-Kqi4EZ-n.js → ishikawaDiagram-PHBUUO56-BjggRlUx.js} +1 -1
- package/dashboard/dist/assets/{journeyDiagram-4ABVD52K-CTfv0Wcr.js → journeyDiagram-4ABVD52K-V4AgexlR.js} +1 -1
- package/dashboard/dist/assets/{kanban-definition-K7BYSVSG-Dmx0lgvR.js → kanban-definition-K7BYSVSG-ChlylQRf.js} +1 -1
- package/dashboard/dist/assets/{layout-KKRbT2Od.js → layout-DLcz9AmA.js} +1 -1
- package/dashboard/dist/assets/{linear-5egaBiw7.js → linear-l2xnSHze.js} +1 -1
- package/dashboard/dist/assets/{mermaid.core-C9pF_oFQ.js → mermaid.core-DKO1ytRW.js} +4 -4
- package/dashboard/dist/assets/{mindmap-definition-YRQLILUH-C7HXYEXt.js → mindmap-definition-YRQLILUH-DTmTPHrT.js} +1 -1
- package/dashboard/dist/assets/{pieDiagram-SKSYHLDU-DkdZm-YP.js → pieDiagram-SKSYHLDU-CwK80y8Y.js} +1 -1
- package/dashboard/dist/assets/{quadrantDiagram-337W2JSQ-DkcRJs5F.js → quadrantDiagram-337W2JSQ-Be1xqW_w.js} +1 -1
- package/dashboard/dist/assets/{requirementDiagram-Z7DCOOCP-BaTDVYTl.js → requirementDiagram-Z7DCOOCP-JcspXCs0.js} +1 -1
- package/dashboard/dist/assets/{sankeyDiagram-WA2Y5GQK-DvPLbGV5.js → sankeyDiagram-WA2Y5GQK-nJb1BInq.js} +1 -1
- package/dashboard/dist/assets/{sequenceDiagram-2WXFIKYE-DQoZ2xMK.js → sequenceDiagram-2WXFIKYE-DUrclEgA.js} +1 -1
- package/dashboard/dist/assets/{stateDiagram-RAJIS63D-CS4l0OjM.js → stateDiagram-RAJIS63D-CjinnNtF.js} +1 -1
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-yfclw-nM.js +1 -0
- package/dashboard/dist/assets/{timeline-definition-YZTLITO2-aC0iCFCW.js → timeline-definition-YZTLITO2-kM-oVLNz.js} +1 -1
- package/dashboard/dist/assets/{treemap-KZPCXAKY-Ie-PFjgx.js → treemap-KZPCXAKY-CYziFlrQ.js} +1 -1
- package/dashboard/dist/assets/{vennDiagram-LZ73GAT5-CJN3ExTQ.js → vennDiagram-LZ73GAT5-DX0DbxBN.js} +1 -1
- package/dashboard/dist/assets/{xychartDiagram-JWTSCODW-DSiDu1CN.js → xychartDiagram-JWTSCODW-BGqM42ZM.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.js +2579 -1185
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +2092 -650
- package/dist/index.js.map +1 -1
- package/examples/playbooks/keep-records-updated.md +14 -8
- package/examples/playbooks/read-before-plan.md +8 -5
- package/examples/sample-project/_status.md +1 -1
- package/examples/sample-project/assignments/design-auth-schema/assignment.md +4 -17
- package/examples/sample-project/assignments/design-auth-schema/comments.md +26 -0
- package/examples/sample-project/assignments/design-auth-schema/progress.md +20 -0
- package/examples/sample-project/assignments/implement-jwt-middleware/assignment.md +4 -17
- package/examples/sample-project/assignments/implement-jwt-middleware/comments.md +17 -0
- package/examples/sample-project/assignments/implement-jwt-middleware/progress.md +20 -0
- package/examples/sample-project/assignments/write-auth-tests/assignment.md +4 -8
- package/examples/sample-project/assignments/write-auth-tests/comments.md +10 -0
- package/examples/sample-project/assignments/write-auth-tests/progress.md +10 -0
- package/package.json +1 -1
- package/platforms/claude-code/agents/syntaur-expert.md +40 -12
- package/platforms/claude-code/references/file-ownership.md +15 -3
- package/platforms/claude-code/references/protocol-summary.md +19 -5
- package/platforms/claude-code/skills/complete-assignment/SKILL.md +14 -0
- package/platforms/claude-code/skills/create-assignment/SKILL.md +12 -10
- package/platforms/claude-code/skills/syntaur-protocol/SKILL.md +21 -11
- package/platforms/codex/agents/syntaur-operator.md +33 -21
- package/platforms/codex/references/file-ownership.md +14 -3
- package/platforms/codex/references/protocol-summary.md +19 -5
- package/platforms/codex/skills/complete-assignment/SKILL.md +1 -0
- package/platforms/codex/skills/create-assignment/SKILL.md +13 -8
- package/platforms/codex/skills/syntaur-protocol/SKILL.md +26 -13
- package/dashboard/dist/assets/channel-DdltvFFH.js +0 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-BHqdFE-8.js +0 -1
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BHqdFE-8.js +0 -1
- package/dashboard/dist/assets/clone-CBJOOeOm.js +0 -1
- package/dashboard/dist/assets/index-CoVCLSh2.css +0 -1
- package/dashboard/dist/assets/index-yyAIuzrP.js +0 -471
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-DkBtE1WJ.js +0 -1
package/dist/dashboard/server.js
CHANGED
|
@@ -389,6 +389,52 @@ async function executeTransition(projectDir, assignmentSlug, command, options =
|
|
|
389
389
|
warnings: warnings.length > 0 ? warnings : void 0
|
|
390
390
|
};
|
|
391
391
|
}
|
|
392
|
+
async function executeTransitionByDir(assignmentDir, command, options = {}) {
|
|
393
|
+
const filePath = resolve2(assignmentDir, "assignment.md");
|
|
394
|
+
const { content, frontmatter } = await readAssignment(filePath);
|
|
395
|
+
const targetStatus = getTargetStatus(frontmatter.status, command, options.transitionTable);
|
|
396
|
+
if (!targetStatus) {
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
message: `Unknown command '${command}' for assignment "${frontmatter.slug || assignmentDir}".`,
|
|
400
|
+
fromStatus: frontmatter.status
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const warnings = [];
|
|
404
|
+
if (command === "start" && !options.standalone && frontmatter.dependsOn.length > 0) {
|
|
405
|
+
const projectDir = resolve2(assignmentDir, "..", "..");
|
|
406
|
+
const depCheck = await checkDependencies(
|
|
407
|
+
projectDir,
|
|
408
|
+
frontmatter.dependsOn,
|
|
409
|
+
options.terminalStatuses
|
|
410
|
+
);
|
|
411
|
+
if (!depCheck.satisfied) {
|
|
412
|
+
warnings.push(`Starting with unmet dependencies: ${depCheck.unmet.join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const updates = {
|
|
416
|
+
status: targetStatus,
|
|
417
|
+
updated: nowTimestamp()
|
|
418
|
+
};
|
|
419
|
+
if (command === "start" && options.agent && !frontmatter.assignee) {
|
|
420
|
+
updates.assignee = options.agent;
|
|
421
|
+
}
|
|
422
|
+
if (command === "block") {
|
|
423
|
+
updates.blockedReason = options.reason ?? null;
|
|
424
|
+
}
|
|
425
|
+
if (command === "unblock") {
|
|
426
|
+
updates.blockedReason = null;
|
|
427
|
+
}
|
|
428
|
+
const updatedContent = updateAssignmentFile(content, updates);
|
|
429
|
+
await writeFileForce(filePath, updatedContent);
|
|
430
|
+
return {
|
|
431
|
+
success: true,
|
|
432
|
+
message: `Assignment "${frontmatter.slug || assignmentDir}" transitioned: ${frontmatter.status} -> ${targetStatus}`,
|
|
433
|
+
fromStatus: frontmatter.status,
|
|
434
|
+
toStatus: targetStatus,
|
|
435
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
436
|
+
};
|
|
437
|
+
}
|
|
392
438
|
var init_transitions = __esm({
|
|
393
439
|
"src/lifecycle/transitions.ts"() {
|
|
394
440
|
"use strict";
|
|
@@ -996,6 +1042,58 @@ function parseDecisionRecord(fileContent) {
|
|
|
996
1042
|
body
|
|
997
1043
|
};
|
|
998
1044
|
}
|
|
1045
|
+
function parseComments(fileContent) {
|
|
1046
|
+
const [fm, body] = extractFrontmatter2(fileContent);
|
|
1047
|
+
const entries = [];
|
|
1048
|
+
const sections = body.split(/^## /m).slice(1);
|
|
1049
|
+
for (const section of sections) {
|
|
1050
|
+
const newlineIdx = section.indexOf("\n");
|
|
1051
|
+
if (newlineIdx === -1) continue;
|
|
1052
|
+
const id = section.slice(0, newlineIdx).trim();
|
|
1053
|
+
const rest = section.slice(newlineIdx + 1);
|
|
1054
|
+
const headerMatch = rest.match(
|
|
1055
|
+
/^\s*\*\*Recorded:\*\*\s*(.*)\n\*\*Author:\*\*\s*(.*)\n\*\*Type:\*\*\s*(question|note|feedback)(?:\n\*\*Reply to:\*\*\s*(.*))?(?:\n\*\*Resolved:\*\*\s*(true|false))?\n+([\s\S]*)$/
|
|
1056
|
+
);
|
|
1057
|
+
if (!headerMatch) continue;
|
|
1058
|
+
const [, timestamp, author, type, replyTo, resolvedStr, entryBody] = headerMatch;
|
|
1059
|
+
const entry = {
|
|
1060
|
+
id,
|
|
1061
|
+
timestamp: timestamp.trim(),
|
|
1062
|
+
author: author.trim(),
|
|
1063
|
+
type,
|
|
1064
|
+
body: entryBody.trim()
|
|
1065
|
+
};
|
|
1066
|
+
if (replyTo) entry.replyTo = replyTo.trim();
|
|
1067
|
+
if (resolvedStr) entry.resolved = resolvedStr === "true";
|
|
1068
|
+
entries.push(entry);
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
assignment: getField(fm, "assignment") ?? "",
|
|
1072
|
+
entryCount: parseInt(getField(fm, "entryCount") ?? "0", 10),
|
|
1073
|
+
updated: getField(fm, "updated") ?? "",
|
|
1074
|
+
entries,
|
|
1075
|
+
body
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function parseProgress(fileContent) {
|
|
1079
|
+
const [fm, body] = extractFrontmatter2(fileContent);
|
|
1080
|
+
const entries = [];
|
|
1081
|
+
const sections = body.split(/^## /m).slice(1);
|
|
1082
|
+
for (const section of sections) {
|
|
1083
|
+
const newlineIdx = section.indexOf("\n");
|
|
1084
|
+
if (newlineIdx === -1) continue;
|
|
1085
|
+
const timestamp = section.slice(0, newlineIdx).trim();
|
|
1086
|
+
const entryBody = section.slice(newlineIdx + 1).trim();
|
|
1087
|
+
entries.push({ timestamp, body: entryBody });
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
assignment: getField(fm, "assignment") ?? "",
|
|
1091
|
+
entryCount: parseInt(getField(fm, "entryCount") ?? "0", 10),
|
|
1092
|
+
updated: getField(fm, "updated") ?? "",
|
|
1093
|
+
entries,
|
|
1094
|
+
body
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
999
1097
|
function parseResource(fileContent) {
|
|
1000
1098
|
const [fm, body] = extractFrontmatter2(fileContent);
|
|
1001
1099
|
return {
|
|
@@ -1044,6 +1142,74 @@ var init_parser = __esm({
|
|
|
1044
1142
|
}
|
|
1045
1143
|
});
|
|
1046
1144
|
|
|
1145
|
+
// src/utils/assignment-resolver.ts
|
|
1146
|
+
import { resolve as resolve4 } from "path";
|
|
1147
|
+
import { readdir, readFile as readFile3 } from "fs/promises";
|
|
1148
|
+
async function resolveAssignmentById(projectsDir, assignmentsDir, id) {
|
|
1149
|
+
let standaloneMatch = null;
|
|
1150
|
+
let projectMatch = null;
|
|
1151
|
+
const standaloneDir = resolve4(assignmentsDir, id);
|
|
1152
|
+
const standalonePath = resolve4(standaloneDir, "assignment.md");
|
|
1153
|
+
if (await fileExists(standalonePath)) {
|
|
1154
|
+
standaloneMatch = {
|
|
1155
|
+
assignmentDir: standaloneDir,
|
|
1156
|
+
projectSlug: null,
|
|
1157
|
+
assignmentSlug: id,
|
|
1158
|
+
id,
|
|
1159
|
+
standalone: true
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
if (await fileExists(projectsDir)) {
|
|
1163
|
+
try {
|
|
1164
|
+
const projects = await readdir(projectsDir, { withFileTypes: true });
|
|
1165
|
+
for (const p of projects) {
|
|
1166
|
+
if (!p.isDirectory()) continue;
|
|
1167
|
+
if (p.name.startsWith(".") || p.name.startsWith("_")) continue;
|
|
1168
|
+
const assignmentsPath = resolve4(projectsDir, p.name, "assignments");
|
|
1169
|
+
if (!await fileExists(assignmentsPath)) continue;
|
|
1170
|
+
const entries = await readdir(assignmentsPath, { withFileTypes: true });
|
|
1171
|
+
for (const a of entries) {
|
|
1172
|
+
if (!a.isDirectory()) continue;
|
|
1173
|
+
const aPath = resolve4(assignmentsPath, a.name, "assignment.md");
|
|
1174
|
+
if (!await fileExists(aPath)) continue;
|
|
1175
|
+
try {
|
|
1176
|
+
const content = await readFile3(aPath, "utf-8");
|
|
1177
|
+
const [fm] = extractFrontmatter2(content);
|
|
1178
|
+
const fileId = getField(fm, "id");
|
|
1179
|
+
if (fileId === id) {
|
|
1180
|
+
projectMatch = {
|
|
1181
|
+
assignmentDir: resolve4(assignmentsPath, a.name),
|
|
1182
|
+
projectSlug: p.name,
|
|
1183
|
+
assignmentSlug: a.name,
|
|
1184
|
+
id,
|
|
1185
|
+
standalone: false
|
|
1186
|
+
};
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
1189
|
+
} catch {
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (projectMatch) break;
|
|
1193
|
+
}
|
|
1194
|
+
} catch {
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (standaloneMatch && projectMatch) {
|
|
1198
|
+
console.warn(
|
|
1199
|
+
`Duplicate assignment ID ${id} found in both standalone and project-nested locations; using standalone`
|
|
1200
|
+
);
|
|
1201
|
+
return standaloneMatch;
|
|
1202
|
+
}
|
|
1203
|
+
return standaloneMatch ?? projectMatch ?? null;
|
|
1204
|
+
}
|
|
1205
|
+
var init_assignment_resolver = __esm({
|
|
1206
|
+
"src/utils/assignment-resolver.ts"() {
|
|
1207
|
+
"use strict";
|
|
1208
|
+
init_fs();
|
|
1209
|
+
init_parser();
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1047
1213
|
// src/dashboard/help.ts
|
|
1048
1214
|
async function buildStatusGuide() {
|
|
1049
1215
|
const config = await getStatusConfig();
|
|
@@ -1460,8 +1626,8 @@ var init_help = __esm({
|
|
|
1460
1626
|
});
|
|
1461
1627
|
|
|
1462
1628
|
// src/dashboard/servers.ts
|
|
1463
|
-
import { readdir, readFile as
|
|
1464
|
-
import { resolve as
|
|
1629
|
+
import { readdir as readdir2, readFile as readFile4, unlink } from "fs/promises";
|
|
1630
|
+
import { resolve as resolve5 } from "path";
|
|
1465
1631
|
function sanitizeSessionName(name) {
|
|
1466
1632
|
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1467
1633
|
}
|
|
@@ -1509,18 +1675,18 @@ async function registerSession(dir, rawName) {
|
|
|
1509
1675
|
lastRefreshed: now,
|
|
1510
1676
|
overrides: {}
|
|
1511
1677
|
});
|
|
1512
|
-
await writeFileForce(
|
|
1678
|
+
await writeFileForce(resolve5(dir, `${name}.md`), content);
|
|
1513
1679
|
return name;
|
|
1514
1680
|
}
|
|
1515
1681
|
async function listSessionFiles(dir) {
|
|
1516
1682
|
if (!await fileExists(dir)) return [];
|
|
1517
|
-
const entries = await
|
|
1683
|
+
const entries = await readdir2(dir);
|
|
1518
1684
|
return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
1519
1685
|
}
|
|
1520
1686
|
async function readSessionFile(dir, name) {
|
|
1521
|
-
const filePath =
|
|
1687
|
+
const filePath = resolve5(dir, `${sanitizeSessionName(name)}.md`);
|
|
1522
1688
|
if (!await fileExists(filePath)) return null;
|
|
1523
|
-
const raw = await
|
|
1689
|
+
const raw = await readFile4(filePath, "utf-8");
|
|
1524
1690
|
const [frontmatter] = extractFrontmatter2(raw);
|
|
1525
1691
|
if (!frontmatter) return null;
|
|
1526
1692
|
const session = getField(frontmatter, "session") ?? name;
|
|
@@ -1560,7 +1726,7 @@ async function readSessionFile(dir, name) {
|
|
|
1560
1726
|
};
|
|
1561
1727
|
}
|
|
1562
1728
|
async function removeSession(dir, name) {
|
|
1563
|
-
const filePath =
|
|
1729
|
+
const filePath = resolve5(dir, `${sanitizeSessionName(name)}.md`);
|
|
1564
1730
|
if (await fileExists(filePath)) {
|
|
1565
1731
|
await unlink(filePath);
|
|
1566
1732
|
}
|
|
@@ -1569,7 +1735,7 @@ async function updateLastRefreshed(dir, name) {
|
|
|
1569
1735
|
const data = await readSessionFile(dir, name);
|
|
1570
1736
|
if (!data) return;
|
|
1571
1737
|
const content = buildSessionContent({ ...data, lastRefreshed: nowTimestamp2() });
|
|
1572
|
-
await writeFileForce(
|
|
1738
|
+
await writeFileForce(resolve5(dir, `${sanitizeSessionName(name)}.md`), content);
|
|
1573
1739
|
}
|
|
1574
1740
|
async function setOverride(dir, sessionName, windowIndex, paneIndex, assignment) {
|
|
1575
1741
|
const data = await readSessionFile(dir, sessionName);
|
|
@@ -1581,7 +1747,7 @@ async function setOverride(dir, sessionName, windowIndex, paneIndex, assignment)
|
|
|
1581
1747
|
delete data.overrides[key];
|
|
1582
1748
|
}
|
|
1583
1749
|
const content = buildSessionContent({ ...data });
|
|
1584
|
-
await writeFileForce(
|
|
1750
|
+
await writeFileForce(resolve5(dir, `${sanitizeSessionName(sessionName)}.md`), content);
|
|
1585
1751
|
}
|
|
1586
1752
|
async function registerAutoSession(dir, rawName, opts) {
|
|
1587
1753
|
const name = sanitizeSessionName(rawName);
|
|
@@ -1598,7 +1764,7 @@ async function registerAutoSession(dir, rawName, opts) {
|
|
|
1598
1764
|
ports: opts.ports,
|
|
1599
1765
|
cwd: opts.cwd
|
|
1600
1766
|
});
|
|
1601
|
-
await writeFileForce(
|
|
1767
|
+
await writeFileForce(resolve5(dir, `${name}.md`), content);
|
|
1602
1768
|
return name;
|
|
1603
1769
|
}
|
|
1604
1770
|
var init_servers = __esm({
|
|
@@ -1630,8 +1796,8 @@ __export(scanner_exports, {
|
|
|
1630
1796
|
});
|
|
1631
1797
|
import { execFile } from "child_process";
|
|
1632
1798
|
import { promisify } from "util";
|
|
1633
|
-
import { resolve as
|
|
1634
|
-
import { realpath, readdir as
|
|
1799
|
+
import { resolve as resolve6 } from "path";
|
|
1800
|
+
import { realpath, readdir as readdir3, readFile as readFile5 } from "fs/promises";
|
|
1635
1801
|
function clearScanCache() {
|
|
1636
1802
|
cache = null;
|
|
1637
1803
|
}
|
|
@@ -1726,8 +1892,8 @@ async function getGitInfo(cwd) {
|
|
|
1726
1892
|
let isWorktree = false;
|
|
1727
1893
|
if (commonDir && gitDir && commonDir !== gitDir) {
|
|
1728
1894
|
try {
|
|
1729
|
-
const resolvedCommon = await realpath(
|
|
1730
|
-
const resolvedGit = await realpath(
|
|
1895
|
+
const resolvedCommon = await realpath(resolve6(cwd, commonDir));
|
|
1896
|
+
const resolvedGit = await realpath(resolve6(cwd, gitDir));
|
|
1731
1897
|
isWorktree = resolvedCommon !== resolvedGit;
|
|
1732
1898
|
} catch {
|
|
1733
1899
|
isWorktree = false;
|
|
@@ -1735,22 +1901,22 @@ async function getGitInfo(cwd) {
|
|
|
1735
1901
|
}
|
|
1736
1902
|
return { branch: branch || null, worktree: isWorktree };
|
|
1737
1903
|
}
|
|
1738
|
-
async function loadWorkspaceRecords(projectsDir) {
|
|
1904
|
+
async function loadWorkspaceRecords(projectsDir, assignmentsDir) {
|
|
1739
1905
|
const records = [];
|
|
1740
1906
|
try {
|
|
1741
1907
|
const projects = await listProjects(projectsDir);
|
|
1742
1908
|
for (const project of projects) {
|
|
1743
|
-
const
|
|
1909
|
+
const projectAssignmentsDir = resolve6(projectsDir, project.slug, "assignments");
|
|
1744
1910
|
let slugs;
|
|
1745
1911
|
try {
|
|
1746
|
-
slugs = await
|
|
1912
|
+
slugs = await readdir3(projectAssignmentsDir);
|
|
1747
1913
|
} catch {
|
|
1748
1914
|
continue;
|
|
1749
1915
|
}
|
|
1750
1916
|
for (const aslug of slugs) {
|
|
1751
|
-
const aFile =
|
|
1917
|
+
const aFile = resolve6(projectAssignmentsDir, aslug, "assignment.md");
|
|
1752
1918
|
try {
|
|
1753
|
-
const raw = await
|
|
1919
|
+
const raw = await readFile5(aFile, "utf-8");
|
|
1754
1920
|
const [fm] = extractFrontmatter2(raw);
|
|
1755
1921
|
if (!fm) continue;
|
|
1756
1922
|
records.push({
|
|
@@ -1767,6 +1933,30 @@ async function loadWorkspaceRecords(projectsDir) {
|
|
|
1767
1933
|
}
|
|
1768
1934
|
} catch {
|
|
1769
1935
|
}
|
|
1936
|
+
if (assignmentsDir) {
|
|
1937
|
+
try {
|
|
1938
|
+
const entries = await readdir3(assignmentsDir);
|
|
1939
|
+
for (const id of entries) {
|
|
1940
|
+
if (id.startsWith(".") || id.startsWith("_")) continue;
|
|
1941
|
+
const aFile = resolve6(assignmentsDir, id, "assignment.md");
|
|
1942
|
+
try {
|
|
1943
|
+
const raw = await readFile5(aFile, "utf-8");
|
|
1944
|
+
const [fm] = extractFrontmatter2(raw);
|
|
1945
|
+
if (!fm) continue;
|
|
1946
|
+
records.push({
|
|
1947
|
+
projectSlug: null,
|
|
1948
|
+
assignmentSlug: id,
|
|
1949
|
+
assignmentTitle: getField(fm, "title") ?? id,
|
|
1950
|
+
worktreePath: getNestedField(fm, "workspace", "worktreePath") ?? null,
|
|
1951
|
+
branch: getNestedField(fm, "workspace", "branch") ?? null
|
|
1952
|
+
});
|
|
1953
|
+
} catch {
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
} catch {
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1770
1960
|
return records;
|
|
1771
1961
|
}
|
|
1772
1962
|
async function resolveAndNormalize(p) {
|
|
@@ -1959,7 +2149,7 @@ async function scanAllSessions(serversDir2, projectsDir, options) {
|
|
|
1959
2149
|
const tmuxAvailable = await checkTmuxAvailable();
|
|
1960
2150
|
const names = await listSessionFiles(serversDir2);
|
|
1961
2151
|
const lsofOutput = await getLsofOutput();
|
|
1962
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir);
|
|
2152
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir, options?.assignmentsDir);
|
|
1963
2153
|
const sessions = [];
|
|
1964
2154
|
for (const name of names) {
|
|
1965
2155
|
const data = await readSessionFile(serversDir2, name);
|
|
@@ -1974,11 +2164,11 @@ async function scanAllSessions(serversDir2, projectsDir, options) {
|
|
|
1974
2164
|
cache = { data: result, expiry: Date.now() + CACHE_TTL_MS };
|
|
1975
2165
|
return result;
|
|
1976
2166
|
}
|
|
1977
|
-
async function scanSingleSession(serversDir2, projectsDir, name) {
|
|
2167
|
+
async function scanSingleSession(serversDir2, projectsDir, name, options) {
|
|
1978
2168
|
const data = await readSessionFile(serversDir2, name);
|
|
1979
2169
|
if (!data) return null;
|
|
1980
2170
|
const lsofOutput = await getLsofOutput();
|
|
1981
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir);
|
|
2171
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir, options?.assignmentsDir);
|
|
1982
2172
|
if (data.kind === "process") {
|
|
1983
2173
|
return scanProcessSession(data, lsofOutput, workspaceRecords);
|
|
1984
2174
|
}
|
|
@@ -1998,8 +2188,28 @@ var init_scanner = __esm({
|
|
|
1998
2188
|
});
|
|
1999
2189
|
|
|
2000
2190
|
// src/dashboard/api.ts
|
|
2001
|
-
import { readdir as
|
|
2002
|
-
import { resolve as
|
|
2191
|
+
import { readdir as readdir4, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
|
|
2192
|
+
import { resolve as resolve7, dirname as dirname2 } from "path";
|
|
2193
|
+
async function listStandaloneRecords(assignmentsDir) {
|
|
2194
|
+
if (!assignmentsDir) return [];
|
|
2195
|
+
if (!await fileExists(assignmentsDir)) return [];
|
|
2196
|
+
const entries = await readdir4(assignmentsDir, { withFileTypes: true });
|
|
2197
|
+
const records = [];
|
|
2198
|
+
for (const entry of entries) {
|
|
2199
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
2200
|
+
const assignmentDir = resolve7(assignmentsDir, entry.name);
|
|
2201
|
+
const assignmentMdPath = resolve7(assignmentDir, "assignment.md");
|
|
2202
|
+
if (!await fileExists(assignmentMdPath)) continue;
|
|
2203
|
+
try {
|
|
2204
|
+
const content = await readFile6(assignmentMdPath, "utf-8");
|
|
2205
|
+
const record = parseAssignmentFull(content);
|
|
2206
|
+
records.push({ assignmentDir, id: entry.name, record });
|
|
2207
|
+
} catch {
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
records.sort((left, right) => compareTimestamps(right.record.updated, left.record.updated));
|
|
2211
|
+
return records;
|
|
2212
|
+
}
|
|
2003
2213
|
function toTitleCase(s) {
|
|
2004
2214
|
return s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
2005
2215
|
}
|
|
@@ -2060,9 +2270,9 @@ async function listProjects(projectsDir) {
|
|
|
2060
2270
|
return projectRecords.map((record) => record.summary);
|
|
2061
2271
|
}
|
|
2062
2272
|
async function readWorkspaceRegistry(projectsDir) {
|
|
2063
|
-
const registryPath =
|
|
2273
|
+
const registryPath = resolve7(dirname2(projectsDir), "workspaces.json");
|
|
2064
2274
|
try {
|
|
2065
|
-
const raw = await
|
|
2275
|
+
const raw = await readFile6(registryPath, "utf-8");
|
|
2066
2276
|
const parsed = JSON.parse(raw);
|
|
2067
2277
|
return Array.isArray(parsed) ? parsed.filter((w) => typeof w === "string") : [];
|
|
2068
2278
|
} catch {
|
|
@@ -2070,7 +2280,7 @@ async function readWorkspaceRegistry(projectsDir) {
|
|
|
2070
2280
|
}
|
|
2071
2281
|
}
|
|
2072
2282
|
async function writeWorkspaceRegistry(projectsDir, workspaces) {
|
|
2073
|
-
const registryPath =
|
|
2283
|
+
const registryPath = resolve7(dirname2(projectsDir), "workspaces.json");
|
|
2074
2284
|
await writeFile2(registryPath, JSON.stringify(workspaces, null, 2) + "\n", "utf-8");
|
|
2075
2285
|
}
|
|
2076
2286
|
async function listWorkspaces(projectsDir) {
|
|
@@ -2103,15 +2313,16 @@ async function deleteWorkspace(projectsDir, name) {
|
|
|
2103
2313
|
const filtered = registered.filter((w) => w !== name);
|
|
2104
2314
|
await writeWorkspaceRegistry(projectsDir, filtered);
|
|
2105
2315
|
}
|
|
2106
|
-
async function getOverview(projectsDir, serversDir2) {
|
|
2316
|
+
async function getOverview(projectsDir, serversDir2, assignmentsDir) {
|
|
2107
2317
|
const projectRecords = await listProjectRecords(projectsDir);
|
|
2108
|
-
const
|
|
2109
|
-
const
|
|
2318
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir);
|
|
2319
|
+
const attention = buildAttentionItems(projectRecords, standaloneRecords);
|
|
2320
|
+
const recentActivity = buildRecentActivity(projectRecords, standaloneRecords);
|
|
2110
2321
|
let serverStats;
|
|
2111
2322
|
if (serversDir2) {
|
|
2112
2323
|
try {
|
|
2113
2324
|
const { scanAllSessions: scanAllSessions2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
|
|
2114
|
-
const servers = await scanAllSessions2(serversDir2, projectsDir);
|
|
2325
|
+
const servers = await scanAllSessions2(serversDir2, projectsDir, { assignmentsDir });
|
|
2115
2326
|
if (servers.tmuxAvailable) {
|
|
2116
2327
|
const alive = servers.sessions.filter((s) => s.alive).length;
|
|
2117
2328
|
const totalPorts = servers.sessions.reduce((sum, s) => sum + s.windows.reduce((ws, w) => ws + w.panes.reduce((ps, p) => ps + p.ports.length, 0), 0), 0);
|
|
@@ -2127,7 +2338,7 @@ async function getOverview(projectsDir, serversDir2) {
|
|
|
2127
2338
|
}
|
|
2128
2339
|
return {
|
|
2129
2340
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2130
|
-
firstRun: projectRecords.length === 0,
|
|
2341
|
+
firstRun: projectRecords.length === 0 && standaloneRecords.length === 0,
|
|
2131
2342
|
stats: {
|
|
2132
2343
|
activeProjects: projectRecords.filter((record) => record.summary.status === "active").length,
|
|
2133
2344
|
inProgressAssignments: projectRecords.reduce(
|
|
@@ -2149,7 +2360,7 @@ async function getOverview(projectsDir, serversDir2) {
|
|
|
2149
2360
|
staleAssignments: projectRecords.reduce(
|
|
2150
2361
|
(total, record) => total + record.assignments.filter((assignment) => isStale(assignment.updated)).length,
|
|
2151
2362
|
0
|
|
2152
|
-
)
|
|
2363
|
+
) + standaloneRecords.filter((sr) => isStale(sr.record.updated)).length
|
|
2153
2364
|
},
|
|
2154
2365
|
attention: attention.slice(0, OVERVIEW_ATTENTION_LIMIT),
|
|
2155
2366
|
recentProjects: projectRecords.map((record) => record.summary).sort((left, right) => compareTimestamps(right.updated, left.updated)).slice(0, RECENT_PROJECTS_LIMIT),
|
|
@@ -2157,13 +2368,14 @@ async function getOverview(projectsDir, serversDir2) {
|
|
|
2157
2368
|
serverStats
|
|
2158
2369
|
};
|
|
2159
2370
|
}
|
|
2160
|
-
async function getAttention(projectsDir, serversDir2) {
|
|
2371
|
+
async function getAttention(projectsDir, serversDir2, assignmentsDir) {
|
|
2161
2372
|
const projectRecords = await listProjectRecords(projectsDir);
|
|
2162
|
-
const
|
|
2373
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir);
|
|
2374
|
+
const items = buildAttentionItems(projectRecords, standaloneRecords);
|
|
2163
2375
|
if (serversDir2) {
|
|
2164
2376
|
try {
|
|
2165
2377
|
const { scanAllSessions: scanAllSessions2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
|
|
2166
|
-
const servers = await scanAllSessions2(serversDir2, projectsDir);
|
|
2378
|
+
const servers = await scanAllSessions2(serversDir2, projectsDir, { assignmentsDir });
|
|
2167
2379
|
for (const session of servers.sessions) {
|
|
2168
2380
|
if (!session.alive) {
|
|
2169
2381
|
items.push({
|
|
@@ -2207,9 +2419,9 @@ async function getAttention(projectsDir, serversDir2) {
|
|
|
2207
2419
|
items: pagedItems
|
|
2208
2420
|
};
|
|
2209
2421
|
}
|
|
2210
|
-
async function listAssignmentsBoard(projectsDir) {
|
|
2422
|
+
async function listAssignmentsBoard(projectsDir, assignmentsDir) {
|
|
2211
2423
|
const projectRecords = await listProjectRecords(projectsDir);
|
|
2212
|
-
const
|
|
2424
|
+
const projectItems = await Promise.all(
|
|
2213
2425
|
projectRecords.flatMap(
|
|
2214
2426
|
async (record) => Promise.all(
|
|
2215
2427
|
record.assignments.map(
|
|
@@ -2218,11 +2430,48 @@ async function listAssignmentsBoard(projectsDir) {
|
|
|
2218
2430
|
)
|
|
2219
2431
|
)
|
|
2220
2432
|
);
|
|
2433
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir);
|
|
2434
|
+
const standaloneItems = await Promise.all(
|
|
2435
|
+
standaloneRecords.map(async (sr) => toStandaloneBoardItem(sr))
|
|
2436
|
+
);
|
|
2221
2437
|
return {
|
|
2222
2438
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2223
|
-
assignments:
|
|
2439
|
+
assignments: [...projectItems.flat(), ...standaloneItems].sort((left, right) => compareTimestamps(right.updated, left.updated))
|
|
2224
2440
|
};
|
|
2225
2441
|
}
|
|
2442
|
+
async function toStandaloneBoardItem(sr) {
|
|
2443
|
+
return {
|
|
2444
|
+
...toAssignmentSummary(sr.record),
|
|
2445
|
+
projectSlug: null,
|
|
2446
|
+
projectTitle: null,
|
|
2447
|
+
blockedReason: sr.record.blockedReason,
|
|
2448
|
+
projectWorkspace: null,
|
|
2449
|
+
availableTransitions: await getStandaloneAvailableTransitions(sr.record)
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
async function getStandaloneAvailableTransitions(assignment) {
|
|
2453
|
+
const config = await getStatusConfig();
|
|
2454
|
+
const transitionDefs = getTransitionDefinitions(config);
|
|
2455
|
+
const actions = [];
|
|
2456
|
+
for (const definition of transitionDefs) {
|
|
2457
|
+
let warning = null;
|
|
2458
|
+
if (definition.command === "start" && !assignment.assignee) {
|
|
2459
|
+
warning = "No assignee set \u2014 consider assigning before starting.";
|
|
2460
|
+
}
|
|
2461
|
+
const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
|
|
2462
|
+
actions.push({
|
|
2463
|
+
command: definition.command,
|
|
2464
|
+
label: definition.label,
|
|
2465
|
+
description: definition.description,
|
|
2466
|
+
targetStatus: target ?? definition.command,
|
|
2467
|
+
disabled: false,
|
|
2468
|
+
disabledReason: null,
|
|
2469
|
+
warning,
|
|
2470
|
+
requiresReason: definition.requiresReason
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
return actions;
|
|
2474
|
+
}
|
|
2226
2475
|
async function getHelp() {
|
|
2227
2476
|
return getDashboardHelp();
|
|
2228
2477
|
}
|
|
@@ -2231,7 +2480,7 @@ async function getEditableDocument(projectsDir, documentType, projectSlug, assig
|
|
|
2231
2480
|
if (!filePath || !await fileExists(filePath)) {
|
|
2232
2481
|
return null;
|
|
2233
2482
|
}
|
|
2234
|
-
const content = await
|
|
2483
|
+
const content = await readFile6(filePath, "utf-8");
|
|
2235
2484
|
const title = getEditableDocumentTitle(documentType, projectSlug, assignmentSlug);
|
|
2236
2485
|
return {
|
|
2237
2486
|
documentType,
|
|
@@ -2242,16 +2491,44 @@ async function getEditableDocument(projectsDir, documentType, projectSlug, assig
|
|
|
2242
2491
|
appendOnly: documentType === "handoff" || documentType === "decision-record"
|
|
2243
2492
|
};
|
|
2244
2493
|
}
|
|
2494
|
+
async function getEditableDocumentById(projectsDir, assignmentsDir, documentType, id) {
|
|
2495
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
2496
|
+
if (!resolved) return null;
|
|
2497
|
+
if (!resolved.standalone && resolved.projectSlug) {
|
|
2498
|
+
return getEditableDocument(
|
|
2499
|
+
projectsDir,
|
|
2500
|
+
documentType,
|
|
2501
|
+
resolved.projectSlug,
|
|
2502
|
+
resolved.assignmentSlug
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
const fileName = documentType === "assignment" ? "assignment.md" : documentType === "plan" ? "plan.md" : documentType === "scratchpad" ? "scratchpad.md" : documentType === "handoff" ? "handoff.md" : documentType === "decision-record" ? "decision-record.md" : null;
|
|
2506
|
+
if (!fileName) return null;
|
|
2507
|
+
const filePath = resolve7(resolved.assignmentDir, fileName);
|
|
2508
|
+
if (!await fileExists(filePath)) return null;
|
|
2509
|
+
const content = await readFile6(filePath, "utf-8");
|
|
2510
|
+
const label = resolved.id;
|
|
2511
|
+
const title = documentType === "assignment" ? `Edit Assignment: ${label}` : documentType === "plan" ? `Edit Plan: ${label}` : documentType === "scratchpad" ? `Edit Scratchpad: ${label}` : documentType === "handoff" ? `Append Handoff: ${label}` : `Append Decision: ${label}`;
|
|
2512
|
+
return {
|
|
2513
|
+
documentType,
|
|
2514
|
+
title,
|
|
2515
|
+
content,
|
|
2516
|
+
projectSlug: null,
|
|
2517
|
+
assignmentSlug: void 0,
|
|
2518
|
+
assignmentId: resolved.id,
|
|
2519
|
+
appendOnly: documentType === "handoff" || documentType === "decision-record"
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2245
2522
|
async function getProjectDetail(projectsDir, slug) {
|
|
2246
|
-
const projectPath =
|
|
2247
|
-
const projectMdPath =
|
|
2523
|
+
const projectPath = resolve7(projectsDir, slug);
|
|
2524
|
+
const projectMdPath = resolve7(projectPath, "project.md");
|
|
2248
2525
|
if (!await fileExists(projectMdPath)) {
|
|
2249
2526
|
return null;
|
|
2250
2527
|
}
|
|
2251
|
-
const projectContent = await
|
|
2528
|
+
const projectContent = await readFile6(projectMdPath, "utf-8");
|
|
2252
2529
|
const project = parseProject(projectContent);
|
|
2253
2530
|
const assignments = await listAssignmentRecords(projectPath);
|
|
2254
|
-
const rollup = buildProjectRollup(project, assignments);
|
|
2531
|
+
const rollup = await buildProjectRollup(projectPath, project, assignments);
|
|
2255
2532
|
const dependencyGraph = await loadDependencyGraph(projectPath, assignments);
|
|
2256
2533
|
const resources = await listResources(projectPath);
|
|
2257
2534
|
const memories = await listMemories(projectPath);
|
|
@@ -2278,17 +2555,17 @@ async function getProjectDetail(projectsDir, slug) {
|
|
|
2278
2555
|
};
|
|
2279
2556
|
}
|
|
2280
2557
|
async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
2281
|
-
const assignmentDir =
|
|
2282
|
-
const assignmentMdPath =
|
|
2558
|
+
const assignmentDir = resolve7(projectsDir, projectSlug, "assignments", assignmentSlug);
|
|
2559
|
+
const assignmentMdPath = resolve7(assignmentDir, "assignment.md");
|
|
2283
2560
|
if (!await fileExists(assignmentMdPath)) {
|
|
2284
2561
|
return null;
|
|
2285
2562
|
}
|
|
2286
|
-
const assignmentContent = await
|
|
2563
|
+
const assignmentContent = await readFile6(assignmentMdPath, "utf-8");
|
|
2287
2564
|
const assignment = parseAssignmentFull(assignmentContent);
|
|
2288
2565
|
let plan = null;
|
|
2289
|
-
const planPath =
|
|
2566
|
+
const planPath = resolve7(assignmentDir, "plan.md");
|
|
2290
2567
|
if (await fileExists(planPath)) {
|
|
2291
|
-
const planContent = await
|
|
2568
|
+
const planContent = await readFile6(planPath, "utf-8");
|
|
2292
2569
|
const parsed = parsePlan(planContent);
|
|
2293
2570
|
plan = {
|
|
2294
2571
|
status: parsed.status,
|
|
@@ -2297,9 +2574,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
2297
2574
|
};
|
|
2298
2575
|
}
|
|
2299
2576
|
let scratchpad = null;
|
|
2300
|
-
const scratchpadPath =
|
|
2577
|
+
const scratchpadPath = resolve7(assignmentDir, "scratchpad.md");
|
|
2301
2578
|
if (await fileExists(scratchpadPath)) {
|
|
2302
|
-
const scratchpadContent = await
|
|
2579
|
+
const scratchpadContent = await readFile6(scratchpadPath, "utf-8");
|
|
2303
2580
|
const parsed = parseScratchpad(scratchpadContent);
|
|
2304
2581
|
scratchpad = {
|
|
2305
2582
|
updated: parsed.updated,
|
|
@@ -2307,9 +2584,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
2307
2584
|
};
|
|
2308
2585
|
}
|
|
2309
2586
|
let handoff = null;
|
|
2310
|
-
const handoffPath =
|
|
2587
|
+
const handoffPath = resolve7(assignmentDir, "handoff.md");
|
|
2311
2588
|
if (await fileExists(handoffPath)) {
|
|
2312
|
-
const handoffContent = await
|
|
2589
|
+
const handoffContent = await readFile6(handoffPath, "utf-8");
|
|
2313
2590
|
const parsed = parseHandoff(handoffContent);
|
|
2314
2591
|
handoff = {
|
|
2315
2592
|
updated: parsed.updated,
|
|
@@ -2318,9 +2595,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
2318
2595
|
};
|
|
2319
2596
|
}
|
|
2320
2597
|
let decisionRecord = null;
|
|
2321
|
-
const decisionRecordPath =
|
|
2598
|
+
const decisionRecordPath = resolve7(assignmentDir, "decision-record.md");
|
|
2322
2599
|
if (await fileExists(decisionRecordPath)) {
|
|
2323
|
-
const decisionRecordContent = await
|
|
2600
|
+
const decisionRecordContent = await readFile6(decisionRecordPath, "utf-8");
|
|
2324
2601
|
const parsed = parseDecisionRecord(decisionRecordContent);
|
|
2325
2602
|
decisionRecord = {
|
|
2326
2603
|
updated: parsed.updated,
|
|
@@ -2328,6 +2605,28 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
2328
2605
|
body: parsed.body
|
|
2329
2606
|
};
|
|
2330
2607
|
}
|
|
2608
|
+
let progress = null;
|
|
2609
|
+
const progressPath = resolve7(assignmentDir, "progress.md");
|
|
2610
|
+
if (await fileExists(progressPath)) {
|
|
2611
|
+
const progressContent = await readFile6(progressPath, "utf-8");
|
|
2612
|
+
const parsed = parseProgress(progressContent);
|
|
2613
|
+
progress = {
|
|
2614
|
+
updated: parsed.updated,
|
|
2615
|
+
entryCount: parsed.entryCount,
|
|
2616
|
+
entries: parsed.entries
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
let comments = null;
|
|
2620
|
+
const commentsPath = resolve7(assignmentDir, "comments.md");
|
|
2621
|
+
if (await fileExists(commentsPath)) {
|
|
2622
|
+
const commentsContent = await readFile6(commentsPath, "utf-8");
|
|
2623
|
+
const parsed = parseComments(commentsContent);
|
|
2624
|
+
comments = {
|
|
2625
|
+
updated: parsed.updated,
|
|
2626
|
+
entryCount: parsed.entryCount,
|
|
2627
|
+
entries: parsed.entries
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2331
2630
|
const detail = {
|
|
2332
2631
|
id: assignment.id,
|
|
2333
2632
|
projectSlug,
|
|
@@ -2351,6 +2650,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
2351
2650
|
scratchpad,
|
|
2352
2651
|
handoff,
|
|
2353
2652
|
decisionRecord,
|
|
2653
|
+
progress,
|
|
2654
|
+
comments,
|
|
2655
|
+
referencedBy: [],
|
|
2354
2656
|
availableTransitions: await getAvailableTransitions(
|
|
2355
2657
|
projectsDir,
|
|
2356
2658
|
projectSlug,
|
|
@@ -2414,25 +2716,212 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
|
|
|
2414
2716
|
});
|
|
2415
2717
|
}
|
|
2416
2718
|
detail.enrichedLinks = enrichedLinks;
|
|
2719
|
+
detail.referencedBy = await computeReferencedBy(
|
|
2720
|
+
{ id: assignment.id, projectSlug, slug: detail.slug },
|
|
2721
|
+
projectsDir,
|
|
2722
|
+
void 0
|
|
2723
|
+
);
|
|
2724
|
+
return detail;
|
|
2725
|
+
}
|
|
2726
|
+
async function computeReferencedBy(target, projectsDir, assignmentsDir) {
|
|
2727
|
+
const sources = [];
|
|
2728
|
+
const projectRecords = await listProjectRecords(projectsDir);
|
|
2729
|
+
for (const rec of projectRecords) {
|
|
2730
|
+
for (const a of rec.assignments) {
|
|
2731
|
+
sources.push({
|
|
2732
|
+
id: a.id,
|
|
2733
|
+
slug: a.slug,
|
|
2734
|
+
title: a.title,
|
|
2735
|
+
projectSlug: rec.summary.slug,
|
|
2736
|
+
assignmentDir: resolve7(rec.projectPath, "assignments", a.slug)
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir);
|
|
2741
|
+
for (const sr of standaloneRecords) {
|
|
2742
|
+
sources.push({
|
|
2743
|
+
id: sr.id,
|
|
2744
|
+
slug: sr.record.slug || sr.id,
|
|
2745
|
+
title: sr.record.title,
|
|
2746
|
+
projectSlug: null,
|
|
2747
|
+
assignmentDir: sr.assignmentDir
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
const references = [];
|
|
2751
|
+
for (const source of sources) {
|
|
2752
|
+
if (source.id === target.id) continue;
|
|
2753
|
+
const mentions = await countMentionsInAssignment(source.assignmentDir, target);
|
|
2754
|
+
if (mentions > 0) {
|
|
2755
|
+
references.push({
|
|
2756
|
+
sourceId: source.id,
|
|
2757
|
+
sourceSlug: source.slug,
|
|
2758
|
+
sourceTitle: source.title,
|
|
2759
|
+
sourceProjectSlug: source.projectSlug,
|
|
2760
|
+
mentions
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
if (references.length >= REFERENCED_BY_LIMIT) break;
|
|
2764
|
+
}
|
|
2765
|
+
return references.slice(0, REFERENCED_BY_LIMIT);
|
|
2766
|
+
}
|
|
2767
|
+
async function countMentionsInAssignment(sourceDir, target) {
|
|
2768
|
+
const bodies = [];
|
|
2769
|
+
const assignmentMd = resolve7(sourceDir, "assignment.md");
|
|
2770
|
+
if (await fileExists(assignmentMd)) {
|
|
2771
|
+
const content = await readFile6(assignmentMd, "utf-8");
|
|
2772
|
+
const todosMatch = content.match(/^## Todos\s*$([\s\S]*?)(?=^## |$(?![\r\n]))/m);
|
|
2773
|
+
if (todosMatch) bodies.push(todosMatch[1]);
|
|
2774
|
+
}
|
|
2775
|
+
for (const filename of ["progress.md", "comments.md", "handoff.md"]) {
|
|
2776
|
+
const path = resolve7(sourceDir, filename);
|
|
2777
|
+
if (await fileExists(path)) {
|
|
2778
|
+
try {
|
|
2779
|
+
bodies.push(await readFile6(path, "utf-8"));
|
|
2780
|
+
} catch {
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
let total = 0;
|
|
2785
|
+
const patterns = buildLinkPatternsForTarget(target);
|
|
2786
|
+
for (const body of bodies) {
|
|
2787
|
+
for (const pattern of patterns) {
|
|
2788
|
+
const matches = body.match(pattern);
|
|
2789
|
+
if (matches) total += matches.length;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
return total;
|
|
2793
|
+
}
|
|
2794
|
+
function buildLinkPatternsForTarget(target) {
|
|
2795
|
+
const patterns = [];
|
|
2796
|
+
patterns.push(new RegExp(`/assignments/${escapeRegExpLocal(target.id)}(?:/|\\b)`, "g"));
|
|
2797
|
+
if (target.projectSlug) {
|
|
2798
|
+
patterns.push(
|
|
2799
|
+
new RegExp(
|
|
2800
|
+
`/projects/${escapeRegExpLocal(target.projectSlug)}/assignments/${escapeRegExpLocal(target.slug)}(?:/|\\b)`,
|
|
2801
|
+
"g"
|
|
2802
|
+
)
|
|
2803
|
+
);
|
|
2804
|
+
patterns.push(
|
|
2805
|
+
new RegExp(`\\.\\./${escapeRegExpLocal(target.slug)}(?:/|\\b)`, "g")
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
return patterns;
|
|
2809
|
+
}
|
|
2810
|
+
function escapeRegExpLocal(value) {
|
|
2811
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2812
|
+
}
|
|
2813
|
+
async function getAssignmentDetailById(projectsDir, assignmentsDir, id) {
|
|
2814
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
2815
|
+
if (!resolved) return null;
|
|
2816
|
+
if (!resolved.standalone && resolved.projectSlug) {
|
|
2817
|
+
const detail = await getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
|
|
2818
|
+
if (!detail) return null;
|
|
2819
|
+
detail.referencedBy = await computeReferencedBy(
|
|
2820
|
+
{ id: detail.id, projectSlug: detail.projectSlug, slug: detail.slug },
|
|
2821
|
+
projectsDir,
|
|
2822
|
+
assignmentsDir
|
|
2823
|
+
);
|
|
2824
|
+
return detail;
|
|
2825
|
+
}
|
|
2826
|
+
const standaloneDetail = await buildStandaloneAssignmentDetail(resolved);
|
|
2827
|
+
if (!standaloneDetail) return null;
|
|
2828
|
+
standaloneDetail.referencedBy = await computeReferencedBy(
|
|
2829
|
+
{ id: standaloneDetail.id, projectSlug: null, slug: standaloneDetail.slug },
|
|
2830
|
+
projectsDir,
|
|
2831
|
+
assignmentsDir
|
|
2832
|
+
);
|
|
2833
|
+
return standaloneDetail;
|
|
2834
|
+
}
|
|
2835
|
+
async function buildStandaloneAssignmentDetail(resolved) {
|
|
2836
|
+
const assignmentDir = resolved.assignmentDir;
|
|
2837
|
+
const assignmentMdPath = resolve7(assignmentDir, "assignment.md");
|
|
2838
|
+
if (!await fileExists(assignmentMdPath)) return null;
|
|
2839
|
+
const assignmentContent = await readFile6(assignmentMdPath, "utf-8");
|
|
2840
|
+
const assignment = parseAssignmentFull(assignmentContent);
|
|
2841
|
+
let plan = null;
|
|
2842
|
+
const planPath = resolve7(assignmentDir, "plan.md");
|
|
2843
|
+
if (await fileExists(planPath)) {
|
|
2844
|
+
const parsed = parsePlan(await readFile6(planPath, "utf-8"));
|
|
2845
|
+
plan = { status: parsed.status, updated: parsed.updated, body: parsed.body };
|
|
2846
|
+
}
|
|
2847
|
+
let scratchpad = null;
|
|
2848
|
+
const scratchpadPath = resolve7(assignmentDir, "scratchpad.md");
|
|
2849
|
+
if (await fileExists(scratchpadPath)) {
|
|
2850
|
+
const parsed = parseScratchpad(await readFile6(scratchpadPath, "utf-8"));
|
|
2851
|
+
scratchpad = { updated: parsed.updated, body: parsed.body };
|
|
2852
|
+
}
|
|
2853
|
+
let handoff = null;
|
|
2854
|
+
const handoffPath = resolve7(assignmentDir, "handoff.md");
|
|
2855
|
+
if (await fileExists(handoffPath)) {
|
|
2856
|
+
const parsed = parseHandoff(await readFile6(handoffPath, "utf-8"));
|
|
2857
|
+
handoff = { updated: parsed.updated, handoffCount: parsed.handoffCount, body: parsed.body };
|
|
2858
|
+
}
|
|
2859
|
+
let decisionRecord = null;
|
|
2860
|
+
const decisionRecordPath = resolve7(assignmentDir, "decision-record.md");
|
|
2861
|
+
if (await fileExists(decisionRecordPath)) {
|
|
2862
|
+
const parsed = parseDecisionRecord(await readFile6(decisionRecordPath, "utf-8"));
|
|
2863
|
+
decisionRecord = { updated: parsed.updated, decisionCount: parsed.decisionCount, body: parsed.body };
|
|
2864
|
+
}
|
|
2865
|
+
let progress = null;
|
|
2866
|
+
const progressPath = resolve7(assignmentDir, "progress.md");
|
|
2867
|
+
if (await fileExists(progressPath)) {
|
|
2868
|
+
const parsed = parseProgress(await readFile6(progressPath, "utf-8"));
|
|
2869
|
+
progress = { updated: parsed.updated, entryCount: parsed.entryCount, entries: parsed.entries };
|
|
2870
|
+
}
|
|
2871
|
+
let comments = null;
|
|
2872
|
+
const commentsPath = resolve7(assignmentDir, "comments.md");
|
|
2873
|
+
if (await fileExists(commentsPath)) {
|
|
2874
|
+
const parsed = parseComments(await readFile6(commentsPath, "utf-8"));
|
|
2875
|
+
comments = { updated: parsed.updated, entryCount: parsed.entryCount, entries: parsed.entries };
|
|
2876
|
+
}
|
|
2877
|
+
const detail = {
|
|
2878
|
+
id: assignment.id,
|
|
2879
|
+
projectSlug: null,
|
|
2880
|
+
slug: assignment.slug || resolved.id,
|
|
2881
|
+
title: assignment.title,
|
|
2882
|
+
status: assignment.status,
|
|
2883
|
+
priority: assignment.priority,
|
|
2884
|
+
assignee: assignment.assignee,
|
|
2885
|
+
dependsOn: [],
|
|
2886
|
+
// standalone cannot declare dependencies
|
|
2887
|
+
links: [],
|
|
2888
|
+
reverseLinks: [],
|
|
2889
|
+
enrichedLinks: [],
|
|
2890
|
+
blockedReason: assignment.blockedReason,
|
|
2891
|
+
workspace: assignment.workspace,
|
|
2892
|
+
externalIds: assignment.externalIds,
|
|
2893
|
+
tags: assignment.tags,
|
|
2894
|
+
created: assignment.created,
|
|
2895
|
+
updated: assignment.updated,
|
|
2896
|
+
body: assignment.body,
|
|
2897
|
+
plan,
|
|
2898
|
+
scratchpad,
|
|
2899
|
+
handoff,
|
|
2900
|
+
decisionRecord,
|
|
2901
|
+
progress,
|
|
2902
|
+
comments,
|
|
2903
|
+
referencedBy: [],
|
|
2904
|
+
availableTransitions: await getStandaloneAvailableTransitions(assignment)
|
|
2905
|
+
};
|
|
2417
2906
|
return detail;
|
|
2418
2907
|
}
|
|
2419
2908
|
async function listProjectRecords(projectsDir) {
|
|
2420
2909
|
if (!await fileExists(projectsDir)) {
|
|
2421
2910
|
return [];
|
|
2422
2911
|
}
|
|
2423
|
-
const entries = await
|
|
2912
|
+
const entries = await readdir4(projectsDir, { withFileTypes: true });
|
|
2424
2913
|
const projectDirs = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
|
|
2425
2914
|
const records = [];
|
|
2426
2915
|
for (const entry of projectDirs) {
|
|
2427
|
-
const projectPath =
|
|
2428
|
-
const projectMdPath =
|
|
2916
|
+
const projectPath = resolve7(projectsDir, entry.name);
|
|
2917
|
+
const projectMdPath = resolve7(projectPath, "project.md");
|
|
2429
2918
|
if (!await fileExists(projectMdPath)) {
|
|
2430
2919
|
continue;
|
|
2431
2920
|
}
|
|
2432
|
-
const projectContent = await
|
|
2921
|
+
const projectContent = await readFile6(projectMdPath, "utf-8");
|
|
2433
2922
|
const project = parseProject(projectContent);
|
|
2434
2923
|
const assignments = await listAssignmentRecords(projectPath);
|
|
2435
|
-
const rollup = buildProjectRollup(project, assignments);
|
|
2924
|
+
const rollup = await buildProjectRollup(projectPath, project, assignments);
|
|
2436
2925
|
const updated = getProjectActivityTimestamp(project.updated, assignments);
|
|
2437
2926
|
records.push({
|
|
2438
2927
|
projectPath,
|
|
@@ -2460,39 +2949,39 @@ async function listProjectRecords(projectsDir) {
|
|
|
2460
2949
|
return records;
|
|
2461
2950
|
}
|
|
2462
2951
|
async function listAssignmentRecords(projectPath) {
|
|
2463
|
-
const assignmentsDir =
|
|
2952
|
+
const assignmentsDir = resolve7(projectPath, "assignments");
|
|
2464
2953
|
if (!await fileExists(assignmentsDir)) {
|
|
2465
2954
|
return [];
|
|
2466
2955
|
}
|
|
2467
|
-
const entries = await
|
|
2956
|
+
const entries = await readdir4(assignmentsDir, { withFileTypes: true });
|
|
2468
2957
|
const records = [];
|
|
2469
2958
|
for (const entry of entries) {
|
|
2470
2959
|
if (!entry.isDirectory()) {
|
|
2471
2960
|
continue;
|
|
2472
2961
|
}
|
|
2473
|
-
const assignmentMd =
|
|
2962
|
+
const assignmentMd = resolve7(assignmentsDir, entry.name, "assignment.md");
|
|
2474
2963
|
if (!await fileExists(assignmentMd)) {
|
|
2475
2964
|
continue;
|
|
2476
2965
|
}
|
|
2477
|
-
const content = await
|
|
2966
|
+
const content = await readFile6(assignmentMd, "utf-8");
|
|
2478
2967
|
records.push(parseAssignmentFull(content));
|
|
2479
2968
|
}
|
|
2480
2969
|
records.sort((left, right) => compareTimestamps(right.updated, left.updated));
|
|
2481
2970
|
return records;
|
|
2482
2971
|
}
|
|
2483
2972
|
async function listResources(projectPath) {
|
|
2484
|
-
const resourcesDir =
|
|
2973
|
+
const resourcesDir = resolve7(projectPath, "resources");
|
|
2485
2974
|
if (!await fileExists(resourcesDir)) {
|
|
2486
2975
|
return [];
|
|
2487
2976
|
}
|
|
2488
|
-
const entries = await
|
|
2977
|
+
const entries = await readdir4(resourcesDir, { withFileTypes: true });
|
|
2489
2978
|
const results = [];
|
|
2490
2979
|
for (const entry of entries) {
|
|
2491
2980
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_")) {
|
|
2492
2981
|
continue;
|
|
2493
2982
|
}
|
|
2494
|
-
const filePath =
|
|
2495
|
-
const content = await
|
|
2983
|
+
const filePath = resolve7(resourcesDir, entry.name);
|
|
2984
|
+
const content = await readFile6(filePath, "utf-8");
|
|
2496
2985
|
const parsed = parseResource(content);
|
|
2497
2986
|
results.push({
|
|
2498
2987
|
name: parsed.name,
|
|
@@ -2507,18 +2996,18 @@ async function listResources(projectPath) {
|
|
|
2507
2996
|
return results;
|
|
2508
2997
|
}
|
|
2509
2998
|
async function listMemories(projectPath) {
|
|
2510
|
-
const memoriesDir =
|
|
2999
|
+
const memoriesDir = resolve7(projectPath, "memories");
|
|
2511
3000
|
if (!await fileExists(memoriesDir)) {
|
|
2512
3001
|
return [];
|
|
2513
3002
|
}
|
|
2514
|
-
const entries = await
|
|
3003
|
+
const entries = await readdir4(memoriesDir, { withFileTypes: true });
|
|
2515
3004
|
const results = [];
|
|
2516
3005
|
for (const entry of entries) {
|
|
2517
3006
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_")) {
|
|
2518
3007
|
continue;
|
|
2519
3008
|
}
|
|
2520
|
-
const filePath =
|
|
2521
|
-
const content = await
|
|
3009
|
+
const filePath = resolve7(memoriesDir, entry.name);
|
|
3010
|
+
const content = await readFile6(filePath, "utf-8");
|
|
2522
3011
|
const parsed = parseMemory(content);
|
|
2523
3012
|
results.push({
|
|
2524
3013
|
name: parsed.name,
|
|
@@ -2533,9 +3022,9 @@ async function listMemories(projectPath) {
|
|
|
2533
3022
|
return results;
|
|
2534
3023
|
}
|
|
2535
3024
|
async function loadDependencyGraph(projectPath, assignments) {
|
|
2536
|
-
const statusPath =
|
|
3025
|
+
const statusPath = resolve7(projectPath, "_status.md");
|
|
2537
3026
|
if (await fileExists(statusPath)) {
|
|
2538
|
-
const statusContent = await
|
|
3027
|
+
const statusContent = await readFile6(statusPath, "utf-8");
|
|
2539
3028
|
const parsed = parseStatus(statusContent);
|
|
2540
3029
|
const derivedGraph = extractMermaidGraph(parsed.body);
|
|
2541
3030
|
if (derivedGraph) {
|
|
@@ -2544,13 +3033,13 @@ async function loadDependencyGraph(projectPath, assignments) {
|
|
|
2544
3033
|
}
|
|
2545
3034
|
return buildDependencyGraph(assignments);
|
|
2546
3035
|
}
|
|
2547
|
-
function buildProjectRollup(project, assignments) {
|
|
3036
|
+
async function buildProjectRollup(projectPath, project, assignments) {
|
|
2548
3037
|
const progress = { total: assignments.length };
|
|
2549
3038
|
let openQuestions = 0;
|
|
2550
3039
|
for (const assignment of assignments) {
|
|
2551
3040
|
const s = assignment.status;
|
|
2552
3041
|
progress[s] = (progress[s] ?? 0) + 1;
|
|
2553
|
-
openQuestions +=
|
|
3042
|
+
openQuestions += await countOpenQuestions(projectPath, assignment.slug);
|
|
2554
3043
|
}
|
|
2555
3044
|
const needsAttention = {
|
|
2556
3045
|
blockedCount: progress["blocked"] ?? 0,
|
|
@@ -2635,7 +3124,7 @@ async function getAvailableTransitions(projectsDir, projectSlug, assignmentSlug,
|
|
|
2635
3124
|
const config = await getStatusConfig();
|
|
2636
3125
|
const transitionDefs = getTransitionDefinitions(config);
|
|
2637
3126
|
const actions = [];
|
|
2638
|
-
const projectPath =
|
|
3127
|
+
const projectPath = resolve7(projectsDir, projectSlug);
|
|
2639
3128
|
for (const definition of transitionDefs) {
|
|
2640
3129
|
let warning = null;
|
|
2641
3130
|
if (definition.command === "start" && !assignment.assignee) {
|
|
@@ -2665,12 +3154,12 @@ async function getUnmetDependencies(projectPath, dependsOn, terminalStatuses) {
|
|
|
2665
3154
|
const terminals = terminalStatuses ?? /* @__PURE__ */ new Set(["completed"]);
|
|
2666
3155
|
const unmet = [];
|
|
2667
3156
|
for (const dependency of dependsOn) {
|
|
2668
|
-
const dependencyPath =
|
|
3157
|
+
const dependencyPath = resolve7(projectPath, "assignments", dependency, "assignment.md");
|
|
2669
3158
|
if (!await fileExists(dependencyPath)) {
|
|
2670
3159
|
unmet.push(`${dependency} (missing)`);
|
|
2671
3160
|
continue;
|
|
2672
3161
|
}
|
|
2673
|
-
const content = await
|
|
3162
|
+
const content = await readFile6(dependencyPath, "utf-8");
|
|
2674
3163
|
const parsed = parseAssignmentFull(content);
|
|
2675
3164
|
if (!terminals.has(parsed.status)) {
|
|
2676
3165
|
unmet.push(`${dependency} (${parsed.status})`);
|
|
@@ -2678,7 +3167,7 @@ async function getUnmetDependencies(projectPath, dependsOn, terminalStatuses) {
|
|
|
2678
3167
|
}
|
|
2679
3168
|
return unmet;
|
|
2680
3169
|
}
|
|
2681
|
-
function buildAttentionItems(projectRecords) {
|
|
3170
|
+
function buildAttentionItems(projectRecords, standaloneRecords = []) {
|
|
2682
3171
|
const items = [];
|
|
2683
3172
|
for (const record of projectRecords) {
|
|
2684
3173
|
for (const assignment of record.assignments) {
|
|
@@ -2728,9 +3217,36 @@ function buildAttentionItems(projectRecords) {
|
|
|
2728
3217
|
}
|
|
2729
3218
|
}
|
|
2730
3219
|
}
|
|
3220
|
+
for (const sr of standaloneRecords) {
|
|
3221
|
+
const assignment = sr.record;
|
|
3222
|
+
const stale = isStale(assignment.updated);
|
|
3223
|
+
const base = {
|
|
3224
|
+
projectSlug: null,
|
|
3225
|
+
projectTitle: null,
|
|
3226
|
+
assignmentSlug: assignment.slug || sr.id,
|
|
3227
|
+
assignmentTitle: assignment.title,
|
|
3228
|
+
status: assignment.status,
|
|
3229
|
+
updated: assignment.updated,
|
|
3230
|
+
href: `/assignments/${sr.id}`,
|
|
3231
|
+
blockedReason: assignment.blockedReason,
|
|
3232
|
+
stale
|
|
3233
|
+
};
|
|
3234
|
+
if (assignment.status === "failed") {
|
|
3235
|
+
items.push({ id: `standalone:${sr.id}:failed`, severity: "critical", reason: "Marked failed and needs a recovery decision.", ...base });
|
|
3236
|
+
}
|
|
3237
|
+
if (assignment.status === "blocked") {
|
|
3238
|
+
items.push({ id: `standalone:${sr.id}:blocked`, severity: "high", reason: assignment.blockedReason || "Blocked and waiting for intervention.", ...base });
|
|
3239
|
+
}
|
|
3240
|
+
if (assignment.status === "review") {
|
|
3241
|
+
items.push({ id: `standalone:${sr.id}:review`, severity: "medium", reason: "Ready for review.", ...base });
|
|
3242
|
+
}
|
|
3243
|
+
if (stale) {
|
|
3244
|
+
items.push({ id: `standalone:${sr.id}:stale`, severity: "low", reason: "No source updates have been recorded in the last 7 days.", ...base });
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
2731
3247
|
return items.sort(compareAttentionItems);
|
|
2732
3248
|
}
|
|
2733
|
-
function buildRecentActivity(projectRecords) {
|
|
3249
|
+
function buildRecentActivity(projectRecords, standaloneRecords = []) {
|
|
2734
3250
|
const activity = [];
|
|
2735
3251
|
for (const record of projectRecords) {
|
|
2736
3252
|
activity.push({
|
|
@@ -2758,6 +3274,20 @@ function buildRecentActivity(projectRecords) {
|
|
|
2758
3274
|
});
|
|
2759
3275
|
}
|
|
2760
3276
|
}
|
|
3277
|
+
for (const sr of standaloneRecords) {
|
|
3278
|
+
const assignment = sr.record;
|
|
3279
|
+
activity.push({
|
|
3280
|
+
id: `standalone-assignment:${sr.id}`,
|
|
3281
|
+
type: "assignment",
|
|
3282
|
+
title: assignment.title,
|
|
3283
|
+
updated: assignment.updated,
|
|
3284
|
+
href: `/assignments/${sr.id}`,
|
|
3285
|
+
projectSlug: null,
|
|
3286
|
+
projectTitle: null,
|
|
3287
|
+
assignmentSlug: assignment.slug || sr.id,
|
|
3288
|
+
summary: `Standalone assignment is ${assignment.status} with ${assignment.priority} priority.`
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
2761
3291
|
activity.sort((left, right) => compareTimestamps(right.updated, left.updated));
|
|
2762
3292
|
return activity;
|
|
2763
3293
|
}
|
|
@@ -2783,9 +3313,25 @@ function isStale(updated) {
|
|
|
2783
3313
|
}
|
|
2784
3314
|
return Date.now() - timestamp > STALE_ASSIGNMENT_MS;
|
|
2785
3315
|
}
|
|
2786
|
-
function
|
|
2787
|
-
const
|
|
2788
|
-
|
|
3316
|
+
async function countOpenQuestions(projectPath, assignmentSlug) {
|
|
3317
|
+
const commentsPath = resolve7(
|
|
3318
|
+
projectPath,
|
|
3319
|
+
"assignments",
|
|
3320
|
+
assignmentSlug,
|
|
3321
|
+
"comments.md"
|
|
3322
|
+
);
|
|
3323
|
+
if (!await fileExists(commentsPath)) {
|
|
3324
|
+
return 0;
|
|
3325
|
+
}
|
|
3326
|
+
try {
|
|
3327
|
+
const content = await readFile6(commentsPath, "utf-8");
|
|
3328
|
+
const parsed = parseComments(content);
|
|
3329
|
+
return parsed.entries.filter(
|
|
3330
|
+
(e) => e.type === "question" && e.resolved !== true
|
|
3331
|
+
).length;
|
|
3332
|
+
} catch {
|
|
3333
|
+
return 0;
|
|
3334
|
+
}
|
|
2789
3335
|
}
|
|
2790
3336
|
function getProjectActivityTimestamp(projectUpdated, assignments) {
|
|
2791
3337
|
let latest = projectUpdated;
|
|
@@ -2799,17 +3345,17 @@ function getProjectActivityTimestamp(projectUpdated, assignments) {
|
|
|
2799
3345
|
function getDocumentPath(projectsDir, documentType, projectSlug, assignmentSlug) {
|
|
2800
3346
|
switch (documentType) {
|
|
2801
3347
|
case "project":
|
|
2802
|
-
return
|
|
3348
|
+
return resolve7(projectsDir, projectSlug, "project.md");
|
|
2803
3349
|
case "assignment":
|
|
2804
|
-
return assignmentSlug ?
|
|
3350
|
+
return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
|
|
2805
3351
|
case "plan":
|
|
2806
|
-
return assignmentSlug ?
|
|
3352
|
+
return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
|
|
2807
3353
|
case "scratchpad":
|
|
2808
|
-
return assignmentSlug ?
|
|
3354
|
+
return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
|
|
2809
3355
|
case "handoff":
|
|
2810
|
-
return assignmentSlug ?
|
|
3356
|
+
return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
|
|
2811
3357
|
case "decision-record":
|
|
2812
|
-
return assignmentSlug ?
|
|
3358
|
+
return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "decision-record.md") : null;
|
|
2813
3359
|
default:
|
|
2814
3360
|
return null;
|
|
2815
3361
|
}
|
|
@@ -2836,12 +3382,12 @@ function getEditableDocumentTitle(documentType, projectSlug, assignmentSlug) {
|
|
|
2836
3382
|
}
|
|
2837
3383
|
async function listPlaybooks(playbooksDir2) {
|
|
2838
3384
|
if (!await fileExists(playbooksDir2)) return [];
|
|
2839
|
-
const entries = await
|
|
3385
|
+
const entries = await readdir4(playbooksDir2, { withFileTypes: true });
|
|
2840
3386
|
const playbooks = [];
|
|
2841
3387
|
for (const entry of entries) {
|
|
2842
3388
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_") || entry.name === "manifest.md") continue;
|
|
2843
|
-
const filePath =
|
|
2844
|
-
const raw = await
|
|
3389
|
+
const filePath = resolve7(playbooksDir2, entry.name);
|
|
3390
|
+
const raw = await readFile6(filePath, "utf-8");
|
|
2845
3391
|
const parsed = parsePlaybook(raw);
|
|
2846
3392
|
const slug = parsed.slug || entry.name.replace(/\.md$/, "");
|
|
2847
3393
|
playbooks.push({
|
|
@@ -2857,9 +3403,9 @@ async function listPlaybooks(playbooksDir2) {
|
|
|
2857
3403
|
return playbooks.sort((a, b) => (b.updated || b.created).localeCompare(a.updated || a.created));
|
|
2858
3404
|
}
|
|
2859
3405
|
async function getPlaybookDetail(playbooksDir2, slug) {
|
|
2860
|
-
const filePath =
|
|
3406
|
+
const filePath = resolve7(playbooksDir2, `${slug}.md`);
|
|
2861
3407
|
if (!await fileExists(filePath)) return null;
|
|
2862
|
-
const raw = await
|
|
3408
|
+
const raw = await readFile6(filePath, "utf-8");
|
|
2863
3409
|
const parsed = parsePlaybook(raw);
|
|
2864
3410
|
return {
|
|
2865
3411
|
slug: parsed.slug || slug,
|
|
@@ -2872,13 +3418,14 @@ async function getPlaybookDetail(playbooksDir2, slug) {
|
|
|
2872
3418
|
body: parsed.body
|
|
2873
3419
|
};
|
|
2874
3420
|
}
|
|
2875
|
-
var STALE_ASSIGNMENT_MS, ATTENTION_PAGE_LIMIT, OVERVIEW_ATTENTION_LIMIT, RECENT_PROJECTS_LIMIT, RECENT_ACTIVITY_LIMIT, DEFAULT_TRANSITION_DEFINITIONS, DEFAULT_STATUS_COLORS, _cachedConfig, DEFAULT_GRAPH_COLORS;
|
|
3421
|
+
var STALE_ASSIGNMENT_MS, ATTENTION_PAGE_LIMIT, OVERVIEW_ATTENTION_LIMIT, RECENT_PROJECTS_LIMIT, RECENT_ACTIVITY_LIMIT, DEFAULT_TRANSITION_DEFINITIONS, DEFAULT_STATUS_COLORS, _cachedConfig, REFERENCED_BY_LIMIT, DEFAULT_GRAPH_COLORS;
|
|
2876
3422
|
var init_api = __esm({
|
|
2877
3423
|
"src/dashboard/api.ts"() {
|
|
2878
3424
|
"use strict";
|
|
2879
3425
|
init_lifecycle();
|
|
2880
3426
|
init_fs();
|
|
2881
3427
|
init_config2();
|
|
3428
|
+
init_assignment_resolver();
|
|
2882
3429
|
init_parser();
|
|
2883
3430
|
init_help();
|
|
2884
3431
|
STALE_ASSIGNMENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
@@ -2939,6 +3486,7 @@ var init_api = __esm({
|
|
|
2939
3486
|
failed: "rose"
|
|
2940
3487
|
};
|
|
2941
3488
|
_cachedConfig = null;
|
|
3489
|
+
REFERENCED_BY_LIMIT = 50;
|
|
2942
3490
|
DEFAULT_GRAPH_COLORS = {
|
|
2943
3491
|
completed: "fill:#4ea84f,stroke:#1f6b29,color:#ffffff",
|
|
2944
3492
|
in_progress: "fill:#1e6fd9,stroke:#0f3f8f,color:#ffffff",
|
|
@@ -2971,8 +3519,8 @@ __export(parser_exports, {
|
|
|
2971
3519
|
writeChecklist: () => writeChecklist
|
|
2972
3520
|
});
|
|
2973
3521
|
import { randomBytes } from "crypto";
|
|
2974
|
-
import { readFile as
|
|
2975
|
-
import { resolve as
|
|
3522
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
3523
|
+
import { resolve as resolve14 } from "path";
|
|
2976
3524
|
function generateShortId() {
|
|
2977
3525
|
return randomBytes(2).toString("hex");
|
|
2978
3526
|
}
|
|
@@ -3132,10 +3680,10 @@ function serializeLogEntry(entry) {
|
|
|
3132
3680
|
return lines.join("\n");
|
|
3133
3681
|
}
|
|
3134
3682
|
function checklistPath(todosDir2, workspace) {
|
|
3135
|
-
return
|
|
3683
|
+
return resolve14(todosDir2, `${workspace}.md`);
|
|
3136
3684
|
}
|
|
3137
3685
|
function logPath(todosDir2, workspace) {
|
|
3138
|
-
return
|
|
3686
|
+
return resolve14(todosDir2, `${workspace}-log.md`);
|
|
3139
3687
|
}
|
|
3140
3688
|
function archivePath(todosDir2, workspace, interval, now = /* @__PURE__ */ new Date()) {
|
|
3141
3689
|
const year = now.getFullYear();
|
|
@@ -3159,14 +3707,14 @@ function archivePath(todosDir2, workspace, interval, now = /* @__PURE__ */ new D
|
|
|
3159
3707
|
default:
|
|
3160
3708
|
suffix = `${year}-${month}-${day}`;
|
|
3161
3709
|
}
|
|
3162
|
-
return
|
|
3710
|
+
return resolve14(todosDir2, "archive", `${workspace}-${suffix}.md`);
|
|
3163
3711
|
}
|
|
3164
3712
|
async function readChecklist(todosDir2, workspace) {
|
|
3165
3713
|
const path = checklistPath(todosDir2, workspace);
|
|
3166
3714
|
if (!await fileExists(path)) {
|
|
3167
3715
|
return { workspace, archiveInterval: "weekly", items: [] };
|
|
3168
3716
|
}
|
|
3169
|
-
const content = await
|
|
3717
|
+
const content = await readFile11(path, "utf-8");
|
|
3170
3718
|
return parseChecklist(content);
|
|
3171
3719
|
}
|
|
3172
3720
|
async function writeChecklist(todosDir2, checklist) {
|
|
@@ -3179,7 +3727,7 @@ async function readLog(todosDir2, workspace) {
|
|
|
3179
3727
|
if (!await fileExists(path)) {
|
|
3180
3728
|
return { workspace, entries: [] };
|
|
3181
3729
|
}
|
|
3182
|
-
const content = await
|
|
3730
|
+
const content = await readFile11(path, "utf-8");
|
|
3183
3731
|
return parseLog(content);
|
|
3184
3732
|
}
|
|
3185
3733
|
async function appendLogEntry2(todosDir2, workspace, entry) {
|
|
@@ -3187,7 +3735,7 @@ async function appendLogEntry2(todosDir2, workspace, entry) {
|
|
|
3187
3735
|
const path = logPath(todosDir2, workspace);
|
|
3188
3736
|
let content;
|
|
3189
3737
|
if (await fileExists(path)) {
|
|
3190
|
-
content = await
|
|
3738
|
+
content = await readFile11(path, "utf-8");
|
|
3191
3739
|
content = content.trimEnd() + "\n\n" + serializeLogEntry(entry) + "\n";
|
|
3192
3740
|
} else {
|
|
3193
3741
|
const fm = `---
|
|
@@ -3223,409 +3771,806 @@ var init_parser2 = __esm({
|
|
|
3223
3771
|
// src/dashboard/server.ts
|
|
3224
3772
|
init_paths();
|
|
3225
3773
|
init_api();
|
|
3774
|
+
init_assignment_resolver();
|
|
3226
3775
|
import express from "express";
|
|
3227
3776
|
import { createServer } from "http";
|
|
3228
|
-
import { resolve as
|
|
3777
|
+
import { resolve as resolve16 } from "path";
|
|
3229
3778
|
import { writeFile as writeFile4, unlink as unlink4 } from "fs/promises";
|
|
3230
3779
|
import { WebSocketServer, WebSocket } from "ws";
|
|
3231
3780
|
|
|
3232
|
-
// src/dashboard/
|
|
3233
|
-
|
|
3234
|
-
import {
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
);
|
|
3291
|
-
};
|
|
3292
|
-
var handleServerChange = handleServerChange2;
|
|
3293
|
-
serversWatcher = watch(serversDir2, {
|
|
3294
|
-
ignoreInitial: true,
|
|
3295
|
-
persistent: true,
|
|
3296
|
-
depth: 1,
|
|
3297
|
-
ignored: /(^|[\/\\])\../
|
|
3298
|
-
});
|
|
3299
|
-
serversWatcher.on("change", handleServerChange2);
|
|
3300
|
-
serversWatcher.on("add", handleServerChange2);
|
|
3301
|
-
serversWatcher.on("unlink", handleServerChange2);
|
|
3302
|
-
}
|
|
3303
|
-
let playbooksWatcher = null;
|
|
3304
|
-
if (playbooksDir2) {
|
|
3305
|
-
let handlePlaybookChange2 = function() {
|
|
3306
|
-
const debounceKey = "__playbooks__";
|
|
3307
|
-
const existing = pendingEvents.get(debounceKey);
|
|
3308
|
-
if (existing) clearTimeout(existing);
|
|
3309
|
-
pendingEvents.set(
|
|
3310
|
-
debounceKey,
|
|
3311
|
-
setTimeout(() => {
|
|
3312
|
-
pendingEvents.delete(debounceKey);
|
|
3313
|
-
const message = {
|
|
3314
|
-
type: "playbooks-updated",
|
|
3315
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3316
|
-
};
|
|
3317
|
-
onMessage(message);
|
|
3318
|
-
}, debounceMs)
|
|
3781
|
+
// src/dashboard/agent-sessions.ts
|
|
3782
|
+
init_fs();
|
|
3783
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
3784
|
+
import { resolve as resolve9 } from "path";
|
|
3785
|
+
|
|
3786
|
+
// src/dashboard/session-db.ts
|
|
3787
|
+
init_paths();
|
|
3788
|
+
init_fs();
|
|
3789
|
+
import Database from "better-sqlite3";
|
|
3790
|
+
import { resolve as resolve8 } from "path";
|
|
3791
|
+
import { readdir as readdir5 } from "fs/promises";
|
|
3792
|
+
var db = null;
|
|
3793
|
+
var SCHEMA_VERSION = "3";
|
|
3794
|
+
var SCHEMA_SQL = `
|
|
3795
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
3796
|
+
session_id TEXT PRIMARY KEY,
|
|
3797
|
+
project_slug TEXT,
|
|
3798
|
+
assignment_slug TEXT,
|
|
3799
|
+
agent TEXT NOT NULL,
|
|
3800
|
+
started TEXT NOT NULL,
|
|
3801
|
+
ended TEXT,
|
|
3802
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
3803
|
+
path TEXT,
|
|
3804
|
+
description TEXT,
|
|
3805
|
+
transcript_path TEXT,
|
|
3806
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
3807
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
3808
|
+
);
|
|
3809
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
3810
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
3811
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
3812
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
3813
|
+
`;
|
|
3814
|
+
function initSessionDb(dbPath) {
|
|
3815
|
+
if (db) return db;
|
|
3816
|
+
const finalPath = dbPath ?? resolve8(syntaurRoot(), "syntaur.db");
|
|
3817
|
+
db = new Database(finalPath);
|
|
3818
|
+
db.pragma("journal_mode = WAL");
|
|
3819
|
+
db.exec(SCHEMA_SQL);
|
|
3820
|
+
db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
3821
|
+
"schema_version",
|
|
3822
|
+
SCHEMA_VERSION
|
|
3823
|
+
);
|
|
3824
|
+
const currentVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
3825
|
+
if (currentVersion?.value === "1") {
|
|
3826
|
+
db.exec(`
|
|
3827
|
+
CREATE TABLE sessions_v2 (
|
|
3828
|
+
session_id TEXT PRIMARY KEY,
|
|
3829
|
+
project_slug TEXT,
|
|
3830
|
+
assignment_slug TEXT,
|
|
3831
|
+
agent TEXT NOT NULL,
|
|
3832
|
+
started TEXT NOT NULL,
|
|
3833
|
+
ended TEXT,
|
|
3834
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
3835
|
+
path TEXT,
|
|
3836
|
+
description TEXT,
|
|
3837
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
3838
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
3319
3839
|
);
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
playbooksWatcher.on("change", handlePlaybookChange2);
|
|
3329
|
-
playbooksWatcher.on("add", handlePlaybookChange2);
|
|
3330
|
-
playbooksWatcher.on("unlink", handlePlaybookChange2);
|
|
3840
|
+
INSERT INTO sessions_v2 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, NULL, created_at, updated_at FROM sessions;
|
|
3841
|
+
DROP TABLE sessions;
|
|
3842
|
+
ALTER TABLE sessions_v2 RENAME TO sessions;
|
|
3843
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
3844
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
3845
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
3846
|
+
UPDATE meta SET value = '2' WHERE key = 'schema_version';
|
|
3847
|
+
`);
|
|
3331
3848
|
}
|
|
3332
|
-
|
|
3333
|
-
if (
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3849
|
+
const versionAfterV1 = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
3850
|
+
if (versionAfterV1?.value === "2") {
|
|
3851
|
+
db.exec(`
|
|
3852
|
+
CREATE TABLE sessions_v3 (
|
|
3853
|
+
session_id TEXT PRIMARY KEY,
|
|
3854
|
+
project_slug TEXT,
|
|
3855
|
+
assignment_slug TEXT,
|
|
3856
|
+
agent TEXT NOT NULL,
|
|
3857
|
+
started TEXT NOT NULL,
|
|
3858
|
+
ended TEXT,
|
|
3859
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
3860
|
+
path TEXT,
|
|
3861
|
+
description TEXT,
|
|
3862
|
+
transcript_path TEXT,
|
|
3863
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
3864
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
3348
3865
|
);
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
todosWatcher.on("change", handleTodoChange2);
|
|
3358
|
-
todosWatcher.on("add", handleTodoChange2);
|
|
3359
|
-
todosWatcher.on("unlink", handleTodoChange2);
|
|
3866
|
+
INSERT INTO sessions_v3 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, description, NULL, created_at, updated_at FROM sessions;
|
|
3867
|
+
DROP TABLE sessions;
|
|
3868
|
+
ALTER TABLE sessions_v3 RENAME TO sessions;
|
|
3869
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
3870
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
3871
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
3872
|
+
UPDATE meta SET value = '3' WHERE key = 'schema_version';
|
|
3873
|
+
`);
|
|
3360
3874
|
}
|
|
3361
|
-
return
|
|
3362
|
-
close: async () => {
|
|
3363
|
-
pendingEvents.forEach((timeout) => {
|
|
3364
|
-
clearTimeout(timeout);
|
|
3365
|
-
});
|
|
3366
|
-
pendingEvents.clear();
|
|
3367
|
-
await projectsWatcher.close();
|
|
3368
|
-
if (serversWatcher) await serversWatcher.close();
|
|
3369
|
-
if (playbooksWatcher) await playbooksWatcher.close();
|
|
3370
|
-
if (todosWatcher) await todosWatcher.close();
|
|
3371
|
-
}
|
|
3372
|
-
};
|
|
3373
|
-
}
|
|
3374
|
-
|
|
3375
|
-
// src/dashboard/server.ts
|
|
3376
|
-
init_fs();
|
|
3377
|
-
init_config2();
|
|
3378
|
-
|
|
3379
|
-
// src/dashboard/api-write.ts
|
|
3380
|
-
init_lifecycle();
|
|
3381
|
-
import { Router } from "express";
|
|
3382
|
-
import { resolve as resolve7 } from "path";
|
|
3383
|
-
import { rm, readFile as readFile6 } from "fs/promises";
|
|
3384
|
-
|
|
3385
|
-
// src/utils/slug.ts
|
|
3386
|
-
function isValidSlug(slug) {
|
|
3387
|
-
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug);
|
|
3388
|
-
}
|
|
3389
|
-
|
|
3390
|
-
// src/utils/uuid.ts
|
|
3391
|
-
import { randomUUID } from "crypto";
|
|
3392
|
-
function generateId() {
|
|
3393
|
-
return randomUUID();
|
|
3875
|
+
return db;
|
|
3394
3876
|
}
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
// src/dashboard/acceptance-criteria.ts
|
|
3402
|
-
function splitFrontmatter(content) {
|
|
3403
|
-
const match = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/);
|
|
3404
|
-
if (!match) {
|
|
3405
|
-
return { prefix: "", body: content };
|
|
3877
|
+
function getSessionDb() {
|
|
3878
|
+
if (!db) {
|
|
3879
|
+
throw new Error(
|
|
3880
|
+
"Session database not initialized. Call initSessionDb() first."
|
|
3881
|
+
);
|
|
3406
3882
|
}
|
|
3407
|
-
return
|
|
3408
|
-
prefix: match[1],
|
|
3409
|
-
body: match[2]
|
|
3410
|
-
};
|
|
3883
|
+
return db;
|
|
3411
3884
|
}
|
|
3412
|
-
function
|
|
3413
|
-
if (
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
const { prefix, body } = splitFrontmatter(content);
|
|
3417
|
-
const lines = body.split("\n");
|
|
3418
|
-
const sectionStart = lines.findIndex((line) => /^##\s+Acceptance Criteria\s*$/i.test(line.trim()));
|
|
3419
|
-
if (sectionStart === -1) {
|
|
3420
|
-
return { error: "Acceptance Criteria section not found." };
|
|
3885
|
+
function closeSessionDb() {
|
|
3886
|
+
if (db) {
|
|
3887
|
+
db.close();
|
|
3888
|
+
db = null;
|
|
3421
3889
|
}
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3890
|
+
}
|
|
3891
|
+
async function migrateFromMarkdown(projectsDir) {
|
|
3892
|
+
const database = getSessionDb();
|
|
3893
|
+
const count = database.prepare("SELECT COUNT(*) as count FROM sessions").get();
|
|
3894
|
+
if (count.count > 0) return 0;
|
|
3895
|
+
if (!await fileExists(projectsDir)) return 0;
|
|
3896
|
+
const entries = await readdir5(projectsDir, { withFileTypes: true });
|
|
3897
|
+
const allSessions = [];
|
|
3898
|
+
for (const entry of entries) {
|
|
3899
|
+
if (!entry.isDirectory()) continue;
|
|
3900
|
+
const projectDir = resolve8(projectsDir, entry.name);
|
|
3901
|
+
const indexPath = resolve8(projectDir, "_index-sessions.md");
|
|
3902
|
+
if (!await fileExists(indexPath)) continue;
|
|
3903
|
+
const sessions = await parseMarkdownSessionsIndex(indexPath, entry.name);
|
|
3904
|
+
allSessions.push(...sessions);
|
|
3428
3905
|
}
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3906
|
+
if (allSessions.length === 0) return 0;
|
|
3907
|
+
const insert = database.prepare(`
|
|
3908
|
+
INSERT OR IGNORE INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path)
|
|
3909
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3910
|
+
`);
|
|
3911
|
+
const insertAll = database.transaction((sessions) => {
|
|
3912
|
+
for (const s of sessions) {
|
|
3913
|
+
insert.run(s.sessionId, s.projectSlug, s.assignmentSlug, s.agent, s.started, s.status, s.path);
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
insertAll(allSessions);
|
|
3917
|
+
console.log(`Migrated ${allSessions.length} sessions from markdown to SQLite.`);
|
|
3918
|
+
return allSessions.length;
|
|
3919
|
+
}
|
|
3920
|
+
async function parseMarkdownSessionsIndex(filePath, projectSlug) {
|
|
3921
|
+
const { readFile: readFile13 } = await import("fs/promises");
|
|
3922
|
+
const raw = await readFile13(filePath, "utf-8");
|
|
3923
|
+
const sessions = [];
|
|
3924
|
+
const lines = raw.split("\n");
|
|
3925
|
+
let inTable = false;
|
|
3926
|
+
let headerSeen = false;
|
|
3927
|
+
for (const line of lines) {
|
|
3928
|
+
const trimmed = line.trim();
|
|
3929
|
+
if (!trimmed) continue;
|
|
3930
|
+
if (trimmed.startsWith("| Assignment") || trimmed.startsWith("|Assignment")) {
|
|
3931
|
+
inTable = true;
|
|
3932
|
+
headerSeen = false;
|
|
3933
|
+
continue;
|
|
3934
|
+
}
|
|
3935
|
+
if (inTable && !headerSeen && trimmed.match(/^\|[-\s|]+\|$/)) {
|
|
3936
|
+
headerSeen = true;
|
|
3937
|
+
continue;
|
|
3938
|
+
}
|
|
3939
|
+
if (inTable && headerSeen && trimmed.startsWith("|")) {
|
|
3940
|
+
const cells = trimmed.split("|").slice(1, -1).map((c) => c.trim());
|
|
3941
|
+
if (cells.length >= 6) {
|
|
3942
|
+
sessions.push({
|
|
3943
|
+
assignmentSlug: cells[0],
|
|
3944
|
+
agent: cells[1],
|
|
3945
|
+
sessionId: cells[2],
|
|
3946
|
+
started: cells[3],
|
|
3947
|
+
status: cells[4] || "active",
|
|
3948
|
+
path: cells[5],
|
|
3949
|
+
projectSlug
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3435
3953
|
}
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3954
|
+
return sessions;
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
// src/dashboard/agent-sessions.ts
|
|
3958
|
+
function rowToSession(row) {
|
|
3441
3959
|
return {
|
|
3442
|
-
|
|
3960
|
+
sessionId: row.session_id,
|
|
3961
|
+
projectSlug: row.project_slug ?? null,
|
|
3962
|
+
assignmentSlug: row.assignment_slug ?? null,
|
|
3963
|
+
agent: row.agent,
|
|
3964
|
+
started: row.started,
|
|
3965
|
+
ended: row.ended ?? null,
|
|
3966
|
+
status: row.status,
|
|
3967
|
+
path: row.path ?? "",
|
|
3968
|
+
description: row.description ?? null,
|
|
3969
|
+
transcriptPath: row.transcript_path ?? null
|
|
3443
3970
|
};
|
|
3444
3971
|
}
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
- [Resources](./resources/_index.md)
|
|
3471
|
-
- [Memories](./memories/_index.md)
|
|
3472
|
-
`;
|
|
3972
|
+
async function appendSession(_projectDir, session) {
|
|
3973
|
+
const db2 = getSessionDb();
|
|
3974
|
+
db2.prepare(`
|
|
3975
|
+
INSERT INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path, description, transcript_path)
|
|
3976
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
3977
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3978
|
+
project_slug = COALESCE(excluded.project_slug, project_slug),
|
|
3979
|
+
assignment_slug = COALESCE(excluded.assignment_slug, assignment_slug),
|
|
3980
|
+
agent = excluded.agent,
|
|
3981
|
+
status = CASE WHEN status IN ('completed','stopped') THEN status ELSE excluded.status END,
|
|
3982
|
+
path = COALESCE(excluded.path, path),
|
|
3983
|
+
description = COALESCE(excluded.description, description),
|
|
3984
|
+
transcript_path = COALESCE(excluded.transcript_path, transcript_path),
|
|
3985
|
+
updated_at = datetime('now')
|
|
3986
|
+
`).run(
|
|
3987
|
+
session.sessionId,
|
|
3988
|
+
session.projectSlug ?? null,
|
|
3989
|
+
session.assignmentSlug ?? null,
|
|
3990
|
+
session.agent,
|
|
3991
|
+
session.started,
|
|
3992
|
+
session.status,
|
|
3993
|
+
session.path,
|
|
3994
|
+
session.description ?? null,
|
|
3995
|
+
session.transcriptPath ?? null
|
|
3996
|
+
);
|
|
3473
3997
|
}
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
)
|
|
3998
|
+
async function updateSessionStatus(_projectDir, sessionId, status) {
|
|
3999
|
+
const db2 = getSessionDb();
|
|
4000
|
+
const isTerminal = status === "completed" || status === "stopped";
|
|
4001
|
+
const result = isTerminal ? db2.prepare(
|
|
4002
|
+
"UPDATE sessions SET status = ?, ended = datetime('now'), updated_at = datetime('now') WHERE session_id = ?"
|
|
4003
|
+
).run(status, sessionId) : db2.prepare(
|
|
4004
|
+
"UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE session_id = ?"
|
|
4005
|
+
).run(status, sessionId);
|
|
4006
|
+
return result.changes > 0;
|
|
4007
|
+
}
|
|
4008
|
+
async function listAllSessions(_projectsDir) {
|
|
4009
|
+
const db2 = getSessionDb();
|
|
4010
|
+
const rows = db2.prepare("SELECT * FROM sessions ORDER BY started DESC").all();
|
|
4011
|
+
return rows.map(rowToSession);
|
|
4012
|
+
}
|
|
4013
|
+
async function listProjectSessions(_projectsDir, projectSlug, assignmentSlug) {
|
|
4014
|
+
const db2 = getSessionDb();
|
|
4015
|
+
if (assignmentSlug) {
|
|
4016
|
+
const rows2 = db2.prepare(
|
|
4017
|
+
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
4018
|
+
).all(projectSlug, assignmentSlug);
|
|
4019
|
+
return rows2.map(rowToSession);
|
|
3481
4020
|
}
|
|
3482
|
-
const
|
|
3483
|
-
return
|
|
4021
|
+
const rows = db2.prepare("SELECT * FROM sessions WHERE project_slug = ? ORDER BY started DESC").all(projectSlug);
|
|
4022
|
+
return rows.map(rowToSession);
|
|
3484
4023
|
}
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
const
|
|
3489
|
-
const
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
4024
|
+
async function deleteSessions(sessionIds) {
|
|
4025
|
+
if (sessionIds.length === 0) return 0;
|
|
4026
|
+
const db2 = getSessionDb();
|
|
4027
|
+
const placeholders = sessionIds.map(() => "?").join(", ");
|
|
4028
|
+
const result = db2.prepare(`DELETE FROM sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
4029
|
+
return result.changes;
|
|
4030
|
+
}
|
|
4031
|
+
var DONE_ASSIGNMENT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "review"]);
|
|
4032
|
+
async function readAssignmentStatusFromPath(assignmentMdPath) {
|
|
4033
|
+
if (!await fileExists(assignmentMdPath)) return null;
|
|
4034
|
+
const raw = await readFile7(assignmentMdPath, "utf-8");
|
|
4035
|
+
const match = raw.match(/^status:\s*(.+)$/m);
|
|
4036
|
+
return match ? match[1].trim() : null;
|
|
4037
|
+
}
|
|
4038
|
+
async function readAssignmentStatus(projectDir, assignmentSlug) {
|
|
4039
|
+
return readAssignmentStatusFromPath(
|
|
4040
|
+
resolve9(projectDir, "assignments", assignmentSlug, "assignment.md")
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
async function reconcileActiveSessions(projectsDir, assignmentsDir) {
|
|
4044
|
+
const db2 = getSessionDb();
|
|
4045
|
+
const activeSessions = db2.prepare("SELECT * FROM sessions WHERE status = 'active' AND assignment_slug IS NOT NULL").all();
|
|
4046
|
+
if (activeSessions.length === 0) return 0;
|
|
4047
|
+
const assignmentStatuses = /* @__PURE__ */ new Map();
|
|
4048
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4049
|
+
for (const session of activeSessions) {
|
|
4050
|
+
const aslug = session.assignment_slug;
|
|
4051
|
+
if (!aslug) continue;
|
|
4052
|
+
const projectKey = session.project_slug ?? "__standalone__";
|
|
4053
|
+
const key = `${projectKey}/${aslug}`;
|
|
4054
|
+
if (seen.has(key)) continue;
|
|
4055
|
+
seen.add(key);
|
|
4056
|
+
if (session.project_slug) {
|
|
4057
|
+
const status = await readAssignmentStatus(
|
|
4058
|
+
resolve9(projectsDir, session.project_slug),
|
|
4059
|
+
aslug
|
|
4060
|
+
);
|
|
4061
|
+
if (status) assignmentStatuses.set(key, status);
|
|
4062
|
+
} else if (assignmentsDir) {
|
|
4063
|
+
const status = await readAssignmentStatusFromPath(
|
|
4064
|
+
resolve9(assignmentsDir, aslug, "assignment.md")
|
|
4065
|
+
);
|
|
4066
|
+
if (status) assignmentStatuses.set(key, status);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
let totalUpdated = 0;
|
|
4070
|
+
for (const session of activeSessions) {
|
|
4071
|
+
const projectKey = session.project_slug ?? "__standalone__";
|
|
4072
|
+
const key = `${projectKey}/${session.assignment_slug}`;
|
|
4073
|
+
const assignmentStatus = assignmentStatuses.get(key);
|
|
4074
|
+
if (!assignmentStatus || !DONE_ASSIGNMENT_STATUSES.has(assignmentStatus)) continue;
|
|
4075
|
+
const newStatus = assignmentStatus === "failed" ? "stopped" : "completed";
|
|
4076
|
+
await updateSessionStatus("", session.session_id, newStatus);
|
|
4077
|
+
totalUpdated++;
|
|
4078
|
+
}
|
|
4079
|
+
return totalUpdated;
|
|
4080
|
+
}
|
|
4081
|
+
async function listSessionsByAssignment(projectSlug, assignmentSlug) {
|
|
4082
|
+
const db2 = getSessionDb();
|
|
4083
|
+
const rows = projectSlug === null ? db2.prepare(
|
|
4084
|
+
"SELECT * FROM sessions WHERE assignment_slug = ? AND project_slug IS NULL ORDER BY started DESC"
|
|
4085
|
+
).all(assignmentSlug) : db2.prepare(
|
|
4086
|
+
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
4087
|
+
).all(projectSlug, assignmentSlug);
|
|
4088
|
+
return rows.map(rowToSession);
|
|
3514
4089
|
}
|
|
3515
4090
|
|
|
3516
|
-
// src/
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
const
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
4091
|
+
// src/dashboard/watcher.ts
|
|
4092
|
+
import { watch } from "chokidar";
|
|
4093
|
+
import { relative, sep } from "path";
|
|
4094
|
+
function createWatcher(options) {
|
|
4095
|
+
const { projectsDir, assignmentsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, onMessage, debounceMs = 300 } = options;
|
|
4096
|
+
const pendingEvents = /* @__PURE__ */ new Map();
|
|
4097
|
+
const projectsWatcher = watch(projectsDir, {
|
|
4098
|
+
ignoreInitial: true,
|
|
4099
|
+
persistent: true,
|
|
4100
|
+
depth: 10,
|
|
4101
|
+
ignored: /(^|[\/\\])\../
|
|
4102
|
+
});
|
|
4103
|
+
function handleProjectChange(filePath) {
|
|
4104
|
+
const rel = relative(projectsDir, filePath);
|
|
4105
|
+
const parts = rel.split(sep);
|
|
4106
|
+
if (parts.length === 0) return;
|
|
4107
|
+
const projectSlug = parts[0];
|
|
4108
|
+
let assignmentSlug;
|
|
4109
|
+
if (parts.length >= 3 && parts[1] === "assignments") {
|
|
4110
|
+
assignmentSlug = parts[2];
|
|
4111
|
+
}
|
|
4112
|
+
const debounceKey = assignmentSlug ? `${projectSlug}/${assignmentSlug}` : projectSlug;
|
|
4113
|
+
const existing = pendingEvents.get(debounceKey);
|
|
4114
|
+
if (existing) clearTimeout(existing);
|
|
4115
|
+
const messageType = assignmentSlug ? "assignment-updated" : "project-updated";
|
|
4116
|
+
pendingEvents.set(
|
|
4117
|
+
debounceKey,
|
|
4118
|
+
setTimeout(() => {
|
|
4119
|
+
pendingEvents.delete(debounceKey);
|
|
4120
|
+
const message = {
|
|
4121
|
+
type: messageType,
|
|
4122
|
+
projectSlug,
|
|
4123
|
+
assignmentSlug,
|
|
4124
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4125
|
+
};
|
|
4126
|
+
onMessage(message);
|
|
4127
|
+
}, debounceMs)
|
|
4128
|
+
);
|
|
4129
|
+
}
|
|
4130
|
+
projectsWatcher.on("change", handleProjectChange);
|
|
4131
|
+
projectsWatcher.on("add", handleProjectChange);
|
|
4132
|
+
projectsWatcher.on("unlink", handleProjectChange);
|
|
4133
|
+
let standaloneWatcher = null;
|
|
4134
|
+
if (assignmentsDir) {
|
|
4135
|
+
let handleStandaloneChange2 = function(filePath) {
|
|
4136
|
+
const rel = relative(assignmentsDir, filePath);
|
|
4137
|
+
const parts = rel.split(sep);
|
|
4138
|
+
if (parts.length === 0) return;
|
|
4139
|
+
const assignmentId = parts[0];
|
|
4140
|
+
if (!assignmentId) return;
|
|
4141
|
+
const debounceKey = `__standalone__/${assignmentId}`;
|
|
4142
|
+
const existing = pendingEvents.get(debounceKey);
|
|
4143
|
+
if (existing) clearTimeout(existing);
|
|
4144
|
+
pendingEvents.set(
|
|
4145
|
+
debounceKey,
|
|
4146
|
+
setTimeout(() => {
|
|
4147
|
+
pendingEvents.delete(debounceKey);
|
|
4148
|
+
const message = {
|
|
4149
|
+
type: "assignment-updated",
|
|
4150
|
+
projectSlug: null,
|
|
4151
|
+
assignmentSlug: assignmentId,
|
|
4152
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4153
|
+
};
|
|
4154
|
+
onMessage(message);
|
|
4155
|
+
}, debounceMs)
|
|
4156
|
+
);
|
|
4157
|
+
};
|
|
4158
|
+
var handleStandaloneChange = handleStandaloneChange2;
|
|
4159
|
+
standaloneWatcher = watch(assignmentsDir, {
|
|
4160
|
+
ignoreInitial: true,
|
|
4161
|
+
persistent: true,
|
|
4162
|
+
depth: 5,
|
|
4163
|
+
ignored: /(^|[\/\\])\../
|
|
4164
|
+
});
|
|
4165
|
+
standaloneWatcher.on("change", handleStandaloneChange2);
|
|
4166
|
+
standaloneWatcher.on("add", handleStandaloneChange2);
|
|
4167
|
+
standaloneWatcher.on("unlink", handleStandaloneChange2);
|
|
4168
|
+
}
|
|
4169
|
+
let serversWatcher = null;
|
|
4170
|
+
if (serversDir2) {
|
|
4171
|
+
let handleServerChange2 = function() {
|
|
4172
|
+
const debounceKey = "__servers__";
|
|
4173
|
+
const existing = pendingEvents.get(debounceKey);
|
|
4174
|
+
if (existing) clearTimeout(existing);
|
|
4175
|
+
pendingEvents.set(
|
|
4176
|
+
debounceKey,
|
|
4177
|
+
setTimeout(() => {
|
|
4178
|
+
pendingEvents.delete(debounceKey);
|
|
4179
|
+
const message = {
|
|
4180
|
+
type: "servers-updated",
|
|
4181
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4182
|
+
};
|
|
4183
|
+
onMessage(message);
|
|
4184
|
+
}, debounceMs)
|
|
4185
|
+
);
|
|
4186
|
+
};
|
|
4187
|
+
var handleServerChange = handleServerChange2;
|
|
4188
|
+
serversWatcher = watch(serversDir2, {
|
|
4189
|
+
ignoreInitial: true,
|
|
4190
|
+
persistent: true,
|
|
4191
|
+
depth: 1,
|
|
4192
|
+
ignored: /(^|[\/\\])\../
|
|
4193
|
+
});
|
|
4194
|
+
serversWatcher.on("change", handleServerChange2);
|
|
4195
|
+
serversWatcher.on("add", handleServerChange2);
|
|
4196
|
+
serversWatcher.on("unlink", handleServerChange2);
|
|
4197
|
+
}
|
|
4198
|
+
let playbooksWatcher = null;
|
|
4199
|
+
if (playbooksDir2) {
|
|
4200
|
+
let handlePlaybookChange2 = function() {
|
|
4201
|
+
const debounceKey = "__playbooks__";
|
|
4202
|
+
const existing = pendingEvents.get(debounceKey);
|
|
4203
|
+
if (existing) clearTimeout(existing);
|
|
4204
|
+
pendingEvents.set(
|
|
4205
|
+
debounceKey,
|
|
4206
|
+
setTimeout(() => {
|
|
4207
|
+
pendingEvents.delete(debounceKey);
|
|
4208
|
+
const message = {
|
|
4209
|
+
type: "playbooks-updated",
|
|
4210
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4211
|
+
};
|
|
4212
|
+
onMessage(message);
|
|
4213
|
+
}, debounceMs)
|
|
4214
|
+
);
|
|
4215
|
+
};
|
|
4216
|
+
var handlePlaybookChange = handlePlaybookChange2;
|
|
4217
|
+
playbooksWatcher = watch(playbooksDir2, {
|
|
4218
|
+
ignoreInitial: true,
|
|
4219
|
+
persistent: true,
|
|
4220
|
+
depth: 1,
|
|
4221
|
+
ignored: /(^|[\/\\])\../
|
|
4222
|
+
});
|
|
4223
|
+
playbooksWatcher.on("change", handlePlaybookChange2);
|
|
4224
|
+
playbooksWatcher.on("add", handlePlaybookChange2);
|
|
4225
|
+
playbooksWatcher.on("unlink", handlePlaybookChange2);
|
|
4226
|
+
}
|
|
4227
|
+
let todosWatcher = null;
|
|
4228
|
+
if (todosDir2) {
|
|
4229
|
+
let handleTodoChange2 = function() {
|
|
4230
|
+
const debounceKey = "__todos__";
|
|
4231
|
+
const existing = pendingEvents.get(debounceKey);
|
|
4232
|
+
if (existing) clearTimeout(existing);
|
|
4233
|
+
pendingEvents.set(
|
|
4234
|
+
debounceKey,
|
|
4235
|
+
setTimeout(() => {
|
|
4236
|
+
pendingEvents.delete(debounceKey);
|
|
4237
|
+
const message = {
|
|
4238
|
+
type: "todos-updated",
|
|
4239
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
4240
|
+
};
|
|
4241
|
+
onMessage(message);
|
|
4242
|
+
}, debounceMs)
|
|
4243
|
+
);
|
|
4244
|
+
};
|
|
4245
|
+
var handleTodoChange = handleTodoChange2;
|
|
4246
|
+
todosWatcher = watch(todosDir2, {
|
|
4247
|
+
ignoreInitial: true,
|
|
4248
|
+
persistent: true,
|
|
4249
|
+
depth: 1,
|
|
4250
|
+
ignored: /(^|[\/\\])\../
|
|
4251
|
+
});
|
|
4252
|
+
todosWatcher.on("change", handleTodoChange2);
|
|
4253
|
+
todosWatcher.on("add", handleTodoChange2);
|
|
4254
|
+
todosWatcher.on("unlink", handleTodoChange2);
|
|
4255
|
+
}
|
|
4256
|
+
return {
|
|
4257
|
+
close: async () => {
|
|
4258
|
+
pendingEvents.forEach((timeout) => {
|
|
4259
|
+
clearTimeout(timeout);
|
|
4260
|
+
});
|
|
4261
|
+
pendingEvents.clear();
|
|
4262
|
+
await projectsWatcher.close();
|
|
4263
|
+
if (standaloneWatcher) await standaloneWatcher.close();
|
|
4264
|
+
if (serversWatcher) await serversWatcher.close();
|
|
4265
|
+
if (playbooksWatcher) await playbooksWatcher.close();
|
|
4266
|
+
if (todosWatcher) await todosWatcher.close();
|
|
4267
|
+
}
|
|
4268
|
+
};
|
|
3582
4269
|
}
|
|
3583
4270
|
|
|
3584
|
-
// src/
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
assignment: ${params.assignmentSlug}
|
|
3588
|
-
updated: "${params.timestamp}"
|
|
3589
|
-
---
|
|
4271
|
+
// src/dashboard/server.ts
|
|
4272
|
+
init_fs();
|
|
4273
|
+
init_config2();
|
|
3590
4274
|
|
|
3591
|
-
|
|
4275
|
+
// src/dashboard/api-write.ts
|
|
4276
|
+
init_lifecycle();
|
|
4277
|
+
import { Router } from "express";
|
|
4278
|
+
import { resolve as resolve10 } from "path";
|
|
4279
|
+
import { rm, readFile as readFile8 } from "fs/promises";
|
|
3592
4280
|
|
|
3593
|
-
|
|
3594
|
-
|
|
4281
|
+
// src/utils/slug.ts
|
|
4282
|
+
function isValidSlug(slug) {
|
|
4283
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug);
|
|
3595
4284
|
}
|
|
3596
4285
|
|
|
3597
|
-
// src/
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
updated: "${params.timestamp}"
|
|
3602
|
-
handoffCount: 0
|
|
3603
|
-
---
|
|
3604
|
-
|
|
3605
|
-
# Handoff Log
|
|
3606
|
-
|
|
3607
|
-
No handoffs recorded yet.
|
|
3608
|
-
`;
|
|
4286
|
+
// src/utils/uuid.ts
|
|
4287
|
+
import { randomUUID } from "crypto";
|
|
4288
|
+
function generateId() {
|
|
4289
|
+
return randomUUID();
|
|
3609
4290
|
}
|
|
3610
4291
|
|
|
3611
|
-
// src/
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
updated: "${params.timestamp}"
|
|
3616
|
-
decisionCount: 0
|
|
3617
|
-
---
|
|
3618
|
-
|
|
3619
|
-
# Decision Record
|
|
4292
|
+
// src/dashboard/api-write.ts
|
|
4293
|
+
init_timestamp();
|
|
4294
|
+
init_fs();
|
|
4295
|
+
init_parser();
|
|
3620
4296
|
|
|
3621
|
-
|
|
3622
|
-
|
|
4297
|
+
// src/dashboard/acceptance-criteria.ts
|
|
4298
|
+
function splitFrontmatter(content) {
|
|
4299
|
+
const match = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/);
|
|
4300
|
+
if (!match) {
|
|
4301
|
+
return { prefix: "", body: content };
|
|
4302
|
+
}
|
|
4303
|
+
return {
|
|
4304
|
+
prefix: match[1],
|
|
4305
|
+
body: match[2]
|
|
4306
|
+
};
|
|
3623
4307
|
}
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
4308
|
+
function toggleAcceptanceCriterion(content, index, checked) {
|
|
4309
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
4310
|
+
return { error: "acceptance criteria index must be a non-negative integer" };
|
|
4311
|
+
}
|
|
4312
|
+
const { prefix, body } = splitFrontmatter(content);
|
|
4313
|
+
const lines = body.split("\n");
|
|
4314
|
+
const sectionStart = lines.findIndex((line) => /^##\s+Acceptance Criteria\s*$/i.test(line.trim()));
|
|
4315
|
+
if (sectionStart === -1) {
|
|
4316
|
+
return { error: "Acceptance Criteria section not found." };
|
|
4317
|
+
}
|
|
4318
|
+
let sectionEnd = lines.length;
|
|
4319
|
+
for (let lineIndex = sectionStart + 1; lineIndex < lines.length; lineIndex += 1) {
|
|
4320
|
+
if (/^#{1,2}\s+\S/.test(lines[lineIndex].trim())) {
|
|
4321
|
+
sectionEnd = lineIndex;
|
|
4322
|
+
break;
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
const checklistLines = lines.map((line, lineIndex) => ({ line, lineIndex })).filter(
|
|
4326
|
+
({ lineIndex, line }) => lineIndex > sectionStart && lineIndex < sectionEnd && /^\s*[-*]\s+\[( |x|X)\]\s+.*$/.test(line)
|
|
4327
|
+
);
|
|
4328
|
+
const target = checklistLines[index];
|
|
4329
|
+
if (!target) {
|
|
4330
|
+
return { error: `Acceptance criteria item ${index} not found.` };
|
|
4331
|
+
}
|
|
4332
|
+
const nextLine = target.line.replace(
|
|
4333
|
+
/^(\s*[-*]\s+\[)( |x|X)(\]\s+.*)$/,
|
|
4334
|
+
`$1${checked ? "x" : " "}$3`
|
|
4335
|
+
);
|
|
4336
|
+
lines[target.lineIndex] = nextLine;
|
|
4337
|
+
return {
|
|
4338
|
+
content: `${prefix}${lines.join("\n")}`
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
// src/dashboard/api-write.ts
|
|
4343
|
+
init_api();
|
|
4344
|
+
init_assignment_resolver();
|
|
4345
|
+
|
|
4346
|
+
// src/templates/index.ts
|
|
4347
|
+
init_config();
|
|
4348
|
+
|
|
4349
|
+
// src/templates/manifest.ts
|
|
4350
|
+
function renderManifest(params) {
|
|
4351
|
+
return `---
|
|
4352
|
+
version: "2.0"
|
|
4353
|
+
project: ${params.slug}
|
|
4354
|
+
generated: "${params.timestamp}"
|
|
4355
|
+
---
|
|
4356
|
+
|
|
4357
|
+
# Project: ${params.slug}
|
|
4358
|
+
|
|
4359
|
+
## Overview
|
|
4360
|
+
- [Project Overview](./project.md)
|
|
4361
|
+
|
|
4362
|
+
## Indexes
|
|
4363
|
+
- [Assignments](./_index-assignments.md)
|
|
4364
|
+
- [Plans](./_index-plans.md)
|
|
4365
|
+
- [Decision Records](./_index-decisions.md)
|
|
4366
|
+
- [Status](./_status.md)
|
|
4367
|
+
- [Resources](./resources/_index.md)
|
|
4368
|
+
- [Memories](./memories/_index.md)
|
|
4369
|
+
`;
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
// src/utils/yaml.ts
|
|
4373
|
+
function escapeYamlString(value) {
|
|
4374
|
+
if (value.includes("\n") || value.includes("\r")) {
|
|
4375
|
+
throw new Error(
|
|
4376
|
+
`YAML string values must be single-line. Got: "${value.slice(0, 50)}..."`
|
|
4377
|
+
);
|
|
4378
|
+
}
|
|
4379
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4380
|
+
return `"${escaped}"`;
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
// src/templates/project.ts
|
|
4384
|
+
function renderProject(params) {
|
|
4385
|
+
const safeTitle = escapeYamlString(params.title);
|
|
4386
|
+
const workspaceLine = params.workspace ? `
|
|
4387
|
+
workspace: ${params.workspace}` : "";
|
|
4388
|
+
return `---
|
|
4389
|
+
id: ${params.id}
|
|
4390
|
+
slug: ${params.slug}
|
|
4391
|
+
title: ${safeTitle}
|
|
4392
|
+
archived: false
|
|
4393
|
+
archivedAt: null
|
|
4394
|
+
archivedReason: null
|
|
4395
|
+
created: "${params.timestamp}"
|
|
4396
|
+
updated: "${params.timestamp}"
|
|
4397
|
+
externalIds: []
|
|
4398
|
+
tags: []${workspaceLine}
|
|
4399
|
+
---
|
|
4400
|
+
|
|
4401
|
+
# ${params.title}
|
|
4402
|
+
|
|
4403
|
+
## Overview
|
|
4404
|
+
|
|
4405
|
+
<!-- Describe the project goal, context, and success criteria here. -->
|
|
4406
|
+
|
|
4407
|
+
## Notes
|
|
4408
|
+
|
|
4409
|
+
<!-- Optional human notes, updates, or context. -->
|
|
4410
|
+
`;
|
|
4411
|
+
}
|
|
4412
|
+
|
|
4413
|
+
// src/templates/assignment.ts
|
|
4414
|
+
function renderAssignment(params) {
|
|
4415
|
+
const safeTitle = escapeYamlString(params.title);
|
|
4416
|
+
const dependsOnYaml = params.dependsOn.length === 0 ? "dependsOn: []" : `dependsOn:
|
|
4417
|
+
- ${params.dependsOn.join("\n - ")}`;
|
|
4418
|
+
const linksYaml = params.links.length === 0 ? "links: []" : `links:
|
|
4419
|
+
- ${params.links.join("\n - ")}`;
|
|
4420
|
+
const projectYaml = `project: ${params.project == null ? "null" : params.project}`;
|
|
4421
|
+
const typeYaml = `type: ${params.type ?? "feature"}`;
|
|
4422
|
+
return `---
|
|
4423
|
+
id: ${params.id}
|
|
4424
|
+
slug: ${params.slug}
|
|
4425
|
+
title: ${safeTitle}
|
|
4426
|
+
${projectYaml}
|
|
4427
|
+
${typeYaml}
|
|
4428
|
+
status: pending
|
|
4429
|
+
priority: ${params.priority}
|
|
4430
|
+
created: "${params.timestamp}"
|
|
4431
|
+
updated: "${params.timestamp}"
|
|
4432
|
+
assignee: null
|
|
4433
|
+
externalIds: []
|
|
4434
|
+
${dependsOnYaml}
|
|
4435
|
+
${linksYaml}
|
|
4436
|
+
blockedReason: null
|
|
4437
|
+
workspace:
|
|
4438
|
+
repository: null
|
|
4439
|
+
worktreePath: null
|
|
4440
|
+
branch: null
|
|
4441
|
+
parentBranch: null
|
|
4442
|
+
tags: []
|
|
4443
|
+
---
|
|
4444
|
+
|
|
4445
|
+
# ${params.title}
|
|
4446
|
+
|
|
4447
|
+
## Objective
|
|
4448
|
+
|
|
4449
|
+
<!-- Clear description of what needs to be done and why. -->
|
|
4450
|
+
|
|
4451
|
+
## Acceptance Criteria
|
|
4452
|
+
|
|
4453
|
+
- [ ] <!-- criterion 1 -->
|
|
4454
|
+
- [ ] <!-- criterion 2 -->
|
|
4455
|
+
- [ ] <!-- criterion 3 -->
|
|
4456
|
+
|
|
4457
|
+
## Todos
|
|
4458
|
+
|
|
4459
|
+
<!--
|
|
4460
|
+
Checklist of work items for this assignment. Items may be simple tasks
|
|
4461
|
+
or a markdown link to a plan file (e.g., "- [ ] Execute [plan](./plan.md)").
|
|
4462
|
+
When a plan is superseded by a new one, mark the old todo as:
|
|
4463
|
+
- [x] ~~Execute [old plan](./plan.md)~~ (superseded by plan-v2)
|
|
4464
|
+
Never delete superseded todos \u2014 preserve the history.
|
|
4465
|
+
-->
|
|
4466
|
+
|
|
4467
|
+
## Context
|
|
4468
|
+
|
|
4469
|
+
<!-- Links to relevant docs, code, or other assignments. -->
|
|
4470
|
+
|
|
4471
|
+
## Links
|
|
4472
|
+
|
|
4473
|
+
- [Progress](./progress.md)
|
|
4474
|
+
- [Comments](./comments.md)
|
|
4475
|
+
- [Scratchpad](./scratchpad.md)
|
|
4476
|
+
- [Handoff](./handoff.md)
|
|
4477
|
+
- [Decision Record](./decision-record.md)
|
|
4478
|
+
`;
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
// src/templates/scratchpad.ts
|
|
4482
|
+
function renderScratchpad(params) {
|
|
4483
|
+
return `---
|
|
4484
|
+
assignment: ${params.assignmentSlug}
|
|
4485
|
+
updated: "${params.timestamp}"
|
|
4486
|
+
---
|
|
4487
|
+
|
|
4488
|
+
# Scratchpad
|
|
4489
|
+
|
|
4490
|
+
No working notes yet.
|
|
4491
|
+
`;
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
// src/templates/handoff.ts
|
|
4495
|
+
function renderHandoff(params) {
|
|
4496
|
+
return `---
|
|
4497
|
+
assignment: ${params.assignmentSlug}
|
|
4498
|
+
updated: "${params.timestamp}"
|
|
4499
|
+
handoffCount: 0
|
|
4500
|
+
---
|
|
4501
|
+
|
|
4502
|
+
# Handoff Log
|
|
4503
|
+
|
|
4504
|
+
No handoffs recorded yet.
|
|
4505
|
+
`;
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
// src/templates/progress.ts
|
|
4509
|
+
function renderProgress(params) {
|
|
4510
|
+
return `---
|
|
4511
|
+
assignment: ${params.assignment}
|
|
4512
|
+
entryCount: 0
|
|
4513
|
+
generated: "${params.timestamp}"
|
|
4514
|
+
updated: "${params.timestamp}"
|
|
4515
|
+
---
|
|
4516
|
+
|
|
4517
|
+
# Progress
|
|
4518
|
+
|
|
4519
|
+
No progress yet.
|
|
4520
|
+
`;
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
// src/templates/comments.ts
|
|
4524
|
+
function renderComments(params) {
|
|
4525
|
+
return `---
|
|
4526
|
+
assignment: ${params.assignment}
|
|
4527
|
+
entryCount: 0
|
|
4528
|
+
generated: "${params.timestamp}"
|
|
4529
|
+
updated: "${params.timestamp}"
|
|
4530
|
+
---
|
|
4531
|
+
|
|
4532
|
+
# Comments
|
|
4533
|
+
|
|
4534
|
+
No comments yet.
|
|
4535
|
+
`;
|
|
4536
|
+
}
|
|
4537
|
+
function formatCommentEntry(comment) {
|
|
4538
|
+
const lines = [];
|
|
4539
|
+
lines.push(`## ${comment.id}`);
|
|
4540
|
+
lines.push("");
|
|
4541
|
+
lines.push(`**Recorded:** ${comment.timestamp}`);
|
|
4542
|
+
lines.push(`**Author:** ${comment.author}`);
|
|
4543
|
+
lines.push(`**Type:** ${comment.type}`);
|
|
4544
|
+
if (comment.replyTo) {
|
|
4545
|
+
lines.push(`**Reply to:** ${comment.replyTo}`);
|
|
4546
|
+
}
|
|
4547
|
+
if (comment.type === "question") {
|
|
4548
|
+
lines.push(`**Resolved:** ${comment.resolved ? "true" : "false"}`);
|
|
4549
|
+
}
|
|
4550
|
+
lines.push("");
|
|
4551
|
+
lines.push(comment.body.trim());
|
|
4552
|
+
lines.push("");
|
|
4553
|
+
return lines.join("\n");
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
// src/templates/decision-record.ts
|
|
4557
|
+
function renderDecisionRecord(params) {
|
|
4558
|
+
return `---
|
|
4559
|
+
assignment: ${params.assignmentSlug}
|
|
4560
|
+
updated: "${params.timestamp}"
|
|
4561
|
+
decisionCount: 0
|
|
4562
|
+
---
|
|
4563
|
+
|
|
4564
|
+
# Decision Record
|
|
4565
|
+
|
|
4566
|
+
No decisions recorded yet.
|
|
4567
|
+
`;
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
// src/templates/index-stubs.ts
|
|
4571
|
+
function renderIndexAssignments(params) {
|
|
4572
|
+
return `---
|
|
4573
|
+
project: ${params.slug}
|
|
3629
4574
|
generated: "${params.timestamp}"
|
|
3630
4575
|
total: 0
|
|
3631
4576
|
by_status:
|
|
@@ -3753,6 +4698,8 @@ tags: []
|
|
|
3753
4698
|
}
|
|
3754
4699
|
|
|
3755
4700
|
// src/dashboard/api-write.ts
|
|
4701
|
+
init_lifecycle();
|
|
4702
|
+
init_parser();
|
|
3756
4703
|
function extractFrontmatter3(content) {
|
|
3757
4704
|
const trimmed = content.trimStart();
|
|
3758
4705
|
if (!trimmed.startsWith("---\n") && !trimmed.startsWith("---\r\n")) {
|
|
@@ -3852,9 +4799,9 @@ async function readCurrentDocument(filePath) {
|
|
|
3852
4799
|
if (!await fileExists(filePath)) {
|
|
3853
4800
|
return null;
|
|
3854
4801
|
}
|
|
3855
|
-
return
|
|
4802
|
+
return readFile8(filePath, "utf-8");
|
|
3856
4803
|
}
|
|
3857
|
-
function createWriteRouter(projectsDir) {
|
|
4804
|
+
function createWriteRouter(projectsDir, assignmentsDir) {
|
|
3858
4805
|
const router = Router();
|
|
3859
4806
|
router.get("/api/templates/project", (_req, res) => {
|
|
3860
4807
|
const content = renderProject({
|
|
@@ -3877,412 +4824,1019 @@ function createWriteRouter(projectsDir) {
|
|
|
3877
4824
|
});
|
|
3878
4825
|
res.json({ content });
|
|
3879
4826
|
});
|
|
3880
|
-
router.get("/api/projects/:slug/edit", async (req, res) => {
|
|
3881
|
-
const slug = getParam(req.params.slug);
|
|
3882
|
-
const document = await getEditableDocument(projectsDir, "project", slug);
|
|
3883
|
-
if (!document) {
|
|
3884
|
-
res.status(404).json({ error: `Project "${slug}" not found` });
|
|
3885
|
-
return;
|
|
4827
|
+
router.get("/api/projects/:slug/edit", async (req, res) => {
|
|
4828
|
+
const slug = getParam(req.params.slug);
|
|
4829
|
+
const document = await getEditableDocument(projectsDir, "project", slug);
|
|
4830
|
+
if (!document) {
|
|
4831
|
+
res.status(404).json({ error: `Project "${slug}" not found` });
|
|
4832
|
+
return;
|
|
4833
|
+
}
|
|
4834
|
+
res.json(document);
|
|
4835
|
+
});
|
|
4836
|
+
router.get("/api/projects/:slug/assignments/:aslug/edit", async (req, res) => {
|
|
4837
|
+
const slug = getParam(req.params.slug);
|
|
4838
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
4839
|
+
const document = await getEditableDocument(
|
|
4840
|
+
projectsDir,
|
|
4841
|
+
"assignment",
|
|
4842
|
+
slug,
|
|
4843
|
+
assignmentSlug
|
|
4844
|
+
);
|
|
4845
|
+
if (!document) {
|
|
4846
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
4847
|
+
return;
|
|
4848
|
+
}
|
|
4849
|
+
res.json(document);
|
|
4850
|
+
});
|
|
4851
|
+
router.get("/api/projects/:slug/assignments/:aslug/plan/edit", async (req, res) => {
|
|
4852
|
+
const slug = getParam(req.params.slug);
|
|
4853
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
4854
|
+
const document = await getEditableDocument(
|
|
4855
|
+
projectsDir,
|
|
4856
|
+
"plan",
|
|
4857
|
+
slug,
|
|
4858
|
+
assignmentSlug
|
|
4859
|
+
);
|
|
4860
|
+
if (!document) {
|
|
4861
|
+
res.status(404).json({ error: "Plan not found" });
|
|
4862
|
+
return;
|
|
4863
|
+
}
|
|
4864
|
+
res.json(document);
|
|
4865
|
+
});
|
|
4866
|
+
router.get("/api/projects/:slug/assignments/:aslug/scratchpad/edit", async (req, res) => {
|
|
4867
|
+
const slug = getParam(req.params.slug);
|
|
4868
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
4869
|
+
const document = await getEditableDocument(
|
|
4870
|
+
projectsDir,
|
|
4871
|
+
"scratchpad",
|
|
4872
|
+
slug,
|
|
4873
|
+
assignmentSlug
|
|
4874
|
+
);
|
|
4875
|
+
if (!document) {
|
|
4876
|
+
res.status(404).json({ error: "Scratchpad not found" });
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4879
|
+
res.json(document);
|
|
4880
|
+
});
|
|
4881
|
+
router.get("/api/projects/:slug/assignments/:aslug/handoff/edit", async (req, res) => {
|
|
4882
|
+
const slug = getParam(req.params.slug);
|
|
4883
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
4884
|
+
const document = await getEditableDocument(
|
|
4885
|
+
projectsDir,
|
|
4886
|
+
"handoff",
|
|
4887
|
+
slug,
|
|
4888
|
+
assignmentSlug
|
|
4889
|
+
);
|
|
4890
|
+
if (!document) {
|
|
4891
|
+
res.status(404).json({ error: "Handoff log not found" });
|
|
4892
|
+
return;
|
|
4893
|
+
}
|
|
4894
|
+
res.json(document);
|
|
4895
|
+
});
|
|
4896
|
+
router.get("/api/projects/:slug/assignments/:aslug/decision-record/edit", async (req, res) => {
|
|
4897
|
+
const slug = getParam(req.params.slug);
|
|
4898
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
4899
|
+
const document = await getEditableDocument(
|
|
4900
|
+
projectsDir,
|
|
4901
|
+
"decision-record",
|
|
4902
|
+
slug,
|
|
4903
|
+
assignmentSlug
|
|
4904
|
+
);
|
|
4905
|
+
if (!document) {
|
|
4906
|
+
res.status(404).json({ error: "Decision record not found" });
|
|
4907
|
+
return;
|
|
4908
|
+
}
|
|
4909
|
+
res.json(document);
|
|
4910
|
+
});
|
|
4911
|
+
router.post("/api/projects", async (req, res) => {
|
|
4912
|
+
try {
|
|
4913
|
+
const content = requireContent(req, res);
|
|
4914
|
+
if (!content) {
|
|
4915
|
+
return;
|
|
4916
|
+
}
|
|
4917
|
+
const fields = extractFrontmatter3(content);
|
|
4918
|
+
if (!fields) {
|
|
4919
|
+
res.status(400).json({ error: "Invalid frontmatter: missing --- delimiters" });
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
const validation = validateRequired(fields, ["slug", "title"]);
|
|
4923
|
+
if (!validation.valid) {
|
|
4924
|
+
res.status(400).json({ error: `Missing required fields: ${validation.missing.join(", ")}` });
|
|
4925
|
+
return;
|
|
4926
|
+
}
|
|
4927
|
+
const slug = fields.slug;
|
|
4928
|
+
if (!isValidSlug(slug)) {
|
|
4929
|
+
res.status(400).json({ error: `Invalid slug "${slug}". Must be lowercase and hyphen-separated.` });
|
|
4930
|
+
return;
|
|
4931
|
+
}
|
|
4932
|
+
const projectDir = resolve10(projectsDir, slug);
|
|
4933
|
+
if (await fileExists(projectDir)) {
|
|
4934
|
+
res.status(409).json({ error: `Project "${slug}" already exists` });
|
|
4935
|
+
return;
|
|
4936
|
+
}
|
|
4937
|
+
const title = fields.title;
|
|
4938
|
+
const timestamp = fields.created || nowTimestamp();
|
|
4939
|
+
await ensureDir(resolve10(projectDir, "assignments"));
|
|
4940
|
+
await ensureDir(resolve10(projectDir, "resources"));
|
|
4941
|
+
await ensureDir(resolve10(projectDir, "memories"));
|
|
4942
|
+
await writeFileForce(resolve10(projectDir, "project.md"), content);
|
|
4943
|
+
try {
|
|
4944
|
+
const companions = [
|
|
4945
|
+
[resolve10(projectDir, "manifest.md"), renderManifest({ slug, timestamp })],
|
|
4946
|
+
[resolve10(projectDir, "_index-assignments.md"), renderIndexAssignments({ slug, title, timestamp })],
|
|
4947
|
+
[resolve10(projectDir, "_index-plans.md"), renderIndexPlans({ slug, title, timestamp })],
|
|
4948
|
+
[resolve10(projectDir, "_index-decisions.md"), renderIndexDecisions({ slug, title, timestamp })],
|
|
4949
|
+
[resolve10(projectDir, "_status.md"), renderStatus({ slug, title, timestamp })],
|
|
4950
|
+
[resolve10(projectDir, "resources", "_index.md"), renderResourcesIndex({ slug, title, timestamp })],
|
|
4951
|
+
[resolve10(projectDir, "memories", "_index.md"), renderMemoriesIndex({ slug, title, timestamp })]
|
|
4952
|
+
];
|
|
4953
|
+
for (const [filePath, fileContent] of companions) {
|
|
4954
|
+
await writeFileForce(filePath, fileContent);
|
|
4955
|
+
}
|
|
4956
|
+
} catch (companionError) {
|
|
4957
|
+
try {
|
|
4958
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
4959
|
+
} catch {
|
|
4960
|
+
}
|
|
4961
|
+
throw companionError;
|
|
4962
|
+
}
|
|
4963
|
+
res.status(201).json({ slug });
|
|
4964
|
+
} catch (error) {
|
|
4965
|
+
console.error("Error creating project:", error);
|
|
4966
|
+
res.status(500).json({ error: `Failed to create project: ${error.message}` });
|
|
4967
|
+
}
|
|
4968
|
+
});
|
|
4969
|
+
router.post("/api/projects/:slug/assignments", async (req, res) => {
|
|
4970
|
+
try {
|
|
4971
|
+
const projectSlug = getParam(req.params.slug);
|
|
4972
|
+
const projectDir = resolve10(projectsDir, projectSlug);
|
|
4973
|
+
const projectMdPath = resolve10(projectDir, "project.md");
|
|
4974
|
+
if (!await fileExists(projectMdPath)) {
|
|
4975
|
+
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
4976
|
+
return;
|
|
4977
|
+
}
|
|
4978
|
+
const content = requireContent(req, res);
|
|
4979
|
+
if (!content) {
|
|
4980
|
+
return;
|
|
4981
|
+
}
|
|
4982
|
+
const fields = extractFrontmatter3(content);
|
|
4983
|
+
if (!fields) {
|
|
4984
|
+
res.status(400).json({ error: "Invalid frontmatter: missing --- delimiters" });
|
|
4985
|
+
return;
|
|
4986
|
+
}
|
|
4987
|
+
const validation = validateRequired(fields, ["slug", "title"]);
|
|
4988
|
+
if (!validation.valid) {
|
|
4989
|
+
res.status(400).json({ error: `Missing required fields: ${validation.missing.join(", ")}` });
|
|
4990
|
+
return;
|
|
4991
|
+
}
|
|
4992
|
+
const assignmentSlug = fields.slug;
|
|
4993
|
+
if (!isValidSlug(assignmentSlug)) {
|
|
4994
|
+
res.status(400).json({ error: `Invalid slug "${assignmentSlug}". Must be lowercase and hyphen-separated.` });
|
|
4995
|
+
return;
|
|
4996
|
+
}
|
|
4997
|
+
const validPriorities = ["low", "medium", "high", "critical"];
|
|
4998
|
+
const priority = fields.priority || "medium";
|
|
4999
|
+
if (!validPriorities.includes(priority)) {
|
|
5000
|
+
res.status(400).json({ error: `Invalid priority "${priority}". Must be low, medium, high, or critical.` });
|
|
5001
|
+
return;
|
|
5002
|
+
}
|
|
5003
|
+
const assignmentDir = resolve10(projectDir, "assignments", assignmentSlug);
|
|
5004
|
+
if (await fileExists(assignmentDir)) {
|
|
5005
|
+
res.status(409).json({
|
|
5006
|
+
error: `Assignment "${assignmentSlug}" already exists in project "${projectSlug}"`
|
|
5007
|
+
});
|
|
5008
|
+
return;
|
|
5009
|
+
}
|
|
5010
|
+
const timestamp = fields.created || nowTimestamp();
|
|
5011
|
+
await ensureDir(assignmentDir);
|
|
5012
|
+
await writeFileForce(resolve10(assignmentDir, "assignment.md"), content);
|
|
5013
|
+
try {
|
|
5014
|
+
const companions = [
|
|
5015
|
+
[resolve10(assignmentDir, "scratchpad.md"), renderScratchpad({ assignmentSlug, timestamp })],
|
|
5016
|
+
[resolve10(assignmentDir, "handoff.md"), renderHandoff({ assignmentSlug, timestamp })],
|
|
5017
|
+
[resolve10(assignmentDir, "decision-record.md"), renderDecisionRecord({ assignmentSlug, timestamp })]
|
|
5018
|
+
];
|
|
5019
|
+
for (const [filePath, fileContent] of companions) {
|
|
5020
|
+
await writeFileForce(filePath, fileContent);
|
|
5021
|
+
}
|
|
5022
|
+
} catch (companionError) {
|
|
5023
|
+
try {
|
|
5024
|
+
await rm(assignmentDir, { recursive: true, force: true });
|
|
5025
|
+
} catch {
|
|
5026
|
+
}
|
|
5027
|
+
throw companionError;
|
|
5028
|
+
}
|
|
5029
|
+
res.status(201).json({ slug: assignmentSlug, projectSlug });
|
|
5030
|
+
} catch (error) {
|
|
5031
|
+
console.error("Error creating assignment:", error);
|
|
5032
|
+
res.status(500).json({ error: `Failed to create assignment: ${error.message}` });
|
|
5033
|
+
}
|
|
5034
|
+
});
|
|
5035
|
+
router.patch("/api/projects/:slug", async (req, res) => {
|
|
5036
|
+
try {
|
|
5037
|
+
const projectSlug = getParam(req.params.slug);
|
|
5038
|
+
const projectPath = resolve10(projectsDir, projectSlug, "project.md");
|
|
5039
|
+
const currentContent = await readCurrentDocument(projectPath);
|
|
5040
|
+
if (!currentContent) {
|
|
5041
|
+
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
5042
|
+
return;
|
|
5043
|
+
}
|
|
5044
|
+
const nextContentRaw = requireContent(req, res);
|
|
5045
|
+
if (!nextContentRaw) {
|
|
5046
|
+
return;
|
|
5047
|
+
}
|
|
5048
|
+
const current = parseProject(currentContent);
|
|
5049
|
+
const next = parseProject(nextContentRaw);
|
|
5050
|
+
if (!next.slug || !next.title) {
|
|
5051
|
+
res.status(400).json({ error: "Project content must include slug and title." });
|
|
5052
|
+
return;
|
|
5053
|
+
}
|
|
5054
|
+
if (next.slug !== current.slug) {
|
|
5055
|
+
res.status(400).json({ error: "Project slug cannot be changed once created." });
|
|
5056
|
+
return;
|
|
5057
|
+
}
|
|
5058
|
+
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
5059
|
+
await writeFileForce(projectPath, nextContent);
|
|
5060
|
+
const project = await getProjectDetail(projectsDir, projectSlug);
|
|
5061
|
+
res.json({ project, content: nextContent });
|
|
5062
|
+
} catch (error) {
|
|
5063
|
+
console.error("Error updating project:", error);
|
|
5064
|
+
res.status(500).json({ error: `Failed to update project: ${error.message}` });
|
|
5065
|
+
}
|
|
5066
|
+
});
|
|
5067
|
+
router.patch("/api/projects/:slug/assignments/:aslug", async (req, res) => {
|
|
5068
|
+
try {
|
|
5069
|
+
const projectSlug = getParam(req.params.slug);
|
|
5070
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5071
|
+
const assignmentPath = resolve10(
|
|
5072
|
+
projectsDir,
|
|
5073
|
+
projectSlug,
|
|
5074
|
+
"assignments",
|
|
5075
|
+
assignmentSlug,
|
|
5076
|
+
"assignment.md"
|
|
5077
|
+
);
|
|
5078
|
+
const currentContent = await readCurrentDocument(assignmentPath);
|
|
5079
|
+
if (!currentContent) {
|
|
5080
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
5081
|
+
return;
|
|
5082
|
+
}
|
|
5083
|
+
const nextContentRaw = requireContent(req, res);
|
|
5084
|
+
if (!nextContentRaw) {
|
|
5085
|
+
return;
|
|
5086
|
+
}
|
|
5087
|
+
const current = parseAssignmentFull(currentContent);
|
|
5088
|
+
const next = parseAssignmentFull(nextContentRaw);
|
|
5089
|
+
if (!next.slug || !next.title) {
|
|
5090
|
+
res.status(400).json({ error: "Assignment content must include slug and title." });
|
|
5091
|
+
return;
|
|
5092
|
+
}
|
|
5093
|
+
if (next.slug !== current.slug) {
|
|
5094
|
+
res.status(400).json({ error: "Assignment slug cannot be changed once created." });
|
|
5095
|
+
return;
|
|
5096
|
+
}
|
|
5097
|
+
let nextContent = nextContentRaw;
|
|
5098
|
+
if (next.status !== current.status && current.status === "blocked" && next.status !== "blocked") {
|
|
5099
|
+
nextContent = setTopLevelField(nextContent, "blockedReason", null);
|
|
5100
|
+
}
|
|
5101
|
+
nextContent = setTopLevelField(nextContent, "updated", nowTimestamp());
|
|
5102
|
+
await writeFileForce(assignmentPath, nextContent);
|
|
5103
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5104
|
+
res.json({ assignment, content: nextContent });
|
|
5105
|
+
} catch (error) {
|
|
5106
|
+
console.error("Error updating assignment:", error);
|
|
5107
|
+
res.status(500).json({ error: `Failed to update assignment: ${error.message}` });
|
|
5108
|
+
}
|
|
5109
|
+
});
|
|
5110
|
+
router.patch("/api/projects/:slug/assignments/:aslug/acceptance-criteria/:index", async (req, res) => {
|
|
5111
|
+
try {
|
|
5112
|
+
const projectSlug = getParam(req.params.slug);
|
|
5113
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5114
|
+
const assignmentPath = resolve10(
|
|
5115
|
+
projectsDir,
|
|
5116
|
+
projectSlug,
|
|
5117
|
+
"assignments",
|
|
5118
|
+
assignmentSlug,
|
|
5119
|
+
"assignment.md"
|
|
5120
|
+
);
|
|
5121
|
+
const currentContent = await readCurrentDocument(assignmentPath);
|
|
5122
|
+
if (!currentContent) {
|
|
5123
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
5124
|
+
return;
|
|
5125
|
+
}
|
|
5126
|
+
const { checked } = req.body || {};
|
|
5127
|
+
if (typeof checked !== "boolean") {
|
|
5128
|
+
res.status(400).json({ error: "checked must be a boolean" });
|
|
5129
|
+
return;
|
|
5130
|
+
}
|
|
5131
|
+
const index = Number.parseInt(getParam(req.params.index), 10);
|
|
5132
|
+
const result = toggleAcceptanceCriterion(currentContent, index, checked);
|
|
5133
|
+
if ("error" in result) {
|
|
5134
|
+
res.status(400).json({ error: result.error });
|
|
5135
|
+
return;
|
|
5136
|
+
}
|
|
5137
|
+
const nextContent = setTopLevelField(result.content, "updated", nowTimestamp());
|
|
5138
|
+
await writeFileForce(assignmentPath, nextContent);
|
|
5139
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5140
|
+
res.json({ assignment, content: nextContent });
|
|
5141
|
+
} catch (error) {
|
|
5142
|
+
console.error("Error toggling acceptance criterion:", error);
|
|
5143
|
+
res.status(500).json({ error: `Failed to toggle acceptance criterion: ${error.message}` });
|
|
5144
|
+
}
|
|
5145
|
+
});
|
|
5146
|
+
router.patch("/api/projects/:slug/assignments/:aslug/plan", async (req, res) => {
|
|
5147
|
+
try {
|
|
5148
|
+
const projectSlug = getParam(req.params.slug);
|
|
5149
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5150
|
+
const planPath = resolve10(
|
|
5151
|
+
projectsDir,
|
|
5152
|
+
projectSlug,
|
|
5153
|
+
"assignments",
|
|
5154
|
+
assignmentSlug,
|
|
5155
|
+
"plan.md"
|
|
5156
|
+
);
|
|
5157
|
+
const currentContent = await readCurrentDocument(planPath);
|
|
5158
|
+
if (!currentContent) {
|
|
5159
|
+
res.status(404).json({ error: "Plan not found" });
|
|
5160
|
+
return;
|
|
5161
|
+
}
|
|
5162
|
+
const nextContentRaw = requireContent(req, res);
|
|
5163
|
+
if (!nextContentRaw) {
|
|
5164
|
+
return;
|
|
5165
|
+
}
|
|
5166
|
+
const next = parsePlan(nextContentRaw);
|
|
5167
|
+
if (!next.assignment) {
|
|
5168
|
+
res.status(400).json({ error: "Plan content must include the assignment field." });
|
|
5169
|
+
return;
|
|
5170
|
+
}
|
|
5171
|
+
if (next.assignment !== assignmentSlug) {
|
|
5172
|
+
res.status(400).json({ error: "Plan assignment field must match the route assignment slug." });
|
|
5173
|
+
return;
|
|
5174
|
+
}
|
|
5175
|
+
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
5176
|
+
await writeFileForce(planPath, nextContent);
|
|
5177
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5178
|
+
res.json({ assignment, content: nextContent });
|
|
5179
|
+
} catch (error) {
|
|
5180
|
+
console.error("Error updating plan:", error);
|
|
5181
|
+
res.status(500).json({ error: `Failed to update plan: ${error.message}` });
|
|
3886
5182
|
}
|
|
3887
|
-
res.json(document);
|
|
3888
5183
|
});
|
|
3889
|
-
router.
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
5184
|
+
router.patch("/api/projects/:slug/assignments/:aslug/scratchpad", async (req, res) => {
|
|
5185
|
+
try {
|
|
5186
|
+
const projectSlug = getParam(req.params.slug);
|
|
5187
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5188
|
+
const scratchpadPath = resolve10(
|
|
5189
|
+
projectsDir,
|
|
5190
|
+
projectSlug,
|
|
5191
|
+
"assignments",
|
|
5192
|
+
assignmentSlug,
|
|
5193
|
+
"scratchpad.md"
|
|
5194
|
+
);
|
|
5195
|
+
const currentContent = await readCurrentDocument(scratchpadPath);
|
|
5196
|
+
if (!currentContent) {
|
|
5197
|
+
res.status(404).json({ error: "Scratchpad not found" });
|
|
5198
|
+
return;
|
|
5199
|
+
}
|
|
5200
|
+
const nextContentRaw = requireContent(req, res);
|
|
5201
|
+
if (!nextContentRaw) {
|
|
5202
|
+
return;
|
|
5203
|
+
}
|
|
5204
|
+
const next = parseScratchpad(nextContentRaw);
|
|
5205
|
+
if (!next.assignment) {
|
|
5206
|
+
res.status(400).json({ error: "Scratchpad content must include the assignment field." });
|
|
5207
|
+
return;
|
|
5208
|
+
}
|
|
5209
|
+
if (next.assignment !== assignmentSlug) {
|
|
5210
|
+
res.status(400).json({ error: "Scratchpad assignment field must match the route assignment slug." });
|
|
5211
|
+
return;
|
|
5212
|
+
}
|
|
5213
|
+
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
5214
|
+
await writeFileForce(scratchpadPath, nextContent);
|
|
5215
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5216
|
+
res.json({ assignment, content: nextContent });
|
|
5217
|
+
} catch (error) {
|
|
5218
|
+
console.error("Error updating scratchpad:", error);
|
|
5219
|
+
res.status(500).json({ error: `Failed to update scratchpad: ${error.message}` });
|
|
3901
5220
|
}
|
|
3902
|
-
res.json(document);
|
|
3903
5221
|
});
|
|
3904
|
-
router.
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
5222
|
+
router.post("/api/projects/:slug/assignments/:aslug/handoff/entries", async (req, res) => {
|
|
5223
|
+
try {
|
|
5224
|
+
const projectSlug = getParam(req.params.slug);
|
|
5225
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5226
|
+
const handoffPath = resolve10(
|
|
5227
|
+
projectsDir,
|
|
5228
|
+
projectSlug,
|
|
5229
|
+
"assignments",
|
|
5230
|
+
assignmentSlug,
|
|
5231
|
+
"handoff.md"
|
|
5232
|
+
);
|
|
5233
|
+
const currentContent = await readCurrentDocument(handoffPath);
|
|
5234
|
+
if (!currentContent) {
|
|
5235
|
+
res.status(404).json({ error: "Handoff log not found" });
|
|
5236
|
+
return;
|
|
5237
|
+
}
|
|
5238
|
+
const { title, body } = req.body || {};
|
|
5239
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
5240
|
+
res.status(400).json({ error: "body is required" });
|
|
5241
|
+
return;
|
|
5242
|
+
}
|
|
5243
|
+
const parsed = parseHandoff(currentContent);
|
|
5244
|
+
const nextContent = appendLogEntry(
|
|
5245
|
+
currentContent,
|
|
5246
|
+
"handoffCount",
|
|
5247
|
+
parsed.handoffCount + 1,
|
|
5248
|
+
title && typeof title === "string" && title.trim() ? title.trim() : `Handoff ${parsed.handoffCount + 1}`,
|
|
5249
|
+
body,
|
|
5250
|
+
"No handoffs recorded yet."
|
|
5251
|
+
);
|
|
5252
|
+
await writeFileForce(handoffPath, nextContent);
|
|
5253
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5254
|
+
res.status(201).json({ assignment, content: nextContent });
|
|
5255
|
+
} catch (error) {
|
|
5256
|
+
console.error("Error appending handoff entry:", error);
|
|
5257
|
+
res.status(500).json({ error: `Failed to append handoff entry: ${error.message}` });
|
|
3916
5258
|
}
|
|
3917
|
-
res.json(document);
|
|
3918
5259
|
});
|
|
3919
|
-
router.
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
5260
|
+
router.post("/api/projects/:slug/assignments/:aslug/decision-record/entries", async (req, res) => {
|
|
5261
|
+
try {
|
|
5262
|
+
const projectSlug = getParam(req.params.slug);
|
|
5263
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5264
|
+
const decisionPath = resolve10(
|
|
5265
|
+
projectsDir,
|
|
5266
|
+
projectSlug,
|
|
5267
|
+
"assignments",
|
|
5268
|
+
assignmentSlug,
|
|
5269
|
+
"decision-record.md"
|
|
5270
|
+
);
|
|
5271
|
+
const currentContent = await readCurrentDocument(decisionPath);
|
|
5272
|
+
if (!currentContent) {
|
|
5273
|
+
res.status(404).json({ error: "Decision record not found" });
|
|
5274
|
+
return;
|
|
5275
|
+
}
|
|
5276
|
+
const { title, body } = req.body || {};
|
|
5277
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
5278
|
+
res.status(400).json({ error: "body is required" });
|
|
5279
|
+
return;
|
|
5280
|
+
}
|
|
5281
|
+
const parsed = parseDecisionRecord(currentContent);
|
|
5282
|
+
const nextContent = appendLogEntry(
|
|
5283
|
+
currentContent,
|
|
5284
|
+
"decisionCount",
|
|
5285
|
+
parsed.decisionCount + 1,
|
|
5286
|
+
title && typeof title === "string" && title.trim() ? title.trim() : `Decision ${parsed.decisionCount + 1}`,
|
|
5287
|
+
body,
|
|
5288
|
+
"No decisions recorded yet."
|
|
5289
|
+
);
|
|
5290
|
+
await writeFileForce(decisionPath, nextContent);
|
|
5291
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5292
|
+
res.status(201).json({ assignment, content: nextContent });
|
|
5293
|
+
} catch (error) {
|
|
5294
|
+
console.error("Error appending decision entry:", error);
|
|
5295
|
+
res.status(500).json({ error: `Failed to append decision entry: ${error.message}` });
|
|
3931
5296
|
}
|
|
3932
|
-
res.json(document);
|
|
3933
5297
|
});
|
|
3934
|
-
router.
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
5298
|
+
router.post("/api/projects/:slug/assignments/:aslug/comments", async (req, res) => {
|
|
5299
|
+
try {
|
|
5300
|
+
const projectSlug = getParam(req.params.slug);
|
|
5301
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5302
|
+
const commentsPath = resolve10(
|
|
5303
|
+
projectsDir,
|
|
5304
|
+
projectSlug,
|
|
5305
|
+
"assignments",
|
|
5306
|
+
assignmentSlug,
|
|
5307
|
+
"comments.md"
|
|
5308
|
+
);
|
|
5309
|
+
const { body, author, type, replyTo } = req.body || {};
|
|
5310
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
5311
|
+
res.status(400).json({ error: "body is required" });
|
|
5312
|
+
return;
|
|
5313
|
+
}
|
|
5314
|
+
const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
|
|
5315
|
+
const timestamp = nowTimestamp();
|
|
5316
|
+
const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
|
|
5317
|
+
let currentContent;
|
|
5318
|
+
let currentCount = 0;
|
|
5319
|
+
if (await fileExists(commentsPath)) {
|
|
5320
|
+
currentContent = await readFile8(commentsPath, "utf-8");
|
|
5321
|
+
const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
|
|
5322
|
+
if (countMatch) currentCount = parseInt(countMatch[1], 10);
|
|
5323
|
+
} else {
|
|
5324
|
+
currentContent = renderComments({
|
|
5325
|
+
assignment: assignmentSlug,
|
|
5326
|
+
timestamp
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
5329
|
+
const comment = {
|
|
5330
|
+
id: generateId().split("-")[0],
|
|
5331
|
+
timestamp,
|
|
5332
|
+
author: entryAuthor,
|
|
5333
|
+
type: commentType,
|
|
5334
|
+
body,
|
|
5335
|
+
replyTo: typeof replyTo === "string" && replyTo.trim() ? replyTo.trim() : void 0,
|
|
5336
|
+
resolved: commentType === "question" ? false : void 0
|
|
5337
|
+
};
|
|
5338
|
+
const entry = formatCommentEntry(comment);
|
|
5339
|
+
let next = setTopLevelField(currentContent, "entryCount", String(currentCount + 1));
|
|
5340
|
+
next = setTopLevelField(next, "updated", `"${timestamp}"`);
|
|
5341
|
+
if (next.includes("No comments yet.")) {
|
|
5342
|
+
next = next.replace("No comments yet.", entry.trimEnd());
|
|
5343
|
+
} else {
|
|
5344
|
+
next = `${next.trimEnd()}
|
|
5345
|
+
|
|
5346
|
+
${entry}`;
|
|
5347
|
+
}
|
|
5348
|
+
await writeFileForce(commentsPath, next);
|
|
5349
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5350
|
+
res.status(201).json({ assignment, comment: { id: comment.id } });
|
|
5351
|
+
} catch (error) {
|
|
5352
|
+
console.error("Error appending comment:", error);
|
|
5353
|
+
res.status(500).json({ error: `Failed to append comment: ${error.message}` });
|
|
3946
5354
|
}
|
|
3947
|
-
res.json(document);
|
|
3948
5355
|
});
|
|
3949
|
-
router.
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
5356
|
+
router.patch("/api/projects/:slug/assignments/:aslug/comments/:commentId/resolved", async (req, res) => {
|
|
5357
|
+
try {
|
|
5358
|
+
const projectSlug = getParam(req.params.slug);
|
|
5359
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5360
|
+
const commentId = getParam(req.params.commentId);
|
|
5361
|
+
const commentsPath = resolve10(
|
|
5362
|
+
projectsDir,
|
|
5363
|
+
projectSlug,
|
|
5364
|
+
"assignments",
|
|
5365
|
+
assignmentSlug,
|
|
5366
|
+
"comments.md"
|
|
5367
|
+
);
|
|
5368
|
+
if (!await fileExists(commentsPath)) {
|
|
5369
|
+
res.status(404).json({ error: "Comments file not found" });
|
|
5370
|
+
return;
|
|
5371
|
+
}
|
|
5372
|
+
const { resolved } = req.body || {};
|
|
5373
|
+
if (typeof resolved !== "boolean") {
|
|
5374
|
+
res.status(400).json({ error: "resolved (boolean) is required" });
|
|
5375
|
+
return;
|
|
5376
|
+
}
|
|
5377
|
+
const content = await readFile8(commentsPath, "utf-8");
|
|
5378
|
+
const parsed = parseComments(content);
|
|
5379
|
+
const target = parsed.entries.find((e) => e.id === commentId);
|
|
5380
|
+
if (!target) {
|
|
5381
|
+
res.status(404).json({ error: `Comment ${commentId} not found` });
|
|
5382
|
+
return;
|
|
5383
|
+
}
|
|
5384
|
+
if (target.type !== "question") {
|
|
5385
|
+
res.status(400).json({ error: "Only questions can be resolved" });
|
|
5386
|
+
return;
|
|
5387
|
+
}
|
|
5388
|
+
const entryBlockRegex = new RegExp(
|
|
5389
|
+
`(^## ${commentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(\\*\\*Resolved:\\*\\*\\s*(?:true|false))`,
|
|
5390
|
+
"m"
|
|
5391
|
+
);
|
|
5392
|
+
const next = content.replace(
|
|
5393
|
+
entryBlockRegex,
|
|
5394
|
+
(_m, preamble) => `${preamble}**Resolved:** ${resolved ? "true" : "false"}`
|
|
5395
|
+
);
|
|
5396
|
+
if (next === content) {
|
|
5397
|
+
res.status(500).json({ error: "Failed to update resolved flag" });
|
|
5398
|
+
return;
|
|
5399
|
+
}
|
|
5400
|
+
const withUpdated = setTopLevelField(next, "updated", `"${nowTimestamp()}"`);
|
|
5401
|
+
await writeFileForce(commentsPath, withUpdated);
|
|
5402
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5403
|
+
res.json({ assignment });
|
|
5404
|
+
} catch (error) {
|
|
5405
|
+
console.error("Error toggling comment resolved flag:", error);
|
|
5406
|
+
res.status(500).json({ error: `Failed to toggle resolved: ${error.message}` });
|
|
3961
5407
|
}
|
|
3962
|
-
res.json(document);
|
|
3963
5408
|
});
|
|
3964
|
-
router.post("/api/projects", async (req, res) => {
|
|
5409
|
+
router.post("/api/projects/:slug/move-workspace", async (req, res) => {
|
|
3965
5410
|
try {
|
|
3966
|
-
const
|
|
3967
|
-
|
|
5411
|
+
const projectSlug = getParam(req.params.slug);
|
|
5412
|
+
const projectPath = resolve10(projectsDir, projectSlug, "project.md");
|
|
5413
|
+
if (!await fileExists(projectPath)) {
|
|
5414
|
+
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
3968
5415
|
return;
|
|
3969
5416
|
}
|
|
3970
|
-
const
|
|
3971
|
-
if (!
|
|
3972
|
-
res.status(400).json({ error: "
|
|
5417
|
+
const { workspace } = req.body || {};
|
|
5418
|
+
if (workspace !== null && (typeof workspace !== "string" || !workspace.trim())) {
|
|
5419
|
+
res.status(400).json({ error: "workspace must be a non-empty string or null (for ungrouped)." });
|
|
3973
5420
|
return;
|
|
3974
5421
|
}
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
5422
|
+
let content = await readFile8(projectPath, "utf-8");
|
|
5423
|
+
content = setTopLevelField(content, "workspace", workspace ?? null);
|
|
5424
|
+
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
5425
|
+
await writeFileForce(projectPath, content);
|
|
5426
|
+
const project = await getProjectDetail(projectsDir, projectSlug);
|
|
5427
|
+
res.json({ project });
|
|
5428
|
+
} catch (error) {
|
|
5429
|
+
console.error("Error moving project workspace:", error);
|
|
5430
|
+
res.status(500).json({ error: `Failed to move workspace: ${error.message}` });
|
|
5431
|
+
}
|
|
5432
|
+
});
|
|
5433
|
+
router.post("/api/projects/:slug/status-override", async (req, res) => {
|
|
5434
|
+
try {
|
|
5435
|
+
const projectSlug = getParam(req.params.slug);
|
|
5436
|
+
const projectPath = resolve10(projectsDir, projectSlug, "project.md");
|
|
5437
|
+
if (!await fileExists(projectPath)) {
|
|
5438
|
+
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
3978
5439
|
return;
|
|
3979
5440
|
}
|
|
3980
|
-
const
|
|
3981
|
-
|
|
3982
|
-
|
|
5441
|
+
const { status } = req.body || {};
|
|
5442
|
+
const config = await getStatusConfig();
|
|
5443
|
+
const validStatuses = ["active", "archived", ...config.statuses.map((s) => s.id)];
|
|
5444
|
+
if (status !== null && (typeof status !== "string" || !validStatuses.includes(status))) {
|
|
5445
|
+
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}, or null to clear.` });
|
|
3983
5446
|
return;
|
|
3984
5447
|
}
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
5448
|
+
let content = await readFile8(projectPath, "utf-8");
|
|
5449
|
+
content = setTopLevelField(content, "statusOverride", status ?? null);
|
|
5450
|
+
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
5451
|
+
await writeFileForce(projectPath, content);
|
|
5452
|
+
const project = await getProjectDetail(projectsDir, projectSlug);
|
|
5453
|
+
res.json({ project });
|
|
5454
|
+
} catch (error) {
|
|
5455
|
+
console.error("Error setting project status override:", error);
|
|
5456
|
+
res.status(500).json({ error: `Failed to set status override: ${error.message}` });
|
|
5457
|
+
}
|
|
5458
|
+
});
|
|
5459
|
+
router.post("/api/projects/:slug/assignments/:aslug/status-override", async (req, res) => {
|
|
5460
|
+
try {
|
|
5461
|
+
const projectSlug = getParam(req.params.slug);
|
|
5462
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5463
|
+
const assignmentPath = resolve10(
|
|
5464
|
+
projectsDir,
|
|
5465
|
+
projectSlug,
|
|
5466
|
+
"assignments",
|
|
5467
|
+
assignmentSlug,
|
|
5468
|
+
"assignment.md"
|
|
5469
|
+
);
|
|
5470
|
+
if (!await fileExists(assignmentPath)) {
|
|
5471
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
3988
5472
|
return;
|
|
3989
5473
|
}
|
|
3990
|
-
const
|
|
3991
|
-
const
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
try {
|
|
3997
|
-
const companions = [
|
|
3998
|
-
[resolve7(projectDir, "manifest.md"), renderManifest({ slug, timestamp })],
|
|
3999
|
-
[resolve7(projectDir, "_index-assignments.md"), renderIndexAssignments({ slug, title, timestamp })],
|
|
4000
|
-
[resolve7(projectDir, "_index-plans.md"), renderIndexPlans({ slug, title, timestamp })],
|
|
4001
|
-
[resolve7(projectDir, "_index-decisions.md"), renderIndexDecisions({ slug, title, timestamp })],
|
|
4002
|
-
[resolve7(projectDir, "_status.md"), renderStatus({ slug, title, timestamp })],
|
|
4003
|
-
[resolve7(projectDir, "resources", "_index.md"), renderResourcesIndex({ slug, title, timestamp })],
|
|
4004
|
-
[resolve7(projectDir, "memories", "_index.md"), renderMemoriesIndex({ slug, title, timestamp })]
|
|
4005
|
-
];
|
|
4006
|
-
for (const [filePath, fileContent] of companions) {
|
|
4007
|
-
await writeFileForce(filePath, fileContent);
|
|
4008
|
-
}
|
|
4009
|
-
} catch (companionError) {
|
|
4010
|
-
try {
|
|
4011
|
-
await rm(projectDir, { recursive: true, force: true });
|
|
4012
|
-
} catch {
|
|
4013
|
-
}
|
|
4014
|
-
throw companionError;
|
|
5474
|
+
const { status } = req.body || {};
|
|
5475
|
+
const config = await getStatusConfig();
|
|
5476
|
+
const validStatuses = config.statuses.map((s) => s.id);
|
|
5477
|
+
if (typeof status !== "string" || !validStatuses.includes(status)) {
|
|
5478
|
+
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
|
|
5479
|
+
return;
|
|
4015
5480
|
}
|
|
4016
|
-
|
|
5481
|
+
let content = await readFile8(assignmentPath, "utf-8");
|
|
5482
|
+
content = setTopLevelField(content, "status", status);
|
|
5483
|
+
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
5484
|
+
if (status !== "blocked") {
|
|
5485
|
+
content = setTopLevelField(content, "blockedReason", null);
|
|
5486
|
+
}
|
|
5487
|
+
await writeFileForce(assignmentPath, content);
|
|
5488
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5489
|
+
res.json({ assignment });
|
|
4017
5490
|
} catch (error) {
|
|
4018
|
-
console.error("Error
|
|
4019
|
-
res.status(500).json({ error: `Failed to
|
|
5491
|
+
console.error("Error overriding assignment status:", error);
|
|
5492
|
+
res.status(500).json({ error: `Failed to override status: ${error.message}` });
|
|
4020
5493
|
}
|
|
4021
5494
|
});
|
|
4022
|
-
router.post("/api/projects/:slug/assignments", async (req, res) => {
|
|
5495
|
+
router.post("/api/projects/:slug/assignments/:aslug/transitions/:command", async (req, res) => {
|
|
4023
5496
|
try {
|
|
4024
5497
|
const projectSlug = getParam(req.params.slug);
|
|
4025
|
-
const
|
|
4026
|
-
const
|
|
4027
|
-
|
|
4028
|
-
|
|
5498
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5499
|
+
const command = req.params.command;
|
|
5500
|
+
const config = await getStatusConfig();
|
|
5501
|
+
const validCommands = [...new Set(config.transitions.map((t) => t.command))];
|
|
5502
|
+
if (!validCommands.includes(command)) {
|
|
5503
|
+
res.status(400).json({ error: `Unsupported transition command "${req.params.command}"` });
|
|
4029
5504
|
return;
|
|
4030
5505
|
}
|
|
4031
|
-
const
|
|
4032
|
-
|
|
5506
|
+
const projectDir = resolve10(projectsDir, projectSlug);
|
|
5507
|
+
const assignmentPath = resolve10(projectDir, "assignments", assignmentSlug, "assignment.md");
|
|
5508
|
+
if (!await fileExists(assignmentPath)) {
|
|
5509
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
4033
5510
|
return;
|
|
4034
5511
|
}
|
|
4035
|
-
const
|
|
4036
|
-
|
|
4037
|
-
|
|
5512
|
+
const { reason } = req.body || {};
|
|
5513
|
+
const result = await executeTransition(projectDir, assignmentSlug, command, {
|
|
5514
|
+
reason: typeof reason === "string" ? reason : void 0,
|
|
5515
|
+
transitionTable: config.custom ? config.transitionTable : void 0,
|
|
5516
|
+
terminalStatuses: config.custom ? config.terminalStatuses : void 0
|
|
5517
|
+
});
|
|
5518
|
+
if (!result.success) {
|
|
5519
|
+
res.status(400).json({ error: result.message });
|
|
4038
5520
|
return;
|
|
4039
5521
|
}
|
|
4040
|
-
const
|
|
4041
|
-
|
|
4042
|
-
|
|
5522
|
+
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
5523
|
+
res.json({ assignment, transition: result });
|
|
5524
|
+
} catch (error) {
|
|
5525
|
+
console.error("Error running assignment transition:", error);
|
|
5526
|
+
res.status(500).json({ error: `Failed to transition assignment: ${error.message}` });
|
|
5527
|
+
}
|
|
5528
|
+
});
|
|
5529
|
+
router.delete("/api/projects/:slug/assignments/:aslug", async (req, res) => {
|
|
5530
|
+
try {
|
|
5531
|
+
const projectSlug = getParam(req.params.slug);
|
|
5532
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
5533
|
+
const assignmentDir = resolve10(projectsDir, projectSlug, "assignments", assignmentSlug);
|
|
5534
|
+
const assignmentPath = resolve10(assignmentDir, "assignment.md");
|
|
5535
|
+
if (!await fileExists(assignmentPath)) {
|
|
5536
|
+
res.status(404).json({ error: `Assignment "${assignmentSlug}" not found in project "${projectSlug}"` });
|
|
4043
5537
|
return;
|
|
4044
5538
|
}
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
5539
|
+
await rm(assignmentDir, { recursive: true, force: true });
|
|
5540
|
+
res.json({ deleted: assignmentSlug, projectSlug });
|
|
5541
|
+
} catch (error) {
|
|
5542
|
+
console.error("Error deleting assignment:", error);
|
|
5543
|
+
res.status(500).json({ error: `Failed to delete assignment: ${error.message}` });
|
|
5544
|
+
}
|
|
5545
|
+
});
|
|
5546
|
+
router.post("/api/assignments", async (req, res) => {
|
|
5547
|
+
try {
|
|
5548
|
+
if (!assignmentsDir) {
|
|
5549
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4048
5550
|
return;
|
|
4049
5551
|
}
|
|
4050
|
-
const
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
5552
|
+
const { title, slug, priority, type } = req.body || {};
|
|
5553
|
+
if (!title || typeof title !== "string" || !title.trim()) {
|
|
5554
|
+
res.status(400).json({ error: "title is required" });
|
|
5555
|
+
return;
|
|
5556
|
+
}
|
|
5557
|
+
const { dependsOn } = req.body || {};
|
|
5558
|
+
if (Array.isArray(dependsOn) && dependsOn.length > 0) {
|
|
5559
|
+
res.status(400).json({ error: "Standalone assignments cannot declare dependsOn." });
|
|
4054
5560
|
return;
|
|
4055
5561
|
}
|
|
4056
|
-
const
|
|
5562
|
+
const id = generateId();
|
|
5563
|
+
const assignmentDir = resolve10(assignmentsDir, id);
|
|
4057
5564
|
if (await fileExists(assignmentDir)) {
|
|
4058
|
-
res.status(
|
|
4059
|
-
error: `Assignment "${assignmentSlug}" already exists in project "${projectSlug}"`
|
|
4060
|
-
});
|
|
5565
|
+
res.status(500).json({ error: "UUID collision \u2014 try again" });
|
|
4061
5566
|
return;
|
|
4062
5567
|
}
|
|
4063
|
-
const timestamp =
|
|
5568
|
+
const timestamp = nowTimestamp();
|
|
5569
|
+
const resolvedSlug = typeof slug === "string" && slug.trim() ? slug.trim() : slugifyLocal(title);
|
|
5570
|
+
const resolvedPriority = typeof priority === "string" && ["low", "medium", "high", "critical"].includes(priority) ? priority : "medium";
|
|
4064
5571
|
await ensureDir(assignmentDir);
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
]
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
}
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
}
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
5572
|
+
const assignmentContent = renderAssignment({
|
|
5573
|
+
id,
|
|
5574
|
+
slug: resolvedSlug,
|
|
5575
|
+
title: title.trim(),
|
|
5576
|
+
timestamp,
|
|
5577
|
+
priority: resolvedPriority,
|
|
5578
|
+
dependsOn: [],
|
|
5579
|
+
links: [],
|
|
5580
|
+
project: null,
|
|
5581
|
+
type: typeof type === "string" ? type : void 0
|
|
5582
|
+
});
|
|
5583
|
+
await writeFileForce(resolve10(assignmentDir, "assignment.md"), assignmentContent);
|
|
5584
|
+
await writeFileForce(
|
|
5585
|
+
resolve10(assignmentDir, "scratchpad.md"),
|
|
5586
|
+
renderScratchpad({ assignmentSlug: id, timestamp })
|
|
5587
|
+
);
|
|
5588
|
+
await writeFileForce(
|
|
5589
|
+
resolve10(assignmentDir, "handoff.md"),
|
|
5590
|
+
renderHandoff({ assignmentSlug: id, timestamp })
|
|
5591
|
+
);
|
|
5592
|
+
await writeFileForce(
|
|
5593
|
+
resolve10(assignmentDir, "decision-record.md"),
|
|
5594
|
+
renderDecisionRecord({ assignmentSlug: id, timestamp })
|
|
5595
|
+
);
|
|
5596
|
+
await writeFileForce(
|
|
5597
|
+
resolve10(assignmentDir, "progress.md"),
|
|
5598
|
+
renderProgress({ assignment: id, timestamp })
|
|
5599
|
+
);
|
|
5600
|
+
await writeFileForce(
|
|
5601
|
+
resolve10(assignmentDir, "comments.md"),
|
|
5602
|
+
renderComments({ assignment: id, timestamp })
|
|
5603
|
+
);
|
|
5604
|
+
const detail = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
5605
|
+
res.status(201).json({ assignment: detail });
|
|
4083
5606
|
} catch (error) {
|
|
4084
|
-
console.error("Error creating assignment:", error);
|
|
4085
|
-
res.status(500).json({ error: `Failed to create assignment: ${error.message}` });
|
|
5607
|
+
console.error("Error creating standalone assignment:", error);
|
|
5608
|
+
res.status(500).json({ error: `Failed to create standalone assignment: ${error.message}` });
|
|
4086
5609
|
}
|
|
4087
5610
|
});
|
|
4088
|
-
router.
|
|
5611
|
+
router.post("/api/assignments/:id/comments", async (req, res) => {
|
|
4089
5612
|
try {
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
const currentContent = await readCurrentDocument(projectPath);
|
|
4093
|
-
if (!currentContent) {
|
|
4094
|
-
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
5613
|
+
if (!assignmentsDir) {
|
|
5614
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4095
5615
|
return;
|
|
4096
5616
|
}
|
|
4097
|
-
const
|
|
4098
|
-
|
|
5617
|
+
const id = getParam(req.params.id);
|
|
5618
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5619
|
+
if (!resolved) {
|
|
5620
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
4099
5621
|
return;
|
|
4100
5622
|
}
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
5623
|
+
await appendCommentTo(resolved.assignmentDir, resolved.standalone ? resolved.id : resolved.assignmentSlug, req, res, async () => {
|
|
5624
|
+
return resolved.standalone ? getAssignmentDetailById(projectsDir, assignmentsDir, id) : getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
|
|
5625
|
+
});
|
|
5626
|
+
} catch (error) {
|
|
5627
|
+
console.error("Error appending comment (by id):", error);
|
|
5628
|
+
res.status(500).json({ error: `Failed to append comment: ${error.message}` });
|
|
5629
|
+
}
|
|
5630
|
+
});
|
|
5631
|
+
router.patch("/api/assignments/:id/comments/:commentId/resolved", async (req, res) => {
|
|
5632
|
+
try {
|
|
5633
|
+
if (!assignmentsDir) {
|
|
5634
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4105
5635
|
return;
|
|
4106
5636
|
}
|
|
4107
|
-
|
|
4108
|
-
|
|
5637
|
+
const id = getParam(req.params.id);
|
|
5638
|
+
const commentId = getParam(req.params.commentId);
|
|
5639
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5640
|
+
if (!resolved) {
|
|
5641
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
4109
5642
|
return;
|
|
4110
5643
|
}
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
res.json({ project, content: nextContent });
|
|
5644
|
+
await toggleCommentResolvedAt(resolved.assignmentDir, commentId, req, res, async () => {
|
|
5645
|
+
return resolved.standalone ? getAssignmentDetailById(projectsDir, assignmentsDir, id) : getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
|
|
5646
|
+
});
|
|
4115
5647
|
} catch (error) {
|
|
4116
|
-
console.error("Error
|
|
4117
|
-
res.status(500).json({ error: `Failed to
|
|
5648
|
+
console.error("Error toggling comment resolved (by id):", error);
|
|
5649
|
+
res.status(500).json({ error: `Failed to toggle resolved: ${error.message}` });
|
|
4118
5650
|
}
|
|
4119
5651
|
});
|
|
4120
|
-
router.
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
5652
|
+
router.get("/api/assignments/:id/edit", async (req, res) => {
|
|
5653
|
+
if (!assignmentsDir) {
|
|
5654
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5655
|
+
return;
|
|
5656
|
+
}
|
|
5657
|
+
const id = getParam(req.params.id);
|
|
5658
|
+
const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "assignment", id);
|
|
5659
|
+
if (!doc) {
|
|
5660
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
5661
|
+
return;
|
|
5662
|
+
}
|
|
5663
|
+
res.json(doc);
|
|
5664
|
+
});
|
|
5665
|
+
router.get("/api/assignments/:id/plan/edit", async (req, res) => {
|
|
5666
|
+
if (!assignmentsDir) {
|
|
5667
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5668
|
+
return;
|
|
5669
|
+
}
|
|
5670
|
+
const id = getParam(req.params.id);
|
|
5671
|
+
const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "plan", id);
|
|
5672
|
+
if (!doc) {
|
|
5673
|
+
res.status(404).json({ error: "Plan not found" });
|
|
5674
|
+
return;
|
|
5675
|
+
}
|
|
5676
|
+
res.json(doc);
|
|
5677
|
+
});
|
|
5678
|
+
router.get("/api/assignments/:id/scratchpad/edit", async (req, res) => {
|
|
5679
|
+
if (!assignmentsDir) {
|
|
5680
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5681
|
+
return;
|
|
5682
|
+
}
|
|
5683
|
+
const id = getParam(req.params.id);
|
|
5684
|
+
const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "scratchpad", id);
|
|
5685
|
+
if (!doc) {
|
|
5686
|
+
res.status(404).json({ error: "Scratchpad not found" });
|
|
5687
|
+
return;
|
|
5688
|
+
}
|
|
5689
|
+
res.json(doc);
|
|
5690
|
+
});
|
|
5691
|
+
router.get("/api/assignments/:id/handoff/edit", async (req, res) => {
|
|
5692
|
+
if (!assignmentsDir) {
|
|
5693
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5694
|
+
return;
|
|
5695
|
+
}
|
|
5696
|
+
const id = getParam(req.params.id);
|
|
5697
|
+
const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "handoff", id);
|
|
5698
|
+
if (!doc) {
|
|
5699
|
+
res.status(404).json({ error: "Handoff log not found" });
|
|
5700
|
+
return;
|
|
5701
|
+
}
|
|
5702
|
+
res.json(doc);
|
|
5703
|
+
});
|
|
5704
|
+
router.get("/api/assignments/:id/decision-record/edit", async (req, res) => {
|
|
5705
|
+
if (!assignmentsDir) {
|
|
5706
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5707
|
+
return;
|
|
5708
|
+
}
|
|
5709
|
+
const id = getParam(req.params.id);
|
|
5710
|
+
const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "decision-record", id);
|
|
5711
|
+
if (!doc) {
|
|
5712
|
+
res.status(404).json({ error: "Decision record not found" });
|
|
5713
|
+
return;
|
|
5714
|
+
}
|
|
5715
|
+
res.json(doc);
|
|
5716
|
+
});
|
|
5717
|
+
router.patch("/api/assignments/:id", async (req, res) => {
|
|
5718
|
+
try {
|
|
5719
|
+
if (!assignmentsDir) {
|
|
5720
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5721
|
+
return;
|
|
5722
|
+
}
|
|
5723
|
+
const id = getParam(req.params.id);
|
|
5724
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5725
|
+
if (!resolved) {
|
|
5726
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
5727
|
+
return;
|
|
5728
|
+
}
|
|
5729
|
+
const assignmentPath = resolve10(resolved.assignmentDir, "assignment.md");
|
|
4131
5730
|
const currentContent = await readCurrentDocument(assignmentPath);
|
|
4132
5731
|
if (!currentContent) {
|
|
4133
5732
|
res.status(404).json({ error: "Assignment not found" });
|
|
4134
5733
|
return;
|
|
4135
5734
|
}
|
|
4136
5735
|
const nextContentRaw = requireContent(req, res);
|
|
4137
|
-
if (!nextContentRaw)
|
|
4138
|
-
return;
|
|
4139
|
-
}
|
|
5736
|
+
if (!nextContentRaw) return;
|
|
4140
5737
|
const current = parseAssignmentFull(currentContent);
|
|
4141
5738
|
const next = parseAssignmentFull(nextContentRaw);
|
|
4142
|
-
if (!next.
|
|
4143
|
-
res.status(400).json({ error: "Assignment content must include
|
|
4144
|
-
return;
|
|
4145
|
-
}
|
|
4146
|
-
if (next.slug !== current.slug) {
|
|
4147
|
-
res.status(400).json({ error: "Assignment slug cannot be changed once created." });
|
|
5739
|
+
if (!next.title) {
|
|
5740
|
+
res.status(400).json({ error: "Assignment content must include a title." });
|
|
4148
5741
|
return;
|
|
4149
5742
|
}
|
|
4150
5743
|
let nextContent = nextContentRaw;
|
|
5744
|
+
if (current.id) nextContent = setTopLevelField(nextContent, "id", current.id);
|
|
5745
|
+
nextContent = setTopLevelField(nextContent, "project", null);
|
|
5746
|
+
if (current.slug) nextContent = setTopLevelField(nextContent, "slug", current.slug);
|
|
4151
5747
|
if (next.status !== current.status && current.status === "blocked" && next.status !== "blocked") {
|
|
4152
5748
|
nextContent = setTopLevelField(nextContent, "blockedReason", null);
|
|
4153
5749
|
}
|
|
4154
5750
|
nextContent = setTopLevelField(nextContent, "updated", nowTimestamp());
|
|
4155
5751
|
await writeFileForce(assignmentPath, nextContent);
|
|
4156
|
-
const assignment = await
|
|
5752
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
4157
5753
|
res.json({ assignment, content: nextContent });
|
|
4158
5754
|
} catch (error) {
|
|
4159
|
-
console.error("Error updating assignment:", error);
|
|
5755
|
+
console.error("Error updating standalone assignment:", error);
|
|
4160
5756
|
res.status(500).json({ error: `Failed to update assignment: ${error.message}` });
|
|
4161
5757
|
}
|
|
4162
5758
|
});
|
|
4163
|
-
router.patch("/api/
|
|
5759
|
+
router.patch("/api/assignments/:id/plan", async (req, res) => {
|
|
4164
5760
|
try {
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
const assignmentPath = resolve7(
|
|
4168
|
-
projectsDir,
|
|
4169
|
-
projectSlug,
|
|
4170
|
-
"assignments",
|
|
4171
|
-
assignmentSlug,
|
|
4172
|
-
"assignment.md"
|
|
4173
|
-
);
|
|
4174
|
-
const currentContent = await readCurrentDocument(assignmentPath);
|
|
4175
|
-
if (!currentContent) {
|
|
4176
|
-
res.status(404).json({ error: "Assignment not found" });
|
|
4177
|
-
return;
|
|
4178
|
-
}
|
|
4179
|
-
const { checked } = req.body || {};
|
|
4180
|
-
if (typeof checked !== "boolean") {
|
|
4181
|
-
res.status(400).json({ error: "checked must be a boolean" });
|
|
5761
|
+
if (!assignmentsDir) {
|
|
5762
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4182
5763
|
return;
|
|
4183
5764
|
}
|
|
4184
|
-
const
|
|
4185
|
-
const
|
|
4186
|
-
if (
|
|
4187
|
-
res.status(
|
|
5765
|
+
const id = getParam(req.params.id);
|
|
5766
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5767
|
+
if (!resolved) {
|
|
5768
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
4188
5769
|
return;
|
|
4189
5770
|
}
|
|
4190
|
-
const
|
|
4191
|
-
await writeFileForce(assignmentPath, nextContent);
|
|
4192
|
-
const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
|
|
4193
|
-
res.json({ assignment, content: nextContent });
|
|
4194
|
-
} catch (error) {
|
|
4195
|
-
console.error("Error toggling acceptance criterion:", error);
|
|
4196
|
-
res.status(500).json({ error: `Failed to toggle acceptance criterion: ${error.message}` });
|
|
4197
|
-
}
|
|
4198
|
-
});
|
|
4199
|
-
router.patch("/api/projects/:slug/assignments/:aslug/plan", async (req, res) => {
|
|
4200
|
-
try {
|
|
4201
|
-
const projectSlug = getParam(req.params.slug);
|
|
4202
|
-
const assignmentSlug = getParam(req.params.aslug);
|
|
4203
|
-
const planPath = resolve7(
|
|
4204
|
-
projectsDir,
|
|
4205
|
-
projectSlug,
|
|
4206
|
-
"assignments",
|
|
4207
|
-
assignmentSlug,
|
|
4208
|
-
"plan.md"
|
|
4209
|
-
);
|
|
5771
|
+
const planPath = resolve10(resolved.assignmentDir, "plan.md");
|
|
4210
5772
|
const currentContent = await readCurrentDocument(planPath);
|
|
4211
5773
|
if (!currentContent) {
|
|
4212
5774
|
res.status(404).json({ error: "Plan not found" });
|
|
4213
5775
|
return;
|
|
4214
5776
|
}
|
|
4215
5777
|
const nextContentRaw = requireContent(req, res);
|
|
4216
|
-
if (!nextContentRaw)
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
const next = parsePlan(nextContentRaw);
|
|
4220
|
-
if (!next.assignment) {
|
|
5778
|
+
if (!nextContentRaw) return;
|
|
5779
|
+
const parsed = parsePlan(nextContentRaw);
|
|
5780
|
+
if (!parsed.assignment) {
|
|
4221
5781
|
res.status(400).json({ error: "Plan content must include the assignment field." });
|
|
4222
5782
|
return;
|
|
4223
5783
|
}
|
|
4224
|
-
if (next.assignment !== assignmentSlug) {
|
|
4225
|
-
res.status(400).json({ error: "Plan assignment field must match the route assignment slug." });
|
|
4226
|
-
return;
|
|
4227
|
-
}
|
|
4228
5784
|
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
4229
5785
|
await writeFileForce(planPath, nextContent);
|
|
4230
|
-
const assignment = await
|
|
5786
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
4231
5787
|
res.json({ assignment, content: nextContent });
|
|
4232
5788
|
} catch (error) {
|
|
4233
|
-
console.error("Error updating plan:", error);
|
|
5789
|
+
console.error("Error updating standalone plan:", error);
|
|
4234
5790
|
res.status(500).json({ error: `Failed to update plan: ${error.message}` });
|
|
4235
5791
|
}
|
|
4236
5792
|
});
|
|
4237
|
-
router.patch("/api/
|
|
5793
|
+
router.patch("/api/assignments/:id/scratchpad", async (req, res) => {
|
|
4238
5794
|
try {
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
5795
|
+
if (!assignmentsDir) {
|
|
5796
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5797
|
+
return;
|
|
5798
|
+
}
|
|
5799
|
+
const id = getParam(req.params.id);
|
|
5800
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5801
|
+
if (!resolved) {
|
|
5802
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
5803
|
+
return;
|
|
5804
|
+
}
|
|
5805
|
+
const scratchpadPath = resolve10(resolved.assignmentDir, "scratchpad.md");
|
|
4248
5806
|
const currentContent = await readCurrentDocument(scratchpadPath);
|
|
4249
5807
|
if (!currentContent) {
|
|
4250
5808
|
res.status(404).json({ error: "Scratchpad not found" });
|
|
4251
5809
|
return;
|
|
4252
5810
|
}
|
|
4253
5811
|
const nextContentRaw = requireContent(req, res);
|
|
4254
|
-
if (!nextContentRaw)
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
const next = parseScratchpad(nextContentRaw);
|
|
4258
|
-
if (!next.assignment) {
|
|
5812
|
+
if (!nextContentRaw) return;
|
|
5813
|
+
const parsed = parseScratchpad(nextContentRaw);
|
|
5814
|
+
if (!parsed.assignment) {
|
|
4259
5815
|
res.status(400).json({ error: "Scratchpad content must include the assignment field." });
|
|
4260
5816
|
return;
|
|
4261
5817
|
}
|
|
4262
|
-
if (next.assignment !== assignmentSlug) {
|
|
4263
|
-
res.status(400).json({ error: "Scratchpad assignment field must match the route assignment slug." });
|
|
4264
|
-
return;
|
|
4265
|
-
}
|
|
4266
5818
|
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
4267
5819
|
await writeFileForce(scratchpadPath, nextContent);
|
|
4268
|
-
const assignment = await
|
|
5820
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
4269
5821
|
res.json({ assignment, content: nextContent });
|
|
4270
5822
|
} catch (error) {
|
|
4271
|
-
console.error("Error updating scratchpad:", error);
|
|
5823
|
+
console.error("Error updating standalone scratchpad:", error);
|
|
4272
5824
|
res.status(500).json({ error: `Failed to update scratchpad: ${error.message}` });
|
|
4273
5825
|
}
|
|
4274
5826
|
});
|
|
4275
|
-
router.post("/api/
|
|
5827
|
+
router.post("/api/assignments/:id/handoff/entries", async (req, res) => {
|
|
4276
5828
|
try {
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
5829
|
+
if (!assignmentsDir) {
|
|
5830
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5831
|
+
return;
|
|
5832
|
+
}
|
|
5833
|
+
const id = getParam(req.params.id);
|
|
5834
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5835
|
+
if (!resolved) {
|
|
5836
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
5837
|
+
return;
|
|
5838
|
+
}
|
|
5839
|
+
const handoffPath = resolve10(resolved.assignmentDir, "handoff.md");
|
|
4286
5840
|
const currentContent = await readCurrentDocument(handoffPath);
|
|
4287
5841
|
if (!currentContent) {
|
|
4288
5842
|
res.status(404).json({ error: "Handoff log not found" });
|
|
@@ -4303,24 +5857,26 @@ function createWriteRouter(projectsDir) {
|
|
|
4303
5857
|
"No handoffs recorded yet."
|
|
4304
5858
|
);
|
|
4305
5859
|
await writeFileForce(handoffPath, nextContent);
|
|
4306
|
-
const assignment = await
|
|
5860
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
4307
5861
|
res.status(201).json({ assignment, content: nextContent });
|
|
4308
5862
|
} catch (error) {
|
|
4309
|
-
console.error("Error appending handoff entry:", error);
|
|
5863
|
+
console.error("Error appending standalone handoff entry:", error);
|
|
4310
5864
|
res.status(500).json({ error: `Failed to append handoff entry: ${error.message}` });
|
|
4311
5865
|
}
|
|
4312
5866
|
});
|
|
4313
|
-
router.post("/api/
|
|
5867
|
+
router.post("/api/assignments/:id/decision-record/entries", async (req, res) => {
|
|
4314
5868
|
try {
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
5869
|
+
if (!assignmentsDir) {
|
|
5870
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
5871
|
+
return;
|
|
5872
|
+
}
|
|
5873
|
+
const id = getParam(req.params.id);
|
|
5874
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5875
|
+
if (!resolved) {
|
|
5876
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
5877
|
+
return;
|
|
5878
|
+
}
|
|
5879
|
+
const decisionPath = resolve10(resolved.assignmentDir, "decision-record.md");
|
|
4324
5880
|
const currentContent = await readCurrentDocument(decisionPath);
|
|
4325
5881
|
if (!currentContent) {
|
|
4326
5882
|
res.status(404).json({ error: "Decision record not found" });
|
|
@@ -4341,74 +5897,26 @@ function createWriteRouter(projectsDir) {
|
|
|
4341
5897
|
"No decisions recorded yet."
|
|
4342
5898
|
);
|
|
4343
5899
|
await writeFileForce(decisionPath, nextContent);
|
|
4344
|
-
const assignment = await
|
|
5900
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
4345
5901
|
res.status(201).json({ assignment, content: nextContent });
|
|
4346
5902
|
} catch (error) {
|
|
4347
|
-
console.error("Error appending decision entry:", error);
|
|
5903
|
+
console.error("Error appending standalone decision entry:", error);
|
|
4348
5904
|
res.status(500).json({ error: `Failed to append decision entry: ${error.message}` });
|
|
4349
5905
|
}
|
|
4350
5906
|
});
|
|
4351
|
-
router.post("/api/
|
|
4352
|
-
try {
|
|
4353
|
-
const projectSlug = getParam(req.params.slug);
|
|
4354
|
-
const projectPath = resolve7(projectsDir, projectSlug, "project.md");
|
|
4355
|
-
if (!await fileExists(projectPath)) {
|
|
4356
|
-
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
4357
|
-
return;
|
|
4358
|
-
}
|
|
4359
|
-
const { workspace } = req.body || {};
|
|
4360
|
-
if (workspace !== null && (typeof workspace !== "string" || !workspace.trim())) {
|
|
4361
|
-
res.status(400).json({ error: "workspace must be a non-empty string or null (for ungrouped)." });
|
|
4362
|
-
return;
|
|
4363
|
-
}
|
|
4364
|
-
let content = await readFile6(projectPath, "utf-8");
|
|
4365
|
-
content = setTopLevelField(content, "workspace", workspace ?? null);
|
|
4366
|
-
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
4367
|
-
await writeFileForce(projectPath, content);
|
|
4368
|
-
const project = await getProjectDetail(projectsDir, projectSlug);
|
|
4369
|
-
res.json({ project });
|
|
4370
|
-
} catch (error) {
|
|
4371
|
-
console.error("Error moving project workspace:", error);
|
|
4372
|
-
res.status(500).json({ error: `Failed to move workspace: ${error.message}` });
|
|
4373
|
-
}
|
|
4374
|
-
});
|
|
4375
|
-
router.post("/api/projects/:slug/status-override", async (req, res) => {
|
|
5907
|
+
router.post("/api/assignments/:id/status-override", async (req, res) => {
|
|
4376
5908
|
try {
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
if (!await fileExists(projectPath)) {
|
|
4380
|
-
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
5909
|
+
if (!assignmentsDir) {
|
|
5910
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4381
5911
|
return;
|
|
4382
5912
|
}
|
|
4383
|
-
const
|
|
4384
|
-
const
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}, or null to clear.` });
|
|
5913
|
+
const id = getParam(req.params.id);
|
|
5914
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5915
|
+
if (!resolved) {
|
|
5916
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
4388
5917
|
return;
|
|
4389
5918
|
}
|
|
4390
|
-
|
|
4391
|
-
content = setTopLevelField(content, "statusOverride", status ?? null);
|
|
4392
|
-
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
4393
|
-
await writeFileForce(projectPath, content);
|
|
4394
|
-
const project = await getProjectDetail(projectsDir, projectSlug);
|
|
4395
|
-
res.json({ project });
|
|
4396
|
-
} catch (error) {
|
|
4397
|
-
console.error("Error setting project status override:", error);
|
|
4398
|
-
res.status(500).json({ error: `Failed to set status override: ${error.message}` });
|
|
4399
|
-
}
|
|
4400
|
-
});
|
|
4401
|
-
router.post("/api/projects/:slug/assignments/:aslug/status-override", async (req, res) => {
|
|
4402
|
-
try {
|
|
4403
|
-
const projectSlug = getParam(req.params.slug);
|
|
4404
|
-
const assignmentSlug = getParam(req.params.aslug);
|
|
4405
|
-
const assignmentPath = resolve7(
|
|
4406
|
-
projectsDir,
|
|
4407
|
-
projectSlug,
|
|
4408
|
-
"assignments",
|
|
4409
|
-
assignmentSlug,
|
|
4410
|
-
"assignment.md"
|
|
4411
|
-
);
|
|
5919
|
+
const assignmentPath = resolve10(resolved.assignmentDir, "assignment.md");
|
|
4412
5920
|
if (!await fileExists(assignmentPath)) {
|
|
4413
5921
|
res.status(404).json({ error: "Assignment not found" });
|
|
4414
5922
|
return;
|
|
@@ -4420,83 +5928,184 @@ function createWriteRouter(projectsDir) {
|
|
|
4420
5928
|
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
|
|
4421
5929
|
return;
|
|
4422
5930
|
}
|
|
4423
|
-
let content = await
|
|
5931
|
+
let content = await readFile8(assignmentPath, "utf-8");
|
|
4424
5932
|
content = setTopLevelField(content, "status", status);
|
|
4425
5933
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
4426
5934
|
if (status !== "blocked") {
|
|
4427
5935
|
content = setTopLevelField(content, "blockedReason", null);
|
|
4428
5936
|
}
|
|
4429
5937
|
await writeFileForce(assignmentPath, content);
|
|
4430
|
-
const assignment = await
|
|
5938
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
4431
5939
|
res.json({ assignment });
|
|
4432
5940
|
} catch (error) {
|
|
4433
|
-
console.error("Error overriding
|
|
5941
|
+
console.error("Error overriding standalone status:", error);
|
|
4434
5942
|
res.status(500).json({ error: `Failed to override status: ${error.message}` });
|
|
4435
5943
|
}
|
|
4436
5944
|
});
|
|
4437
|
-
router.
|
|
5945
|
+
router.patch("/api/assignments/:id/acceptance-criteria/:index", async (req, res) => {
|
|
4438
5946
|
try {
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
const command = req.params.command;
|
|
4442
|
-
const config = await getStatusConfig();
|
|
4443
|
-
const validCommands = [...new Set(config.transitions.map((t) => t.command))];
|
|
4444
|
-
if (!validCommands.includes(command)) {
|
|
4445
|
-
res.status(400).json({ error: `Unsupported transition command "${req.params.command}"` });
|
|
5947
|
+
if (!assignmentsDir) {
|
|
5948
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4446
5949
|
return;
|
|
4447
5950
|
}
|
|
4448
|
-
const
|
|
4449
|
-
const
|
|
4450
|
-
if (!
|
|
5951
|
+
const id = getParam(req.params.id);
|
|
5952
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5953
|
+
if (!resolved) {
|
|
5954
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
5955
|
+
return;
|
|
5956
|
+
}
|
|
5957
|
+
const assignmentPath = resolve10(resolved.assignmentDir, "assignment.md");
|
|
5958
|
+
const currentContent = await readCurrentDocument(assignmentPath);
|
|
5959
|
+
if (!currentContent) {
|
|
4451
5960
|
res.status(404).json({ error: "Assignment not found" });
|
|
4452
5961
|
return;
|
|
4453
5962
|
}
|
|
4454
|
-
const {
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
transitionTable: config.custom ? config.transitionTable : void 0,
|
|
4458
|
-
terminalStatuses: config.custom ? config.terminalStatuses : void 0
|
|
4459
|
-
});
|
|
4460
|
-
if (!result.success) {
|
|
4461
|
-
res.status(400).json({ error: result.message });
|
|
5963
|
+
const { checked } = req.body || {};
|
|
5964
|
+
if (typeof checked !== "boolean") {
|
|
5965
|
+
res.status(400).json({ error: "checked must be a boolean" });
|
|
4462
5966
|
return;
|
|
4463
5967
|
}
|
|
4464
|
-
const
|
|
4465
|
-
|
|
5968
|
+
const index = Number.parseInt(getParam(req.params.index), 10);
|
|
5969
|
+
const result = toggleAcceptanceCriterion(currentContent, index, checked);
|
|
5970
|
+
if ("error" in result) {
|
|
5971
|
+
res.status(400).json({ error: result.error });
|
|
5972
|
+
return;
|
|
5973
|
+
}
|
|
5974
|
+
const nextContent = setTopLevelField(result.content, "updated", nowTimestamp());
|
|
5975
|
+
await writeFileForce(assignmentPath, nextContent);
|
|
5976
|
+
const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
|
|
5977
|
+
res.json({ assignment, content: nextContent });
|
|
4466
5978
|
} catch (error) {
|
|
4467
|
-
console.error("Error
|
|
4468
|
-
res.status(500).json({ error: `Failed to
|
|
5979
|
+
console.error("Error toggling standalone acceptance criterion:", error);
|
|
5980
|
+
res.status(500).json({ error: `Failed to toggle acceptance criterion: ${error.message}` });
|
|
4469
5981
|
}
|
|
4470
5982
|
});
|
|
4471
|
-
router.
|
|
5983
|
+
router.post("/api/assignments/:id/transitions/:command", async (req, res) => {
|
|
4472
5984
|
try {
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
const assignmentDir = resolve7(projectsDir, projectSlug, "assignments", assignmentSlug);
|
|
4476
|
-
const assignmentPath = resolve7(assignmentDir, "assignment.md");
|
|
4477
|
-
if (!await fileExists(assignmentPath)) {
|
|
4478
|
-
res.status(404).json({ error: `Assignment "${assignmentSlug}" not found in project "${projectSlug}"` });
|
|
5985
|
+
if (!assignmentsDir) {
|
|
5986
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
4479
5987
|
return;
|
|
4480
5988
|
}
|
|
4481
|
-
|
|
4482
|
-
|
|
5989
|
+
const id = getParam(req.params.id);
|
|
5990
|
+
const command = getParam(req.params.command);
|
|
5991
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
|
|
5992
|
+
if (!resolved) {
|
|
5993
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
5994
|
+
return;
|
|
5995
|
+
}
|
|
5996
|
+
const { reason } = req.body || {};
|
|
5997
|
+
const transitionResult = await executeTransitionByDir(
|
|
5998
|
+
resolved.assignmentDir,
|
|
5999
|
+
command,
|
|
6000
|
+
{
|
|
6001
|
+
standalone: resolved.standalone,
|
|
6002
|
+
reason: typeof reason === "string" ? reason : void 0
|
|
6003
|
+
}
|
|
6004
|
+
);
|
|
6005
|
+
if (!transitionResult.success) {
|
|
6006
|
+
res.status(400).json({ error: transitionResult.message, fromStatus: transitionResult.fromStatus });
|
|
6007
|
+
return;
|
|
6008
|
+
}
|
|
6009
|
+
const detail = resolved.standalone ? await getAssignmentDetailById(projectsDir, assignmentsDir, id) : await getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
|
|
6010
|
+
res.json({ assignment: detail, warnings: transitionResult.warnings ?? [] });
|
|
4483
6011
|
} catch (error) {
|
|
4484
|
-
console.error("Error
|
|
4485
|
-
res.status(500).json({ error: `Failed to
|
|
6012
|
+
console.error("Error transitioning by id:", error);
|
|
6013
|
+
res.status(500).json({ error: `Failed to transition: ${error.message}` });
|
|
4486
6014
|
}
|
|
4487
6015
|
});
|
|
4488
6016
|
return router;
|
|
4489
6017
|
}
|
|
6018
|
+
function slugifyLocal(input) {
|
|
6019
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
|
6020
|
+
}
|
|
6021
|
+
async function appendCommentTo(assignmentDir, assignmentRef, req, res, reloadDetail) {
|
|
6022
|
+
const commentsPath = resolve10(assignmentDir, "comments.md");
|
|
6023
|
+
const { body, author, type, replyTo } = req.body || {};
|
|
6024
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
6025
|
+
res.status(400).json({ error: "body is required" });
|
|
6026
|
+
return;
|
|
6027
|
+
}
|
|
6028
|
+
const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
|
|
6029
|
+
const timestamp = nowTimestamp();
|
|
6030
|
+
const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
|
|
6031
|
+
let currentContent;
|
|
6032
|
+
let currentCount = 0;
|
|
6033
|
+
if (await fileExists(commentsPath)) {
|
|
6034
|
+
currentContent = await readFile8(commentsPath, "utf-8");
|
|
6035
|
+
const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
|
|
6036
|
+
if (countMatch) currentCount = parseInt(countMatch[1], 10);
|
|
6037
|
+
} else {
|
|
6038
|
+
currentContent = renderComments({ assignment: assignmentRef, timestamp });
|
|
6039
|
+
}
|
|
6040
|
+
const comment = {
|
|
6041
|
+
id: generateId().split("-")[0],
|
|
6042
|
+
timestamp,
|
|
6043
|
+
author: entryAuthor,
|
|
6044
|
+
type: commentType,
|
|
6045
|
+
body,
|
|
6046
|
+
replyTo: typeof replyTo === "string" && replyTo.trim() ? replyTo.trim() : void 0,
|
|
6047
|
+
resolved: commentType === "question" ? false : void 0
|
|
6048
|
+
};
|
|
6049
|
+
const entry = formatCommentEntry(comment);
|
|
6050
|
+
let next = setTopLevelField(currentContent, "entryCount", String(currentCount + 1));
|
|
6051
|
+
next = setTopLevelField(next, "updated", `"${timestamp}"`);
|
|
6052
|
+
if (next.includes("No comments yet.")) {
|
|
6053
|
+
next = next.replace("No comments yet.", entry.trimEnd());
|
|
6054
|
+
} else {
|
|
6055
|
+
next = `${next.trimEnd()}
|
|
6056
|
+
|
|
6057
|
+
${entry}`;
|
|
6058
|
+
}
|
|
6059
|
+
await writeFileForce(commentsPath, next);
|
|
6060
|
+
const assignment = await reloadDetail();
|
|
6061
|
+
res.status(201).json({ assignment, comment: { id: comment.id } });
|
|
6062
|
+
}
|
|
6063
|
+
async function toggleCommentResolvedAt(assignmentDir, commentId, req, res, reloadDetail) {
|
|
6064
|
+
const commentsPath = resolve10(assignmentDir, "comments.md");
|
|
6065
|
+
if (!await fileExists(commentsPath)) {
|
|
6066
|
+
res.status(404).json({ error: "Comments file not found" });
|
|
6067
|
+
return;
|
|
6068
|
+
}
|
|
6069
|
+
const { resolved: desired } = req.body || {};
|
|
6070
|
+
if (typeof desired !== "boolean") {
|
|
6071
|
+
res.status(400).json({ error: "resolved (boolean) is required" });
|
|
6072
|
+
return;
|
|
6073
|
+
}
|
|
6074
|
+
const content = await readFile8(commentsPath, "utf-8");
|
|
6075
|
+
const parsed = parseComments(content);
|
|
6076
|
+
const target = parsed.entries.find((e) => e.id === commentId);
|
|
6077
|
+
if (!target) {
|
|
6078
|
+
res.status(404).json({ error: `Comment ${commentId} not found` });
|
|
6079
|
+
return;
|
|
6080
|
+
}
|
|
6081
|
+
if (target.type !== "question") {
|
|
6082
|
+
res.status(400).json({ error: "Only questions can be resolved" });
|
|
6083
|
+
return;
|
|
6084
|
+
}
|
|
6085
|
+
const entryBlockRegex = new RegExp(
|
|
6086
|
+
`(^## ${commentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(\\*\\*Resolved:\\*\\*\\s*(?:true|false))`,
|
|
6087
|
+
"m"
|
|
6088
|
+
);
|
|
6089
|
+
const next = content.replace(entryBlockRegex, (_m, preamble) => `${preamble}**Resolved:** ${desired ? "true" : "false"}`);
|
|
6090
|
+
if (next === content) {
|
|
6091
|
+
res.status(500).json({ error: "Failed to update resolved flag" });
|
|
6092
|
+
return;
|
|
6093
|
+
}
|
|
6094
|
+
const withUpdated = setTopLevelField(next, "updated", `"${nowTimestamp()}"`);
|
|
6095
|
+
await writeFileForce(commentsPath, withUpdated);
|
|
6096
|
+
const assignment = await reloadDetail();
|
|
6097
|
+
res.json({ assignment });
|
|
6098
|
+
}
|
|
4490
6099
|
|
|
4491
6100
|
// src/dashboard/api-servers.ts
|
|
4492
6101
|
init_servers();
|
|
4493
6102
|
init_scanner();
|
|
4494
6103
|
import { Router as Router2 } from "express";
|
|
4495
|
-
function createServersRouter(serversDir2, projectsDir) {
|
|
6104
|
+
function createServersRouter(serversDir2, projectsDir, assignmentsDir) {
|
|
4496
6105
|
const router = Router2();
|
|
4497
6106
|
router.get("/", async (_req, res) => {
|
|
4498
6107
|
try {
|
|
4499
|
-
const result = await scanAllSessions(serversDir2, projectsDir);
|
|
6108
|
+
const result = await scanAllSessions(serversDir2, projectsDir, { assignmentsDir });
|
|
4500
6109
|
res.json(result);
|
|
4501
6110
|
} catch (error) {
|
|
4502
6111
|
res.status(500).json({ error: error instanceof Error ? error.message : "Scan failed" });
|
|
@@ -4504,7 +6113,7 @@ function createServersRouter(serversDir2, projectsDir) {
|
|
|
4504
6113
|
});
|
|
4505
6114
|
router.get("/:name", async (req, res) => {
|
|
4506
6115
|
try {
|
|
4507
|
-
const session = await scanSingleSession(serversDir2, projectsDir, req.params.name);
|
|
6116
|
+
const session = await scanSingleSession(serversDir2, projectsDir, req.params.name, { assignmentsDir });
|
|
4508
6117
|
if (!session) {
|
|
4509
6118
|
res.status(404).json({ error: "Session not found" });
|
|
4510
6119
|
return;
|
|
@@ -4555,7 +6164,7 @@ function createServersRouter(serversDir2, projectsDir) {
|
|
|
4555
6164
|
await updateLastRefreshed(serversDir2, name);
|
|
4556
6165
|
}
|
|
4557
6166
|
clearScanCache();
|
|
4558
|
-
const result = await scanAllSessions(serversDir2, projectsDir, { bypassCache: true });
|
|
6167
|
+
const result = await scanAllSessions(serversDir2, projectsDir, { bypassCache: true, assignmentsDir });
|
|
4559
6168
|
res.json(result);
|
|
4560
6169
|
} catch (error) {
|
|
4561
6170
|
res.status(500).json({ error: error instanceof Error ? error.message : "Refresh failed" });
|
|
@@ -4570,7 +6179,7 @@ function createServersRouter(serversDir2, projectsDir) {
|
|
|
4570
6179
|
}
|
|
4571
6180
|
await updateLastRefreshed(serversDir2, req.params.name);
|
|
4572
6181
|
clearScanCache();
|
|
4573
|
-
const session = await scanSingleSession(serversDir2, projectsDir, req.params.name);
|
|
6182
|
+
const session = await scanSingleSession(serversDir2, projectsDir, req.params.name, { assignmentsDir });
|
|
4574
6183
|
res.json(session);
|
|
4575
6184
|
} catch (error) {
|
|
4576
6185
|
res.status(500).json({ error: error instanceof Error ? error.message : "Refresh failed" });
|
|
@@ -4607,266 +6216,13 @@ function createServersRouter(serversDir2, projectsDir) {
|
|
|
4607
6216
|
|
|
4608
6217
|
// src/dashboard/api-agent-sessions.ts
|
|
4609
6218
|
import { Router as Router3 } from "express";
|
|
4610
|
-
import { resolve as
|
|
4611
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
4612
|
-
|
|
4613
|
-
// src/dashboard/agent-sessions.ts
|
|
4614
|
-
init_fs();
|
|
4615
|
-
import { readFile as readFile7 } from "fs/promises";
|
|
4616
|
-
import { resolve as resolve9 } from "path";
|
|
4617
|
-
|
|
4618
|
-
// src/dashboard/session-db.ts
|
|
4619
|
-
init_paths();
|
|
4620
|
-
init_fs();
|
|
4621
|
-
import Database from "better-sqlite3";
|
|
4622
|
-
import { resolve as resolve8 } from "path";
|
|
4623
|
-
import { readdir as readdir4 } from "fs/promises";
|
|
4624
|
-
var db = null;
|
|
4625
|
-
var SCHEMA_VERSION = "2";
|
|
4626
|
-
var SCHEMA_SQL = `
|
|
4627
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
4628
|
-
session_id TEXT PRIMARY KEY,
|
|
4629
|
-
project_slug TEXT,
|
|
4630
|
-
assignment_slug TEXT,
|
|
4631
|
-
agent TEXT NOT NULL,
|
|
4632
|
-
started TEXT NOT NULL,
|
|
4633
|
-
ended TEXT,
|
|
4634
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
4635
|
-
path TEXT,
|
|
4636
|
-
description TEXT,
|
|
4637
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
4638
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
4639
|
-
);
|
|
4640
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
4641
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
4642
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
4643
|
-
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
4644
|
-
`;
|
|
4645
|
-
function initSessionDb(dbPath) {
|
|
4646
|
-
if (db) return db;
|
|
4647
|
-
const finalPath = dbPath ?? resolve8(syntaurRoot(), "syntaur.db");
|
|
4648
|
-
db = new Database(finalPath);
|
|
4649
|
-
db.pragma("journal_mode = WAL");
|
|
4650
|
-
db.exec(SCHEMA_SQL);
|
|
4651
|
-
db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
4652
|
-
"schema_version",
|
|
4653
|
-
SCHEMA_VERSION
|
|
4654
|
-
);
|
|
4655
|
-
const currentVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
4656
|
-
if (currentVersion?.value === "1") {
|
|
4657
|
-
db.exec(`
|
|
4658
|
-
CREATE TABLE sessions_v2 (
|
|
4659
|
-
session_id TEXT PRIMARY KEY,
|
|
4660
|
-
project_slug TEXT,
|
|
4661
|
-
assignment_slug TEXT,
|
|
4662
|
-
agent TEXT NOT NULL,
|
|
4663
|
-
started TEXT NOT NULL,
|
|
4664
|
-
ended TEXT,
|
|
4665
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
4666
|
-
path TEXT,
|
|
4667
|
-
description TEXT,
|
|
4668
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
4669
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
4670
|
-
);
|
|
4671
|
-
INSERT INTO sessions_v2 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, NULL, created_at, updated_at FROM sessions;
|
|
4672
|
-
DROP TABLE sessions;
|
|
4673
|
-
ALTER TABLE sessions_v2 RENAME TO sessions;
|
|
4674
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
4675
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
4676
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
4677
|
-
UPDATE meta SET value = '2' WHERE key = 'schema_version';
|
|
4678
|
-
`);
|
|
4679
|
-
}
|
|
4680
|
-
return db;
|
|
4681
|
-
}
|
|
4682
|
-
function getSessionDb() {
|
|
4683
|
-
if (!db) {
|
|
4684
|
-
throw new Error(
|
|
4685
|
-
"Session database not initialized. Call initSessionDb() first."
|
|
4686
|
-
);
|
|
4687
|
-
}
|
|
4688
|
-
return db;
|
|
4689
|
-
}
|
|
4690
|
-
function closeSessionDb() {
|
|
4691
|
-
if (db) {
|
|
4692
|
-
db.close();
|
|
4693
|
-
db = null;
|
|
4694
|
-
}
|
|
4695
|
-
}
|
|
4696
|
-
async function migrateFromMarkdown(projectsDir) {
|
|
4697
|
-
const database = getSessionDb();
|
|
4698
|
-
const count = database.prepare("SELECT COUNT(*) as count FROM sessions").get();
|
|
4699
|
-
if (count.count > 0) return 0;
|
|
4700
|
-
if (!await fileExists(projectsDir)) return 0;
|
|
4701
|
-
const entries = await readdir4(projectsDir, { withFileTypes: true });
|
|
4702
|
-
const allSessions = [];
|
|
4703
|
-
for (const entry of entries) {
|
|
4704
|
-
if (!entry.isDirectory()) continue;
|
|
4705
|
-
const projectDir = resolve8(projectsDir, entry.name);
|
|
4706
|
-
const indexPath = resolve8(projectDir, "_index-sessions.md");
|
|
4707
|
-
if (!await fileExists(indexPath)) continue;
|
|
4708
|
-
const sessions = await parseMarkdownSessionsIndex(indexPath, entry.name);
|
|
4709
|
-
allSessions.push(...sessions);
|
|
4710
|
-
}
|
|
4711
|
-
if (allSessions.length === 0) return 0;
|
|
4712
|
-
const insert = database.prepare(`
|
|
4713
|
-
INSERT OR IGNORE INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path)
|
|
4714
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
4715
|
-
`);
|
|
4716
|
-
const insertAll = database.transaction((sessions) => {
|
|
4717
|
-
for (const s of sessions) {
|
|
4718
|
-
insert.run(s.sessionId, s.projectSlug, s.assignmentSlug, s.agent, s.started, s.status, s.path);
|
|
4719
|
-
}
|
|
4720
|
-
});
|
|
4721
|
-
insertAll(allSessions);
|
|
4722
|
-
console.log(`Migrated ${allSessions.length} sessions from markdown to SQLite.`);
|
|
4723
|
-
return allSessions.length;
|
|
4724
|
-
}
|
|
4725
|
-
async function parseMarkdownSessionsIndex(filePath, projectSlug) {
|
|
4726
|
-
const { readFile: readFile12 } = await import("fs/promises");
|
|
4727
|
-
const raw = await readFile12(filePath, "utf-8");
|
|
4728
|
-
const sessions = [];
|
|
4729
|
-
const lines = raw.split("\n");
|
|
4730
|
-
let inTable = false;
|
|
4731
|
-
let headerSeen = false;
|
|
4732
|
-
for (const line of lines) {
|
|
4733
|
-
const trimmed = line.trim();
|
|
4734
|
-
if (!trimmed) continue;
|
|
4735
|
-
if (trimmed.startsWith("| Assignment") || trimmed.startsWith("|Assignment")) {
|
|
4736
|
-
inTable = true;
|
|
4737
|
-
headerSeen = false;
|
|
4738
|
-
continue;
|
|
4739
|
-
}
|
|
4740
|
-
if (inTable && !headerSeen && trimmed.match(/^\|[-\s|]+\|$/)) {
|
|
4741
|
-
headerSeen = true;
|
|
4742
|
-
continue;
|
|
4743
|
-
}
|
|
4744
|
-
if (inTable && headerSeen && trimmed.startsWith("|")) {
|
|
4745
|
-
const cells = trimmed.split("|").slice(1, -1).map((c) => c.trim());
|
|
4746
|
-
if (cells.length >= 6) {
|
|
4747
|
-
sessions.push({
|
|
4748
|
-
assignmentSlug: cells[0],
|
|
4749
|
-
agent: cells[1],
|
|
4750
|
-
sessionId: cells[2],
|
|
4751
|
-
started: cells[3],
|
|
4752
|
-
status: cells[4] || "active",
|
|
4753
|
-
path: cells[5],
|
|
4754
|
-
projectSlug
|
|
4755
|
-
});
|
|
4756
|
-
}
|
|
4757
|
-
}
|
|
4758
|
-
}
|
|
4759
|
-
return sessions;
|
|
4760
|
-
}
|
|
4761
|
-
|
|
4762
|
-
// src/dashboard/agent-sessions.ts
|
|
4763
|
-
function rowToSession(row) {
|
|
4764
|
-
return {
|
|
4765
|
-
sessionId: row.session_id,
|
|
4766
|
-
projectSlug: row.project_slug ?? null,
|
|
4767
|
-
assignmentSlug: row.assignment_slug ?? null,
|
|
4768
|
-
agent: row.agent,
|
|
4769
|
-
started: row.started,
|
|
4770
|
-
ended: row.ended ?? null,
|
|
4771
|
-
status: row.status,
|
|
4772
|
-
path: row.path ?? "",
|
|
4773
|
-
description: row.description ?? null
|
|
4774
|
-
};
|
|
4775
|
-
}
|
|
4776
|
-
async function appendSession(_projectDir, session) {
|
|
4777
|
-
const db2 = getSessionDb();
|
|
4778
|
-
db2.prepare(`
|
|
4779
|
-
INSERT INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path, description)
|
|
4780
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
4781
|
-
`).run(
|
|
4782
|
-
session.sessionId,
|
|
4783
|
-
session.projectSlug ?? null,
|
|
4784
|
-
session.assignmentSlug ?? null,
|
|
4785
|
-
session.agent,
|
|
4786
|
-
session.started,
|
|
4787
|
-
session.status,
|
|
4788
|
-
session.path,
|
|
4789
|
-
session.description ?? null
|
|
4790
|
-
);
|
|
4791
|
-
}
|
|
4792
|
-
async function updateSessionStatus(_projectDir, sessionId, status) {
|
|
4793
|
-
const db2 = getSessionDb();
|
|
4794
|
-
const isTerminal = status === "completed" || status === "stopped";
|
|
4795
|
-
const result = isTerminal ? db2.prepare(
|
|
4796
|
-
"UPDATE sessions SET status = ?, ended = datetime('now'), updated_at = datetime('now') WHERE session_id = ?"
|
|
4797
|
-
).run(status, sessionId) : db2.prepare(
|
|
4798
|
-
"UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE session_id = ?"
|
|
4799
|
-
).run(status, sessionId);
|
|
4800
|
-
return result.changes > 0;
|
|
4801
|
-
}
|
|
4802
|
-
async function listAllSessions(_projectsDir) {
|
|
4803
|
-
const db2 = getSessionDb();
|
|
4804
|
-
const rows = db2.prepare("SELECT * FROM sessions ORDER BY started DESC").all();
|
|
4805
|
-
return rows.map(rowToSession);
|
|
4806
|
-
}
|
|
4807
|
-
async function listProjectSessions(_projectsDir, projectSlug, assignmentSlug) {
|
|
4808
|
-
const db2 = getSessionDb();
|
|
4809
|
-
if (assignmentSlug) {
|
|
4810
|
-
const rows2 = db2.prepare(
|
|
4811
|
-
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
4812
|
-
).all(projectSlug, assignmentSlug);
|
|
4813
|
-
return rows2.map(rowToSession);
|
|
4814
|
-
}
|
|
4815
|
-
const rows = db2.prepare("SELECT * FROM sessions WHERE project_slug = ? ORDER BY started DESC").all(projectSlug);
|
|
4816
|
-
return rows.map(rowToSession);
|
|
4817
|
-
}
|
|
4818
|
-
async function deleteSessions(sessionIds) {
|
|
4819
|
-
if (sessionIds.length === 0) return 0;
|
|
4820
|
-
const db2 = getSessionDb();
|
|
4821
|
-
const placeholders = sessionIds.map(() => "?").join(", ");
|
|
4822
|
-
const result = db2.prepare(`DELETE FROM sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
4823
|
-
return result.changes;
|
|
4824
|
-
}
|
|
4825
|
-
var DONE_ASSIGNMENT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "review"]);
|
|
4826
|
-
async function readAssignmentStatus(projectDir, assignmentSlug) {
|
|
4827
|
-
const assignmentPath = resolve9(projectDir, "assignments", assignmentSlug, "assignment.md");
|
|
4828
|
-
if (!await fileExists(assignmentPath)) return null;
|
|
4829
|
-
const raw = await readFile7(assignmentPath, "utf-8");
|
|
4830
|
-
const match = raw.match(/^status:\s*(.+)$/m);
|
|
4831
|
-
return match ? match[1].trim() : null;
|
|
4832
|
-
}
|
|
4833
|
-
async function reconcileActiveSessions(projectsDir) {
|
|
4834
|
-
const db2 = getSessionDb();
|
|
4835
|
-
const activeSessions = db2.prepare("SELECT * FROM sessions WHERE status = 'active' AND project_slug IS NOT NULL AND assignment_slug IS NOT NULL").all();
|
|
4836
|
-
if (activeSessions.length === 0) return 0;
|
|
4837
|
-
const toCheck = /* @__PURE__ */ new Map();
|
|
4838
|
-
for (const session of activeSessions) {
|
|
4839
|
-
const slugs = toCheck.get(session.project_slug) ?? /* @__PURE__ */ new Set();
|
|
4840
|
-
slugs.add(session.assignment_slug);
|
|
4841
|
-
toCheck.set(session.project_slug, slugs);
|
|
4842
|
-
}
|
|
4843
|
-
const assignmentStatuses = /* @__PURE__ */ new Map();
|
|
4844
|
-
for (const [projectSlug, slugs] of toCheck) {
|
|
4845
|
-
const projectDir = resolve9(projectsDir, projectSlug);
|
|
4846
|
-
for (const slug of slugs) {
|
|
4847
|
-
const status = await readAssignmentStatus(projectDir, slug);
|
|
4848
|
-
if (status) assignmentStatuses.set(`${projectSlug}/${slug}`, status);
|
|
4849
|
-
}
|
|
4850
|
-
}
|
|
4851
|
-
let totalUpdated = 0;
|
|
4852
|
-
for (const session of activeSessions) {
|
|
4853
|
-
const key = `${session.project_slug}/${session.assignment_slug}`;
|
|
4854
|
-
const assignmentStatus = assignmentStatuses.get(key);
|
|
4855
|
-
if (!assignmentStatus || !DONE_ASSIGNMENT_STATUSES.has(assignmentStatus)) continue;
|
|
4856
|
-
const newStatus = assignmentStatus === "failed" ? "stopped" : "completed";
|
|
4857
|
-
await updateSessionStatus("", session.session_id, newStatus);
|
|
4858
|
-
totalUpdated++;
|
|
4859
|
-
}
|
|
4860
|
-
return totalUpdated;
|
|
4861
|
-
}
|
|
4862
|
-
|
|
4863
|
-
// src/dashboard/api-agent-sessions.ts
|
|
6219
|
+
import { resolve as resolve11 } from "path";
|
|
4864
6220
|
init_fs();
|
|
4865
|
-
function createAgentSessionsRouter(projectsDir, broadcast) {
|
|
6221
|
+
function createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir) {
|
|
4866
6222
|
const router = Router3();
|
|
4867
6223
|
router.get("/", async (_req, res) => {
|
|
4868
6224
|
try {
|
|
4869
|
-
await reconcileActiveSessions(projectsDir);
|
|
6225
|
+
await reconcileActiveSessions(projectsDir, assignmentsDir);
|
|
4870
6226
|
const sessions = await listAllSessions(projectsDir);
|
|
4871
6227
|
res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4872
6228
|
} catch (error) {
|
|
@@ -4877,12 +6233,12 @@ function createAgentSessionsRouter(projectsDir, broadcast) {
|
|
|
4877
6233
|
try {
|
|
4878
6234
|
const { projectSlug } = req.params;
|
|
4879
6235
|
const assignment = req.query.assignment;
|
|
4880
|
-
const projectDir =
|
|
6236
|
+
const projectDir = resolve11(projectsDir, projectSlug);
|
|
4881
6237
|
if (!await fileExists(projectDir)) {
|
|
4882
6238
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
4883
6239
|
return;
|
|
4884
6240
|
}
|
|
4885
|
-
await reconcileActiveSessions(projectsDir);
|
|
6241
|
+
await reconcileActiveSessions(projectsDir, assignmentsDir);
|
|
4886
6242
|
const sessions = await listProjectSessions(projectsDir, projectSlug, assignment);
|
|
4887
6243
|
res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4888
6244
|
} catch (error) {
|
|
@@ -4891,32 +6247,38 @@ function createAgentSessionsRouter(projectsDir, broadcast) {
|
|
|
4891
6247
|
});
|
|
4892
6248
|
router.post("/", async (req, res) => {
|
|
4893
6249
|
try {
|
|
4894
|
-
const { projectSlug, assignmentSlug, agent, sessionId, path, description } = req.body;
|
|
6250
|
+
const { projectSlug, assignmentSlug, agent, sessionId, path, description, transcriptPath } = req.body;
|
|
4895
6251
|
if (!agent) {
|
|
4896
6252
|
res.status(400).json({ error: "agent is required" });
|
|
4897
6253
|
return;
|
|
4898
6254
|
}
|
|
6255
|
+
if (!sessionId) {
|
|
6256
|
+
res.status(400).json({
|
|
6257
|
+
error: "sessionId is required. Pass the real agent-generated session id \u2014 do not synthesize one."
|
|
6258
|
+
});
|
|
6259
|
+
return;
|
|
6260
|
+
}
|
|
4899
6261
|
if (projectSlug) {
|
|
4900
|
-
const projectDir =
|
|
6262
|
+
const projectDir = resolve11(projectsDir, projectSlug);
|
|
4901
6263
|
if (!await fileExists(projectDir)) {
|
|
4902
6264
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
4903
6265
|
return;
|
|
4904
6266
|
}
|
|
4905
6267
|
}
|
|
4906
|
-
const id = sessionId || randomUUID2();
|
|
4907
6268
|
const session = {
|
|
4908
6269
|
projectSlug: projectSlug || null,
|
|
4909
6270
|
assignmentSlug: assignmentSlug || null,
|
|
4910
6271
|
agent,
|
|
4911
|
-
sessionId
|
|
6272
|
+
sessionId,
|
|
4912
6273
|
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4913
6274
|
status: "active",
|
|
4914
6275
|
path: path || "",
|
|
4915
|
-
description: description || null
|
|
6276
|
+
description: description || null,
|
|
6277
|
+
transcriptPath: transcriptPath || null
|
|
4916
6278
|
};
|
|
4917
6279
|
await appendSession("", session);
|
|
4918
6280
|
broadcast?.({ type: "agent-sessions-updated", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
4919
|
-
res.status(201).json({ sessionId
|
|
6281
|
+
res.status(201).json({ sessionId });
|
|
4920
6282
|
} catch (error) {
|
|
4921
6283
|
res.status(500).json({ error: error instanceof Error ? error.message : "Registration failed" });
|
|
4922
6284
|
}
|
|
@@ -4965,8 +6327,8 @@ function createAgentSessionsRouter(projectsDir, broadcast) {
|
|
|
4965
6327
|
init_api();
|
|
4966
6328
|
init_parser();
|
|
4967
6329
|
import { Router as Router4 } from "express";
|
|
4968
|
-
import { resolve as
|
|
4969
|
-
import { readFile as
|
|
6330
|
+
import { resolve as resolve13 } from "path";
|
|
6331
|
+
import { readFile as readFile10, unlink as unlink2 } from "fs/promises";
|
|
4970
6332
|
init_timestamp();
|
|
4971
6333
|
init_fs();
|
|
4972
6334
|
|
|
@@ -4974,15 +6336,15 @@ init_fs();
|
|
|
4974
6336
|
init_fs();
|
|
4975
6337
|
init_parser();
|
|
4976
6338
|
init_timestamp();
|
|
4977
|
-
import { resolve as
|
|
4978
|
-
import { readdir as
|
|
6339
|
+
import { resolve as resolve12 } from "path";
|
|
6340
|
+
import { readdir as readdir6, readFile as readFile9 } from "fs/promises";
|
|
4979
6341
|
async function rebuildPlaybookManifest(playbooksDir2) {
|
|
4980
6342
|
if (!await fileExists(playbooksDir2)) return;
|
|
4981
|
-
const entries = await
|
|
6343
|
+
const entries = await readdir6(playbooksDir2, { withFileTypes: true });
|
|
4982
6344
|
const rows = [];
|
|
4983
6345
|
for (const entry of entries) {
|
|
4984
6346
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_") || entry.name === "manifest.md") continue;
|
|
4985
|
-
const raw = await
|
|
6347
|
+
const raw = await readFile9(resolve12(playbooksDir2, entry.name), "utf-8");
|
|
4986
6348
|
const parsed = parsePlaybook(raw);
|
|
4987
6349
|
const slug = parsed.slug || entry.name.replace(/\.md$/, "");
|
|
4988
6350
|
rows.push({
|
|
@@ -5012,7 +6374,7 @@ async function rebuildPlaybookManifest(playbooksDir2) {
|
|
|
5012
6374
|
}
|
|
5013
6375
|
}
|
|
5014
6376
|
lines.push("");
|
|
5015
|
-
await writeFileForce(
|
|
6377
|
+
await writeFileForce(resolve12(playbooksDir2, "manifest.md"), lines.join("\n"));
|
|
5016
6378
|
}
|
|
5017
6379
|
|
|
5018
6380
|
// src/dashboard/api-playbooks.ts
|
|
@@ -5053,12 +6415,12 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
5053
6415
|
});
|
|
5054
6416
|
router.get("/:slug/edit", async (req, res) => {
|
|
5055
6417
|
try {
|
|
5056
|
-
const filePath =
|
|
6418
|
+
const filePath = resolve13(playbooksDir2, `${req.params.slug}.md`);
|
|
5057
6419
|
if (!await fileExists(filePath)) {
|
|
5058
6420
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
5059
6421
|
return;
|
|
5060
6422
|
}
|
|
5061
|
-
const content = await
|
|
6423
|
+
const content = await readFile10(filePath, "utf-8");
|
|
5062
6424
|
res.json({
|
|
5063
6425
|
documentType: "playbook",
|
|
5064
6426
|
title: `Edit Playbook: ${req.params.slug}`,
|
|
@@ -5083,7 +6445,7 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
5083
6445
|
return;
|
|
5084
6446
|
}
|
|
5085
6447
|
await ensureDir(playbooksDir2);
|
|
5086
|
-
const filePath =
|
|
6448
|
+
const filePath = resolve13(playbooksDir2, `${slug}.md`);
|
|
5087
6449
|
if (await fileExists(filePath)) {
|
|
5088
6450
|
res.status(409).json({ error: `Playbook "${slug}" already exists` });
|
|
5089
6451
|
return;
|
|
@@ -5102,7 +6464,7 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
5102
6464
|
res.status(400).json({ error: "content is required" });
|
|
5103
6465
|
return;
|
|
5104
6466
|
}
|
|
5105
|
-
const filePath =
|
|
6467
|
+
const filePath = resolve13(playbooksDir2, `${req.params.slug}.md`);
|
|
5106
6468
|
if (!await fileExists(filePath)) {
|
|
5107
6469
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
5108
6470
|
return;
|
|
@@ -5120,7 +6482,7 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
5120
6482
|
res.status(403).json({ error: "The playbook manifest cannot be deleted" });
|
|
5121
6483
|
return;
|
|
5122
6484
|
}
|
|
5123
|
-
const filePath =
|
|
6485
|
+
const filePath = resolve13(playbooksDir2, `${req.params.slug}.md`);
|
|
5124
6486
|
if (!await fileExists(filePath)) {
|
|
5125
6487
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
5126
6488
|
return;
|
|
@@ -5139,7 +6501,7 @@ function createPlaybooksRouter(playbooksDir2) {
|
|
|
5139
6501
|
init_parser2();
|
|
5140
6502
|
init_fs();
|
|
5141
6503
|
import { Router as Router5 } from "express";
|
|
5142
|
-
import { readdir as
|
|
6504
|
+
import { readdir as readdir7 } from "fs/promises";
|
|
5143
6505
|
var WORKSPACE_REGEX = /^[a-z0-9_][a-z0-9-]*$/;
|
|
5144
6506
|
function getWorkspaceParam(value) {
|
|
5145
6507
|
if (Array.isArray(value)) {
|
|
@@ -5173,7 +6535,7 @@ function createTodosRouter(todosDir2, broadcast) {
|
|
|
5173
6535
|
router.get("/", async (_req, res) => {
|
|
5174
6536
|
try {
|
|
5175
6537
|
await ensureDir(todosDir2);
|
|
5176
|
-
const files = await
|
|
6538
|
+
const files = await readdir7(todosDir2).catch(() => []);
|
|
5177
6539
|
const workspaces = [];
|
|
5178
6540
|
for (const file of files) {
|
|
5179
6541
|
if (typeof file !== "string") continue;
|
|
@@ -5278,8 +6640,8 @@ function createTodosRouter(todosDir2, broadcast) {
|
|
|
5278
6640
|
router.post("/:workspace/archive", async (req, res) => {
|
|
5279
6641
|
try {
|
|
5280
6642
|
const { archivePath: archivePath2 } = await Promise.resolve().then(() => (init_parser2(), parser_exports));
|
|
5281
|
-
const { resolve:
|
|
5282
|
-
const { readFile:
|
|
6643
|
+
const { resolve: resolve17 } = await import("path");
|
|
6644
|
+
const { readFile: readFile13 } = await import("fs/promises");
|
|
5283
6645
|
const { writeFileForce: writeFileForce2 } = await Promise.resolve().then(() => (init_fs(), fs_exports));
|
|
5284
6646
|
const workspace = getWorkspaceParam(req.params.workspace);
|
|
5285
6647
|
const checklist = await readChecklist(todosDir2, workspace);
|
|
@@ -5295,10 +6657,10 @@ function createTodosRouter(todosDir2, broadcast) {
|
|
|
5295
6657
|
(e) => e.itemIds.every((id) => completedIds.has(id))
|
|
5296
6658
|
);
|
|
5297
6659
|
const archFile = archivePath2(todosDir2, workspace, checklist.archiveInterval);
|
|
5298
|
-
await ensureDir(
|
|
6660
|
+
await ensureDir(resolve17(todosDir2, "archive"));
|
|
5299
6661
|
let archContent = "";
|
|
5300
6662
|
if (await fileExists(archFile)) {
|
|
5301
|
-
archContent = await
|
|
6663
|
+
archContent = await readFile13(archFile, "utf-8");
|
|
5302
6664
|
archContent = archContent.trimEnd() + "\n\n";
|
|
5303
6665
|
} else {
|
|
5304
6666
|
archContent = `---
|
|
@@ -5558,8 +6920,8 @@ init_fs();
|
|
|
5558
6920
|
init_config2();
|
|
5559
6921
|
import { execFile as execFile2 } from "child_process";
|
|
5560
6922
|
import { promisify as promisify2 } from "util";
|
|
5561
|
-
import { cp, mkdtemp, rm as rm2, readFile as
|
|
5562
|
-
import { resolve as
|
|
6923
|
+
import { cp, mkdtemp, rm as rm2, readFile as readFile12, writeFile as writeFile3, unlink as unlink3, stat, open, rename as rename2 } from "fs/promises";
|
|
6924
|
+
import { resolve as resolve15, join as join2 } from "path";
|
|
5563
6925
|
import { tmpdir } from "os";
|
|
5564
6926
|
var exec2 = promisify2(execFile2);
|
|
5565
6927
|
var VALID_CATEGORIES = ["projects", "playbooks", "todos", "servers", "config"];
|
|
@@ -5599,7 +6961,7 @@ async function resolveCategoryPath(category) {
|
|
|
5599
6961
|
case "servers":
|
|
5600
6962
|
return { sourcePath: serversDir(), repoPath: "servers", isFile: false };
|
|
5601
6963
|
case "config":
|
|
5602
|
-
return { sourcePath:
|
|
6964
|
+
return { sourcePath: resolve15(syntaurRoot(), "config.md"), repoPath: "config.md", isFile: true };
|
|
5603
6965
|
}
|
|
5604
6966
|
}
|
|
5605
6967
|
async function checkGitInstalled() {
|
|
@@ -5610,7 +6972,7 @@ async function checkGitInstalled() {
|
|
|
5610
6972
|
}
|
|
5611
6973
|
}
|
|
5612
6974
|
async function acquireLock() {
|
|
5613
|
-
const lockPath =
|
|
6975
|
+
const lockPath = resolve15(syntaurRoot(), LOCK_FILE_NAME);
|
|
5614
6976
|
await ensureDir(syntaurRoot());
|
|
5615
6977
|
try {
|
|
5616
6978
|
const handle = await open(lockPath, "wx");
|
|
@@ -5619,7 +6981,7 @@ async function acquireLock() {
|
|
|
5619
6981
|
return lockPath;
|
|
5620
6982
|
} catch (err) {
|
|
5621
6983
|
if (err.code === "EEXIST") {
|
|
5622
|
-
const pid = await
|
|
6984
|
+
const pid = await readFile12(lockPath, "utf-8").catch(() => "");
|
|
5623
6985
|
throw new Error(
|
|
5624
6986
|
`Backup operation already in progress (lock file at ${lockPath}, pid ${pid.trim() || "unknown"}). If stale, delete the file and retry.`
|
|
5625
6987
|
);
|
|
@@ -5657,7 +7019,7 @@ async function copyRecursive(src, dest) {
|
|
|
5657
7019
|
await ensureDir(dest);
|
|
5658
7020
|
await cp(src, dest, { recursive: true, force: true });
|
|
5659
7021
|
} else {
|
|
5660
|
-
await ensureDir(
|
|
7022
|
+
await ensureDir(resolve15(dest, ".."));
|
|
5661
7023
|
await cp(src, dest, { force: true });
|
|
5662
7024
|
}
|
|
5663
7025
|
}
|
|
@@ -5666,7 +7028,7 @@ function resolveCategoriesStrict(csv) {
|
|
|
5666
7028
|
return parseCategoriesStrict(parts);
|
|
5667
7029
|
}
|
|
5668
7030
|
async function readSanitizedConfig(configPath) {
|
|
5669
|
-
const content = await
|
|
7031
|
+
const content = await readFile12(configPath, "utf-8");
|
|
5670
7032
|
return content.replace(/^(\s*lastBackup:\s*).*$/m, "$1null").replace(/^(\s*lastRestore:\s*).*$/m, "$1null");
|
|
5671
7033
|
}
|
|
5672
7034
|
async function backupToGithub(overrides) {
|
|
@@ -5705,7 +7067,7 @@ async function backupToGithub(overrides) {
|
|
|
5705
7067
|
}
|
|
5706
7068
|
if (category === "config") {
|
|
5707
7069
|
const sanitized = await readSanitizedConfig(sourcePath);
|
|
5708
|
-
await ensureDir(
|
|
7070
|
+
await ensureDir(resolve15(destPath, ".."));
|
|
5709
7071
|
await writeFile3(destPath, sanitized, "utf-8");
|
|
5710
7072
|
} else {
|
|
5711
7073
|
await copyRecursive(sourcePath, destPath);
|
|
@@ -5759,7 +7121,7 @@ async function backupToGithub(overrides) {
|
|
|
5759
7121
|
}
|
|
5760
7122
|
async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
|
|
5761
7123
|
if (isFile) {
|
|
5762
|
-
await ensureDir(
|
|
7124
|
+
await ensureDir(resolve15(localPath, ".."));
|
|
5763
7125
|
await cp(repoSrcPath, localPath, { force: true });
|
|
5764
7126
|
return;
|
|
5765
7127
|
}
|
|
@@ -5860,7 +7222,7 @@ async function restoreFromGithub(overrides) {
|
|
|
5860
7222
|
}
|
|
5861
7223
|
async function getBackupStatus() {
|
|
5862
7224
|
const config = await readConfig();
|
|
5863
|
-
const lockPath =
|
|
7225
|
+
const lockPath = resolve15(syntaurRoot(), LOCK_FILE_NAME);
|
|
5864
7226
|
const locked = await fileExists(lockPath);
|
|
5865
7227
|
return {
|
|
5866
7228
|
repo: config.backup?.repo ?? null,
|
|
@@ -6015,7 +7377,7 @@ async function stopAutodiscovery() {
|
|
|
6015
7377
|
function runReconcile() {
|
|
6016
7378
|
if (activeReconcile || !savedOptions) return;
|
|
6017
7379
|
const opts = savedOptions;
|
|
6018
|
-
activeReconcile = reconcile(opts.serversDir, opts.projectsDir, opts.excludePids).catch((err) => {
|
|
7380
|
+
activeReconcile = reconcile(opts.serversDir, opts.projectsDir, opts.excludePids, opts.assignmentsDir).catch((err) => {
|
|
6019
7381
|
console.error("[autodiscovery] reconcile failed:", err);
|
|
6020
7382
|
}).finally(() => {
|
|
6021
7383
|
activeReconcile = null;
|
|
@@ -6026,10 +7388,10 @@ async function listAllTmuxSessions() {
|
|
|
6026
7388
|
if (!output) return [];
|
|
6027
7389
|
return output.split("\n").filter((line) => line.length > 0);
|
|
6028
7390
|
}
|
|
6029
|
-
async function discoverTmuxSessions(serversDir2, projectsDir, existingNames) {
|
|
7391
|
+
async function discoverTmuxSessions(serversDir2, projectsDir, existingNames, assignmentsDir) {
|
|
6030
7392
|
const tmuxAvailable = await checkTmuxAvailable();
|
|
6031
7393
|
if (!tmuxAvailable) return false;
|
|
6032
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir);
|
|
7394
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir, assignmentsDir);
|
|
6033
7395
|
if (workspaceRecords.length === 0) return false;
|
|
6034
7396
|
const sessions = await listAllTmuxSessions();
|
|
6035
7397
|
let changed = false;
|
|
@@ -6070,8 +7432,8 @@ async function getProcessCwd(pid) {
|
|
|
6070
7432
|
}
|
|
6071
7433
|
return null;
|
|
6072
7434
|
}
|
|
6073
|
-
async function discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids) {
|
|
6074
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir);
|
|
7435
|
+
async function discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids, assignmentsDir) {
|
|
7436
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir, assignmentsDir);
|
|
6075
7437
|
if (workspaceRecords.length === 0) return false;
|
|
6076
7438
|
const lsofOutput = await getLsofOutput();
|
|
6077
7439
|
if (!lsofOutput) return false;
|
|
@@ -6136,7 +7498,7 @@ async function isProcessAlive(pid) {
|
|
|
6136
7498
|
return false;
|
|
6137
7499
|
}
|
|
6138
7500
|
}
|
|
6139
|
-
async function reconcile(serversDir2, projectsDir, excludePids) {
|
|
7501
|
+
async function reconcile(serversDir2, projectsDir, excludePids, assignmentsDir) {
|
|
6140
7502
|
const names = await listSessionFiles(serversDir2);
|
|
6141
7503
|
const existingFiles = /* @__PURE__ */ new Map();
|
|
6142
7504
|
for (const name of names) {
|
|
@@ -6148,8 +7510,8 @@ async function reconcile(serversDir2, projectsDir, excludePids) {
|
|
|
6148
7510
|
existingFiles.delete(name);
|
|
6149
7511
|
}
|
|
6150
7512
|
const existingNames = new Set(existingFiles.keys());
|
|
6151
|
-
const tmuxChanged = await discoverTmuxSessions(serversDir2, projectsDir, existingNames);
|
|
6152
|
-
const processChanged = await discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids);
|
|
7513
|
+
const tmuxChanged = await discoverTmuxSessions(serversDir2, projectsDir, existingNames, assignmentsDir);
|
|
7514
|
+
const processChanged = await discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids, assignmentsDir);
|
|
6153
7515
|
if (tmuxChanged || processChanged || cleanupChanged) {
|
|
6154
7516
|
clearScanCache();
|
|
6155
7517
|
}
|
|
@@ -6157,7 +7519,7 @@ async function reconcile(serversDir2, projectsDir, excludePids) {
|
|
|
6157
7519
|
|
|
6158
7520
|
// src/dashboard/server.ts
|
|
6159
7521
|
function createDashboardServer(options) {
|
|
6160
|
-
const { port, projectsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, serveStaticUi, dashboardDistPath } = options;
|
|
7522
|
+
const { port, projectsDir, assignmentsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, serveStaticUi, dashboardDistPath } = options;
|
|
6161
7523
|
const app = express();
|
|
6162
7524
|
const server = createServer(app);
|
|
6163
7525
|
const wss = new WebSocketServer({ noServer: true });
|
|
@@ -6197,7 +7559,7 @@ function createDashboardServer(options) {
|
|
|
6197
7559
|
app.use(express.json());
|
|
6198
7560
|
app.get("/api/overview", async (_req, res) => {
|
|
6199
7561
|
try {
|
|
6200
|
-
const overview = await getOverview(projectsDir, serversDir2);
|
|
7562
|
+
const overview = await getOverview(projectsDir, serversDir2, assignmentsDir);
|
|
6201
7563
|
res.json(overview);
|
|
6202
7564
|
} catch (error) {
|
|
6203
7565
|
console.error("Error getting overview:", error);
|
|
@@ -6206,7 +7568,7 @@ function createDashboardServer(options) {
|
|
|
6206
7568
|
});
|
|
6207
7569
|
app.get("/api/attention", async (_req, res) => {
|
|
6208
7570
|
try {
|
|
6209
|
-
const attention = await getAttention(projectsDir, serversDir2);
|
|
7571
|
+
const attention = await getAttention(projectsDir, serversDir2, assignmentsDir);
|
|
6210
7572
|
res.json(attention);
|
|
6211
7573
|
} catch (error) {
|
|
6212
7574
|
console.error("Error getting attention queue:", error);
|
|
@@ -6326,7 +7688,7 @@ function createDashboardServer(options) {
|
|
|
6326
7688
|
});
|
|
6327
7689
|
app.get("/api/assignments", async (req, res) => {
|
|
6328
7690
|
try {
|
|
6329
|
-
const result = await listAssignmentsBoard(projectsDir);
|
|
7691
|
+
const result = await listAssignmentsBoard(projectsDir, assignmentsDir);
|
|
6330
7692
|
const workspaceParam = req.query.workspace;
|
|
6331
7693
|
if (workspaceParam) {
|
|
6332
7694
|
if (workspaceParam === "_ungrouped") {
|
|
@@ -6354,6 +7716,37 @@ function createDashboardServer(options) {
|
|
|
6354
7716
|
res.status(500).json({ error: "Failed to get project detail" });
|
|
6355
7717
|
}
|
|
6356
7718
|
});
|
|
7719
|
+
app.get("/api/assignments/:id", async (req, res) => {
|
|
7720
|
+
try {
|
|
7721
|
+
const detail = await getAssignmentDetailById(projectsDir, assignmentsDir, req.params.id);
|
|
7722
|
+
if (!detail) {
|
|
7723
|
+
res.status(404).json({ error: `Assignment "${req.params.id}" not found` });
|
|
7724
|
+
return;
|
|
7725
|
+
}
|
|
7726
|
+
res.json(detail);
|
|
7727
|
+
} catch (error) {
|
|
7728
|
+
console.error("Error getting assignment by id:", error);
|
|
7729
|
+
res.status(500).json({ error: "Failed to get assignment" });
|
|
7730
|
+
}
|
|
7731
|
+
});
|
|
7732
|
+
app.get("/api/assignments/:id/sessions", async (req, res) => {
|
|
7733
|
+
try {
|
|
7734
|
+
const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, req.params.id);
|
|
7735
|
+
if (!resolved) {
|
|
7736
|
+
res.status(404).json({ error: `Assignment "${req.params.id}" not found` });
|
|
7737
|
+
return;
|
|
7738
|
+
}
|
|
7739
|
+
await reconcileActiveSessions(projectsDir, assignmentsDir);
|
|
7740
|
+
const sessions = await listSessionsByAssignment(
|
|
7741
|
+
resolved.standalone ? null : resolved.projectSlug,
|
|
7742
|
+
resolved.standalone ? resolved.id : resolved.assignmentSlug
|
|
7743
|
+
);
|
|
7744
|
+
res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
7745
|
+
} catch (error) {
|
|
7746
|
+
console.error("Error listing sessions by id:", error);
|
|
7747
|
+
res.status(500).json({ error: "Failed to list sessions" });
|
|
7748
|
+
}
|
|
7749
|
+
});
|
|
6357
7750
|
app.get("/api/projects/:slug/assignments/:aslug", async (req, res) => {
|
|
6358
7751
|
try {
|
|
6359
7752
|
const detail = await getAssignmentDetail(
|
|
@@ -6373,16 +7766,16 @@ function createDashboardServer(options) {
|
|
|
6373
7766
|
res.status(500).json({ error: "Failed to get assignment detail" });
|
|
6374
7767
|
}
|
|
6375
7768
|
});
|
|
6376
|
-
app.use(createWriteRouter(projectsDir));
|
|
6377
|
-
app.use("/api/servers", createServersRouter(serversDir2, projectsDir));
|
|
6378
|
-
app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir, broadcast));
|
|
7769
|
+
app.use(createWriteRouter(projectsDir, assignmentsDir));
|
|
7770
|
+
app.use("/api/servers", createServersRouter(serversDir2, projectsDir, assignmentsDir));
|
|
7771
|
+
app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir));
|
|
6379
7772
|
app.use("/api/playbooks", createPlaybooksRouter(playbooksDir2));
|
|
6380
7773
|
app.use("/api/todos", createTodosRouter(todosDir2, broadcast));
|
|
6381
7774
|
app.use("/api/backup", createBackupRouter());
|
|
6382
7775
|
if (serveStaticUi && dashboardDistPath) {
|
|
6383
7776
|
app.use(express.static(dashboardDistPath));
|
|
6384
7777
|
app.get("{*path}", async (_req, res) => {
|
|
6385
|
-
const indexPath =
|
|
7778
|
+
const indexPath = resolve16(dashboardDistPath, "index.html");
|
|
6386
7779
|
if (await fileExists(indexPath)) {
|
|
6387
7780
|
res.sendFile(indexPath);
|
|
6388
7781
|
} else {
|
|
@@ -6397,12 +7790,13 @@ function createDashboardServer(options) {
|
|
|
6397
7790
|
async start() {
|
|
6398
7791
|
watcherHandle = createWatcher({
|
|
6399
7792
|
projectsDir,
|
|
7793
|
+
assignmentsDir,
|
|
6400
7794
|
serversDir: serversDir2,
|
|
6401
7795
|
playbooksDir: playbooksDir2,
|
|
6402
7796
|
todosDir: todosDir2,
|
|
6403
7797
|
onMessage: broadcast
|
|
6404
7798
|
});
|
|
6405
|
-
startAutodiscovery({ serversDir: serversDir2, projectsDir, excludePids: /* @__PURE__ */ new Set([process.pid]) });
|
|
7799
|
+
startAutodiscovery({ serversDir: serversDir2, projectsDir, assignmentsDir, excludePids: /* @__PURE__ */ new Set([process.pid]) });
|
|
6406
7800
|
return new Promise((resolvePromise, reject) => {
|
|
6407
7801
|
server.on("error", (err) => {
|
|
6408
7802
|
if (err.code === "EADDRINUSE") {
|
|
@@ -6414,7 +7808,7 @@ function createDashboardServer(options) {
|
|
|
6414
7808
|
}
|
|
6415
7809
|
});
|
|
6416
7810
|
server.listen(port, () => {
|
|
6417
|
-
const portFile =
|
|
7811
|
+
const portFile = resolve16(syntaurRoot(), "dashboard-port");
|
|
6418
7812
|
writeFile4(portFile, String(port), "utf-8").catch(() => {
|
|
6419
7813
|
});
|
|
6420
7814
|
resolvePromise();
|
|
@@ -6431,7 +7825,7 @@ function createDashboardServer(options) {
|
|
|
6431
7825
|
client.terminate();
|
|
6432
7826
|
}
|
|
6433
7827
|
clients.clear();
|
|
6434
|
-
const portFile =
|
|
7828
|
+
const portFile = resolve16(syntaurRoot(), "dashboard-port");
|
|
6435
7829
|
await unlink4(portFile).catch(() => {
|
|
6436
7830
|
});
|
|
6437
7831
|
server.closeAllConnections?.();
|