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.
Files changed (87) hide show
  1. package/dashboard/dist/assets/{_basePickBy-CHKX1r7P.js → _basePickBy-BhaCV7eH.js} +1 -1
  2. package/dashboard/dist/assets/{_baseUniq-CTxTc4MS.js → _baseUniq-CDPcqrs2.js} +1 -1
  3. package/dashboard/dist/assets/{arc-BUo5zftd.js → arc-BP0RxLwl.js} +1 -1
  4. package/dashboard/dist/assets/{architectureDiagram-2XIMDMQ5-CrJLm-P0.js → architectureDiagram-2XIMDMQ5-BDzvaeJp.js} +1 -1
  5. package/dashboard/dist/assets/{blockDiagram-WCTKOSBZ-BK60lBBJ.js → blockDiagram-WCTKOSBZ-ZeL9mROo.js} +1 -1
  6. package/dashboard/dist/assets/{c4Diagram-IC4MRINW-C7oJEvA0.js → c4Diagram-IC4MRINW-7S5bvFLp.js} +1 -1
  7. package/dashboard/dist/assets/channel-CcB_wcgb.js +1 -0
  8. package/dashboard/dist/assets/{chunk-4BX2VUAB-CjUPlzHz.js → chunk-4BX2VUAB-Ca7R4nv5.js} +1 -1
  9. package/dashboard/dist/assets/{chunk-55IACEB6-6HmWguiO.js → chunk-55IACEB6-flEv13FB.js} +1 -1
  10. package/dashboard/dist/assets/{chunk-FMBD7UC4-CLuJnd1b.js → chunk-FMBD7UC4-CfcYWBM6.js} +1 -1
  11. package/dashboard/dist/assets/{chunk-JSJVCQXG-B4d62qWV.js → chunk-JSJVCQXG-Dw4yL0VS.js} +1 -1
  12. package/dashboard/dist/assets/{chunk-KX2RTZJC-AsEKRPq2.js → chunk-KX2RTZJC-B2cDe40G.js} +1 -1
  13. package/dashboard/dist/assets/{chunk-NQ4KR5QH-DQhHHvwY.js → chunk-NQ4KR5QH-LZVm0IWg.js} +1 -1
  14. package/dashboard/dist/assets/{chunk-QZHKN3VN-Ds1TtI3E.js → chunk-QZHKN3VN-Dg0EeHNI.js} +1 -1
  15. package/dashboard/dist/assets/{chunk-WL4C6EOR-C7jE3-cR.js → chunk-WL4C6EOR-v3rXNwXc.js} +1 -1
  16. package/dashboard/dist/assets/classDiagram-VBA2DB6C-BJr38z2g.js +1 -0
  17. package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BJr38z2g.js +1 -0
  18. package/dashboard/dist/assets/clone-Cfs2GUGt.js +1 -0
  19. package/dashboard/dist/assets/{cose-bilkent-S5V4N54A-C9ka5v1m.js → cose-bilkent-S5V4N54A-D-3JzLoS.js} +1 -1
  20. package/dashboard/dist/assets/{dagre-KLK3FWXG-BbgPQBKy.js → dagre-KLK3FWXG-d_mbczhU.js} +1 -1
  21. package/dashboard/dist/assets/{diagram-E7M64L7V-DpdeZFD4.js → diagram-E7M64L7V-BUyAp8pW.js} +1 -1
  22. package/dashboard/dist/assets/{diagram-IFDJBPK2-FlHLQzOV.js → diagram-IFDJBPK2-C8doXcyQ.js} +1 -1
  23. package/dashboard/dist/assets/{diagram-P4PSJMXO-B22NkEF_.js → diagram-P4PSJMXO-BUSmHa55.js} +1 -1
  24. package/dashboard/dist/assets/{erDiagram-INFDFZHY-zSqmtDid.js → erDiagram-INFDFZHY-Bn5_0LPU.js} +1 -1
  25. package/dashboard/dist/assets/{flowDiagram-PKNHOUZH-BP_0XmVV.js → flowDiagram-PKNHOUZH-CnEjerQM.js} +1 -1
  26. package/dashboard/dist/assets/{ganttDiagram-A5KZAMGK-8uRyYgZV.js → ganttDiagram-A5KZAMGK-CL94fbyy.js} +1 -1
  27. package/dashboard/dist/assets/{gitGraphDiagram-K3NZZRJ6-JFqg8sv4.js → gitGraphDiagram-K3NZZRJ6-4i_PeG8V.js} +1 -1
  28. package/dashboard/dist/assets/{graph-a-PAH599.js → graph-BtoFhoAd.js} +1 -1
  29. package/dashboard/dist/assets/index-DZUGYrvE.css +1 -0
  30. package/dashboard/dist/assets/index-Dv_-SxuL.js +481 -0
  31. package/dashboard/dist/assets/{infoDiagram-LFFYTUFH-C3kq7Nbv.js → infoDiagram-LFFYTUFH-CdUsuNgZ.js} +1 -1
  32. package/dashboard/dist/assets/{ishikawaDiagram-PHBUUO56-Kqi4EZ-n.js → ishikawaDiagram-PHBUUO56-BjggRlUx.js} +1 -1
  33. package/dashboard/dist/assets/{journeyDiagram-4ABVD52K-CTfv0Wcr.js → journeyDiagram-4ABVD52K-V4AgexlR.js} +1 -1
  34. package/dashboard/dist/assets/{kanban-definition-K7BYSVSG-Dmx0lgvR.js → kanban-definition-K7BYSVSG-ChlylQRf.js} +1 -1
  35. package/dashboard/dist/assets/{layout-KKRbT2Od.js → layout-DLcz9AmA.js} +1 -1
  36. package/dashboard/dist/assets/{linear-5egaBiw7.js → linear-l2xnSHze.js} +1 -1
  37. package/dashboard/dist/assets/{mermaid.core-C9pF_oFQ.js → mermaid.core-DKO1ytRW.js} +4 -4
  38. package/dashboard/dist/assets/{mindmap-definition-YRQLILUH-C7HXYEXt.js → mindmap-definition-YRQLILUH-DTmTPHrT.js} +1 -1
  39. package/dashboard/dist/assets/{pieDiagram-SKSYHLDU-DkdZm-YP.js → pieDiagram-SKSYHLDU-CwK80y8Y.js} +1 -1
  40. package/dashboard/dist/assets/{quadrantDiagram-337W2JSQ-DkcRJs5F.js → quadrantDiagram-337W2JSQ-Be1xqW_w.js} +1 -1
  41. package/dashboard/dist/assets/{requirementDiagram-Z7DCOOCP-BaTDVYTl.js → requirementDiagram-Z7DCOOCP-JcspXCs0.js} +1 -1
  42. package/dashboard/dist/assets/{sankeyDiagram-WA2Y5GQK-DvPLbGV5.js → sankeyDiagram-WA2Y5GQK-nJb1BInq.js} +1 -1
  43. package/dashboard/dist/assets/{sequenceDiagram-2WXFIKYE-DQoZ2xMK.js → sequenceDiagram-2WXFIKYE-DUrclEgA.js} +1 -1
  44. package/dashboard/dist/assets/{stateDiagram-RAJIS63D-CS4l0OjM.js → stateDiagram-RAJIS63D-CjinnNtF.js} +1 -1
  45. package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-yfclw-nM.js +1 -0
  46. package/dashboard/dist/assets/{timeline-definition-YZTLITO2-aC0iCFCW.js → timeline-definition-YZTLITO2-kM-oVLNz.js} +1 -1
  47. package/dashboard/dist/assets/{treemap-KZPCXAKY-Ie-PFjgx.js → treemap-KZPCXAKY-CYziFlrQ.js} +1 -1
  48. package/dashboard/dist/assets/{vennDiagram-LZ73GAT5-CJN3ExTQ.js → vennDiagram-LZ73GAT5-DX0DbxBN.js} +1 -1
  49. package/dashboard/dist/assets/{xychartDiagram-JWTSCODW-DSiDu1CN.js → xychartDiagram-JWTSCODW-BGqM42ZM.js} +1 -1
  50. package/dashboard/dist/index.html +2 -2
  51. package/dist/dashboard/server.d.ts +5 -0
  52. package/dist/dashboard/server.js +2579 -1185
  53. package/dist/dashboard/server.js.map +1 -1
  54. package/dist/index.js +2092 -650
  55. package/dist/index.js.map +1 -1
  56. package/examples/playbooks/keep-records-updated.md +14 -8
  57. package/examples/playbooks/read-before-plan.md +8 -5
  58. package/examples/sample-project/_status.md +1 -1
  59. package/examples/sample-project/assignments/design-auth-schema/assignment.md +4 -17
  60. package/examples/sample-project/assignments/design-auth-schema/comments.md +26 -0
  61. package/examples/sample-project/assignments/design-auth-schema/progress.md +20 -0
  62. package/examples/sample-project/assignments/implement-jwt-middleware/assignment.md +4 -17
  63. package/examples/sample-project/assignments/implement-jwt-middleware/comments.md +17 -0
  64. package/examples/sample-project/assignments/implement-jwt-middleware/progress.md +20 -0
  65. package/examples/sample-project/assignments/write-auth-tests/assignment.md +4 -8
  66. package/examples/sample-project/assignments/write-auth-tests/comments.md +10 -0
  67. package/examples/sample-project/assignments/write-auth-tests/progress.md +10 -0
  68. package/package.json +1 -1
  69. package/platforms/claude-code/agents/syntaur-expert.md +40 -12
  70. package/platforms/claude-code/references/file-ownership.md +15 -3
  71. package/platforms/claude-code/references/protocol-summary.md +19 -5
  72. package/platforms/claude-code/skills/complete-assignment/SKILL.md +14 -0
  73. package/platforms/claude-code/skills/create-assignment/SKILL.md +12 -10
  74. package/platforms/claude-code/skills/syntaur-protocol/SKILL.md +21 -11
  75. package/platforms/codex/agents/syntaur-operator.md +33 -21
  76. package/platforms/codex/references/file-ownership.md +14 -3
  77. package/platforms/codex/references/protocol-summary.md +19 -5
  78. package/platforms/codex/skills/complete-assignment/SKILL.md +1 -0
  79. package/platforms/codex/skills/create-assignment/SKILL.md +13 -8
  80. package/platforms/codex/skills/syntaur-protocol/SKILL.md +26 -13
  81. package/dashboard/dist/assets/channel-DdltvFFH.js +0 -1
  82. package/dashboard/dist/assets/classDiagram-VBA2DB6C-BHqdFE-8.js +0 -1
  83. package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BHqdFE-8.js +0 -1
  84. package/dashboard/dist/assets/clone-CBJOOeOm.js +0 -1
  85. package/dashboard/dist/assets/index-CoVCLSh2.css +0 -1
  86. package/dashboard/dist/assets/index-yyAIuzrP.js +0 -471
  87. package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-DkBtE1WJ.js +0 -1
@@ -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 readFile3, unlink } from "fs/promises";
1464
- import { resolve as resolve4 } from "path";
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(resolve4(dir, `${name}.md`), content);
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 readdir(dir);
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 = resolve4(dir, `${sanitizeSessionName(name)}.md`);
1687
+ const filePath = resolve5(dir, `${sanitizeSessionName(name)}.md`);
1522
1688
  if (!await fileExists(filePath)) return null;
1523
- const raw = await readFile3(filePath, "utf-8");
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 = resolve4(dir, `${sanitizeSessionName(name)}.md`);
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(resolve4(dir, `${sanitizeSessionName(name)}.md`), content);
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(resolve4(dir, `${sanitizeSessionName(sessionName)}.md`), content);
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(resolve4(dir, `${name}.md`), content);
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 resolve5 } from "path";
1634
- import { realpath, readdir as readdir2, readFile as readFile4 } from "fs/promises";
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(resolve5(cwd, commonDir));
1730
- const resolvedGit = await realpath(resolve5(cwd, gitDir));
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 assignmentsDir = resolve5(projectsDir, project.slug, "assignments");
1909
+ const projectAssignmentsDir = resolve6(projectsDir, project.slug, "assignments");
1744
1910
  let slugs;
1745
1911
  try {
1746
- slugs = await readdir2(assignmentsDir);
1912
+ slugs = await readdir3(projectAssignmentsDir);
1747
1913
  } catch {
1748
1914
  continue;
1749
1915
  }
1750
1916
  for (const aslug of slugs) {
1751
- const aFile = resolve5(assignmentsDir, aslug, "assignment.md");
1917
+ const aFile = resolve6(projectAssignmentsDir, aslug, "assignment.md");
1752
1918
  try {
1753
- const raw = await readFile4(aFile, "utf-8");
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 readdir3, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
2002
- import { resolve as resolve6, dirname as dirname2 } from "path";
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 = resolve6(dirname2(projectsDir), "workspaces.json");
2273
+ const registryPath = resolve7(dirname2(projectsDir), "workspaces.json");
2064
2274
  try {
2065
- const raw = await readFile5(registryPath, "utf-8");
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 = resolve6(dirname2(projectsDir), "workspaces.json");
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 attention = buildAttentionItems(projectRecords);
2109
- const recentActivity = buildRecentActivity(projectRecords);
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 items = buildAttentionItems(projectRecords);
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 assignments = await Promise.all(
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: assignments.flat().sort((left, right) => compareTimestamps(right.updated, left.updated))
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 readFile5(filePath, "utf-8");
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 = resolve6(projectsDir, slug);
2247
- const projectMdPath = resolve6(projectPath, "project.md");
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 readFile5(projectMdPath, "utf-8");
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 = resolve6(projectsDir, projectSlug, "assignments", assignmentSlug);
2282
- const assignmentMdPath = resolve6(assignmentDir, "assignment.md");
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 readFile5(assignmentMdPath, "utf-8");
2563
+ const assignmentContent = await readFile6(assignmentMdPath, "utf-8");
2287
2564
  const assignment = parseAssignmentFull(assignmentContent);
2288
2565
  let plan = null;
2289
- const planPath = resolve6(assignmentDir, "plan.md");
2566
+ const planPath = resolve7(assignmentDir, "plan.md");
2290
2567
  if (await fileExists(planPath)) {
2291
- const planContent = await readFile5(planPath, "utf-8");
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 = resolve6(assignmentDir, "scratchpad.md");
2577
+ const scratchpadPath = resolve7(assignmentDir, "scratchpad.md");
2301
2578
  if (await fileExists(scratchpadPath)) {
2302
- const scratchpadContent = await readFile5(scratchpadPath, "utf-8");
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 = resolve6(assignmentDir, "handoff.md");
2587
+ const handoffPath = resolve7(assignmentDir, "handoff.md");
2311
2588
  if (await fileExists(handoffPath)) {
2312
- const handoffContent = await readFile5(handoffPath, "utf-8");
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 = resolve6(assignmentDir, "decision-record.md");
2598
+ const decisionRecordPath = resolve7(assignmentDir, "decision-record.md");
2322
2599
  if (await fileExists(decisionRecordPath)) {
2323
- const decisionRecordContent = await readFile5(decisionRecordPath, "utf-8");
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 readdir3(projectsDir, { withFileTypes: true });
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 = resolve6(projectsDir, entry.name);
2428
- const projectMdPath = resolve6(projectPath, "project.md");
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 readFile5(projectMdPath, "utf-8");
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 = resolve6(projectPath, "assignments");
2952
+ const assignmentsDir = resolve7(projectPath, "assignments");
2464
2953
  if (!await fileExists(assignmentsDir)) {
2465
2954
  return [];
2466
2955
  }
2467
- const entries = await readdir3(assignmentsDir, { withFileTypes: true });
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 = resolve6(assignmentsDir, entry.name, "assignment.md");
2962
+ const assignmentMd = resolve7(assignmentsDir, entry.name, "assignment.md");
2474
2963
  if (!await fileExists(assignmentMd)) {
2475
2964
  continue;
2476
2965
  }
2477
- const content = await readFile5(assignmentMd, "utf-8");
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 = resolve6(projectPath, "resources");
2973
+ const resourcesDir = resolve7(projectPath, "resources");
2485
2974
  if (!await fileExists(resourcesDir)) {
2486
2975
  return [];
2487
2976
  }
2488
- const entries = await readdir3(resourcesDir, { withFileTypes: true });
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 = resolve6(resourcesDir, entry.name);
2495
- const content = await readFile5(filePath, "utf-8");
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 = resolve6(projectPath, "memories");
2999
+ const memoriesDir = resolve7(projectPath, "memories");
2511
3000
  if (!await fileExists(memoriesDir)) {
2512
3001
  return [];
2513
3002
  }
2514
- const entries = await readdir3(memoriesDir, { withFileTypes: true });
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 = resolve6(memoriesDir, entry.name);
2521
- const content = await readFile5(filePath, "utf-8");
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 = resolve6(projectPath, "_status.md");
3025
+ const statusPath = resolve7(projectPath, "_status.md");
2537
3026
  if (await fileExists(statusPath)) {
2538
- const statusContent = await readFile5(statusPath, "utf-8");
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 += countPendingAnswers(assignment.body);
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 = resolve6(projectsDir, projectSlug);
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 = resolve6(projectPath, "assignments", dependency, "assignment.md");
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 readFile5(dependencyPath, "utf-8");
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 countPendingAnswers(body) {
2787
- const matches = body.match(/^\*\*A:\*\*\s+pending\s*$/gim);
2788
- return matches ? matches.length : 0;
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 resolve6(projectsDir, projectSlug, "project.md");
3348
+ return resolve7(projectsDir, projectSlug, "project.md");
2803
3349
  case "assignment":
2804
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
3350
+ return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
2805
3351
  case "plan":
2806
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
3352
+ return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
2807
3353
  case "scratchpad":
2808
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
3354
+ return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
2809
3355
  case "handoff":
2810
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
3356
+ return assignmentSlug ? resolve7(projectsDir, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
2811
3357
  case "decision-record":
2812
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "decision-record.md") : null;
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 readdir3(playbooksDir2, { withFileTypes: true });
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 = resolve6(playbooksDir2, entry.name);
2844
- const raw = await readFile5(filePath, "utf-8");
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 = resolve6(playbooksDir2, `${slug}.md`);
3406
+ const filePath = resolve7(playbooksDir2, `${slug}.md`);
2861
3407
  if (!await fileExists(filePath)) return null;
2862
- const raw = await readFile5(filePath, "utf-8");
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 readFile10 } from "fs/promises";
2975
- import { resolve as resolve13 } from "path";
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 resolve13(todosDir2, `${workspace}.md`);
3683
+ return resolve14(todosDir2, `${workspace}.md`);
3136
3684
  }
3137
3685
  function logPath(todosDir2, workspace) {
3138
- return resolve13(todosDir2, `${workspace}-log.md`);
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 resolve13(todosDir2, "archive", `${workspace}-${suffix}.md`);
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 readFile10(path, "utf-8");
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 readFile10(path, "utf-8");
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 readFile10(path, "utf-8");
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 resolve15 } from "path";
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/watcher.ts
3233
- import { watch } from "chokidar";
3234
- import { relative, sep } from "path";
3235
- function createWatcher(options) {
3236
- const { projectsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, onMessage, debounceMs = 300 } = options;
3237
- const pendingEvents = /* @__PURE__ */ new Map();
3238
- const projectsWatcher = watch(projectsDir, {
3239
- ignoreInitial: true,
3240
- persistent: true,
3241
- depth: 10,
3242
- ignored: /(^|[\/\\])\../
3243
- });
3244
- function handleProjectChange(filePath) {
3245
- const rel = relative(projectsDir, filePath);
3246
- const parts = rel.split(sep);
3247
- if (parts.length === 0) return;
3248
- const projectSlug = parts[0];
3249
- let assignmentSlug;
3250
- if (parts.length >= 3 && parts[1] === "assignments") {
3251
- assignmentSlug = parts[2];
3252
- }
3253
- const debounceKey = assignmentSlug ? `${projectSlug}/${assignmentSlug}` : projectSlug;
3254
- const existing = pendingEvents.get(debounceKey);
3255
- if (existing) clearTimeout(existing);
3256
- const messageType = assignmentSlug ? "assignment-updated" : "project-updated";
3257
- pendingEvents.set(
3258
- debounceKey,
3259
- setTimeout(() => {
3260
- pendingEvents.delete(debounceKey);
3261
- const message = {
3262
- type: messageType,
3263
- projectSlug,
3264
- assignmentSlug,
3265
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3266
- };
3267
- onMessage(message);
3268
- }, debounceMs)
3269
- );
3270
- }
3271
- projectsWatcher.on("change", handleProjectChange);
3272
- projectsWatcher.on("add", handleProjectChange);
3273
- projectsWatcher.on("unlink", handleProjectChange);
3274
- let serversWatcher = null;
3275
- if (serversDir2) {
3276
- let handleServerChange2 = function() {
3277
- const debounceKey = "__servers__";
3278
- const existing = pendingEvents.get(debounceKey);
3279
- if (existing) clearTimeout(existing);
3280
- pendingEvents.set(
3281
- debounceKey,
3282
- setTimeout(() => {
3283
- pendingEvents.delete(debounceKey);
3284
- const message = {
3285
- type: "servers-updated",
3286
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3287
- };
3288
- onMessage(message);
3289
- }, debounceMs)
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
- var handlePlaybookChange = handlePlaybookChange2;
3322
- playbooksWatcher = watch(playbooksDir2, {
3323
- ignoreInitial: true,
3324
- persistent: true,
3325
- depth: 1,
3326
- ignored: /(^|[\/\\])\../
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
- let todosWatcher = null;
3333
- if (todosDir2) {
3334
- let handleTodoChange2 = function() {
3335
- const debounceKey = "__todos__";
3336
- const existing = pendingEvents.get(debounceKey);
3337
- if (existing) clearTimeout(existing);
3338
- pendingEvents.set(
3339
- debounceKey,
3340
- setTimeout(() => {
3341
- pendingEvents.delete(debounceKey);
3342
- const message = {
3343
- type: "todos-updated",
3344
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3345
- };
3346
- onMessage(message);
3347
- }, debounceMs)
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
- var handleTodoChange = handleTodoChange2;
3351
- todosWatcher = watch(todosDir2, {
3352
- ignoreInitial: true,
3353
- persistent: true,
3354
- depth: 1,
3355
- ignored: /(^|[\/\\])\../
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
- // src/dashboard/api-write.ts
3397
- init_timestamp();
3398
- init_fs();
3399
- init_parser();
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 toggleAcceptanceCriterion(content, index, checked) {
3413
- if (!Number.isInteger(index) || index < 0) {
3414
- return { error: "acceptance criteria index must be a non-negative integer" };
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
- let sectionEnd = lines.length;
3423
- for (let lineIndex = sectionStart + 1; lineIndex < lines.length; lineIndex += 1) {
3424
- if (/^#{1,2}\s+\S/.test(lines[lineIndex].trim())) {
3425
- sectionEnd = lineIndex;
3426
- break;
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
- const checklistLines = lines.map((line, lineIndex) => ({ line, lineIndex })).filter(
3430
- ({ lineIndex, line }) => lineIndex > sectionStart && lineIndex < sectionEnd && /^\s*[-*]\s+\[( |x|X)\]\s+.*$/.test(line)
3431
- );
3432
- const target = checklistLines[index];
3433
- if (!target) {
3434
- return { error: `Acceptance criteria item ${index} not found.` };
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
- const nextLine = target.line.replace(
3437
- /^(\s*[-*]\s+\[)( |x|X)(\]\s+.*)$/,
3438
- `$1${checked ? "x" : " "}$3`
3439
- );
3440
- lines[target.lineIndex] = nextLine;
3954
+ return sessions;
3955
+ }
3956
+
3957
+ // src/dashboard/agent-sessions.ts
3958
+ function rowToSession(row) {
3441
3959
  return {
3442
- content: `${prefix}${lines.join("\n")}`
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
- // src/dashboard/api-write.ts
3447
- init_api();
3448
-
3449
- // src/templates/index.ts
3450
- init_config();
3451
-
3452
- // src/templates/manifest.ts
3453
- function renderManifest(params) {
3454
- return `---
3455
- version: "2.0"
3456
- project: ${params.slug}
3457
- generated: "${params.timestamp}"
3458
- ---
3459
-
3460
- # Project: ${params.slug}
3461
-
3462
- ## Overview
3463
- - [Project Overview](./project.md)
3464
-
3465
- ## Indexes
3466
- - [Assignments](./_index-assignments.md)
3467
- - [Plans](./_index-plans.md)
3468
- - [Decision Records](./_index-decisions.md)
3469
- - [Status](./_status.md)
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
- // src/utils/yaml.ts
3476
- function escapeYamlString(value) {
3477
- if (value.includes("\n") || value.includes("\r")) {
3478
- throw new Error(
3479
- `YAML string values must be single-line. Got: "${value.slice(0, 50)}..."`
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 escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3483
- return `"${escaped}"`;
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
- // src/templates/project.ts
3487
- function renderProject(params) {
3488
- const safeTitle = escapeYamlString(params.title);
3489
- const workspaceLine = params.workspace ? `
3490
- workspace: ${params.workspace}` : "";
3491
- return `---
3492
- id: ${params.id}
3493
- slug: ${params.slug}
3494
- title: ${safeTitle}
3495
- archived: false
3496
- archivedAt: null
3497
- archivedReason: null
3498
- created: "${params.timestamp}"
3499
- updated: "${params.timestamp}"
3500
- externalIds: []
3501
- tags: []${workspaceLine}
3502
- ---
3503
-
3504
- # ${params.title}
3505
-
3506
- ## Overview
3507
-
3508
- <!-- Describe the project goal, context, and success criteria here. -->
3509
-
3510
- ## Notes
3511
-
3512
- <!-- Optional human notes, updates, or context. -->
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/templates/assignment.ts
3517
- function renderAssignment(params) {
3518
- const safeTitle = escapeYamlString(params.title);
3519
- const dependsOnYaml = params.dependsOn.length === 0 ? "dependsOn: []" : `dependsOn:
3520
- - ${params.dependsOn.join("\n - ")}`;
3521
- const linksYaml = params.links.length === 0 ? "links: []" : `links:
3522
- - ${params.links.join("\n - ")}`;
3523
- const projectYaml = `project: ${params.project == null ? "null" : params.project}`;
3524
- const typeYaml = `type: ${params.type ?? "feature"}`;
3525
- return `---
3526
- id: ${params.id}
3527
- slug: ${params.slug}
3528
- title: ${safeTitle}
3529
- ${projectYaml}
3530
- ${typeYaml}
3531
- status: pending
3532
- priority: ${params.priority}
3533
- created: "${params.timestamp}"
3534
- updated: "${params.timestamp}"
3535
- assignee: null
3536
- externalIds: []
3537
- ${dependsOnYaml}
3538
- ${linksYaml}
3539
- blockedReason: null
3540
- workspace:
3541
- repository: null
3542
- worktreePath: null
3543
- branch: null
3544
- parentBranch: null
3545
- tags: []
3546
- ---
3547
-
3548
- # ${params.title}
3549
-
3550
- ## Objective
3551
-
3552
- <!-- Clear description of what needs to be done and why. -->
3553
-
3554
- ## Acceptance Criteria
3555
-
3556
- - [ ] <!-- criterion 1 -->
3557
- - [ ] <!-- criterion 2 -->
3558
- - [ ] <!-- criterion 3 -->
3559
-
3560
- ## Todos
3561
-
3562
- <!--
3563
- Checklist of work items for this assignment. Items may be simple tasks
3564
- or a markdown link to a plan file (e.g., "- [ ] Execute [plan](./plan.md)").
3565
- When a plan is superseded by a new one, mark the old todo as:
3566
- - [x] ~~Execute [old plan](./plan.md)~~ (superseded by plan-v2)
3567
- Never delete superseded todos \u2014 preserve the history.
3568
- -->
3569
-
3570
- ## Context
3571
-
3572
- <!-- Links to relevant docs, code, or other assignments. -->
3573
-
3574
- ## Links
3575
-
3576
- - [Progress](./progress.md)
3577
- - [Comments](./comments.md)
3578
- - [Scratchpad](./scratchpad.md)
3579
- - [Handoff](./handoff.md)
3580
- - [Decision Record](./decision-record.md)
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/templates/scratchpad.ts
3585
- function renderScratchpad(params) {
3586
- return `---
3587
- assignment: ${params.assignmentSlug}
3588
- updated: "${params.timestamp}"
3589
- ---
4271
+ // src/dashboard/server.ts
4272
+ init_fs();
4273
+ init_config2();
3590
4274
 
3591
- # Scratchpad
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
- No working notes yet.
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/templates/handoff.ts
3598
- function renderHandoff(params) {
3599
- return `---
3600
- assignment: ${params.assignmentSlug}
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/templates/decision-record.ts
3612
- function renderDecisionRecord(params) {
3613
- return `---
3614
- assignment: ${params.assignmentSlug}
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
- No decisions recorded yet.
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
- // src/templates/index-stubs.ts
3626
- function renderIndexAssignments(params) {
3627
- return `---
3628
- project: ${params.slug}
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 readFile6(filePath, "utf-8");
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.get("/api/projects/:slug/assignments/:aslug/edit", async (req, res) => {
3890
- const slug = getParam(req.params.slug);
3891
- const assignmentSlug = getParam(req.params.aslug);
3892
- const document = await getEditableDocument(
3893
- projectsDir,
3894
- "assignment",
3895
- slug,
3896
- assignmentSlug
3897
- );
3898
- if (!document) {
3899
- res.status(404).json({ error: "Assignment not found" });
3900
- return;
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.get("/api/projects/:slug/assignments/:aslug/plan/edit", async (req, res) => {
3905
- const slug = getParam(req.params.slug);
3906
- const assignmentSlug = getParam(req.params.aslug);
3907
- const document = await getEditableDocument(
3908
- projectsDir,
3909
- "plan",
3910
- slug,
3911
- assignmentSlug
3912
- );
3913
- if (!document) {
3914
- res.status(404).json({ error: "Plan not found" });
3915
- return;
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.get("/api/projects/:slug/assignments/:aslug/scratchpad/edit", async (req, res) => {
3920
- const slug = getParam(req.params.slug);
3921
- const assignmentSlug = getParam(req.params.aslug);
3922
- const document = await getEditableDocument(
3923
- projectsDir,
3924
- "scratchpad",
3925
- slug,
3926
- assignmentSlug
3927
- );
3928
- if (!document) {
3929
- res.status(404).json({ error: "Scratchpad not found" });
3930
- return;
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.get("/api/projects/:slug/assignments/:aslug/handoff/edit", async (req, res) => {
3935
- const slug = getParam(req.params.slug);
3936
- const assignmentSlug = getParam(req.params.aslug);
3937
- const document = await getEditableDocument(
3938
- projectsDir,
3939
- "handoff",
3940
- slug,
3941
- assignmentSlug
3942
- );
3943
- if (!document) {
3944
- res.status(404).json({ error: "Handoff log not found" });
3945
- return;
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.get("/api/projects/:slug/assignments/:aslug/decision-record/edit", async (req, res) => {
3950
- const slug = getParam(req.params.slug);
3951
- const assignmentSlug = getParam(req.params.aslug);
3952
- const document = await getEditableDocument(
3953
- projectsDir,
3954
- "decision-record",
3955
- slug,
3956
- assignmentSlug
3957
- );
3958
- if (!document) {
3959
- res.status(404).json({ error: "Decision record not found" });
3960
- return;
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 content = requireContent(req, res);
3967
- if (!content) {
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 fields = extractFrontmatter3(content);
3971
- if (!fields) {
3972
- res.status(400).json({ error: "Invalid frontmatter: missing --- delimiters" });
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
- const validation = validateRequired(fields, ["slug", "title"]);
3976
- if (!validation.valid) {
3977
- res.status(400).json({ error: `Missing required fields: ${validation.missing.join(", ")}` });
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 slug = fields.slug;
3981
- if (!isValidSlug(slug)) {
3982
- res.status(400).json({ error: `Invalid slug "${slug}". Must be lowercase and hyphen-separated.` });
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
- const projectDir = resolve7(projectsDir, slug);
3986
- if (await fileExists(projectDir)) {
3987
- res.status(409).json({ error: `Project "${slug}" already exists` });
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 title = fields.title;
3991
- const timestamp = fields.created || nowTimestamp();
3992
- await ensureDir(resolve7(projectDir, "assignments"));
3993
- await ensureDir(resolve7(projectDir, "resources"));
3994
- await ensureDir(resolve7(projectDir, "memories"));
3995
- await writeFileForce(resolve7(projectDir, "project.md"), content);
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
- res.status(201).json({ slug });
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 creating project:", error);
4019
- res.status(500).json({ error: `Failed to create project: ${error.message}` });
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 projectDir = resolve7(projectsDir, projectSlug);
4026
- const projectMdPath = resolve7(projectDir, "project.md");
4027
- if (!await fileExists(projectMdPath)) {
4028
- res.status(404).json({ error: `Project "${projectSlug}" not found` });
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 content = requireContent(req, res);
4032
- if (!content) {
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 fields = extractFrontmatter3(content);
4036
- if (!fields) {
4037
- res.status(400).json({ error: "Invalid frontmatter: missing --- delimiters" });
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 validation = validateRequired(fields, ["slug", "title"]);
4041
- if (!validation.valid) {
4042
- res.status(400).json({ error: `Missing required fields: ${validation.missing.join(", ")}` });
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
- const assignmentSlug = fields.slug;
4046
- if (!isValidSlug(assignmentSlug)) {
4047
- res.status(400).json({ error: `Invalid slug "${assignmentSlug}". Must be lowercase and hyphen-separated.` });
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 validPriorities = ["low", "medium", "high", "critical"];
4051
- const priority = fields.priority || "medium";
4052
- if (!validPriorities.includes(priority)) {
4053
- res.status(400).json({ error: `Invalid priority "${priority}". Must be low, medium, high, or critical.` });
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 assignmentDir = resolve7(projectDir, "assignments", assignmentSlug);
5562
+ const id = generateId();
5563
+ const assignmentDir = resolve10(assignmentsDir, id);
4057
5564
  if (await fileExists(assignmentDir)) {
4058
- res.status(409).json({
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 = fields.created || nowTimestamp();
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
- await writeFileForce(resolve7(assignmentDir, "assignment.md"), content);
4066
- try {
4067
- const companions = [
4068
- [resolve7(assignmentDir, "scratchpad.md"), renderScratchpad({ assignmentSlug, timestamp })],
4069
- [resolve7(assignmentDir, "handoff.md"), renderHandoff({ assignmentSlug, timestamp })],
4070
- [resolve7(assignmentDir, "decision-record.md"), renderDecisionRecord({ assignmentSlug, timestamp })]
4071
- ];
4072
- for (const [filePath, fileContent] of companions) {
4073
- await writeFileForce(filePath, fileContent);
4074
- }
4075
- } catch (companionError) {
4076
- try {
4077
- await rm(assignmentDir, { recursive: true, force: true });
4078
- } catch {
4079
- }
4080
- throw companionError;
4081
- }
4082
- res.status(201).json({ slug: assignmentSlug, projectSlug });
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.patch("/api/projects/:slug", async (req, res) => {
5611
+ router.post("/api/assignments/:id/comments", async (req, res) => {
4089
5612
  try {
4090
- const projectSlug = getParam(req.params.slug);
4091
- const projectPath = resolve7(projectsDir, projectSlug, "project.md");
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 nextContentRaw = requireContent(req, res);
4098
- if (!nextContentRaw) {
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
- const current = parseProject(currentContent);
4102
- const next = parseProject(nextContentRaw);
4103
- if (!next.slug || !next.title) {
4104
- res.status(400).json({ error: "Project content must include slug and title." });
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
- if (next.slug !== current.slug) {
4108
- res.status(400).json({ error: "Project slug cannot be changed once created." });
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
- const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
4112
- await writeFileForce(projectPath, nextContent);
4113
- const project = await getProjectDetail(projectsDir, projectSlug);
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 updating project:", error);
4117
- res.status(500).json({ error: `Failed to update project: ${error.message}` });
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.patch("/api/projects/:slug/assignments/:aslug", async (req, res) => {
4121
- try {
4122
- const projectSlug = getParam(req.params.slug);
4123
- const assignmentSlug = getParam(req.params.aslug);
4124
- const assignmentPath = resolve7(
4125
- projectsDir,
4126
- projectSlug,
4127
- "assignments",
4128
- assignmentSlug,
4129
- "assignment.md"
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.slug || !next.title) {
4143
- res.status(400).json({ error: "Assignment content must include slug and title." });
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 getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
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/projects/:slug/assignments/:aslug/acceptance-criteria/:index", async (req, res) => {
5759
+ router.patch("/api/assignments/:id/plan", async (req, res) => {
4164
5760
  try {
4165
- const projectSlug = getParam(req.params.slug);
4166
- const assignmentSlug = getParam(req.params.aslug);
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 index = Number.parseInt(getParam(req.params.index), 10);
4185
- const result = toggleAcceptanceCriterion(currentContent, index, checked);
4186
- if ("error" in result) {
4187
- res.status(400).json({ error: result.error });
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 nextContent = setTopLevelField(result.content, "updated", nowTimestamp());
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
- return;
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 getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
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/projects/:slug/assignments/:aslug/scratchpad", async (req, res) => {
5793
+ router.patch("/api/assignments/:id/scratchpad", async (req, res) => {
4238
5794
  try {
4239
- const projectSlug = getParam(req.params.slug);
4240
- const assignmentSlug = getParam(req.params.aslug);
4241
- const scratchpadPath = resolve7(
4242
- projectsDir,
4243
- projectSlug,
4244
- "assignments",
4245
- assignmentSlug,
4246
- "scratchpad.md"
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
- return;
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 getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
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/projects/:slug/assignments/:aslug/handoff/entries", async (req, res) => {
5827
+ router.post("/api/assignments/:id/handoff/entries", async (req, res) => {
4276
5828
  try {
4277
- const projectSlug = getParam(req.params.slug);
4278
- const assignmentSlug = getParam(req.params.aslug);
4279
- const handoffPath = resolve7(
4280
- projectsDir,
4281
- projectSlug,
4282
- "assignments",
4283
- assignmentSlug,
4284
- "handoff.md"
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 getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
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/projects/:slug/assignments/:aslug/decision-record/entries", async (req, res) => {
5867
+ router.post("/api/assignments/:id/decision-record/entries", async (req, res) => {
4314
5868
  try {
4315
- const projectSlug = getParam(req.params.slug);
4316
- const assignmentSlug = getParam(req.params.aslug);
4317
- const decisionPath = resolve7(
4318
- projectsDir,
4319
- projectSlug,
4320
- "assignments",
4321
- assignmentSlug,
4322
- "decision-record.md"
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 getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
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/projects/:slug/move-workspace", async (req, res) => {
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
- const projectSlug = getParam(req.params.slug);
4378
- const projectPath = resolve7(projectsDir, projectSlug, "project.md");
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 { status } = req.body || {};
4384
- const config = await getStatusConfig();
4385
- const validStatuses = ["active", "archived", ...config.statuses.map((s) => s.id)];
4386
- if (status !== null && (typeof status !== "string" || !validStatuses.includes(status))) {
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
- let content = await readFile6(projectPath, "utf-8");
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 readFile6(assignmentPath, "utf-8");
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 getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
5938
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
4431
5939
  res.json({ assignment });
4432
5940
  } catch (error) {
4433
- console.error("Error overriding assignment status:", error);
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.post("/api/projects/:slug/assignments/:aslug/transitions/:command", async (req, res) => {
5945
+ router.patch("/api/assignments/:id/acceptance-criteria/:index", async (req, res) => {
4438
5946
  try {
4439
- const projectSlug = getParam(req.params.slug);
4440
- const assignmentSlug = getParam(req.params.aslug);
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 projectDir = resolve7(projectsDir, projectSlug);
4449
- const assignmentPath = resolve7(projectDir, "assignments", assignmentSlug, "assignment.md");
4450
- if (!await fileExists(assignmentPath)) {
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 { reason } = req.body || {};
4455
- const result = await executeTransition(projectDir, assignmentSlug, command, {
4456
- reason: typeof reason === "string" ? reason : void 0,
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 assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
4465
- res.json({ assignment, transition: result });
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 running assignment transition:", error);
4468
- res.status(500).json({ error: `Failed to transition assignment: ${error.message}` });
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.delete("/api/projects/:slug/assignments/:aslug", async (req, res) => {
5983
+ router.post("/api/assignments/:id/transitions/:command", async (req, res) => {
4472
5984
  try {
4473
- const projectSlug = getParam(req.params.slug);
4474
- const assignmentSlug = getParam(req.params.aslug);
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
- await rm(assignmentDir, { recursive: true, force: true });
4482
- res.json({ deleted: assignmentSlug, projectSlug });
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 deleting assignment:", error);
4485
- res.status(500).json({ error: `Failed to delete assignment: ${error.message}` });
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 resolve10 } from "path";
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 = resolve10(projectsDir, projectSlug);
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 = resolve10(projectsDir, projectSlug);
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: id,
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: id });
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 resolve12 } from "path";
4969
- import { readFile as readFile9, unlink as unlink2 } from "fs/promises";
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 resolve11 } from "path";
4978
- import { readdir as readdir5, readFile as readFile8 } from "fs/promises";
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 readdir5(playbooksDir2, { withFileTypes: true });
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 readFile8(resolve11(playbooksDir2, entry.name), "utf-8");
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(resolve11(playbooksDir2, "manifest.md"), lines.join("\n"));
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 = resolve12(playbooksDir2, `${req.params.slug}.md`);
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 readFile9(filePath, "utf-8");
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 = resolve12(playbooksDir2, `${slug}.md`);
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 = resolve12(playbooksDir2, `${req.params.slug}.md`);
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 = resolve12(playbooksDir2, `${req.params.slug}.md`);
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 readdir6 } from "fs/promises";
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 readdir6(todosDir2).catch(() => []);
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: resolve16 } = await import("path");
5282
- const { readFile: readFile12 } = await import("fs/promises");
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(resolve16(todosDir2, "archive"));
6660
+ await ensureDir(resolve17(todosDir2, "archive"));
5299
6661
  let archContent = "";
5300
6662
  if (await fileExists(archFile)) {
5301
- archContent = await readFile12(archFile, "utf-8");
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 readFile11, writeFile as writeFile3, unlink as unlink3, stat, open, rename as rename2 } from "fs/promises";
5562
- import { resolve as resolve14, join as join2 } from "path";
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: resolve14(syntaurRoot(), "config.md"), repoPath: "config.md", isFile: true };
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 = resolve14(syntaurRoot(), LOCK_FILE_NAME);
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 readFile11(lockPath, "utf-8").catch(() => "");
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(resolve14(dest, ".."));
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 readFile11(configPath, "utf-8");
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(resolve14(destPath, ".."));
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(resolve14(localPath, ".."));
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 = resolve14(syntaurRoot(), LOCK_FILE_NAME);
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 = resolve15(dashboardDistPath, "index.html");
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 = resolve15(syntaurRoot(), "dashboard-port");
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 = resolve15(syntaurRoot(), "dashboard-port");
7828
+ const portFile = resolve16(syntaurRoot(), "dashboard-port");
6435
7829
  await unlink4(portFile).catch(() => {
6436
7830
  });
6437
7831
  server.closeAllConnections?.();